From 5b1a4c2fa76fce1b721d9f6cbafea349e4211488 Mon Sep 17 00:00:00 2001 From: xl-openai Date: Tue, 12 May 2026 00:58:37 -0700 Subject: [PATCH 1/6] feat: Normalize remote plugin summary identities. (#22265) Makes plugin summaries use config-style plugin@marketplace IDs while exposing backend remote IDs separately as remotePluginId. Also fix the consistency issue of REMOTE_SHARED_WITH_ME_MARKETPLACE_NAME --- .../codex_app_server_protocol.schemas.json | 7 ++ .../codex_app_server_protocol.v2.schemas.json | 7 ++ .../schema/json/v2/PluginListResponse.json | 7 ++ .../schema/json/v2/PluginReadResponse.json | 7 ++ .../json/v2/PluginShareListResponse.json | 7 ++ .../schema/typescript/v2/PluginSummary.ts | 6 +- .../src/protocol/v2/plugin.rs | 2 + .../src/protocol/v2/tests.rs | 8 ++- .../src/request_processors/plugins.rs | 4 +- .../app-server/tests/suite/v2/plugin_list.rs | 35 +++++++--- .../app-server/tests/suite/v2/plugin_read.rs | 18 +++-- .../app-server/tests/suite/v2/plugin_share.rs | 9 ++- .../tests/suite/v2/plugin_uninstall.rs | 8 ++- codex-rs/core-plugins/src/remote.rs | 67 +++++++++++++------ .../remote/remote_installed_plugin_sync.rs | 53 ++++++++++++++- .../core-plugins/src/remote/share/tests.rs | 7 +- codex-rs/tui/src/chatwidget/tests/helpers.rs | 1 + 17 files changed, 209 insertions(+), 44 deletions(-) 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 4e061f8591..9934e36940 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 @@ -12708,6 +12708,13 @@ "name": { "type": "string" }, + "remotePluginId": { + "description": "Backend remote plugin identifier when available.", + "type": [ + "string", + "null" + ] + }, "shareContext": { "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 c7130126f9..1926b22091 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 @@ -9257,6 +9257,13 @@ "name": { "type": "string" }, + "remotePluginId": { + "description": "Backend remote plugin identifier when available.", + "type": [ + "string", + "null" + ] + }, "shareContext": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v2/PluginListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/PluginListResponse.json index 473d1a2a5d..72b1f7c9aa 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/PluginListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginListResponse.json @@ -452,6 +452,13 @@ "name": { "type": "string" }, + "remotePluginId": { + "description": "Backend remote plugin identifier when available.", + "type": [ + "string", + "null" + ] + }, "shareContext": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json b/codex-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json index d4473d96ac..945b4897cb 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginReadResponse.json @@ -506,6 +506,13 @@ "name": { "type": "string" }, + "remotePluginId": { + "description": "Backend remote plugin identifier when available.", + "type": [ + "string", + "null" + ] + }, "shareContext": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/json/v2/PluginShareListResponse.json b/codex-rs/app-server-protocol/schema/json/v2/PluginShareListResponse.json index ae70ed5cdd..ed2beacada 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/PluginShareListResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/PluginShareListResponse.json @@ -408,6 +408,13 @@ "name": { "type": "string" }, + "remotePluginId": { + "description": "Backend remote plugin identifier when available.", + "type": [ + "string", + "null" + ] + }, "shareContext": { "anyOf": [ { diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PluginSummary.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PluginSummary.ts index d855f3d31c..8fae7ae865 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/PluginSummary.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/PluginSummary.ts @@ -8,7 +8,11 @@ import type { PluginInterface } from "./PluginInterface"; import type { PluginShareContext } from "./PluginShareContext"; import type { PluginSource } from "./PluginSource"; -export type PluginSummary = { id: string, name: string, +export type PluginSummary = { id: string, +/** + * Backend remote plugin identifier when available. + */ +remotePluginId: string | null, name: string, /** * Remote sharing context associated with this plugin when available. */ diff --git a/codex-rs/app-server-protocol/src/protocol/v2/plugin.rs b/codex-rs/app-server-protocol/src/protocol/v2/plugin.rs index ed03cc6ff3..82568446b7 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/plugin.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/plugin.rs @@ -538,6 +538,8 @@ pub enum PluginAvailability { #[ts(export_to = "v2/")] pub struct PluginSummary { pub id: String, + /// Backend remote plugin identifier when available. + pub remote_plugin_id: Option, pub name: String, /// Remote sharing context associated with this plugin when available. pub share_context: Option, diff --git a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs index 30599776ae..73b22cecb4 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs @@ -3003,7 +3003,10 @@ fn plugin_share_list_response_serializes_share_items() { serde_json::to_value(PluginShareListResponse { data: vec![PluginShareListItem { plugin: PluginSummary { - id: "plugins~Plugin_00000000000000000000000000000000".to_string(), + id: "gmail@chatgpt-global".to_string(), + remote_plugin_id: Some( + "plugins~Plugin_00000000000000000000000000000000".to_string(), + ), name: "gmail".to_string(), share_context: None, source: PluginSource::Remote, @@ -3022,7 +3025,8 @@ fn plugin_share_list_response_serializes_share_items() { json!({ "data": [{ "plugin": { - "id": "plugins~Plugin_00000000000000000000000000000000", + "id": "gmail@chatgpt-global", + "remotePluginId": "plugins~Plugin_00000000000000000000000000000000", "name": "gmail", "shareContext": null, "source": { "type": "remote" }, diff --git a/codex-rs/app-server/src/request_processors/plugins.rs b/codex-rs/app-server/src/request_processors/plugins.rs index 23d76d55b7..125accad82 100644 --- a/codex-rs/app-server/src/request_processors/plugins.rs +++ b/codex-rs/app-server/src/request_processors/plugins.rs @@ -482,6 +482,7 @@ impl PluginRequestProcessor { ); PluginSummary { id: plugin.id, + remote_plugin_id: None, installed: plugin.installed, enabled: plugin.enabled, name: plugin.name, @@ -723,6 +724,7 @@ impl PluginRequestProcessor { marketplace_path: outcome.marketplace_path, summary: PluginSummary { id: outcome.plugin.id, + remote_plugin_id: None, name: outcome.plugin.name, share_context, source: marketplace_plugin_source_to_info(outcome.plugin.source), @@ -1107,7 +1109,6 @@ impl PluginRequestProcessor { ) })?; if remote_detail.summary.availability == PluginAvailability::DisabledByAdmin { - let remote_plugin_id = &remote_detail.summary.id; return Err(invalid_request(format!( "remote plugin {remote_plugin_id} is disabled by admin" ))); @@ -1587,6 +1588,7 @@ fn remote_marketplace_to_info(marketplace: RemoteMarketplace) -> PluginMarketpla fn remote_plugin_summary_to_info(summary: RemoteCatalogPluginSummary) -> PluginSummary { PluginSummary { id: summary.id, + remote_plugin_id: Some(summary.remote_plugin_id), name: summary.name, share_context: summary .share_context diff --git a/codex-rs/app-server/tests/suite/v2/plugin_list.rs b/codex-rs/app-server/tests/suite/v2/plugin_list.rs index 8b8e104a03..6576ba87b9 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_list.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_list.rs @@ -240,6 +240,7 @@ async fn plugin_list_keeps_valid_marketplaces_when_another_marketplace_fails_to_ interface: None, plugins: vec![PluginSummary { id: "valid-plugin@valid-marketplace".to_string(), + remote_plugin_id: None, name: "valid-plugin".to_string(), share_context: None, source: PluginSource::Local { @@ -529,6 +530,7 @@ async fn plugin_list_uses_alternate_discoverable_manifest_and_keeps_undiscoverab plugins: vec![ PluginSummary { id: "valid-plugin@alternate-marketplace".to_string(), + remote_plugin_id: None, name: "valid-plugin".to_string(), share_context: None, source: PluginSource::Local { @@ -562,6 +564,7 @@ async fn plugin_list_uses_alternate_discoverable_manifest_and_keeps_undiscoverab }, PluginSummary { id: "missing-plugin@alternate-marketplace".to_string(), + remote_plugin_id: None, name: "missing-plugin".to_string(), share_context: None, source: PluginSource::Local { @@ -688,6 +691,7 @@ async fn plugin_list_returns_share_context_for_shared_local_plugin() -> Result<( .flat_map(|marketplace| marketplace.plugins.iter()) .find(|plugin| plugin.name == "demo-plugin") .expect("expected demo-plugin entry"); + assert_eq!(plugin.remote_plugin_id, None); let share_context = plugin .share_context .as_ref() @@ -1464,11 +1468,7 @@ async fn plugin_list_sync_upgrades_and_removes_remote_installed_plugin_bundles() .into_iter() .map(|plugin| (plugin.id, plugin.installed, plugin.enabled)) .collect::>(), - vec![( - "plugins~Plugin_00000000000000000000000000000000".to_string(), - true, - true - )] + vec![("linear@chatgpt-global".to_string(), true, true)] ); wait_for_path_exists(&new_path.join(".codex-plugin/plugin.json")).await?; @@ -1629,9 +1629,10 @@ async fn plugin_list_includes_remote_marketplaces_when_remote_plugin_enabled() - Some("ChatGPT Plugins") ); assert_eq!(remote_marketplace.plugins.len(), 1); + assert_eq!(remote_marketplace.plugins[0].id, "linear@chatgpt-global"); assert_eq!( - remote_marketplace.plugins[0].id, - "plugins~Plugin_00000000000000000000000000000000" + remote_marketplace.plugins[0].remote_plugin_id.as_deref(), + Some("plugins~Plugin_00000000000000000000000000000000") ); assert_eq!(remote_marketplace.plugins[0].name, "linear"); assert_eq!(remote_marketplace.plugins[0].source, PluginSource::Remote); @@ -1725,12 +1726,14 @@ async fn plugin_list_fetches_workspace_directory_kind_without_remote_plugin_flag "plugins~Plugin_11111111111111111111111111111111", "workspace-linear", "Workspace Linear", + "LISTED", /*enabled*/ None, ); let workspace_installed_body = workspace_remote_plugin_page_body( "plugins~Plugin_11111111111111111111111111111111", "workspace-linear", "Workspace Linear", + "LISTED", /*enabled*/ Some(false), ); mount_remote_plugin_list(&server, "WORKSPACE", &workspace_plugin_body).await; @@ -1764,6 +1767,14 @@ async fn plugin_list_fetches_workspace_directory_kind_without_remote_plugin_flag Some("Workspace Directory") ); assert_eq!(marketplace.plugins.len(), 1); + assert_eq!( + marketplace.plugins[0].id, + "workspace-linear@workspace-directory" + ); + assert_eq!( + marketplace.plugins[0].remote_plugin_id.as_deref(), + Some("plugins~Plugin_11111111111111111111111111111111") + ); assert_eq!(marketplace.plugins[0].name, "workspace-linear"); assert_eq!(marketplace.plugins[0].installed, true); assert_eq!(marketplace.plugins[0].enabled, false); @@ -1803,6 +1814,7 @@ async fn plugin_list_fetches_shared_with_me_kind() -> Result<()> { "plugins~Plugin_22222222222222222222222222222222", "shared-linear", "Shared Linear", + "PRIVATE", /*enabled*/ None, ))?; shared_plugin_body["plugins"][0]["share_principals"] = serde_json::Value::Null; @@ -1811,6 +1823,7 @@ async fn plugin_list_fetches_shared_with_me_kind() -> Result<()> { "plugins~Plugin_22222222222222222222222222222222", "shared-linear", "Shared Linear", + "PRIVATE", /*enabled*/ Some(true), ); mount_shared_workspace_plugins(&server, &shared_plugin_body).await; @@ -1844,6 +1857,11 @@ async fn plugin_list_fetches_shared_with_me_kind() -> Result<()> { Some("Shared with me") ); assert_eq!(marketplace.plugins.len(), 1); + assert_eq!(marketplace.plugins[0].id, "shared-linear@shared-with-me"); + assert_eq!( + marketplace.plugins[0].remote_plugin_id.as_deref(), + Some("plugins~Plugin_22222222222222222222222222222222") + ); assert_eq!(marketplace.plugins[0].name, "shared-linear"); assert_eq!(marketplace.plugins[0].installed, true); assert_eq!(marketplace.plugins[0].enabled, true); @@ -2381,6 +2399,7 @@ fn workspace_remote_plugin_page_body( remote_plugin_id: &str, plugin_name: &str, display_name: &str, + discoverability: &str, enabled: Option, ) -> String { let enabled_field = enabled @@ -2393,7 +2412,7 @@ fn workspace_remote_plugin_page_body( "id": "{remote_plugin_id}", "name": "{plugin_name}", "scope": "WORKSPACE", - "discoverability": "PRIVATE", + "discoverability": "{discoverability}", "creator_account_user_id": "user-gavin__account-123", "share_url": "https://chatgpt.example/plugins/share/share-key-1", "installation_policy": "AVAILABLE", diff --git a/codex-rs/app-server/tests/suite/v2/plugin_read.rs b/codex-rs/app-server/tests/suite/v2/plugin_read.rs index 769749b34b..62294b2f95 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_read.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_read.rs @@ -209,9 +209,10 @@ plugins = true let response: PluginReadResponse = to_response(response)?; assert_eq!(response.plugin.marketplace_name, "chatgpt-global"); + assert_eq!(response.plugin.summary.id, "linear@chatgpt-global"); assert_eq!( - response.plugin.summary.id, - "plugins~Plugin_00000000000000000000000000000000" + response.plugin.summary.remote_plugin_id.as_deref(), + Some("plugins~Plugin_00000000000000000000000000000000") ); assert_eq!(response.plugin.summary.name, "linear"); assert_eq!(response.plugin.summary.source, PluginSource::Remote); @@ -313,6 +314,12 @@ async fn plugin_read_returns_share_context_for_shared_remote_plugin() -> Result< .await??; let response: PluginReadResponse = to_response(response)?; + assert_eq!(response.plugin.marketplace_name, "shared-with-me"); + assert_eq!(response.plugin.summary.id, "shared-linear@shared-with-me"); + assert_eq!( + response.plugin.summary.remote_plugin_id.as_deref(), + Some("plugins~Plugin_11111111111111111111111111111111") + ); let share_context = response .plugin .summary @@ -482,9 +489,10 @@ async fn plugin_read_reads_remote_plugin_details_when_remote_plugin_enabled() -> assert_eq!(response.plugin.marketplace_name, "chatgpt-global"); assert_eq!(response.plugin.marketplace_path, None); assert_eq!(response.plugin.summary.source, PluginSource::Remote); + assert_eq!(response.plugin.summary.id, "linear@chatgpt-global"); assert_eq!( - response.plugin.summary.id, - "plugins~Plugin_00000000000000000000000000000000" + response.plugin.summary.remote_plugin_id.as_deref(), + Some("plugins~Plugin_00000000000000000000000000000000") ); assert_eq!(response.plugin.summary.name, "linear"); assert_eq!(response.plugin.summary.installed, true); @@ -856,6 +864,7 @@ async fn plugin_read_returns_share_context_for_shared_local_plugin() -> Result<( .await??; let response: PluginReadResponse = to_response(response)?; + assert_eq!(response.plugin.summary.remote_plugin_id, None); let share_context = response .plugin .summary @@ -931,6 +940,7 @@ async fn plugin_read_falls_back_to_local_share_context_without_remote_auth() -> .await??; let response: PluginReadResponse = to_response(response)?; + assert_eq!(response.plugin.summary.remote_plugin_id, None); let share_context = response .plugin .summary diff --git a/codex-rs/app-server/tests/suite/v2/plugin_share.rs b/codex-rs/app-server/tests/suite/v2/plugin_share.rs index b081017ad4..46f3dceb5e 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_share.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_share.rs @@ -162,7 +162,8 @@ async fn plugin_share_save_uploads_local_plugin() -> Result<()> { PluginShareListResponse { data: vec![PluginShareListItem { plugin: PluginSummary { - id: "plugins_123".to_string(), + id: "demo-plugin@shared-with-me".to_string(), + remote_plugin_id: Some("plugins_123".to_string()), name: "demo-plugin".to_string(), share_context: Some(expected_share_context("plugins_123")), source: PluginSource::Remote, @@ -506,7 +507,8 @@ async fn plugin_share_list_returns_created_workspace_plugins() -> Result<()> { PluginShareListResponse { data: vec![PluginShareListItem { plugin: PluginSummary { - id: "plugins_123".to_string(), + id: "demo-plugin@shared-with-me".to_string(), + remote_plugin_id: Some("plugins_123".to_string()), name: "demo-plugin".to_string(), share_context: Some(expected_share_context("plugins_123")), source: PluginSource::Remote, @@ -725,7 +727,8 @@ async fn plugin_share_delete_removes_created_workspace_plugin() -> Result<()> { PluginShareListResponse { data: vec![PluginShareListItem { plugin: PluginSummary { - id: "plugins_123".to_string(), + id: "demo-plugin@shared-with-me".to_string(), + remote_plugin_id: Some("plugins_123".to_string()), name: "demo-plugin".to_string(), share_context: Some(expected_share_context("plugins_123")), source: PluginSource::Remote, diff --git a/codex-rs/app-server/tests/suite/v2/plugin_uninstall.rs b/codex-rs/app-server/tests/suite/v2/plugin_uninstall.rs index 5679234d2b..8fdf149a01 100644 --- a/codex-rs/app-server/tests/suite/v2/plugin_uninstall.rs +++ b/codex-rs/app-server/tests/suite/v2/plugin_uninstall.rs @@ -617,11 +617,17 @@ async fn mount_remote_plugin_detail_with_name( release_version: &str, scope: &str, ) { + let discoverability = if scope == "WORKSPACE" { + r#" + "discoverability": "LISTED","# + } else { + "" + }; let detail_body = format!( r#"{{ "id": "{remote_plugin_id}", "name": "{plugin_name}", - "scope": "{scope}", + "scope": "{scope}",{discoverability} "installation_policy": "AVAILABLE", "authentication_policy": "ON_USE", "release": {{ diff --git a/codex-rs/core-plugins/src/remote.rs b/codex-rs/core-plugins/src/remote.rs index ed05053045..bb0e13a79d 100644 --- a/codex-rs/core-plugins/src/remote.rs +++ b/codex-rs/core-plugins/src/remote.rs @@ -87,6 +87,7 @@ pub struct RemoteInstalledPlugin { #[derive(Debug, Clone, PartialEq)] pub struct RemotePluginSummary { pub id: String, + pub remote_plugin_id: String, pub name: String, pub share_context: Option, pub installed: bool, @@ -379,6 +380,32 @@ struct RemotePluginDirectoryItem { release: RemotePluginReleaseResponse, } +fn remote_plugin_canonical_marketplace_name( + plugin: &RemotePluginDirectoryItem, +) -> Result<&'static str, RemotePluginCatalogError> { + match plugin.scope { + RemotePluginScope::Global => Ok(REMOTE_GLOBAL_MARKETPLACE_NAME), + RemotePluginScope::Workspace => match workspace_plugin_discoverability(plugin)? { + RemotePluginShareDiscoverability::Listed => Ok(REMOTE_WORKSPACE_MARKETPLACE_NAME), + RemotePluginShareDiscoverability::Unlisted + | RemotePluginShareDiscoverability::Private => { + Ok(REMOTE_SHARED_WITH_ME_MARKETPLACE_NAME) + } + }, + } +} + +fn workspace_plugin_discoverability( + plugin: &RemotePluginDirectoryItem, +) -> Result { + plugin.discoverability.ok_or_else(|| { + RemotePluginCatalogError::UnexpectedResponse(format!( + "workspace plugin `{}` did not include discoverability", + plugin.id + )) + }) +} + #[derive(Debug, Clone, PartialEq, Eq, Deserialize)] struct RemotePluginDirectorySharePrincipal { principal_type: RemotePluginSharePrincipalType, @@ -550,12 +577,9 @@ pub async fn fetch_remote_installed_plugins( let (global, workspace) = tokio::try_join!(global, workspace)?; let mut installed_plugins = [global, workspace] .into_iter() - .flat_map(|(scope, plugins)| { - plugins - .into_iter() - .map(move |plugin| remote_installed_plugin_to_info(scope, &plugin)) - }) - .collect::>(); + .flat_map(|(_scope, plugins)| plugins) + .map(|plugin| remote_installed_plugin_to_info(&plugin)) + .collect::, _>>()?; installed_plugins.sort_by(|left, right| { left.marketplace_name .cmp(&right.marketplace_name) @@ -655,7 +679,7 @@ async fn fetch_remote_plugin_detail_with_download_url_option( let auth = ensure_chatgpt_auth(auth)?; let plugin = fetch_plugin_detail(config, auth, plugin_id, include_download_urls).await?; let scope = plugin.scope; - let marketplace_name = scope.marketplace_name().to_string(); + let marketplace_name = remote_plugin_canonical_marketplace_name(&plugin)?.to_string(); // Remote plugin IDs uniquely identify remote plugins, so the caller-provided // marketplace name is not validated here. The backend detail response is the // source of truth for the plugin's actual scope/marketplace. @@ -756,7 +780,7 @@ pub async fn uninstall_remote_plugin( config, auth, plugin_id, /*include_download_urls*/ false, ) .await?; - let marketplace_name = plugin.scope.marketplace_name().to_string(); + let marketplace_name = remote_plugin_canonical_marketplace_name(&plugin)?.to_string(); let plugin_name = plugin.name; let base_url = config.chatgpt_base_url.trim_end_matches('/'); @@ -841,8 +865,17 @@ fn build_remote_plugin_summary( plugin: &RemotePluginDirectoryItem, installed_plugin: Option<&RemotePluginInstalledItem>, ) -> Result { + let marketplace_name = remote_plugin_canonical_marketplace_name(plugin)?; + let plugin_id = + PluginId::new(plugin.name.clone(), marketplace_name.to_string()).map_err(|err| { + RemotePluginCatalogError::UnexpectedResponse(format!( + "invalid remote plugin config id for `{}` in `{marketplace_name}`: {err}", + plugin.name + )) + })?; Ok(RemotePluginSummary { - id: plugin.id.clone(), + id: plugin_id.as_key(), + remote_plugin_id: plugin.id.clone(), name: plugin.name.clone(), share_context: remote_plugin_share_context(plugin)?, installed: installed_plugin.is_some(), @@ -861,12 +894,7 @@ fn remote_plugin_share_context( match plugin.scope { RemotePluginScope::Global => Ok(None), RemotePluginScope::Workspace => { - let discoverability = plugin.discoverability.ok_or_else(|| { - RemotePluginCatalogError::UnexpectedResponse(format!( - "workspace plugin `{}` did not include discoverability", - plugin.id - )) - })?; + let discoverability = workspace_plugin_discoverability(plugin)?; Ok(Some(RemotePluginShareContext { remote_plugin_id: plugin.id.clone(), discoverability, @@ -890,19 +918,18 @@ fn remote_plugin_share_context( } fn remote_installed_plugin_to_info( - scope: RemotePluginScope, installed_plugin: &RemotePluginInstalledItem, -) -> RemoteInstalledPlugin { +) -> Result { let plugin = &installed_plugin.plugin; // Remote per-skill disabled state (`disabled_skill_names`) is intentionally // not projected into skills/list yet; local skills.config remains the // supported source for skill enablement. - RemoteInstalledPlugin { - marketplace_name: scope.marketplace_name().to_string(), + Ok(RemoteInstalledPlugin { + marketplace_name: remote_plugin_canonical_marketplace_name(plugin)?.to_string(), id: plugin.id.clone(), name: plugin.name.clone(), enabled: installed_plugin.enabled, - } + }) } fn remote_plugin_interface_to_info(plugin: &RemotePluginDirectoryItem) -> Option { diff --git a/codex-rs/core-plugins/src/remote/remote_installed_plugin_sync.rs b/codex-rs/core-plugins/src/remote/remote_installed_plugin_sync.rs index 4e5caca294..26cdce343a 100644 --- a/codex-rs/core-plugins/src/remote/remote_installed_plugin_sync.rs +++ b/codex-rs/core-plugins/src/remote/remote_installed_plugin_sync.rs @@ -1,10 +1,12 @@ use super::REMOTE_GLOBAL_MARKETPLACE_NAME; +use super::REMOTE_SHARED_WITH_ME_MARKETPLACE_NAME; use super::REMOTE_WORKSPACE_MARKETPLACE_NAME; use super::RemotePluginCatalogError; use super::RemotePluginScope; use super::RemotePluginServiceConfig; use super::ensure_chatgpt_auth; use super::fetch_installed_plugins_for_scope_with_download_url; +use super::remote_plugin_canonical_marketplace_name; use crate::store::PLUGINS_CACHE_DIR; use crate::store::PluginStore; use crate::store::PluginStoreError; @@ -150,14 +152,18 @@ pub async fn sync_remote_installed_plugin_bundles_once( REMOTE_WORKSPACE_MARKETPLACE_NAME.to_string(), BTreeSet::new(), ), + ( + REMOTE_SHARED_WITH_ME_MARKETPLACE_NAME.to_string(), + BTreeSet::new(), + ), ]); let mut installed_plugin_ids = BTreeSet::new(); let mut failed_remote_plugin_ids = BTreeSet::new(); - for (scope, installed_plugins) in [global, workspace] { - let marketplace_name = scope.marketplace_name().to_string(); + for (_scope, installed_plugins) in [global, workspace] { for installed_plugin in installed_plugins { let plugin = installed_plugin.plugin; + let marketplace_name = remote_plugin_canonical_marketplace_name(&plugin)?.to_string(); installed_plugin_names_by_marketplace .entry(marketplace_name.clone()) .or_default() @@ -292,6 +298,7 @@ fn remove_stale_remote_plugin_caches( for marketplace_name in [ REMOTE_GLOBAL_MARKETPLACE_NAME, REMOTE_WORKSPACE_MARKETPLACE_NAME, + REMOTE_SHARED_WITH_ME_MARKETPLACE_NAME, ] { let marketplace_root = codex_home.join(PLUGINS_CACHE_DIR).join(marketplace_name); if !marketplace_root.exists() { @@ -449,6 +456,10 @@ mod tests { REMOTE_WORKSPACE_MARKETPLACE_NAME.to_string(), BTreeSet::new(), ), + ( + REMOTE_SHARED_WITH_ME_MARKETPLACE_NAME.to_string(), + BTreeSet::new(), + ), ]); let guard = mark_remote_plugin_cache_mutation_in_flight( @@ -487,4 +498,42 @@ mod tests { assert_eq!(removed, vec!["linear@chatgpt-global".to_string()]); assert!(!cached_manifest.exists()); } + + #[test] + fn stale_remote_plugin_cleanup_removes_shared_with_me_cache() { + let codex_home = tempfile::tempdir().expect("create codex home"); + let cached_manifest = codex_home + .path() + .join(PLUGINS_CACHE_DIR) + .join(REMOTE_SHARED_WITH_ME_MARKETPLACE_NAME) + .join("private-plugin") + .join("1.2.3") + .join(".codex-plugin") + .join("plugin.json"); + std::fs::create_dir_all(cached_manifest.parent().expect("manifest parent")) + .expect("create cached plugin manifest parent"); + std::fs::write(&cached_manifest, r#"{"name":"private-plugin"}"#) + .expect("write cached plugin manifest"); + let installed_plugin_names_by_marketplace = + BTreeMap::>::from_iter([ + (REMOTE_GLOBAL_MARKETPLACE_NAME.to_string(), BTreeSet::new()), + ( + REMOTE_WORKSPACE_MARKETPLACE_NAME.to_string(), + BTreeSet::new(), + ), + ( + REMOTE_SHARED_WITH_ME_MARKETPLACE_NAME.to_string(), + BTreeSet::new(), + ), + ]); + + let removed = remove_stale_remote_plugin_caches( + codex_home.path(), + &installed_plugin_names_by_marketplace, + ) + .expect("cleanup shared-with-me cache"); + + assert_eq!(removed, vec!["private-plugin@shared-with-me".to_string()]); + assert!(!cached_manifest.exists()); + } } diff --git a/codex-rs/core-plugins/src/remote/share/tests.rs b/codex-rs/core-plugins/src/remote/share/tests.rs index 8791cde597..8b27b28c3f 100644 --- a/codex-rs/core-plugins/src/remote/share/tests.rs +++ b/codex-rs/core-plugins/src/remote/share/tests.rs @@ -93,6 +93,7 @@ fn remote_plugin_json(plugin_id: &str) -> serde_json::Value { "id": plugin_id, "name": "demo-plugin", "scope": "WORKSPACE", + "discoverability": "PRIVATE", "installation_policy": "AVAILABLE", "authentication_policy": "ON_USE", "release": { @@ -584,7 +585,8 @@ async fn list_remote_plugin_shares_fetches_created_workspace_plugins() { vec![ RemotePluginShareSummary { summary: RemotePluginSummary { - id: "plugins_123".to_string(), + id: "demo-plugin@shared-with-me".to_string(), + remote_plugin_id: "plugins_123".to_string(), name: "demo-plugin".to_string(), share_context: Some(RemotePluginShareContext { remote_plugin_id: "plugins_123".to_string(), @@ -621,7 +623,8 @@ async fn list_remote_plugin_shares_fetches_created_workspace_plugins() { }, RemotePluginShareSummary { summary: RemotePluginSummary { - id: "plugins_456".to_string(), + id: "demo-plugin@shared-with-me".to_string(), + remote_plugin_id: "plugins_456".to_string(), name: "demo-plugin".to_string(), share_context: Some(RemotePluginShareContext { remote_plugin_id: "plugins_456".to_string(), diff --git a/codex-rs/tui/src/chatwidget/tests/helpers.rs b/codex-rs/tui/src/chatwidget/tests/helpers.rs index f11cd64843..f5007e3faf 100644 --- a/codex-rs/tui/src/chatwidget/tests/helpers.rs +++ b/codex-rs/tui/src/chatwidget/tests/helpers.rs @@ -1314,6 +1314,7 @@ pub(super) fn plugins_test_summary( ) -> PluginSummary { PluginSummary { id: id.to_string(), + remote_plugin_id: None, name: name.to_string(), share_context: None, source: PluginSource::Local { From d996f5366f461728019e0906663261a1e010d699 Mon Sep 17 00:00:00 2001 From: jif-oai Date: Tue, 12 May 2026 14:41:45 +0200 Subject: [PATCH 2/6] feat: guardian as an extension (contributors part) (#22216) Part 1 of guardian as extension. This bind all the logic to spawn another agent from an extension and it adds `ThreadId` in the start thread collaborator --- codex-rs/Cargo.lock | 11 +++ codex-rs/Cargo.toml | 2 + codex-rs/app-server/Cargo.toml | 1 + codex-rs/app-server/src/extensions.rs | 32 +++++++- codex-rs/app-server/src/mcp_refresh.rs | 27 ++++--- codex-rs/app-server/src/message_processor.rs | 33 +++++---- codex-rs/core/src/session/session.rs | 1 + codex-rs/core/src/thread_manager.rs | 30 ++++++++ .../extension-api/src/capabilities/agent.rs | 38 ++++++++++ .../ext/extension-api/src/capabilities/mod.rs | 4 + .../ext/extension-api/src/contributors.rs | 13 +++- codex-rs/ext/extension-api/src/lib.rs | 3 + codex-rs/ext/git-attribution/Cargo.toml | 1 + codex-rs/ext/git-attribution/src/lib.rs | 2 + codex-rs/ext/guardian/BUILD.bazel | 6 ++ codex-rs/ext/guardian/Cargo.toml | 19 +++++ codex-rs/ext/guardian/src/lib.rs | 73 +++++++++++++++++++ 17 files changed, 265 insertions(+), 31 deletions(-) create mode 100644 codex-rs/ext/extension-api/src/capabilities/agent.rs create mode 100644 codex-rs/ext/extension-api/src/capabilities/mod.rs create mode 100644 codex-rs/ext/guardian/BUILD.bazel create mode 100644 codex-rs/ext/guardian/Cargo.toml create mode 100644 codex-rs/ext/guardian/src/lib.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index bbb61f8337..e92dbb07da 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -1904,6 +1904,7 @@ dependencies = [ "codex-file-watcher", "codex-git-attribution", "codex-git-utils", + "codex-guardian", "codex-hooks", "codex-login", "codex-mcp", @@ -2906,6 +2907,7 @@ dependencies = [ "codex-core", "codex-extension-api", "codex-features", + "codex-protocol", "pretty_assertions", ] @@ -2933,6 +2935,15 @@ dependencies = [ "walkdir", ] +[[package]] +name = "codex-guardian" +version = "0.0.0" +dependencies = [ + "codex-core", + "codex-extension-api", + "codex-protocol", +] + [[package]] name = "codex-hooks" version = "0.0.0" diff --git a/codex-rs/Cargo.toml b/codex-rs/Cargo.toml index 7e5c647421..5a21e98995 100644 --- a/codex-rs/Cargo.toml +++ b/codex-rs/Cargo.toml @@ -45,6 +45,7 @@ members = [ "execpolicy", "execpolicy-legacy", "ext/extension-api", + "ext/guardian", "ext/git-attribution", "external-agent-migration", "external-agent-sessions", @@ -162,6 +163,7 @@ codex-file-system = { path = "file-system" } codex-exec-server = { path = "exec-server" } codex-execpolicy = { path = "execpolicy" } codex-extension-api = { path = "ext/extension-api" } +codex-guardian = { path = "ext/guardian" } codex-git-attribution = { path = "ext/git-attribution" } codex-external-agent-migration = { path = "external-agent-migration" } codex-external-agent-sessions = { path = "external-agent-sessions" } diff --git a/codex-rs/app-server/Cargo.toml b/codex-rs/app-server/Cargo.toml index 734475f946..fcf731aa9b 100644 --- a/codex-rs/app-server/Cargo.toml +++ b/codex-rs/app-server/Cargo.toml @@ -40,6 +40,7 @@ codex-extension-api = { workspace = true } codex-external-agent-migration = { workspace = true } codex-external-agent-sessions = { workspace = true } codex-features = { workspace = true } +codex-guardian = { workspace = true } codex-git-attribution = { workspace = true } codex-git-utils = { workspace = true } codex-file-watcher = { workspace = true } diff --git a/codex-rs/app-server/src/extensions.rs b/codex-rs/app-server/src/extensions.rs index 9fe468dc05..411685e7d8 100644 --- a/codex-rs/app-server/src/extensions.rs +++ b/codex-rs/app-server/src/extensions.rs @@ -1,11 +1,41 @@ use std::sync::Arc; +use std::sync::Weak; +use codex_core::NewThread; +use codex_core::StartThreadOptions; +use codex_core::ThreadManager; use codex_core::config::Config; +use codex_extension_api::AgentSpawnFuture; +use codex_extension_api::AgentSpawner; use codex_extension_api::ExtensionRegistry; use codex_extension_api::ExtensionRegistryBuilder; +use codex_protocol::ThreadId; +use codex_protocol::error::CodexErr; -pub(crate) fn thread_extensions() -> Arc> { +pub(crate) fn thread_extensions(guardian_agent_spawner: S) -> Arc> +where + S: AgentSpawner + 'static, +{ let mut builder = ExtensionRegistryBuilder::::new(); codex_git_attribution::install(&mut builder); + codex_guardian::install(&mut builder, guardian_agent_spawner); Arc::new(builder.build()) } + +pub(crate) fn guardian_agent_spawner( + thread_manager: Weak, +) -> impl AgentSpawner { + move |forked_from_thread_id: ThreadId, + options: StartThreadOptions| + -> AgentSpawnFuture<'static, NewThread, CodexErr> { + let thread_manager = thread_manager.clone(); + Box::pin(async move { + let thread_manager = thread_manager.upgrade().ok_or_else(|| { + CodexErr::UnsupportedOperation("thread manager dropped".to_string()) + })?; + thread_manager + .spawn_subagent(forked_from_thread_id, options) + .await + }) + } +} diff --git a/codex-rs/app-server/src/mcp_refresh.rs b/codex-rs/app-server/src/mcp_refresh.rs index c085e44cf6..31a6f4afd8 100644 --- a/codex-rs/app-server/src/mcp_refresh.rs +++ b/codex-rs/app-server/src/mcp_refresh.rs @@ -99,6 +99,7 @@ async fn queue_refresh( #[cfg(test)] mod tests { use super::*; + use crate::extensions::guardian_agent_spawner; use crate::extensions::thread_extensions; use async_trait::async_trait; use codex_arg0::Arg0DispatchPaths; @@ -179,18 +180,20 @@ mod tests { .await .expect("refresh tests require state db"); let thread_store = thread_store_from_config(&good_config, Some(state_db.clone())); - let thread_manager = Arc::new(ThreadManager::new( - &good_config, - auth_manager, - SessionSource::Exec, - Arc::new(EnvironmentManager::default_for_tests()), - thread_extensions(), - /*analytics_events_client*/ None, - thread_store, - Some(state_db.clone()), - "11111111-1111-4111-8111-111111111111".to_string(), - /*attestation_provider*/ None, - )); + let thread_manager = Arc::new_cyclic(|thread_manager| { + ThreadManager::new( + &good_config, + auth_manager, + SessionSource::Exec, + Arc::new(EnvironmentManager::default_for_tests()), + thread_extensions(guardian_agent_spawner(thread_manager.clone())), + /*analytics_events_client*/ None, + thread_store, + Some(state_db.clone()), + "11111111-1111-4111-8111-111111111111".to_string(), + /*attestation_provider*/ None, + ) + }); thread_manager.start_thread(good_config).await?; thread_manager.start_thread(bad_config).await?; diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 4897684564..867cd64c99 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -8,6 +8,7 @@ use crate::attestation::app_server_attestation_provider; use crate::config_manager::ConfigManager; use crate::connection_rpc_gate::ConnectionRpcGate; use crate::error_code::invalid_request; +use crate::extensions::guardian_agent_spawner; use crate::extensions::thread_extensions; use crate::fs_watch::FsWatchManager; use crate::outgoing_message::ConnectionId; @@ -298,21 +299,23 @@ impl MessageProcessor { // affect per-thread behavior, but they must not move newly started, // resumed, or forked threads to a different persistence backend/root. let thread_store = codex_core::thread_store_from_config(config.as_ref(), state_db.clone()); - let thread_manager = Arc::new(ThreadManager::new( - config.as_ref(), - auth_manager.clone(), - session_source, - environment_manager, - thread_extensions(), - Some(analytics_events_client.clone()), - Arc::clone(&thread_store), - state_db.clone(), - installation_id, - Some(app_server_attestation_provider( - outgoing.clone(), - thread_state_manager.clone(), - )), - )); + let thread_manager = Arc::new_cyclic(|thread_manager| { + ThreadManager::new( + config.as_ref(), + auth_manager.clone(), + session_source, + environment_manager, + thread_extensions(guardian_agent_spawner(thread_manager.clone())), + Some(analytics_events_client.clone()), + Arc::clone(&thread_store), + state_db.clone(), + installation_id, + Some(app_server_attestation_provider( + outgoing.clone(), + thread_state_manager.clone(), + )), + ) + }); thread_manager .plugins_manager() .set_analytics_events_client(analytics_events_client.clone()); diff --git a/codex-rs/core/src/session/session.rs b/codex-rs/core/src/session/session.rs index 32aaf8fb0b..747a6f5b4f 100644 --- a/codex-rs/core/src/session/session.rs +++ b/codex-rs/core/src/session/session.rs @@ -815,6 +815,7 @@ impl Session { let thread_extension_data = codex_extension_api::ExtensionData::new(); for contributor in extensions.thread_start_contributors() { contributor.contribute( + thread_id, config.as_ref(), &session_extension_data, &thread_extension_data, diff --git a/codex-rs/core/src/thread_manager.rs b/codex-rs/core/src/thread_manager.rs index 83bfe3b3d1..27cc600213 100644 --- a/codex-rs/core/src/thread_manager.rs +++ b/codex-rs/core/src/thread_manager.rs @@ -567,6 +567,36 @@ impl ThreadManager { .await } + // TODO(jif) merge with fork_agent + /// Spawn a subagent by forking persisted history from `forked_from_thread_id`. + pub async fn spawn_subagent( + &self, + forked_from_thread_id: ThreadId, + mut options: StartThreadOptions, + ) -> CodexResult { + let fork_source = self.get_thread(forked_from_thread_id).await?; + // Persist queued rollout updates before reading the fork snapshot. + fork_source.ensure_rollout_materialized().await; + fork_source.flush_rollout().await?; + let stored_thread = fork_source + .read_thread( + /*include_archived*/ true, /*include_history*/ true, + ) + .await + .map_err(|err| { + CodexErr::Fatal(format!( + "failed to read subagent fork source {forked_from_thread_id}: {err}" + )) + })?; + let history = stored_thread_to_initial_history(stored_thread, fork_source.rollout_path())?; + options.initial_history = fork_history_from_snapshot( + ForkSnapshot::Interrupted, + history, + InterruptedTurnHistoryMarker::from_config(&options.config), + ); + self.start_thread_with_options(options).await + } + pub async fn resume_thread_from_rollout( &self, config: Config, diff --git a/codex-rs/ext/extension-api/src/capabilities/agent.rs b/codex-rs/ext/extension-api/src/capabilities/agent.rs new file mode 100644 index 0000000000..3ecdfdf5fd --- /dev/null +++ b/codex-rs/ext/extension-api/src/capabilities/agent.rs @@ -0,0 +1,38 @@ +use std::future::Future; +use std::pin::Pin; + +use codex_protocol::ThreadId; + +/// Future returned by one injected subagent-spawn helper. +pub type AgentSpawnFuture<'a, T, E> = Pin> + Send + 'a>>; + +/// Constructor-injected host helper for extensions that need to spawn subagents. +/// +/// The extension owns the request shape and resulting handle types. The host +/// provides the implementation when it constructs the extension. +pub trait AgentSpawner: Send + Sync { + type Spawned; + type Error; + + fn spawn_subagent<'a>( + &'a self, + forked_from_thread_id: ThreadId, + request: R, + ) -> AgentSpawnFuture<'a, Self::Spawned, Self::Error>; +} + +impl AgentSpawner for F +where + F: Fn(ThreadId, R) -> AgentSpawnFuture<'static, S, E> + Send + Sync, +{ + type Spawned = S; + type Error = E; + + fn spawn_subagent<'a>( + &'a self, + forked_from_thread_id: ThreadId, + request: R, + ) -> AgentSpawnFuture<'a, Self::Spawned, Self::Error> { + self(forked_from_thread_id, request) + } +} diff --git a/codex-rs/ext/extension-api/src/capabilities/mod.rs b/codex-rs/ext/extension-api/src/capabilities/mod.rs new file mode 100644 index 0000000000..bcc33da975 --- /dev/null +++ b/codex-rs/ext/extension-api/src/capabilities/mod.rs @@ -0,0 +1,4 @@ +mod agent; + +pub use agent::AgentSpawnFuture; +pub use agent::AgentSpawner; diff --git a/codex-rs/ext/extension-api/src/contributors.rs b/codex-rs/ext/extension-api/src/contributors.rs index 4475e9271d..18ca7b0299 100644 --- a/codex-rs/ext/extension-api/src/contributors.rs +++ b/codex-rs/ext/extension-api/src/contributors.rs @@ -1,5 +1,6 @@ use std::future::Future; +use codex_protocol::ThreadId; use codex_protocol::items::TurnItem; use codex_tool_api::ToolBundle; @@ -10,10 +11,16 @@ mod prompt; pub use prompt::PromptFragment; pub use prompt::PromptSlot; -/// Contributor that receives host-owned thread-start input before later -/// contributors read from extension stores. +/// Contributor that receives the live thread id and host-owned thread-start +/// input before later contributors read from extension stores. pub trait ThreadStartContributor: Send + Sync { - fn contribute(&self, input: &C, session_store: &ExtensionData, thread_store: &ExtensionData); + fn contribute( + &self, + thread_id: ThreadId, + input: &C, + session_store: &ExtensionData, + thread_store: &ExtensionData, + ); } /// Extension contribution that adds prompt fragments during prompt assembly. diff --git a/codex-rs/ext/extension-api/src/lib.rs b/codex-rs/ext/extension-api/src/lib.rs index 7c9e412fd6..3c9ee38a02 100644 --- a/codex-rs/ext/extension-api/src/lib.rs +++ b/codex-rs/ext/extension-api/src/lib.rs @@ -1,7 +1,10 @@ +mod capabilities; mod contributors; mod registry; mod state; +pub use capabilities::AgentSpawnFuture; +pub use capabilities::AgentSpawner; pub use codex_tool_api::FunctionToolSpec; pub use codex_tool_api::ToolBundle; pub use codex_tool_api::ToolCall; diff --git a/codex-rs/ext/git-attribution/Cargo.toml b/codex-rs/ext/git-attribution/Cargo.toml index edf8c07196..500ce47aec 100644 --- a/codex-rs/ext/git-attribution/Cargo.toml +++ b/codex-rs/ext/git-attribution/Cargo.toml @@ -16,6 +16,7 @@ workspace = true codex-core = { workspace = true } codex-extension-api = { workspace = true } codex-features = { workspace = true } +codex-protocol = { workspace = true } [dev-dependencies] pretty_assertions = { workspace = true } diff --git a/codex-rs/ext/git-attribution/src/lib.rs b/codex-rs/ext/git-attribution/src/lib.rs index bb73a83011..eae906fa56 100644 --- a/codex-rs/ext/git-attribution/src/lib.rs +++ b/codex-rs/ext/git-attribution/src/lib.rs @@ -7,6 +7,7 @@ use codex_extension_api::ExtensionRegistryBuilder; use codex_extension_api::PromptFragment; use codex_extension_api::ThreadStartContributor; use codex_features::Feature; +use codex_protocol::ThreadId; const DEFAULT_ATTRIBUTION_VALUE: &str = "Codex "; @@ -42,6 +43,7 @@ struct GitAttributionConfig { impl ThreadStartContributor for GitAttributionExtension { fn contribute( &self, + _thread_id: ThreadId, config: &Config, _session_store: &ExtensionData, thread_store: &ExtensionData, diff --git a/codex-rs/ext/guardian/BUILD.bazel b/codex-rs/ext/guardian/BUILD.bazel new file mode 100644 index 0000000000..adcbb090bc --- /dev/null +++ b/codex-rs/ext/guardian/BUILD.bazel @@ -0,0 +1,6 @@ +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + name = "guardian", + crate_name = "codex_guardian", +) diff --git a/codex-rs/ext/guardian/Cargo.toml b/codex-rs/ext/guardian/Cargo.toml new file mode 100644 index 0000000000..513254b7cf --- /dev/null +++ b/codex-rs/ext/guardian/Cargo.toml @@ -0,0 +1,19 @@ +[package] +edition.workspace = true +license.workspace = true +name = "codex-guardian" +version.workspace = true + +[lib] +name = "codex_guardian" +path = "src/lib.rs" +test = false +doctest = false + +[lints] +workspace = true + +[dependencies] +codex-core = { workspace = true } +codex-extension-api = { workspace = true } +codex-protocol = { workspace = true } diff --git a/codex-rs/ext/guardian/src/lib.rs b/codex-rs/ext/guardian/src/lib.rs new file mode 100644 index 0000000000..d8c9834d60 --- /dev/null +++ b/codex-rs/ext/guardian/src/lib.rs @@ -0,0 +1,73 @@ +use std::sync::Arc; + +use codex_core::config::Config; +use codex_extension_api::AgentSpawnFuture; +use codex_extension_api::AgentSpawner; +use codex_extension_api::ExtensionData; +use codex_extension_api::ExtensionRegistryBuilder; +use codex_extension_api::ThreadStartContributor; +use codex_protocol::ThreadId; + +/// Guardian extension dependencies supplied by the host at construction time. +#[derive(Clone, Debug)] +pub struct GuardianExtension { + agent_spawner: S, +} + +impl GuardianExtension { + /// Creates a guardian extension with its host-provided agent spawn helper. + pub fn new(agent_spawner: S) -> Self { + Self { agent_spawner } + } + + /// Delegates one guardian-owned subagent spawn request to the host helper. + pub fn spawn_subagent<'a, R>( + &'a self, + forked_from_thread_id: ThreadId, + request: R, + ) -> AgentSpawnFuture<'a, >::Spawned, >::Error> + where + S: AgentSpawner, + { + self.agent_spawner + .spawn_subagent(forked_from_thread_id, request) + } +} + +/// Thread-local guardian state captured when the host starts a thread. +#[derive(Clone, Copy, Debug)] +pub struct GuardianThreadContext { + forked_from_thread_id: ThreadId, +} + +impl GuardianThreadContext { + /// Returns the thread that future guardian subagents should fork from by default. + pub fn forked_from_thread_id(&self) -> ThreadId { + self.forked_from_thread_id + } +} + +impl ThreadStartContributor for GuardianExtension +where + S: Send + Sync, +{ + fn contribute( + &self, + thread_id: ThreadId, + _input: &Config, + _session_store: &ExtensionData, + thread_store: &ExtensionData, + ) { + thread_store.insert(GuardianThreadContext { + forked_from_thread_id: thread_id, + }); + } +} + +/// Installs the guardian contributors into the extension registry. +pub fn install(registry: &mut ExtensionRegistryBuilder, agent_spawner: S) +where + S: Send + Sync + 'static, +{ + registry.thread_start_contributor(Arc::new(GuardianExtension::new(agent_spawner))); +} From cb55b769d13003bc2b9ddb75466e55e1248f79ec Mon Sep 17 00:00:00 2001 From: cassirer-openai Date: Tue, 12 May 2026 05:55:11 -0700 Subject: [PATCH 3/6] [rollout-trace] Add x-codex-inference-call-id header to inference calls. (#22311) This allows us to attach call logs to inference requests in traces. --- codex-rs/Cargo.lock | 1 + codex-rs/core/src/client.rs | 3 +- codex-rs/rollout-trace/Cargo.toml | 3 +- codex-rs/rollout-trace/src/inference.rs | 71 +++++++++++++++++++++++-- 4 files changed, 72 insertions(+), 6 deletions(-) diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index e92dbb07da..efd7d58d5a 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -3514,6 +3514,7 @@ dependencies = [ "anyhow", "codex-code-mode", "codex-protocol", + "http 1.4.0", "pretty_assertions", "serde", "serde_json", diff --git a/codex-rs/core/src/client.rs b/codex-rs/core/src/client.rs index 9c0a7d742f..545d131c69 100644 --- a/codex-rs/core/src/client.rs +++ b/codex-rs/core/src/client.rs @@ -1256,7 +1256,7 @@ impl ModelClientSession { self.client.state.auth_env_telemetry.clone(), ); let compression = self.responses_request_compression(client_setup.auth.as_ref()); - let options = self + let mut options = self .build_responses_options(turn_metadata_header, compression) .await; @@ -1269,6 +1269,7 @@ impl ModelClientSession { service_tier.clone(), )?; let inference_trace_attempt = inference_trace.start_attempt(); + inference_trace_attempt.add_request_headers(&mut options.extra_headers); inference_trace_attempt.record_started(&request); let client = ApiResponsesClient::new( transport, diff --git a/codex-rs/rollout-trace/Cargo.toml b/codex-rs/rollout-trace/Cargo.toml index a743046115..b368c9acc5 100644 --- a/codex-rs/rollout-trace/Cargo.toml +++ b/codex-rs/rollout-trace/Cargo.toml @@ -16,10 +16,11 @@ workspace = true anyhow = { workspace = true } codex-code-mode = { workspace = true } codex-protocol = { workspace = true } +http = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } tracing = { workspace = true } -uuid = { workspace = true } +uuid = { workspace = true, features = ["v4"] } [dev-dependencies] pretty_assertions = { workspace = true } diff --git a/codex-rs/rollout-trace/src/inference.rs b/codex-rs/rollout-trace/src/inference.rs index b05f891e7b..20366c2c70 100644 --- a/codex-rs/rollout-trace/src/inference.rs +++ b/codex-rs/rollout-trace/src/inference.rs @@ -7,13 +7,15 @@ use std::fmt::Display; use std::sync::Arc; use std::sync::atomic::AtomicBool; -use std::sync::atomic::AtomicU64; use std::sync::atomic::Ordering; use codex_protocol::models::ResponseItem; use codex_protocol::protocol::TokenUsage; +use http::HeaderMap; +use http::HeaderValue; use serde::Serialize; use serde_json::Value as JsonValue; +use uuid::Uuid; use crate::model::AgentThreadId; use crate::model::CodexTurnId; @@ -23,7 +25,7 @@ use crate::raw_event::RawTraceEventContext; use crate::raw_event::RawTraceEventPayload; use crate::writer::TraceWriter; -static NEXT_INFERENCE_ATTEMPT: AtomicU64 = AtomicU64::new(1); +const INFERENCE_CALL_ID_HEADER: &str = "x-codex-inference-call-id"; /// Turn-local inference tracing context. /// @@ -140,6 +142,30 @@ impl InferenceTraceAttempt { } } + fn inference_call_id(&self) -> Option<&str> { + match &self.state { + InferenceTraceAttemptState::Disabled => None, + InferenceTraceAttemptState::Enabled(attempt) => { + Some(attempt.inference_call_id.as_str()) + } + } + } + + /// Adds rollout-trace propagation headers for this attempt when tracing is enabled. + pub fn add_request_headers(&self, headers: &mut HeaderMap) { + let Some(inference_call_id) = self.inference_call_id() else { + return; + }; + let Ok(inference_call_id) = HeaderValue::from_str(inference_call_id) else { + // These IDs are generated internally as UUID strings, so rejection + // should be impossible in practice. Tracing remains best-effort, + // though, and must never make provider requests fail. + return; + }; + + headers.insert(INFERENCE_CALL_ID_HEADER, inference_call_id); + } + /// Records the exact request object about to be sent to the model provider. pub fn record_started(&self, request: &impl Serialize) { let InferenceTraceAttemptState::Enabled(attempt) = &self.state else { @@ -315,8 +341,7 @@ pub(crate) fn trace_response_item_json(item: &ResponseItem) -> JsonValue { } fn next_inference_call_id() -> InferenceCallId { - let ordinal = NEXT_INFERENCE_ATTEMPT.fetch_add(1, Ordering::Relaxed); - format!("inference:{ordinal}") + Uuid::new_v4().to_string() } fn write_json_payload_best_effort( @@ -372,6 +397,44 @@ mod tests { use crate::model::ExecutionStatus; use crate::replay_bundle; + #[test] + fn disabled_attempt_adds_no_request_headers() { + let mut headers = HeaderMap::new(); + + InferenceTraceAttempt::disabled().add_request_headers(&mut headers); + + assert!(headers.is_empty()); + } + + #[test] + fn enabled_attempt_adds_inference_request_header() -> anyhow::Result<()> { + let temp = TempDir::new()?; + let writer = Arc::new(TraceWriter::create( + temp.path(), + "trace-1".to_string(), + "rollout-1".to_string(), + "thread-root".to_string(), + )?); + let context = InferenceTraceContext::enabled( + writer, + "thread-root".to_string(), + "turn-1".to_string(), + "gpt-test".to_string(), + "test-provider".to_string(), + ); + let attempt = context.start_attempt(); + let mut headers = HeaderMap::new(); + + attempt.add_request_headers(&mut headers); + + let header = headers + .get(INFERENCE_CALL_ID_HEADER) + .expect("inference header present"); + assert_eq!(Some(header.to_str()?), attempt.inference_call_id()); + assert!(Uuid::parse_str(header.to_str()?).is_ok()); + Ok(()) + } + #[test] fn enabled_context_records_replayable_inference_attempt() -> anyhow::Result<()> { let temp = TempDir::new()?; From 95b332c82028b4ef0ee896ebcba2005180feefe3 Mon Sep 17 00:00:00 2001 From: Felipe Coury Date: Tue, 12 May 2026 10:43:17 -0300 Subject: [PATCH 4/6] feat(tui): add ambient terminal pets (#21206) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Why The Codex App has animated pets, but the TUI had no equivalent ambient companion surface. This brings that experience into terminal Codex while keeping the main chat flow usable: the pet should feel present, but it cannot cover transcript text, composer input, approvals, or picker content. The feature also needs to be terminal-aware. Different terminals support different image protocols, tmux can interfere with image rendering, and some users will want pets disabled entirely or anchored differently depending on their layout.
CleanShot 2026-05-05 at 12 41
45@2x

macOS - Ghostty, iTerm2 and WezTerm with Custom Pet

![Uploading CleanShot 2026-05-10 at 20.28.30.png…]()

Windows Terminal

CleanShot 2026-05-05 at 12 39
02@2x

Linux - WezTerm and Ghostty

## What Changed - Add a TUI ambient pet renderer in `codex-rs/tui/src/pets/`. - Port the app-style pet animation states so the sprite changes with task status, waiting-for-input states, review/ready states, and failures. - Add `/pets` selection UI with a preview pane, loading state, built-in pet choices, and a first-row `Disable terminal pets` option. - Download built-in pet spritesheets on demand from the same public CDN path already used by Android, under `https://persistent.oaistatic.com/codex/pets/v1/...`, and cache them locally under `~/.codex/cache/tui-pets/`. - Keep custom pets local. - Add config support for pet selection, disabling pets, and choosing whether the pet follows the composer bottom or anchors to the terminal bottom. - Reserve layout space around the pet so transcript wrapping, live responses, and composer input do not render underneath the sprite. - Gate image rendering by terminal capability, disable image pets under tmux, and support both Kitty Graphics and SIXEL terminals. - Add redraw cleanup for terminal image artifacts, including sixel cell clearing. ## Current Scope - This is an initial TUI version of ambient pets, not full App parity. - It focuses on ambient sprite rendering, `/pets` selection, custom pets, terminal capability gating, and on-demand CDN-backed built-in assets. - The ambient text overlay is currently disabled, so the TUI renders the pet sprite without extra status text beside it. ## How to Test 1. Start Codex TUI in a terminal with image support. 2. Run `/pets`. 3. Confirm the picker shows built-in pets plus custom pets, and the first item is `Disable terminal pets`. 4. On a fresh `~/.codex/cache/tui-pets/`, move onto a built-in pet and confirm the first preview downloads the spritesheet from the shared Codex pets CDN and renders successfully. 5. Move through the pet list and confirm subsequent built-in previews use the local cache. 6. Select a pet, then send and receive messages. Confirm transcript and composer text wrap before the pet instead of rendering underneath the sprite. 7. Change the pet anchor setting and confirm the pet can either follow the composer bottom or sit at the terminal bottom. 8. Return to `/pets`, choose `Disable terminal pets`, and confirm the sprite disappears cleanly. Targeted tests: - `cargo test -p codex-tui ambient_pet_` - `cargo test -p codex-tui resize_reflow_wraps_transcript_early_when_pet_is_enabled` - `cargo insta pending-snapshots` --- codex-rs/Cargo.lock | 7 +- codex-rs/config/src/types.rs | 22 + codex-rs/core-api/src/lib.rs | 1 + codex-rs/core/config.schema.json | 32 + codex-rs/core/src/config/config_tests.rs | 76 ++ codex-rs/core/src/config/edit.rs | 8 + codex-rs/core/src/config/mod.rs | 13 + codex-rs/thread-manager-sample/src/main.rs | 3 + codex-rs/tui/Cargo.toml | 3 +- codex-rs/tui/src/app.rs | 34 + codex-rs/tui/src/app/config_persistence.rs | 41 + codex-rs/tui/src/app/event_dispatch.rs | 33 +- codex-rs/tui/src/app/history_ui.rs | 4 +- codex-rs/tui/src/app/pets.rs | 181 +++ codex-rs/tui/src/app/resize_reflow.rs | 7 +- codex-rs/tui/src/app/tests.rs | 28 + codex-rs/tui/src/app_backtrack.rs | 4 +- codex-rs/tui/src/app_event.rs | 32 + codex-rs/tui/src/bottom_pane/chat_composer.rs | 124 +- codex-rs/tui/src/bottom_pane/mod.rs | 99 +- ...chat_composer__tests__slash_popup_pet.snap | 9 + codex-rs/tui/src/chatwidget.rs | 171 ++- codex-rs/tui/src/chatwidget/pets.rs | 335 ++++++ codex-rs/tui/src/chatwidget/slash_dispatch.rs | 17 +- ...hatwidget__tests__approval_modal_exec.snap | 2 +- ...l_exec_multiline_prefix_no_execpolicy.snap | 2 +- ..._tests__approval_modal_exec_no_reason.snap | 2 +- ...get__tests__approvals_selection_popup.snap | 2 +- ...twidget__tests__chat_small_running_h3.snap | 4 +- ...exec_and_status_layout_vt100_snapshot.snap | 4 +- ...s__feedback_good_result_consent_popup.snap | 2 +- ..._tests__feedback_upload_consent_popup.snap | 2 +- ...tests__full_access_confirmation_popup.snap | 2 +- ...widget__tests__memories_enable_prompt.snap | 2 +- ...ests__model_reasoning_selection_popup.snap | 2 +- ...get__tests__multi_agent_enable_prompt.snap | 2 +- ...t__tests__personality_selection_popup.snap | 2 +- ...get__tests__plan_implementation_popup.snap | 2 +- ...plan_implementation_popup_no_selected.snap | 2 +- ...get__tests__plugin_detail_error_popup.snap | 2 +- ...ests__plugin_detail_popup_installable.snap | 4 +- ..._tests__plugin_detail_popup_installed.snap | 4 +- ...tests__rate_limit_switch_prompt_popup.snap | 2 +- ...tests__realtime_audio_selection_popup.snap | 2 +- ...realtime_audio_selection_popup_narrow.snap | 2 +- ...sts__realtime_microphone_picker_popup.snap | 2 +- ...t__tests__renamed_thread_footer_title.snap | 4 +- ..._chatwidget__tests__slash_pets_picker.snap | 25 + ...__tests__status_line_fast_mode_footer.snap | 4 +- ...model_with_reasoning_plan_mode_footer.snap | 4 +- ...sts__status_widget_and_approval_modal.snap | 4 +- codex-rs/tui/src/chatwidget/tests/guardian.rs | 8 +- .../tui/src/chatwidget/tests/mcp_startup.rs | 2 +- .../src/chatwidget/tests/slash_commands.rs | 115 ++ ...al_requests__exec_approval_modal_exec.snap | 2 +- .../src/chatwidget/tests/status_and_layout.rs | 252 +++- codex-rs/tui/src/lib.rs | 1 + codex-rs/tui/src/pets/ambient.rs | 528 +++++++++ codex-rs/tui/src/pets/asset_pack.rs | 190 +++ codex-rs/tui/src/pets/catalog.rs | 77 ++ codex-rs/tui/src/pets/frames.rs | 116 ++ codex-rs/tui/src/pets/image_protocol.rs | 591 ++++++++++ codex-rs/tui/src/pets/mod.rs | 468 ++++++++ codex-rs/tui/src/pets/model.rs | 1036 +++++++++++++++++ codex-rs/tui/src/pets/picker.rs | 324 ++++++ codex-rs/tui/src/pets/preview.rs | 164 +++ codex-rs/tui/src/pets/sixel.rs | 315 +++++ codex-rs/tui/src/resume_picker.rs | 6 +- codex-rs/tui/src/session_log.rs | 27 + codex-rs/tui/src/slash_command.rs | 12 +- codex-rs/tui/src/tui.rs | 56 + 71 files changed, 5576 insertions(+), 91 deletions(-) create mode 100644 codex-rs/tui/src/app/pets.rs create mode 100644 codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_pet.snap create mode 100644 codex-rs/tui/src/chatwidget/pets.rs create mode 100644 codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__slash_pets_picker.snap create mode 100644 codex-rs/tui/src/pets/ambient.rs create mode 100644 codex-rs/tui/src/pets/asset_pack.rs create mode 100644 codex-rs/tui/src/pets/catalog.rs create mode 100644 codex-rs/tui/src/pets/frames.rs create mode 100644 codex-rs/tui/src/pets/image_protocol.rs create mode 100644 codex-rs/tui/src/pets/mod.rs create mode 100644 codex-rs/tui/src/pets/model.rs create mode 100644 codex-rs/tui/src/pets/picker.rs create mode 100644 codex-rs/tui/src/pets/preview.rs create mode 100644 codex-rs/tui/src/pets/sixel.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index efd7d58d5a..30d4bf3640 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -3802,6 +3802,7 @@ dependencies = [ "serde", "serde_json", "serial_test", + "sha2", "shlex", "strum 0.27.2", "strum_macros 0.28.0", @@ -8981,7 +8982,7 @@ version = "5.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "51e219e79014df21a225b1860a479e2dcd7cbd9130f4defd4bd0e191ea31d67d" dependencies = [ - "base64 0.21.7", + "base64 0.22.1", "chrono", "getrandom 0.2.17", "http 1.4.0", @@ -9449,7 +9450,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.45.0", + "windows-sys 0.61.2", ] [[package]] @@ -14093,7 +14094,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/codex-rs/config/src/types.rs b/codex-rs/config/src/types.rs index 39fd0a442f..dfc81852ea 100644 --- a/codex-rs/config/src/types.rs +++ b/codex-rs/config/src/types.rs @@ -599,6 +599,16 @@ impl fmt::Display for NotificationCondition { } } +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, JsonSchema, Default)] +#[serde(rename_all = "kebab-case")] +pub enum TuiPetAnchor { + /// Anchor the pet to the bottom of the current TUI composer viewport. + #[default] + Composer, + /// Anchor the pet to the physical bottom of the terminal screen. + ScreenBottom, +} + #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, Default, JsonSchema)] #[schemars(deny_unknown_fields)] pub struct TuiNotificationSettings { @@ -695,6 +705,18 @@ pub struct Tui { #[serde(default)] pub theme: Option, + /// Pet id to preselect in the terminal pet picker. + /// + /// Custom pet ids resolve against CODEX_HOME/pets//pet.json. + #[serde(default)] + pub pet: Option, + + /// Where the terminal pet should anchor vertically. + /// + /// Defaults to `composer`, which follows the current TUI composer viewport. + #[serde(default)] + pub pet_anchor: TuiPetAnchor, + /// Preferred layout for resume/fork session picker results. #[serde(default)] pub session_picker_view: Option, diff --git a/codex-rs/core-api/src/lib.rs b/codex-rs/core-api/src/lib.rs index ea3fe042c4..04ebaf8e7e 100644 --- a/codex-rs/core-api/src/lib.rs +++ b/codex-rs/core-api/src/lib.rs @@ -22,6 +22,7 @@ pub use codex_config::types::SessionPickerViewMode; pub use codex_config::types::ToolSuggestConfig; pub use codex_config::types::TuiKeymap; pub use codex_config::types::TuiNotificationSettings; +pub use codex_config::types::TuiPetAnchor; pub use codex_config::types::UriBasedFileOpener; pub use codex_core::CodexThread; pub use codex_core::ForkSnapshot; diff --git a/codex-rs/core/config.schema.json b/codex-rs/core/config.schema.json index 1f17bd0282..5af6b7435b 100644 --- a/codex-rs/core/config.schema.json +++ b/codex-rs/core/config.schema.json @@ -2692,6 +2692,20 @@ "default": true, "description": "Enable desktop notifications from the TUI. Defaults to `true`." }, + "pet": { + "default": null, + "description": "Pet id to preselect in the terminal pet picker.\n\nCustom pet ids resolve against CODEX_HOME/pets//pet.json.", + "type": "string" + }, + "pet_anchor": { + "allOf": [ + { + "$ref": "#/definitions/TuiPetAnchor" + } + ], + "default": "composer", + "description": "Where the terminal pet should anchor vertically.\n\nDefaults to `composer`, which follows the current TUI composer viewport." + }, "raw_output_mode": { "default": false, "description": "Start the TUI in raw scrollback mode for copy-friendly transcript output. Defaults to `false`.", @@ -3436,6 +3450,24 @@ }, "type": "object" }, + "TuiPetAnchor": { + "oneOf": [ + { + "description": "Anchor the pet to the bottom of the current TUI composer viewport.", + "enum": [ + "composer" + ], + "type": "string" + }, + { + "description": "Anchor the pet to the physical bottom of the terminal screen.", + "enum": [ + "screen-bottom" + ], + "type": "string" + } + ] + }, "TuiVimNormalKeymap": { "additionalProperties": false, "description": "Vim normal-mode keybindings for modal editing inside text areas.\n\nActions that use uppercase letters (like `A` for append-line-end) should be specified as `shift-a` in config; the runtime matcher handles cross-terminal shift-reporting differences automatically.", diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 8ff8df4e83..59ea489e3a 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -55,6 +55,7 @@ use codex_config::types::ToolSuggestDiscoverableType; use codex_config::types::Tui; use codex_config::types::TuiKeymap; use codex_config::types::TuiNotificationSettings; +use codex_config::types::TuiPetAnchor; use codex_config::types::WindowsSandboxModeToml; use codex_config::types::WindowsToml; use codex_core_plugins::PluginsManager; @@ -566,6 +567,8 @@ fn config_toml_deserializes_model_availability_nux() { status_line_use_colors: true, terminal_title: None, theme: None, + pet: None, + pet_anchor: TuiPetAnchor::Composer, session_picker_view: None, keymap: TuiKeymap::default(), model_availability_nux: ModelAvailabilityNuxConfig { @@ -2530,6 +2533,19 @@ session_picker_view = "dense" ); } +#[test] +fn tui_pet_deserializes_from_toml() { + let cfg = r#" +[tui] +pet = "chefito" +"#; + let parsed = toml::from_str::(cfg).expect("TOML deserialization should succeed"); + assert_eq!( + parsed.tui.as_ref().and_then(|t| t.pet.as_deref()), + Some("chefito"), + ); +} + #[test] fn tui_session_picker_view_defaults_to_none() { let cfg = r#" @@ -2542,6 +2558,56 @@ fn tui_session_picker_view_defaults_to_none() { ); } +#[test] +fn tui_pet_defaults_to_none() { + let cfg = r#" +[tui] +"#; + let parsed = toml::from_str::(cfg).expect("TOML deserialization should succeed"); + assert_eq!(parsed.tui.as_ref().and_then(|t| t.pet.as_deref()), None); +} + +#[test] +fn tui_pet_anchor_deserializes_from_toml() { + let cfg = r#" +[tui] +pet_anchor = "screen-bottom" +"#; + let parsed = toml::from_str::(cfg).expect("TOML deserialization should succeed"); + assert_eq!( + parsed.tui.as_ref().map(|t| t.pet_anchor), + Some(TuiPetAnchor::ScreenBottom), + ); +} + +#[test] +fn tui_pet_anchor_defaults_to_composer() { + let cfg = r#" +[tui] +"#; + let parsed = toml::from_str::(cfg).expect("TOML deserialization should succeed"); + assert_eq!( + parsed.tui.as_ref().map(|t| t.pet_anchor), + Some(TuiPetAnchor::Composer), + ); +} + +#[test] +fn tui_pet_anchor_rejects_unknown_value() { + let cfg = r#" +[tui] +pet_anchor = "bottom" +"#; + let err = toml::from_str::(cfg).expect_err("reject unknown pet anchor"); + let err = err.to_string(); + assert!( + err.contains("unknown variant `bottom`") + && err.contains("composer") + && err.contains("screen-bottom"), + "unexpected error: {err}" + ); +} + #[test] fn tui_config_missing_notifications_field_defaults_to_enabled() { let cfg = r#" @@ -2565,6 +2631,8 @@ fn tui_config_missing_notifications_field_defaults_to_enabled() { status_line_use_colors: true, terminal_title: None, theme: None, + pet: None, + pet_anchor: TuiPetAnchor::Composer, session_picker_view: None, keymap: TuiKeymap::default(), model_availability_nux: ModelAvailabilityNuxConfig::default(), @@ -7322,6 +7390,8 @@ async fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> { tui_status_line_use_colors: true, tui_terminal_title: None, tui_theme: None, + tui_pet: None, + tui_pet_anchor: TuiPetAnchor::Composer, tui_session_picker_view: SessionPickerViewMode::Dense, otel: OtelConfig::default(), }, @@ -7766,6 +7836,8 @@ async fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> { tui_status_line_use_colors: true, tui_terminal_title: None, tui_theme: None, + tui_pet: None, + tui_pet_anchor: TuiPetAnchor::Composer, tui_session_picker_view: SessionPickerViewMode::Dense, otel: OtelConfig::default(), }; @@ -7924,6 +7996,8 @@ async fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> { tui_status_line_use_colors: true, tui_terminal_title: None, tui_theme: None, + tui_pet: None, + tui_pet_anchor: TuiPetAnchor::Composer, tui_session_picker_view: SessionPickerViewMode::Dense, otel: OtelConfig::default(), }; @@ -8067,6 +8141,8 @@ async fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> { tui_status_line_use_colors: true, tui_terminal_title: None, tui_theme: None, + tui_pet: None, + tui_pet_anchor: TuiPetAnchor::Composer, tui_session_picker_view: SessionPickerViewMode::Dense, otel: OtelConfig::default(), }; diff --git a/codex-rs/core/src/config/edit.rs b/codex-rs/core/src/config/edit.rs index 959eaaf153..4dde2c0cb0 100644 --- a/codex-rs/core/src/config/edit.rs +++ b/codex-rs/core/src/config/edit.rs @@ -92,6 +92,14 @@ pub fn syntax_theme_edit(name: &str) -> ConfigEdit { } } +/// Produces a config edit that sets [tui].pet = "". +pub fn tui_pet_edit(name: &str) -> ConfigEdit { + ConfigEdit::SetPath { + segments: vec!["tui".to_string(), "pet".to_string()], + value: value(name.to_string()), + } +} + /// Produces a config edit that sets `[tui].session_picker_view = ""`. pub fn session_picker_view_edit(mode: SessionPickerViewMode) -> ConfigEdit { ConfigEdit::SetPath { diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index c65b713a9e..f5dda3fc38 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -51,6 +51,7 @@ use codex_config::types::ToolSuggestDisabledTool; use codex_config::types::ToolSuggestDiscoverable; use codex_config::types::TuiKeymap; use codex_config::types::TuiNotificationSettings; +use codex_config::types::TuiPetAnchor; use codex_config::types::UriBasedFileOpener; use codex_config::types::WindowsSandboxModeToml; use codex_core_plugins::PluginsConfigInput; @@ -549,6 +550,12 @@ pub struct Config { /// Syntax highlighting theme override (kebab-case name). pub tui_theme: Option, + /// Pet id preselected by the terminal pet picker. + pub tui_pet: Option, + + /// Vertical anchor used by terminal pet rendering. + pub tui_pet_anchor: TuiPetAnchor, + /// Preferred layout for resume/fork session picker results. pub tui_session_picker_view: SessionPickerViewMode, @@ -3205,6 +3212,12 @@ impl Config { .unwrap_or(true), tui_terminal_title: cfg.tui.as_ref().and_then(|t| t.terminal_title.clone()), tui_theme: cfg.tui.as_ref().and_then(|t| t.theme.clone()), + tui_pet: cfg.tui.as_ref().and_then(|t| t.pet.clone()), + tui_pet_anchor: cfg + .tui + .as_ref() + .map(|t| t.pet_anchor) + .unwrap_or_default(), tui_session_picker_view: config_profile .tui .as_ref() diff --git a/codex-rs/thread-manager-sample/src/main.rs b/codex-rs/thread-manager-sample/src/main.rs index 191c294c9e..335dd4ac5b 100644 --- a/codex-rs/thread-manager-sample/src/main.rs +++ b/codex-rs/thread-manager-sample/src/main.rs @@ -48,6 +48,7 @@ use codex_core_api::ThreadStoreConfig; use codex_core_api::ToolSuggestConfig; use codex_core_api::TuiKeymap; use codex_core_api::TuiNotificationSettings; +use codex_core_api::TuiPetAnchor; use codex_core_api::UriBasedFileOpener; use codex_core_api::UserInput; use codex_core_api::WebSearchMode; @@ -205,6 +206,8 @@ fn new_config(model: Option, arg0_paths: Arg0DispatchPaths) -> anyhow::R tui_terminal_title: None, tui_theme: None, tui_raw_output_mode: false, + tui_pet: None, + tui_pet_anchor: TuiPetAnchor::Composer, terminal_resize_reflow: TerminalResizeReflowConfig::default(), tui_keymap: TuiKeymap::default(), tui_session_picker_view: SessionPickerViewMode::Dense, diff --git a/codex-rs/tui/Cargo.toml b/codex-rs/tui/Cargo.toml index d3d28461b6..92062f88ab 100644 --- a/codex-rs/tui/Cargo.toml +++ b/codex-rs/tui/Cargo.toml @@ -86,10 +86,11 @@ ratatui = { workspace = true, features = [ ] } ratatui-macros = { workspace = true } regex-lite = { workspace = true } -reqwest = { workspace = true, features = ["json"] } +reqwest = { workspace = true, features = ["blocking", "json"] } rmcp = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true, features = ["preserve_order"] } +sha2 = { workspace = true } shlex = { workspace = true } strum = { workspace = true } strum_macros = { workspace = true } diff --git a/codex-rs/tui/src/app.rs b/codex-rs/tui/src/app.rs index 8aefbd0f47..81515603db 100644 --- a/codex-rs/tui/src/app.rs +++ b/codex-rs/tui/src/app.rs @@ -155,6 +155,7 @@ use crossterm::event::KeyEvent; use crossterm::event::KeyEventKind; use crossterm::event::KeyModifiers; use ratatui::backend::Backend; +use ratatui::layout::Rect; use ratatui::style::Stylize; use ratatui::text::Line; use ratatui::widgets::Paragraph; @@ -192,6 +193,7 @@ mod history_ui; mod input; mod loaded_threads; mod pending_interactive_replay; +mod pets; mod platform_actions; mod replay_filter; mod resize_reflow; @@ -1080,13 +1082,18 @@ See the Codex keymap documentation for supported actions and examples." if let Err(err) = app_server.shutdown().await { tracing::warn!(error = %err, "failed to shut down embedded app server"); } + let clear_pet_result = tui.clear_ambient_pet_image(); let clear_result = tui.terminal.clear(); let exit_reason = match exit_reason_result { Ok(exit_reason) => { + clear_pet_result?; clear_result?; exit_reason } Err(err) => { + if let Err(clear_pet_err) = clear_pet_result { + tracing::warn!(error = %clear_pet_err, "failed to clear ambient pet image"); + } if let Err(clear_err) = clear_result { tracing::warn!(error = %clear_err, "failed to clear terminal UI"); } @@ -1154,9 +1161,11 @@ See the Codex keymap documentation for supported actions and examples." self.chat_widget.pre_draw_tick(); let desired_height = self.chat_widget.desired_height(tui.terminal.size()?.width); + let mut rendered_area = Rect::default(); if terminal_resize_reflow_enabled { tui.draw_with_resize_reflow(desired_height, |frame| { let area = frame.area(); + rendered_area = area; self.chat_widget.render(area, frame.buffer); if let Some((x, y)) = self.chat_widget.cursor_pos(area) { frame.set_cursor_style(self.chat_widget.cursor_style(area)); @@ -1166,6 +1175,7 @@ See the Codex keymap documentation for supported actions and examples." } else { tui.draw(desired_height, |frame| { let area = frame.area(); + rendered_area = area; self.chat_widget.render(area, frame.buffer); if let Some((x, y)) = self.chat_widget.cursor_pos(area) { frame.set_cursor_style(self.chat_widget.cursor_style(area)); @@ -1173,6 +1183,30 @@ See the Codex keymap documentation for supported actions and examples." } })?; } + if self.chat_widget.ambient_pet_image_enabled() { + let terminal_size = tui.terminal.size()?; + let ambient_pet_area = Rect::new( + /*x*/ 0, + /*y*/ 0, + terminal_size.width, + terminal_size.height, + ); + if let Err(err) = tui.draw_ambient_pet_image( + self.chat_widget + .ambient_pet_draw(ambient_pet_area, rendered_area.bottom()), + ) { + self.handle_ambient_pet_image_render_error(tui, err)?; + } + } + if let Some(request) = self.chat_widget.pet_picker_preview_draw() { + if let Err(err) = tui.draw_pet_picker_preview_image(Some(request)) { + self.handle_pet_picker_preview_image_render_error(tui, err)?; + } + } else if self.chat_widget.should_clear_pet_picker_preview_image() + && let Err(err) = tui.draw_pet_picker_preview_image(/*request*/ None) + { + self.handle_pet_picker_preview_image_render_error(tui, err)?; + } if self.chat_widget.external_editor_state() == ExternalEditorState::Requested { self.chat_widget .set_external_editor_state(ExternalEditorState::Active); diff --git a/codex-rs/tui/src/app/config_persistence.rs b/codex-rs/tui/src/app/config_persistence.rs index e50c2782ca..d20ffb8218 100644 --- a/codex-rs/tui/src/app/config_persistence.rs +++ b/codex-rs/tui/src/app/config_persistence.rs @@ -515,6 +515,18 @@ impl App { self.chat_widget.set_tui_theme(Some(name)); } + #[cfg(test)] + pub(super) fn sync_tui_pet_selection(&mut self, pet: String) { + self.config.tui_pet = Some(pet.clone()); + self.chat_widget.set_tui_pet(Some(pet)); + } + + pub(super) fn sync_tui_pet_disabled(&mut self) { + let pet = crate::pets::DISABLED_PET_ID.to_string(); + self.config.tui_pet = Some(pet.clone()); + self.chat_widget.set_tui_pet(Some(pet)); + } + pub(super) fn restore_runtime_theme_from_config(&self) { if let Some(name) = self.config.tui_theme.as_deref() && let Some(theme) = @@ -732,4 +744,33 @@ terminal_resize_reflow_max_rows = 9000 Some("dracula") ); } + + #[tokio::test] + async fn sync_tui_pet_selection_updates_chat_widget_config_copy() { + let mut app = make_test_app().await; + + app.sync_tui_pet_selection("chefito".to_string()); + + assert_eq!(app.config.tui_pet.as_deref(), Some("chefito")); + assert_eq!( + app.chat_widget.config_ref().tui_pet.as_deref(), + Some("chefito") + ); + } + + #[tokio::test] + async fn sync_tui_pet_disabled_updates_chat_widget_config_copy() { + let mut app = make_test_app().await; + + app.sync_tui_pet_disabled(); + + assert_eq!( + app.config.tui_pet.as_deref(), + Some(crate::pets::DISABLED_PET_ID) + ); + assert_eq!( + app.chat_widget.config_ref().tui_pet.as_deref(), + Some(crate::pets::DISABLED_PET_ID) + ); + } } diff --git a/codex-rs/tui/src/app/event_dispatch.rs b/codex-rs/tui/src/app/event_dispatch.rs index 886aa9175e..e1ae3099a6 100644 --- a/codex-rs/tui/src/app/event_dispatch.rs +++ b/codex-rs/tui/src/app/event_dispatch.rs @@ -204,13 +204,15 @@ impl App { self.insert_history_cell_lines_with_initial_replay_buffer( tui, cell.as_ref(), - tui.terminal.last_known_screen_size.width, + self.chat_widget + .history_wrap_width(tui.terminal.last_known_screen_size.width), ); } else { self.insert_history_cell_lines( tui, cell.as_ref(), - tui.terminal.last_known_screen_size.width, + self.chat_widget + .history_wrap_width(tui.terminal.last_known_screen_size.width), ); } } @@ -262,7 +264,8 @@ impl App { self.insert_history_cell_lines( tui, consolidated.as_ref(), - tui.terminal.last_known_screen_size.width, + self.chat_widget + .history_wrap_width(tui.terminal.last_known_screen_size.width), ); self.maybe_finish_stream_reflow(tui)?; @@ -389,6 +392,30 @@ impl App { AppEvent::OpenUrlInBrowser { url } => { self.open_url_in_browser(url); } + AppEvent::PetSelected { pet_id } => { + self.handle_pet_selected(tui, pet_id); + } + AppEvent::PetDisabled => { + self.handle_pet_disabled(tui).await; + } + AppEvent::PetPreviewRequested { pet_id } => { + self.chat_widget.start_pet_picker_preview(pet_id); + } + AppEvent::PetPreviewLoaded { request_id, result } => { + self.handle_pet_preview_loaded(tui, request_id, result); + } + AppEvent::PetSelectionLoaded { + request_id, + pet_id, + result, + } => { + return self + .handle_pet_selection_loaded(tui, request_id, pet_id, result) + .await; + } + AppEvent::ConfiguredPetLoaded { pet_id, result } => { + self.handle_configured_pet_loaded(tui, pet_id, result); + } AppEvent::RefreshConnectors { force_refetch } => { self.chat_widget.refresh_connectors(force_refetch); } diff --git a/codex-rs/tui/src/app/history_ui.rs b/codex-rs/tui/src/app/history_ui.rs index f6fec98f13..e923db1770 100644 --- a/codex-rs/tui/src/app/history_ui.rs +++ b/codex-rs/tui/src/app/history_ui.rs @@ -41,7 +41,9 @@ impl App { } pub(super) fn queue_clear_ui_header(&mut self, tui: &mut tui::Tui) { - let width = tui.terminal.last_known_screen_size.width; + let width = self + .chat_widget + .history_wrap_width(tui.terminal.last_known_screen_size.width); let header_lines = self.clear_ui_header_lines(width); if !header_lines.is_empty() { tui.insert_history_lines(header_lines); diff --git a/codex-rs/tui/src/app/pets.rs b/codex-rs/tui/src/app/pets.rs new file mode 100644 index 0000000000..fe6e90673b --- /dev/null +++ b/codex-rs/tui/src/app/pets.rs @@ -0,0 +1,181 @@ +//! App-level handlers for ambient terminal pet events. + +use super::*; + +impl App { + pub(super) fn handle_ambient_pet_image_render_error( + &mut self, + tui: &mut tui::Tui, + err: crate::pets::PetImageRenderError, + ) -> Result<()> { + match err { + crate::pets::PetImageRenderError::Terminal(err) => Err(err.into()), + crate::pets::PetImageRenderError::Asset(err) => { + tracing::warn!( + error = %err, + "failed to render ambient pet image; disabling pet for session" + ); + self.chat_widget.disable_ambient_pet_for_session(); + if let Err(clear_err) = tui.clear_ambient_pet_image() { + match clear_err { + crate::pets::PetImageRenderError::Terminal(err) => return Err(err.into()), + crate::pets::PetImageRenderError::Asset(err) => { + tracing::warn!( + error = %err, + "failed to clear ambient pet image after render failure" + ); + } + } + } + Ok(()) + } + } + } + + pub(super) fn handle_pet_picker_preview_image_render_error( + &mut self, + tui: &mut tui::Tui, + err: crate::pets::PetImageRenderError, + ) -> Result<()> { + match err { + crate::pets::PetImageRenderError::Terminal(err) => Err(err.into()), + crate::pets::PetImageRenderError::Asset(err) => { + tracing::warn!(error = %err, "failed to render pet picker preview image"); + self.chat_widget + .fail_pet_picker_preview_render(err.to_string()); + if let Err(clear_err) = tui.draw_pet_picker_preview_image(/*request*/ None) { + match clear_err { + crate::pets::PetImageRenderError::Terminal(err) => return Err(err.into()), + crate::pets::PetImageRenderError::Asset(err) => { + tracing::warn!( + error = %err, + "failed to clear pet picker preview image after render failure" + ); + } + } + } + Ok(()) + } + } + } + + pub(super) fn handle_pet_selected(&mut self, tui: &mut tui::Tui, pet_id: String) { + let request_id = self.chat_widget.show_pet_selection_loading_popup(); + tui.frame_requester().schedule_frame(); + let codex_home = self.config.codex_home.clone(); + let frame_requester = tui.frame_requester(); + let animations_enabled = self.config.animations; + let tx = self.app_event_tx.clone(); + std::mem::drop(tokio::task::spawn_blocking(move || { + let result = crate::pets::ensure_builtin_pack_for_pet(&pet_id, &codex_home) + .and_then(|()| { + crate::pets::AmbientPet::load( + Some(&pet_id), + &codex_home, + frame_requester, + animations_enabled, + ) + }) + .map(Some) + .map_err(|err| err.to_string()); + tx.send(AppEvent::PetSelectionLoaded { + request_id, + pet_id, + result, + }); + })); + } + + pub(super) async fn handle_pet_disabled(&mut self, tui: &mut tui::Tui) { + let edit = crate::legacy_core::config::edit::tui_pet_edit(crate::pets::DISABLED_PET_ID); + let apply_result = ConfigEditsBuilder::new(&self.config.codex_home) + .with_edits([edit]) + .apply() + .await; + match apply_result { + Ok(()) => { + self.sync_tui_pet_disabled(); + tui.frame_requester().schedule_frame(); + } + Err(err) => { + self.chat_widget + .add_error_message(format!("Failed to disable pets: {err}")); + } + } + } + + pub(super) fn handle_pet_preview_loaded( + &mut self, + tui: &mut tui::Tui, + request_id: u64, + result: Result, + ) { + self.chat_widget + .finish_pet_picker_preview_load(request_id, result); + tui.frame_requester().schedule_frame(); + } + + pub(super) async fn handle_pet_selection_loaded( + &mut self, + tui: &mut tui::Tui, + request_id: u64, + pet_id: String, + result: Result, String>, + ) -> Result { + if !self + .chat_widget + .finish_pet_selection_loading_popup(request_id) + { + return Ok(AppRunControl::Continue); + } + match result { + Ok(ambient_pet) => { + let edit = crate::legacy_core::config::edit::tui_pet_edit(&pet_id); + match ConfigEditsBuilder::new(&self.config.codex_home) + .with_edits([edit]) + .apply() + .await + { + Ok(()) => { + self.config.tui_pet = Some(pet_id.clone()); + self.chat_widget + .set_tui_pet_loaded(Some(pet_id), ambient_pet); + } + Err(err) => { + self.chat_widget + .add_error_message(format!("Failed to save pet selection: {err}")); + } + } + } + Err(err) => { + self.chat_widget + .add_error_message(format!("Failed to load pet: {err}")); + } + } + tui.frame_requester().schedule_frame(); + Ok(AppRunControl::Continue) + } + + pub(super) fn handle_configured_pet_loaded( + &mut self, + tui: &mut tui::Tui, + pet_id: String, + result: Result, String>, + ) { + if self.config.tui_pet.as_deref() != Some(pet_id.as_str()) { + return; + } + + match result { + Ok(ambient_pet) => { + self.chat_widget + .set_tui_pet_loaded(Some(pet_id), ambient_pet); + tui.frame_requester().schedule_frame(); + } + Err(err) => { + self.chat_widget + .add_warning_message(format!("Failed to load configured pet: {err}")); + } + } + } +} diff --git a/codex-rs/tui/src/app/resize_reflow.rs b/codex-rs/tui/src/app/resize_reflow.rs index 7775aed71b..6ef1721266 100644 --- a/codex-rs/tui/src/app/resize_reflow.rs +++ b/codex-rs/tui/src/app/resize_reflow.rs @@ -419,12 +419,13 @@ impl App { } pub(super) fn reflow_transcript_now(&mut self, tui: &mut tui::Tui) -> Result { - let width = tui.terminal.size()?.width; + let terminal_width = tui.terminal.size()?.width; + let width = self.chat_widget.history_wrap_width(terminal_width); if self.transcript_cells.is_empty() { // Drop any queued pre-resize/pre-consolidation inserts before rebuilding from cells. tui.clear_pending_history_lines(); self.reset_history_emission_state(); - return Ok(width); + return Ok(terminal_width); } let reflow_result = self.render_transcript_lines_for_reflow(width); @@ -442,7 +443,7 @@ impl App { ); } - Ok(width) + Ok(terminal_width) } /// Render transcript cells for the current resize rebuild. diff --git a/codex-rs/tui/src/app/tests.rs b/codex-rs/tui/src/app/tests.rs index d2a942bb42..6d1e4d31c6 100644 --- a/codex-rs/tui/src/app/tests.rs +++ b/codex-rs/tui/src/app/tests.rs @@ -13,6 +13,7 @@ use crate::chatwidget::tests::make_chatwidget_manual_with_sender; use crate::chatwidget::tests::set_chatgpt_auth; use crate::chatwidget::tests::set_fast_mode_test_catalog; use crate::file_search::FileSearchManager; +use crate::history_cell::AgentMarkdownCell; use crate::history_cell::AgentMessageCell; use crate::history_cell::HistoryCell; use crate::history_cell::PlainHistoryCell; @@ -85,6 +86,7 @@ use crossterm::event::KeyModifiers; use insta::assert_snapshot; use pretty_assertions::assert_eq; use ratatui::prelude::Line; +use std::path::Path; use std::path::PathBuf; use std::sync::Arc; use std::sync::atomic::AtomicBool; @@ -4159,6 +4161,32 @@ async fn uncapped_resize_reflow_renders_all_cells_when_row_cap_absent() { assert_eq!(rendered_line_text(&rendered.lines[38]), "cell 19"); } +#[tokio::test] +async fn resize_reflow_wraps_transcript_early_when_pet_is_enabled() { + let (mut app, _rx, _op_rx) = make_test_app_with_channels().await; + app.config.terminal_resize_reflow.max_rows = TerminalResizeReflowMaxRows::Disabled; + app.transcript_cells = vec![Arc::new(AgentMarkdownCell::new( + "alpha beta gamma delta epsilon zeta eta theta iota kappa lambda".to_string(), + Path::new("/tmp"), + ))]; + + let without_pet = app.render_transcript_lines_for_reflow(/*width*/ 40); + app.chat_widget + .set_pet_image_support_for_tests(crate::pets::PetImageSupport::Supported( + crate::pets::ImageProtocol::Kitty, + )); + app.chat_widget + .install_test_ambient_pet_for_tests(/*animations_enabled*/ false); + let width = app.chat_widget.history_wrap_width(/*width*/ 40); + assert!(width < 40); + let with_pet = app.render_transcript_lines_for_reflow(width); + + assert!( + with_pet.lines.len() > without_pet.lines.len(), + "expected pet-enabled transcript reflow to wrap earlier" + ); +} + #[tokio::test] async fn uncapped_resize_reflow_renders_all_cells_under_row_limit() { let (mut app, _rx, _op_rx) = make_test_app_with_channels().await; diff --git a/codex-rs/tui/src/app_backtrack.rs b/codex-rs/tui/src/app_backtrack.rs index 307fb8559a..625f8d0841 100644 --- a/codex-rs/tui/src/app_backtrack.rs +++ b/codex-rs/tui/src/app_backtrack.rs @@ -259,7 +259,9 @@ impl App { /// Useful when switching sessions to ensure prior history remains visible. pub(crate) fn render_transcript_once(&mut self, tui: &mut tui::Tui) { if !self.transcript_cells.is_empty() { - let width = tui.terminal.last_known_screen_size.width; + let width = self + .chat_widget + .history_wrap_width(tui.terminal.last_known_screen_size.width); for cell in &self.transcript_cells { tui.insert_history_lines_with_wrap_policy( cell.display_lines_for_mode(width, self.chat_widget.history_render_mode()), diff --git a/codex-rs/tui/src/app_event.rs b/codex-rs/tui/src/app_event.rs index 03434b130b..530e8876a7 100644 --- a/codex-rs/tui/src/app_event.rs +++ b/codex-rs/tui/src/app_event.rs @@ -318,6 +318,38 @@ pub(crate) enum AppEvent { url: String, }, + /// Persist a pet selection and reload the ambient pet. + PetSelected { + pet_id: String, + }, + + /// Persist terminal pets as disabled and remove the ambient pet. + PetDisabled, + + /// Start loading the side preview for the pet picker. + PetPreviewRequested { + pet_id: String, + }, + + /// Result of loading the side preview for the pet picker. + PetPreviewLoaded { + request_id: u64, + result: Result, + }, + + /// Result of loading the selected ambient pet before config persistence. + PetSelectionLoaded { + request_id: u64, + pet_id: String, + result: Result, String>, + }, + + /// Result of restoring the configured ambient pet during startup. + ConfiguredPetLoaded { + pet_id: String, + result: Result, String>, + }, + /// Refresh app connector state and mention bindings. RefreshConnectors { force_refetch: bool, diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs index 1312365339..5f93febfa7 100644 --- a/codex-rs/tui/src/bottom_pane/chat_composer.rs +++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs @@ -800,6 +800,14 @@ impl ChatComposer { self.windows_degraded_sandbox_active = enabled; } fn layout_areas(&self, area: Rect) -> [Rect; 4] { + self.layout_areas_with_textarea_right_reserve(area, /*textarea_right_reserve*/ 0) + } + + fn layout_areas_with_textarea_right_reserve( + &self, + area: Rect, + textarea_right_reserve: u16, + ) -> [Rect; 4] { let footer_props = self.footer_props(); let footer_hint_height = self .custom_footer_height() @@ -825,7 +833,7 @@ impl ChatComposer { /*top*/ 1, LIVE_PREFIX_COLS, /*bottom*/ 1, - /*right*/ 1, + /*right*/ 1u16.saturating_add(textarea_right_reserve), )); let remote_images_height = self .remote_images_lines(textarea_rect.width) @@ -855,7 +863,15 @@ impl ChatComposer { } pub fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { - if !self.input_enabled { + self.cursor_pos_with_textarea_right_reserve(area, /*textarea_right_reserve*/ 0) + } + + pub(crate) fn cursor_pos_with_textarea_right_reserve( + &self, + area: Rect, + textarea_right_reserve: u16, + ) -> Option<(u16, u16)> { + if !self.input_enabled || self.selected_remote_image_index.is_some() { return None; } @@ -863,7 +879,8 @@ impl ChatComposer { return Some(pos); } - let [_, _, textarea_rect, _] = self.layout_areas(area); + let [_, _, textarea_rect, _] = + self.layout_areas_with_textarea_right_reserve(area, textarea_right_reserve); let state = *self.textarea_state.borrow(); self.textarea.cursor_pos_with_state(textarea_rect, state) } @@ -4459,17 +4476,7 @@ fn find_next_mention_token_range(text: &str, token: &str, from: usize) -> Option impl Renderable for ChatComposer { fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { - if !self.input_enabled || self.selected_remote_image_index.is_some() { - return None; - } - - if let Some(pos) = self.history_search_cursor_pos(area) { - return Some(pos); - } - - let [_, _, textarea_rect, _] = self.layout_areas(area); - let state = *self.textarea_state.borrow(); - self.textarea.cursor_pos_with_state(textarea_rect, state) + self.cursor_pos_with_textarea_right_reserve(area, /*textarea_right_reserve*/ 0) } fn cursor_style(&self, _area: Rect) -> crossterm::cursor::SetCursorStyle { @@ -4481,6 +4488,20 @@ impl Renderable for ChatComposer { } fn desired_height(&self, width: u16) -> u16 { + self.desired_height_with_textarea_right_reserve(width, /*textarea_right_reserve*/ 0) + } + + fn render(&self, area: Rect, buf: &mut Buffer) { + self.render_with_mask(area, buf, /*mask_char*/ None); + } +} + +impl ChatComposer { + pub(crate) fn desired_height_with_textarea_right_reserve( + &self, + width: u16, + textarea_right_reserve: u16, + ) -> u16 { let footer_props = self.footer_props(); let footer_hint_height = self .custom_footer_height() @@ -4488,7 +4509,8 @@ impl Renderable for ChatComposer { let footer_spacing = Self::footer_spacing(footer_hint_height); let footer_total_height = footer_hint_height + footer_spacing; const COLS_WITH_MARGIN: u16 = LIVE_PREFIX_COLS + 1; - let inner_width = width.saturating_sub(COLS_WITH_MARGIN); + let inner_width = + width.saturating_sub(COLS_WITH_MARGIN.saturating_add(textarea_right_reserve)); let remote_images_height: u16 = self .remote_images_lines(inner_width) .len() @@ -4507,16 +4529,24 @@ impl Renderable for ChatComposer { ActivePopup::MentionV2(c) => c.calculate_required_height(width), } } - - fn render(&self, area: Rect, buf: &mut Buffer) { - self.render_with_mask(area, buf, /*mask_char*/ None); - } } impl ChatComposer { pub(crate) fn render_with_mask(&self, area: Rect, buf: &mut Buffer, mask_char: Option) { + self.render_with_mask_and_textarea_right_reserve( + area, buf, mask_char, /*textarea_right_reserve*/ 0, + ); + } + + pub(crate) fn render_with_mask_and_textarea_right_reserve( + &self, + area: Rect, + buf: &mut Buffer, + mask_char: Option, + textarea_right_reserve: u16, + ) { let [composer_rect, remote_images_rect, textarea_rect, popup_rect] = - self.layout_areas(area); + self.layout_areas_with_textarea_right_reserve(area, textarea_right_reserve); match &self.active_popup { ActivePopup::Command(popup) => { popup.render_ref(popup_rect, buf); @@ -7873,6 +7903,60 @@ mod tests { } } + #[test] + fn slash_popup_pets_for_pet_ui() { + use ratatui::Terminal; + use ratatui::backend::TestBackend; + + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + + type_chars_humanlike(&mut composer, &['/', 'p', 'e', 't']); + + let mut terminal = Terminal::new(TestBackend::new(60, 5)).expect("terminal"); + terminal + .draw(|f| composer.render(f.area(), f.buffer_mut())) + .expect("draw composer"); + + insta::assert_snapshot!("slash_popup_pet", terminal.backend()); + } + + #[test] + fn slash_popup_pets_for_pet_logic() { + use super::super::command_popup::CommandItem; + let (tx, _rx) = unbounded_channel::(); + let sender = AppEventSender::new(tx); + let mut composer = ChatComposer::new( + /*has_input_focus*/ true, + sender, + /*enhanced_keys_supported*/ false, + "Ask Codex to do anything".to_string(), + /*disable_paste_burst*/ false, + ); + type_chars_humanlike(&mut composer, &['/', 'p', 'e', 't']); + + match &composer.active_popup { + ActivePopup::Command(popup) => match popup.selected_item() { + Some(CommandItem::Builtin(cmd)) => { + assert_eq!(cmd.command(), "pets") + } + Some(CommandItem::ServiceTier(command)) => { + panic!("expected pets command, got service tier {command:?}") + } + None => panic!("no selected command for '/pet'"), + }, + _ => panic!("slash popup not active after typing '/pet'"), + } + } + #[test] fn service_tier_slash_command_dispatches_from_catalog_name() { let (tx, _rx) = unbounded_channel::(); diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs index 975062e4c0..5a0f967ee2 100644 --- a/codex-rs/tui/src/bottom_pane/mod.rs +++ b/codex-rs/tui/src/bottom_pane/mod.rs @@ -106,6 +106,7 @@ pub(crate) use footer::GoalStatusIndicator; pub(crate) use footer::goal_status_indicator_line; pub(crate) use list_selection_view::ColumnWidthMode; pub(crate) use list_selection_view::ListSelectionView; +pub(crate) use list_selection_view::OnSelectionChangedCallback; pub(crate) use list_selection_view::SelectionRowDisplay; pub(crate) use list_selection_view::SelectionToggle; pub(crate) use list_selection_view::SelectionViewParams; @@ -1129,6 +1130,20 @@ impl BottomPane { .and_then(|view| view.active_tab_id()) } + pub(crate) fn dismiss_active_view_if_id(&mut self, view_id: &'static str) -> bool { + let is_match = self + .view_stack + .last() + .is_some_and(|view| view.view_id() == Some(view_id)); + if !is_match { + return false; + } + + self.view_stack.pop(); + self.request_redraw(); + true + } + /// Update the pending-input preview shown above the composer. pub(crate) fn set_pending_input_preview( &mut self, @@ -1529,6 +1544,13 @@ impl BottomPane { } fn as_renderable(&'_ self) -> RenderableItem<'_> { + self.as_renderable_with_composer_right_reserve(/*composer_right_reserve*/ 0) + } + + fn as_renderable_with_composer_right_reserve( + &'_ self, + composer_right_reserve: u16, + ) -> RenderableItem<'_> { if let Some(view) = self.active_view() { RenderableItem::Borrowed(view) } else { @@ -1570,11 +1592,56 @@ impl BottomPane { } let mut flex2 = FlexRenderable::new(); flex2.push(/*flex*/ 1, RenderableItem::Owned(flex.into())); - flex2.push(/*flex*/ 0, RenderableItem::Borrowed(&self.composer)); + let composer: RenderableItem<'_> = if composer_right_reserve == 0 { + RenderableItem::Borrowed(&self.composer) + } else { + RenderableItem::Owned(Box::new(ChatComposerRightReserveRenderable { + composer: &self.composer, + right_reserve: composer_right_reserve, + })) + }; + flex2.push(/*flex*/ 0, composer); RenderableItem::Owned(Box::new(flex2)) } } + pub(crate) fn render_with_composer_right_reserve( + &self, + area: Rect, + buf: &mut Buffer, + composer_right_reserve: u16, + ) { + self.as_renderable_with_composer_right_reserve(composer_right_reserve) + .render(area, buf); + } + + pub(crate) fn desired_height_with_composer_right_reserve( + &self, + width: u16, + composer_right_reserve: u16, + ) -> u16 { + self.as_renderable_with_composer_right_reserve(composer_right_reserve) + .desired_height(width) + } + + pub(crate) fn cursor_pos_with_composer_right_reserve( + &self, + area: Rect, + composer_right_reserve: u16, + ) -> Option<(u16, u16)> { + self.as_renderable_with_composer_right_reserve(composer_right_reserve) + .cursor_pos(area) + } + + pub(crate) fn cursor_style_with_composer_right_reserve( + &self, + area: Rect, + composer_right_reserve: u16, + ) -> crossterm::cursor::SetCursorStyle { + self.as_renderable_with_composer_right_reserve(composer_right_reserve) + .cursor_style(area) + } + pub(crate) fn set_status_line(&mut self, status_line: Option>) { if self.composer.set_status_line(status_line) { self.request_redraw(); @@ -1610,6 +1677,36 @@ impl BottomPane { } } +struct ChatComposerRightReserveRenderable<'a> { + composer: &'a chat_composer::ChatComposer, + right_reserve: u16, +} + +impl Renderable for ChatComposerRightReserveRenderable<'_> { + fn render(&self, area: Rect, buf: &mut Buffer) { + self.composer.render_with_mask_and_textarea_right_reserve( + area, + buf, + /*mask_char*/ None, + self.right_reserve, + ); + } + + fn desired_height(&self, width: u16) -> u16 { + self.composer + .desired_height_with_textarea_right_reserve(width, self.right_reserve) + } + + fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { + self.composer + .cursor_pos_with_textarea_right_reserve(area, self.right_reserve) + } + + fn cursor_style(&self, area: Rect) -> crossterm::cursor::SetCursorStyle { + self.composer.cursor_style(area) + } +} + #[cfg(not(target_os = "linux"))] impl BottomPane { pub(crate) fn insert_recording_meter_placeholder(&mut self, text: &str) -> String { diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_pet.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_pet.snap new file mode 100644 index 0000000000..3ac116bb46 --- /dev/null +++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__chat_composer__tests__slash_popup_pet.snap @@ -0,0 +1,9 @@ +--- +source: tui/src/bottom_pane/chat_composer.rs +expression: terminal.backend() +--- +" " +"› /pet " +" " +" " +" /pets choose or hide the terminal pet " diff --git a/codex-rs/tui/src/chatwidget.rs b/codex-rs/tui/src/chatwidget.rs index 131a3bf3ef..9efc995313 100644 --- a/codex-rs/tui/src/chatwidget.rs +++ b/codex-rs/tui/src/chatwidget.rs @@ -182,7 +182,10 @@ use ratatui::style::Modifier; use ratatui::style::Style; use ratatui::style::Stylize; use ratatui::text::Line; +use ratatui::text::Text; +use ratatui::widgets::Clear; use ratatui::widgets::Paragraph; +use ratatui::widgets::Widget; use ratatui::widgets::Wrap; use tokio::sync::mpsc::UnboundedSender; use tracing::debug; @@ -203,6 +206,8 @@ const PLAN_MODE_REASONING_SCOPE_TITLE: &str = "Apply reasoning change"; const PLAN_MODE_REASONING_SCOPE_PLAN_ONLY: &str = "Apply to Plan mode override"; const PLAN_MODE_REASONING_SCOPE_ALL_MODES: &str = "Apply to global default and Plan mode override"; const CONNECTORS_SELECTION_VIEW_ID: &str = "connectors-selection"; +const PET_SELECTION_LOADING_VIEW_ID: &str = "pet-selection-loading"; +const AMBIENT_PET_WRAP_GAP_COLUMNS: u16 = 2; const TUI_STUB_MESSAGE: &str = "Not available in TUI yet."; /// Choose the keybinding used to edit the most-recently queued message. @@ -334,6 +339,7 @@ use self::interrupts::InterruptManager; mod keymap_picker; mod mcp_startup; use self::mcp_startup::McpStartupStatus; +mod pets; mod session_header; use self::session_header::SessionHeader; mod hooks; @@ -743,6 +749,15 @@ pub(crate) struct ChatWidget { review: ReviewState, // Active hook runs render in a dedicated live cell so they can run alongside tools. active_hook_cell: Option, + // Ambient companion rendered over the transcript area, never inside the footer rows. + ambient_pet: Option, + pet_picker_preview_state: crate::pets::PetPickerPreviewState, + pet_picker_preview_pet: Option, + pet_picker_preview_request_id: u64, + pet_picker_preview_image_visible: std::cell::Cell, + pet_selection_load_request_id: u64, + #[cfg(test)] + pet_image_support_override: Option, thread_id: Option, /// Nudge dismissals that should survive draft edits within the current thread scope. /// @@ -2270,6 +2285,10 @@ impl ChatWidget { self.set_status_header(String::from("Working")); self.full_reasoning_buffer.clear(); self.reasoning_buffer.clear(); + self.set_ambient_pet_notification( + crate::pets::PetNotificationKind::Running, + /*body*/ None, + ); self.request_redraw(); } @@ -2363,6 +2382,10 @@ impl ChatWidget { self.suppressed_exec_calls.clear(); self.last_unified_wait = None; self.unified_exec_wait_streak = None; + if !from_replay { + let body = Notification::agent_turn_preview(¬ification_response); + self.set_ambient_pet_notification(crate::pets::PetNotificationKind::Review, body); + } self.request_redraw(); let had_pending_steers = !self.input_queue.pending_steers.is_empty(); @@ -2847,6 +2870,10 @@ impl ChatWidget { self.input_queue.submit_pending_steers_after_interrupt = false; self.finalize_turn(); self.add_to_history(history_cell::new_error_event(message)); + self.set_ambient_pet_notification( + crate::pets::PetNotificationKind::Failed, + /*body*/ None, + ); self.request_redraw(); // After an error ends the turn, try sending the next queued input. @@ -4051,6 +4078,9 @@ impl ChatWidget { self.update_due_hook_visibility(); self.schedule_hook_timer_if_needed(); self.bottom_pane.pre_draw_tick(); + if let Some(pet) = self.ambient_pet.as_ref() { + pet.schedule_next_frame(); + } self.refresh_plan_mode_nudge(); self.refresh_goal_status_indicator_for_time_tick(); if self.terminal_title_shows_action_required() != self.last_terminal_title_requires_action { @@ -4395,6 +4425,10 @@ impl ChatWidget { }; self.bottom_pane .push_approval_request(request, &self.config.features); + self.set_ambient_pet_notification( + crate::pets::PetNotificationKind::Waiting, + /*body*/ None, + ); self.request_redraw(); } @@ -4411,6 +4445,10 @@ impl ChatWidget { }; self.bottom_pane .push_approval_request(request, &self.config.features); + self.set_ambient_pet_notification( + crate::pets::PetNotificationKind::Waiting, + /*body*/ None, + ); self.request_redraw(); self.notify(Notification::EditApprovalRequested { cwd: self.config.cwd.to_path_buf(), @@ -4469,12 +4507,20 @@ impl ChatWidget { } } } + self.set_ambient_pet_notification( + crate::pets::PetNotificationKind::Waiting, + /*body*/ None, + ); self.request_redraw(); } pub(crate) fn push_approval_request(&mut self, request: ApprovalRequest) { self.bottom_pane .push_approval_request(request, &self.config.features); + self.set_ambient_pet_notification( + crate::pets::PetNotificationKind::Waiting, + /*body*/ None, + ); self.request_redraw(); } @@ -4484,6 +4530,10 @@ impl ChatWidget { ) { self.bottom_pane .push_mcp_server_elicitation_request(request); + self.set_ambient_pet_notification( + crate::pets::PetNotificationKind::Waiting, + /*body*/ None, + ); self.request_redraw(); } @@ -4498,6 +4548,10 @@ impl ChatWidget { }; self.notify(Notification::PlanModePrompt { title }); self.bottom_pane.push_user_input_request(ev); + self.set_ambient_pet_notification( + crate::pets::PetNotificationKind::Waiting, + /*body*/ None, + ); self.request_redraw(); } @@ -4512,6 +4566,10 @@ impl ChatWidget { }; self.bottom_pane .push_approval_request(request, &self.config.features); + self.set_ambient_pet_notification( + crate::pets::PetNotificationKind::Waiting, + /*body*/ None, + ); self.request_redraw(); } @@ -4772,6 +4830,12 @@ impl ChatWidget { &chat_keymap.edit_queued_message, current_terminal_info, ); + pets::start_configured_pet_load_if_needed( + &config, + /*ambient_pet_missing*/ true, + frame_requester.clone(), + app_event_tx.clone(), + ); let mut widget = Self { app_event_tx: app_event_tx.clone(), frame_requester: frame_requester.clone(), @@ -4846,6 +4910,14 @@ impl ChatWidget { status_state: StatusState::default(), review: ReviewState::default(), active_hook_cell: None, + ambient_pet: None, + pet_picker_preview_state: crate::pets::PetPickerPreviewState::default(), + pet_picker_preview_pet: None, + pet_picker_preview_request_id: 0, + pet_picker_preview_image_visible: std::cell::Cell::new(/*value*/ false), + pet_selection_load_request_id: 0, + #[cfg(test)] + pet_image_support_override: None, thread_id: None, dismissed_plan_mode_nudge_scopes: HashSet::new(), thread_name: None, @@ -9369,6 +9441,11 @@ impl ChatWidget { self.request_redraw(); } + pub(crate) fn add_warning_message(&mut self, message: String) { + self.add_to_history(history_cell::new_warning_event(message)); + self.request_redraw(); + } + pub(crate) fn add_error_message(&mut self, message: String) { self.add_to_history(history_cell::new_error_event(message)); self.request_redraw(); @@ -9775,6 +9852,8 @@ impl ChatWidget { if width == 0 { None } else { + let width = u16::try_from(width).unwrap_or(u16::MAX); + let width = usize::from(self.history_wrap_width(width)); Some(crate::width::usable_content_width(width, reserved_cols).unwrap_or(1)) } }) @@ -10405,17 +10484,22 @@ impl ChatWidget { } fn as_renderable(&self) -> RenderableItem<'_> { + let active_cell_right_reserve = self.ambient_pet_wrap_reserved_cols(); let active_cell_renderable = match &self.transcript.active_cell { - Some(cell) => RenderableItem::Borrowed(cell).inset(Insets::tlbr( - /*top*/ 1, /*left*/ 0, /*bottom*/ 0, /*right*/ 0, - )), + Some(cell) => RenderableItem::Owned(Box::new(TranscriptAreaRenderable { + child: cell.as_ref(), + top: 1, + right: active_cell_right_reserve, + })), None => RenderableItem::Owned(Box::new(())), }; let active_hook_cell_renderable = match &self.active_hook_cell { Some(cell) if cell.should_render() => { - RenderableItem::Borrowed(cell).inset(Insets::tlbr( - /*top*/ 1, /*left*/ 0, /*bottom*/ 0, /*right*/ 0, - )) + RenderableItem::Owned(Box::new(TranscriptAreaRenderable { + child: cell, + top: 1, + right: active_cell_right_reserve, + })) } _ => RenderableItem::Owned(Box::new(())), }; @@ -10424,7 +10508,11 @@ impl ChatWidget { flex.push(/*flex*/ 0, active_hook_cell_renderable); flex.push( /*flex*/ 0, - RenderableItem::Borrowed(&self.bottom_pane).inset(Insets::tlbr( + RenderableItem::Owned(Box::new(BottomPaneComposerReserveRenderable { + bottom_pane: &self.bottom_pane, + right_reserve: active_cell_right_reserve, + })) + .inset(Insets::tlbr( /*top*/ 1, /*left*/ 0, /*bottom*/ 0, /*right*/ 0, )), ); @@ -10432,6 +10520,75 @@ impl ChatWidget { } } +struct BottomPaneComposerReserveRenderable<'a> { + bottom_pane: &'a BottomPane, + right_reserve: u16, +} + +impl Renderable for BottomPaneComposerReserveRenderable<'_> { + fn render(&self, area: Rect, buf: &mut Buffer) { + self.bottom_pane + .render_with_composer_right_reserve(area, buf, self.right_reserve); + } + + fn desired_height(&self, width: u16) -> u16 { + self.bottom_pane + .desired_height_with_composer_right_reserve(width, self.right_reserve) + } + + fn cursor_pos(&self, area: Rect) -> Option<(u16, u16)> { + self.bottom_pane + .cursor_pos_with_composer_right_reserve(area, self.right_reserve) + } + + fn cursor_style(&self, area: Rect) -> crossterm::cursor::SetCursorStyle { + self.bottom_pane + .cursor_style_with_composer_right_reserve(area, self.right_reserve) + } +} + +struct TranscriptAreaRenderable<'a> { + child: &'a dyn HistoryCell, + top: u16, + right: u16, +} + +impl Renderable for TranscriptAreaRenderable<'_> { + fn render(&self, area: Rect, buf: &mut Buffer) { + let area = self.child_area(area); + let lines = self.child.display_lines(area.width); + let paragraph = Paragraph::new(Text::from(lines)).wrap(Wrap { trim: false }); + let y = if area.height == 0 { + 0 + } else { + let overflow = paragraph + .line_count(area.width) + .saturating_sub(usize::from(area.height)); + u16::try_from(overflow).unwrap_or(u16::MAX) + }; + Clear.render(area, buf); + paragraph.scroll((y, 0)).render(area, buf); + } + + fn desired_height(&self, width: u16) -> u16 { + let child_width = width.saturating_sub(self.right).max(1); + HistoryCell::desired_height(self.child, child_width) + self.top + } +} + +impl TranscriptAreaRenderable<'_> { + fn child_area(&self, area: Rect) -> Rect { + let y = area.y.saturating_add(self.top); + let height = area.height.saturating_sub(self.top); + Rect::new( + area.x, + y, + area.width.saturating_sub(self.right).max(1), + height, + ) + } +} + #[cfg(not(target_os = "linux"))] impl ChatWidget { pub(crate) fn update_recording_meter_in_place(&mut self, id: &str, text: &str) -> bool { diff --git a/codex-rs/tui/src/chatwidget/pets.rs b/codex-rs/tui/src/chatwidget/pets.rs new file mode 100644 index 0000000000..71583414f4 --- /dev/null +++ b/codex-rs/tui/src/chatwidget/pets.rs @@ -0,0 +1,335 @@ +//! Chat widget helpers for ambient terminal pets and the pets picker. + +use super::*; +use codex_config::types::TuiPetAnchor; + +pub(super) fn load_ambient_pet( + config: &Config, + frame_requester: FrameRequester, +) -> Option { + let selected_pet = config.tui_pet.as_deref()?; + if selected_pet == crate::pets::DISABLED_PET_ID { + return None; + } + + crate::pets::AmbientPet::load( + Some(selected_pet), + &config.codex_home, + frame_requester, + config.animations, + ) + .ok() +} + +pub(super) fn start_configured_pet_load_if_needed( + config: &Config, + ambient_pet_missing: bool, + frame_requester: FrameRequester, + app_event_tx: AppEventSender, +) { + let Some(pet_id) = config.tui_pet.clone() else { + return; + }; + if pet_id == crate::pets::DISABLED_PET_ID || !ambient_pet_missing { + return; + } + + let codex_home = config.codex_home.clone(); + let animations_enabled = config.animations; + spawn_pet_load(move || { + let result = crate::pets::ensure_builtin_pack_for_pet(&pet_id, &codex_home) + .and_then(|()| { + crate::pets::AmbientPet::load( + Some(&pet_id), + &codex_home, + frame_requester, + animations_enabled, + ) + }) + .map(Some) + .map_err(|err| err.to_string()); + app_event_tx.send(AppEvent::ConfiguredPetLoaded { pet_id, result }); + }); +} + +impl ChatWidget { + pub(super) fn set_ambient_pet_notification( + &mut self, + kind: crate::pets::PetNotificationKind, + body: Option, + ) { + if let Some(pet) = self.ambient_pet.as_mut() { + pet.set_notification(kind, body); + } + } + + pub(crate) fn ambient_pet_image_enabled(&self) -> bool { + self.ambient_pet + .as_ref() + .is_some_and(crate::pets::AmbientPet::image_enabled) + } + + pub(crate) fn disable_ambient_pet_for_session(&mut self) { + self.ambient_pet = None; + self.request_redraw(); + } + + pub(crate) fn ambient_pet_draw( + &self, + area: Rect, + composer_bottom_y: u16, + ) -> Option { + if !self.bottom_pane.no_modal_or_popup_active() { + return None; + } + + let anchor_bottom_y = match self.config.tui_pet_anchor { + TuiPetAnchor::Composer => composer_bottom_y, + TuiPetAnchor::ScreenBottom => area.bottom(), + }; + self.ambient_pet + .as_ref()? + .draw_request(area, anchor_bottom_y) + } + + pub(super) fn ambient_pet_wrap_reserved_cols(&self) -> u16 { + self.ambient_pet + .as_ref() + .filter(|pet| pet.image_enabled()) + .map(|pet| { + pet.image_columns() + .saturating_add(AMBIENT_PET_WRAP_GAP_COLUMNS) + }) + .unwrap_or(0) + } + + pub(crate) fn history_wrap_width(&self, width: u16) -> u16 { + width + .saturating_sub(self.ambient_pet_wrap_reserved_cols()) + .max(1) + } + + pub(crate) fn pet_picker_preview_draw(&self) -> Option { + self.bottom_pane + .selected_index_for_active_view(crate::pets::PET_PICKER_VIEW_ID)?; + let area = self.pet_picker_preview_state.area()?; + let request = self + .pet_picker_preview_pet + .as_ref()? + .preview_draw_request(area)?; + self.pet_picker_preview_image_visible.set(true); + Some(request) + } + + pub(crate) fn should_clear_pet_picker_preview_image(&self) -> bool { + self.pet_picker_preview_image_visible.replace(false) + } + + pub(crate) fn fail_pet_picker_preview_render(&mut self, message: String) { + self.pet_picker_preview_state.set_error(message); + self.pet_picker_preview_pet = None; + self.request_redraw(); + } + + pub(crate) fn open_pets_picker(&mut self) { + if self.warn_if_pets_unsupported() { + return; + } + + self.pet_picker_preview_state.clear(); + self.pet_picker_preview_pet = None; + let params = crate::pets::build_pet_picker_params( + self.config.tui_pet.as_deref(), + &self.config.codex_home, + self.pet_picker_preview_state.clone(), + ); + self.bottom_pane.show_selection_view(params); + let initial_pet_id = self + .config + .tui_pet + .as_deref() + .unwrap_or(crate::pets::DEFAULT_PET_ID) + .to_string(); + self.start_pet_picker_preview(initial_pet_id); + } + + pub(crate) fn select_pet_by_id(&mut self, pet_id: String) { + if self.warn_if_pets_unsupported() { + return; + } + + self.app_event_tx.send(AppEvent::PetSelected { pet_id }); + } + + fn warn_if_pets_unsupported(&mut self) -> bool { + let support = self.pet_image_support(); + let Some(message) = support.unsupported_message() else { + return false; + }; + + self.add_warning_message(message.to_string()); + true + } + + fn pet_image_support(&self) -> crate::pets::PetImageSupport { + #[cfg(test)] + if let Some(support) = self.pet_image_support_override { + return support; + } + + #[cfg(test)] + return crate::pets::PetImageSupport::Unsupported( + crate::pets::PetImageUnsupportedReason::Terminal, + ); + + #[cfg(not(test))] + crate::pets::detect_pet_image_support() + } + + /// Set the pet preselected by the TUI picker in the widget's config copy. + pub(crate) fn set_tui_pet(&mut self, pet: Option) { + self.config.tui_pet = pet; + self.ambient_pet = load_ambient_pet(&self.config, self.frame_requester.clone()); + self.apply_ambient_pet_image_support_override_for_tests(); + self.request_redraw(); + } + + pub(crate) fn set_tui_pet_loaded( + &mut self, + pet: Option, + ambient_pet: Option, + ) { + self.config.tui_pet = pet; + self.ambient_pet = ambient_pet; + self.apply_ambient_pet_image_support_override_for_tests(); + self.request_redraw(); + } + + #[cfg(test)] + fn apply_ambient_pet_image_support_override_for_tests(&mut self) { + if let Some(support) = self.pet_image_support_override + && let Some(pet) = self.ambient_pet.as_mut() + { + pet.set_image_support_for_tests(support); + } + } + + #[cfg(not(test))] + fn apply_ambient_pet_image_support_override_for_tests(&mut self) {} + + pub(crate) fn start_pet_picker_preview(&mut self, pet_id: String) { + self.pet_picker_preview_request_id = + self.pet_picker_preview_request_id.wrapping_add(/*rhs*/ 1); + let request_id = self.pet_picker_preview_request_id; + self.pet_picker_preview_pet = None; + if pet_id == crate::pets::DISABLED_PET_ID { + self.pet_picker_preview_state.set_disabled(); + self.request_redraw(); + return; + } + + self.pet_picker_preview_state.set_loading(); + self.request_redraw(); + + let codex_home = self.config.codex_home.clone(); + let frame_requester = self.frame_requester.clone(); + let tx = self.app_event_tx.clone(); + spawn_pet_load(move || { + let result = crate::pets::ensure_builtin_pack_for_pet(&pet_id, &codex_home) + .and_then(|()| { + crate::pets::AmbientPet::load( + Some(&pet_id), + &codex_home, + frame_requester, + /*animations_enabled*/ false, + ) + }) + .map_err(|err| err.to_string()); + tx.send(AppEvent::PetPreviewLoaded { request_id, result }); + }); + } + + pub(crate) fn finish_pet_picker_preview_load( + &mut self, + request_id: u64, + result: Result, + ) { + if request_id != self.pet_picker_preview_request_id { + return; + } + + match result { + Ok(pet) => { + self.pet_picker_preview_state.set_ready(); + self.pet_picker_preview_pet = Some(pet); + #[cfg(test)] + if let Some(support) = self.pet_image_support_override + && let Some(pet) = self.pet_picker_preview_pet.as_mut() + { + pet.set_image_support_for_tests(support); + } + } + Err(message) => { + self.pet_picker_preview_state.set_error(message); + self.pet_picker_preview_pet = None; + } + } + self.request_redraw(); + } + + pub(crate) fn show_pet_selection_loading_popup(&mut self) -> u64 { + self.pet_selection_load_request_id = + self.pet_selection_load_request_id.wrapping_add(/*rhs*/ 1); + self.pet_picker_preview_state.clear(); + self.pet_picker_preview_pet = None; + self.bottom_pane.show_selection_view(SelectionViewParams { + view_id: Some(PET_SELECTION_LOADING_VIEW_ID), + title: Some("Loading Pet".to_string()), + subtitle: Some("Preparing the terminal pet.".to_string()), + items: vec![SelectionItem { + name: "Loading selected pet...".to_string(), + is_disabled: true, + ..Default::default() + }], + ..Default::default() + }); + self.pet_selection_load_request_id + } + + pub(crate) fn finish_pet_selection_loading_popup(&mut self, request_id: u64) -> bool { + if request_id != self.pet_selection_load_request_id { + return false; + } + self.bottom_pane + .dismiss_active_view_if_id(PET_SELECTION_LOADING_VIEW_ID); + true + } + + #[cfg(test)] + pub(crate) fn set_pet_image_support_for_tests( + &mut self, + support: crate::pets::PetImageSupport, + ) { + self.pet_image_support_override = Some(support); + self.apply_ambient_pet_image_support_override_for_tests(); + } + + #[cfg(test)] + pub(crate) fn install_test_ambient_pet_for_tests(&mut self, animations_enabled: bool) { + self.set_tui_pet_loaded( + Some("test".to_string()), + Some(crate::pets::test_ambient_pet( + self.frame_requester.clone(), + animations_enabled, + )), + ); + } +} + +fn spawn_pet_load(f: impl FnOnce() + Send + 'static) { + if let Ok(handle) = tokio::runtime::Handle::try_current() { + std::mem::drop(handle.spawn_blocking(f)); + } else { + let _ = std::thread::spawn(f); + } +} diff --git a/codex-rs/tui/src/chatwidget/slash_dispatch.rs b/codex-rs/tui/src/chatwidget/slash_dispatch.rs index 5f047b9afb..14a2dffb12 100644 --- a/codex-rs/tui/src/chatwidget/slash_dispatch.rs +++ b/codex-rs/tui/src/chatwidget/slash_dispatch.rs @@ -405,6 +405,9 @@ impl ChatWidget { SlashCommand::Theme => { self.open_theme_picker(); } + SlashCommand::Pets => { + self.open_pets_picker(); + } SlashCommand::Ps => { self.add_ps_output(); } @@ -781,6 +784,17 @@ impl ChatWidget { self.app_event_tx .send(AppEvent::BeginWindowsSandboxGrantReadRoot { path: args }); } + SlashCommand::Pets + if matches!( + args.trim().to_ascii_lowercase().as_str(), + "disable" | "disabled" | "hide" | "hidden" | "off" | "none" + ) => + { + self.app_event_tx.send(AppEvent::PetDisabled); + } + SlashCommand::Pets if !trimmed.is_empty() => { + self.select_pet_by_id(args); + } _ => self.dispatch_command(cmd), } if source == SlashCommandDispatchSource::Live && cmd != SlashCommand::Goal { @@ -970,7 +984,8 @@ impl ChatWidget { | SlashCommand::Hooks | SlashCommand::Title | SlashCommand::Statusline - | SlashCommand::Theme => QueueDrain::Stop, + | SlashCommand::Theme + | SlashCommand::Pets => QueueDrain::Stop, } } diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec.snap index 15511611a1..f26da349a1 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec.snap @@ -1,5 +1,5 @@ --- -source: tui/src/chatwidget/tests.rs +source: tui/src/chatwidget/tests/exec_flow.rs expression: terminal.backend().vt100().screen().contents() --- diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec_multiline_prefix_no_execpolicy.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec_multiline_prefix_no_execpolicy.snap index 3c256fe923..00cc144ebb 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec_multiline_prefix_no_execpolicy.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec_multiline_prefix_no_execpolicy.snap @@ -1,5 +1,5 @@ --- -source: tui/src/chatwidget/tests.rs +source: tui/src/chatwidget/tests/exec_flow.rs expression: contents --- diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec_no_reason.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec_no_reason.snap index 04b278a126..7395f8d577 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec_no_reason.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approval_modal_exec_no_reason.snap @@ -1,5 +1,5 @@ --- -source: tui/src/chatwidget/tests.rs +source: tui/src/chatwidget/tests/exec_flow.rs expression: terminal.backend().vt100().screen().contents() --- diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approvals_selection_popup.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approvals_selection_popup.snap index f0956946c9..52ac576149 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approvals_selection_popup.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__approvals_selection_popup.snap @@ -1,5 +1,5 @@ --- -source: tui/src/chatwidget/tests.rs +source: tui/src/chatwidget/tests/permissions.rs expression: popup --- Update Model Permissions diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h3.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h3.snap index 4487d0652e..19e8ac6003 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h3.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chat_small_running_h3.snap @@ -1,6 +1,6 @@ --- -source: tui/src/chatwidget/tests.rs -expression: terminal.backend() +source: tui/src/chatwidget/tests/status_and_layout.rs +expression: normalized_backend_snapshot(terminal.backend()) --- " " " " diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_exec_and_status_layout_vt100_snapshot.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_exec_and_status_layout_vt100_snapshot.snap index 52779fd840..dfcfae199f 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_exec_and_status_layout_vt100_snapshot.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__chatwidget_exec_and_status_layout_vt100_snapshot.snap @@ -1,6 +1,6 @@ --- -source: tui/src/chatwidget/tests.rs -expression: term.backend().vt100().screen().contents() +source: tui/src/chatwidget/tests/status_and_layout.rs +expression: normalize_snapshot_paths(term.backend().vt100().screen().contents()) --- diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__feedback_good_result_consent_popup.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__feedback_good_result_consent_popup.snap index ffc3e7f15a..2ba27d409f 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__feedback_good_result_consent_popup.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__feedback_good_result_consent_popup.snap @@ -1,5 +1,5 @@ --- -source: tui/src/chatwidget/tests.rs +source: tui/src/chatwidget/tests/popups_and_settings.rs expression: popup --- Upload logs? diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__feedback_upload_consent_popup.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__feedback_upload_consent_popup.snap index 01f1175f3c..b027333ad7 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__feedback_upload_consent_popup.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__feedback_upload_consent_popup.snap @@ -1,5 +1,5 @@ --- -source: tui/src/chatwidget/tests.rs +source: tui/src/chatwidget/tests/popups_and_settings.rs expression: popup --- Upload logs? diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__full_access_confirmation_popup.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__full_access_confirmation_popup.snap index 71dac5f590..d98ce2b772 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__full_access_confirmation_popup.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__full_access_confirmation_popup.snap @@ -1,5 +1,5 @@ --- -source: tui/src/chatwidget/tests.rs +source: tui/src/chatwidget/tests/permissions.rs expression: popup --- Enable full access? diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__memories_enable_prompt.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__memories_enable_prompt.snap index f9b0daefcc..a229db53b3 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__memories_enable_prompt.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__memories_enable_prompt.snap @@ -1,5 +1,5 @@ --- -source: tui/src/chatwidget/tests.rs +source: tui/src/chatwidget/tests/popups_and_settings.rs expression: popup --- Enable memories? diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_reasoning_selection_popup.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_reasoning_selection_popup.snap index 27c7d061ee..baef861f4c 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_reasoning_selection_popup.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__model_reasoning_selection_popup.snap @@ -1,5 +1,5 @@ --- -source: tui/src/chatwidget/tests.rs +source: tui/src/chatwidget/tests/popups_and_settings.rs expression: popup --- Select Reasoning Level for gpt-5.4 diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__multi_agent_enable_prompt.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__multi_agent_enable_prompt.snap index 0d57fceac9..58478fa919 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__multi_agent_enable_prompt.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__multi_agent_enable_prompt.snap @@ -1,5 +1,5 @@ --- -source: tui/src/chatwidget/tests.rs +source: tui/src/chatwidget/tests/popups_and_settings.rs expression: popup --- Enable subagents? diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__personality_selection_popup.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__personality_selection_popup.snap index d9a6e0a23c..8613d66814 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__personality_selection_popup.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__personality_selection_popup.snap @@ -1,5 +1,5 @@ --- -source: tui/src/chatwidget/tests.rs +source: tui/src/chatwidget/tests/popups_and_settings.rs expression: popup --- Select Personality diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plan_implementation_popup.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plan_implementation_popup.snap index 25897974fa..c64088b38a 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plan_implementation_popup.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plan_implementation_popup.snap @@ -1,5 +1,5 @@ --- -source: tui/src/chatwidget/tests.rs +source: tui/src/chatwidget/tests/plan_mode.rs expression: popup --- Implement this plan? diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plan_implementation_popup_no_selected.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plan_implementation_popup_no_selected.snap index e8083a0e01..a24ce666d8 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plan_implementation_popup_no_selected.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plan_implementation_popup_no_selected.snap @@ -1,5 +1,5 @@ --- -source: tui/src/chatwidget/tests.rs +source: tui/src/chatwidget/tests/plan_mode.rs expression: popup --- Implement this plan? diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugin_detail_error_popup.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugin_detail_error_popup.snap index 5305a9fc78..7350ccc0b9 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugin_detail_error_popup.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugin_detail_error_popup.snap @@ -1,5 +1,5 @@ --- -source: tui/src/chatwidget/tests.rs +source: tui/src/chatwidget/tests/popups_and_settings.rs expression: popup --- Plugins diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugin_detail_popup_installable.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugin_detail_popup_installable.snap index e55edcae89..c7edae1714 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugin_detail_popup_installable.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugin_detail_popup_installable.snap @@ -1,6 +1,6 @@ --- -source: tui/src/chatwidget/tests.rs -expression: popup +source: tui/src/chatwidget/tests/popups_and_settings.rs +expression: strip_osc8_for_snapshot(&popup) --- Plugins Figma · Can be installed · ChatGPT Marketplace diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugin_detail_popup_installed.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugin_detail_popup_installed.snap index 272ebb7c2a..4d91b17f34 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugin_detail_popup_installed.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__plugin_detail_popup_installed.snap @@ -1,6 +1,6 @@ --- -source: tui/src/chatwidget/tests.rs -expression: popup +source: tui/src/chatwidget/tests/popups_and_settings.rs +expression: strip_osc8_for_snapshot(&popup) --- Plugins Figma · Installed · ChatGPT Marketplace diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__rate_limit_switch_prompt_popup.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__rate_limit_switch_prompt_popup.snap index bb217615d9..83213897ef 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__rate_limit_switch_prompt_popup.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__rate_limit_switch_prompt_popup.snap @@ -1,5 +1,5 @@ --- -source: tui/src/chatwidget/tests.rs +source: tui/src/chatwidget/tests/status_and_layout.rs expression: popup --- Approaching rate limits diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__realtime_audio_selection_popup.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__realtime_audio_selection_popup.snap index 8c60f961f9..05c5acac40 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__realtime_audio_selection_popup.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__realtime_audio_selection_popup.snap @@ -1,5 +1,5 @@ --- -source: tui/src/chatwidget/tests.rs +source: tui/src/chatwidget/tests/popups_and_settings.rs expression: popup --- Settings diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__realtime_audio_selection_popup_narrow.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__realtime_audio_selection_popup_narrow.snap index 8c60f961f9..05c5acac40 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__realtime_audio_selection_popup_narrow.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__realtime_audio_selection_popup_narrow.snap @@ -1,5 +1,5 @@ --- -source: tui/src/chatwidget/tests.rs +source: tui/src/chatwidget/tests/popups_and_settings.rs expression: popup --- Settings diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__realtime_microphone_picker_popup.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__realtime_microphone_picker_popup.snap index 418fb5c9ef..76ee7ec7e6 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__realtime_microphone_picker_popup.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__realtime_microphone_picker_popup.snap @@ -1,5 +1,5 @@ --- -source: tui/src/chatwidget/tests.rs +source: tui/src/chatwidget/tests/popups_and_settings.rs expression: popup --- Select Microphone diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__renamed_thread_footer_title.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__renamed_thread_footer_title.snap index e024f534ae..2616a5981c 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__renamed_thread_footer_title.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__renamed_thread_footer_title.snap @@ -1,6 +1,6 @@ --- -source: tui/src/chatwidget/tests.rs -expression: terminal.backend() +source: tui/src/chatwidget/tests/status_and_layout.rs +expression: normalized_backend_snapshot(terminal.backend()) --- " " " " diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__slash_pets_picker.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__slash_pets_picker.snap new file mode 100644 index 0000000000..88210edc5e --- /dev/null +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__slash_pets_picker.snap @@ -0,0 +1,25 @@ +--- +source: tui/src/chatwidget/tests/slash_commands.rs +expression: popup +--- + Select Pet + Choose a pet to wake in the terminal. + + Type to filter pets... + Disable terminal pets + BSOD A tiny blue-screen + gremlin +› Codex The original Codex + companion + Dewey A tidy duck for calm Loading preview... + workspace days + Fireball Hot path energy for + fast iteration + Null Signal Quiet signal from the + void + Rocky A steady rock when + the diff gets large + Seedy Small green shoots + for new ideas + + Press enter to confirm or esc to go back diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_line_fast_mode_footer.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_line_fast_mode_footer.snap index ad644699c4..c558ac4726 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_line_fast_mode_footer.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_line_fast_mode_footer.snap @@ -1,6 +1,6 @@ --- -source: tui/src/chatwidget/tests.rs -expression: terminal.backend() +source: tui/src/chatwidget/tests/status_and_layout.rs +expression: normalized_backend_snapshot(terminal.backend()) --- " " " " diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_line_model_with_reasoning_plan_mode_footer.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_line_model_with_reasoning_plan_mode_footer.snap index 0eef02e0a8..fc82518882 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_line_model_with_reasoning_plan_mode_footer.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_line_model_with_reasoning_plan_mode_footer.snap @@ -1,6 +1,6 @@ --- -source: tui/src/chatwidget/tests.rs -expression: terminal.backend() +source: tui/src/chatwidget/tests/status_and_layout.rs +expression: normalized_backend_snapshot(terminal.backend()) --- " " " " diff --git a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_and_approval_modal.snap b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_and_approval_modal.snap index 5e6e33dece..532b44c6cb 100644 --- a/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_and_approval_modal.snap +++ b/codex-rs/tui/src/chatwidget/snapshots/codex_tui__chatwidget__tests__status_widget_and_approval_modal.snap @@ -1,6 +1,6 @@ --- -source: tui/src/chatwidget/tests.rs -expression: terminal.backend() +source: tui/src/chatwidget/tests/status_and_layout.rs +expression: normalized_backend_snapshot(terminal.backend()) --- " " " " diff --git a/codex-rs/tui/src/chatwidget/tests/guardian.rs b/codex-rs/tui/src/chatwidget/tests/guardian.rs index 87ec94ef1f..27a226db02 100644 --- a/codex-rs/tui/src/chatwidget/tests/guardian.rs +++ b/codex-rs/tui/src/chatwidget/tests/guardian.rs @@ -149,7 +149,7 @@ async fn guardian_approved_exec_renders_approved_request() { let width: u16 = 120; let ui_height: u16 = chat.desired_height(width); - let vt_height: u16 = 12; + let vt_height: u16 = ui_height.saturating_add(1).max(12); let viewport = Rect::new(0, vt_height - ui_height - 1, width, ui_height); let backend = VT100Backend::new(width, vt_height); @@ -227,7 +227,7 @@ async fn guardian_approved_request_permissions_renders_request_summary() { let width: u16 = 110; let ui_height: u16 = chat.desired_height(width); - let vt_height: u16 = 12; + let vt_height: u16 = ui_height.saturating_add(1).max(12); let viewport = Rect::new(0, vt_height - ui_height - 1, width, ui_height); let backend = VT100Backend::new(width, vt_height); @@ -412,7 +412,7 @@ async fn app_server_guardian_review_denied_renders_denied_request_snapshot() { let width: u16 = 140; let ui_height: u16 = chat.desired_height(width); - let vt_height: u16 = 16; + let vt_height: u16 = ui_height.saturating_add(1).max(16); let viewport = Rect::new(0, vt_height - ui_height - 1, width, ui_height); let backend = VT100Backend::new(width, vt_height); @@ -493,7 +493,7 @@ async fn app_server_guardian_review_timed_out_renders_timed_out_request_snapshot let width: u16 = 140; let ui_height: u16 = chat.desired_height(width); - let vt_height: u16 = 16; + let vt_height: u16 = ui_height.saturating_add(1).max(16); let viewport = Rect::new(0, vt_height - ui_height - 1, width, ui_height); let backend = VT100Backend::new(width, vt_height); diff --git a/codex-rs/tui/src/chatwidget/tests/mcp_startup.rs b/codex-rs/tui/src/chatwidget/tests/mcp_startup.rs index 977bea2887..da4777bfb1 100644 --- a/codex-rs/tui/src/chatwidget/tests/mcp_startup.rs +++ b/codex-rs/tui/src/chatwidget/tests/mcp_startup.rs @@ -102,7 +102,7 @@ async fn app_server_mcp_startup_failure_renders_warning_history() { let width: u16 = 120; let ui_height: u16 = chat.desired_height(width); - let vt_height: u16 = 10; + let vt_height: u16 = ui_height.saturating_add(1).max(10); let viewport = Rect::new(0, vt_height - ui_height - 1, width, ui_height); let backend = VT100Backend::new(width, vt_height); diff --git a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs index a1bf28c807..e4865e64f3 100644 --- a/codex-rs/tui/src/chatwidget/tests/slash_commands.rs +++ b/codex-rs/tui/src/chatwidget/tests/slash_commands.rs @@ -1,6 +1,19 @@ use super::*; use crate::bottom_pane::slash_commands::ServiceTierCommand; use pretty_assertions::assert_eq; +use serial_test::serial; + +fn force_pet_image_support(chat: &mut ChatWidget) { + chat.set_pet_image_support_for_tests(crate::pets::PetImageSupport::Supported( + crate::pets::ImageProtocol::Kitty, + )); +} + +fn force_tmux_pet_image_unsupported(chat: &mut ChatWidget) { + chat.set_pet_image_support_for_tests(crate::pets::PetImageSupport::Unsupported( + crate::pets::PetImageUnsupportedReason::Tmux, + )); +} fn fast_tier_command() -> ServiceTierCommand { ServiceTierCommand { @@ -1819,6 +1832,108 @@ async fn slash_resume_with_arg_requests_named_session() { assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty)); } +#[tokio::test] +#[serial] +async fn slash_pets_opens_picker() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + force_pet_image_support(&mut chat); + + chat.dispatch_command(SlashCommand::Pets); + + assert!(chat.bottom_pane.has_active_view()); + assert_matches!(rx.try_recv(), Err(TryRecvError::Empty)); + + let popup = render_bottom_popup(&chat, /*width*/ 80); + assert_chatwidget_snapshot!("slash_pets_picker", popup); +} + +#[tokio::test] +#[serial] +async fn slash_pets_with_arg_selects_named_pet() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + force_pet_image_support(&mut chat); + + chat.bottom_pane + .set_composer_text("/pets chefito".to_string(), Vec::new(), Vec::new()); + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + + assert_matches!( + rx.try_recv(), + Ok(AppEvent::PetSelected { pet_id }) if pet_id == "chefito" + ); + assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty)); +} + +#[tokio::test] +#[serial] +async fn slash_pets_disable_disables_pets_even_on_unsupported_terminal() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + force_tmux_pet_image_unsupported(&mut chat); + + chat.bottom_pane + .set_composer_text("/pets disable".to_string(), Vec::new(), Vec::new()); + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + + assert_matches!(rx.try_recv(), Ok(AppEvent::PetDisabled)); + assert_matches!(rx.try_recv(), Err(TryRecvError::Empty)); + assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty)); +} + +#[tokio::test] +#[serial] +async fn slash_pet_hide_disables_pets_even_on_unsupported_terminal() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + force_tmux_pet_image_unsupported(&mut chat); + + chat.bottom_pane + .set_composer_text("/pet hide".to_string(), Vec::new(), Vec::new()); + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + + assert_matches!(rx.try_recv(), Ok(AppEvent::PetDisabled)); + assert_matches!(rx.try_recv(), Err(TryRecvError::Empty)); + assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty)); +} + +#[tokio::test] +#[serial] +async fn slash_pets_on_unsupported_terminal_warns_without_picker() { + let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + force_tmux_pet_image_unsupported(&mut chat); + + chat.dispatch_command(SlashCommand::Pets); + + assert!(!chat.bottom_pane.has_active_view()); + let cells = drain_insert_history(&mut rx); + let rendered = cells + .iter() + .map(|lines| lines_to_single_string(lines)) + .collect::>() + .join("\n"); + assert!(rendered.contains("Pets are disabled in tmux.")); + assert!(rendered.contains("outside tmux")); +} + +#[tokio::test] +#[serial] +async fn slash_pets_with_arg_on_unsupported_terminal_warns_without_selection() { + let (mut chat, mut rx, mut op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + force_tmux_pet_image_unsupported(&mut chat); + + chat.bottom_pane + .set_composer_text("/pets chefito".to_string(), Vec::new(), Vec::new()); + chat.handle_key_event(KeyEvent::from(KeyCode::Enter)); + + let cells = drain_insert_history(&mut rx); + let rendered = cells + .iter() + .map(|lines| lines_to_single_string(lines)) + .collect::>() + .join("\n"); + assert!(rendered.contains("Pets are disabled in tmux.")); + assert_matches!(rx.try_recv(), Err(TryRecvError::Empty)); + assert_matches!(op_rx.try_recv(), Err(TryRecvError::Empty)); +} + #[tokio::test] async fn slash_fork_requests_current_fork() { let (mut chat, mut rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; diff --git a/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__approval_requests__exec_approval_modal_exec.snap b/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__approval_requests__exec_approval_modal_exec.snap index 055a6292f1..7e766d67ed 100644 --- a/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__approval_requests__exec_approval_modal_exec.snap +++ b/codex-rs/tui/src/chatwidget/tests/snapshots/codex_tui__chatwidget__tests__approval_requests__exec_approval_modal_exec.snap @@ -1,5 +1,5 @@ --- -source: tui/src/chatwidget/tests.rs +source: tui/src/chatwidget/tests/approval_requests.rs expression: "format!(\"{buf:?}\")" --- Buffer { diff --git a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs index 2f4aa30b3b..6cc9abc704 100644 --- a/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs +++ b/codex-rs/tui/src/chatwidget/tests/status_and_layout.rs @@ -1,6 +1,15 @@ use super::*; use crate::bottom_pane::goal_status_indicator_line; use pretty_assertions::assert_eq; +use ratatui::backend::TestBackend; +use serial_test::serial; + +fn enable_test_ambient_pet(chat: &mut ChatWidget) { + chat.set_pet_image_support_for_tests(crate::pets::PetImageSupport::Supported( + crate::pets::ImageProtocol::Kitty, + )); + chat.install_test_ambient_pet_for_tests(/*animations_enabled*/ false); +} /// Receiving a token usage update without usage clears the context indicator. #[tokio::test] @@ -392,10 +401,12 @@ async fn completed_plan_table_tail_skips_provisional_history_insert() { } #[tokio::test] -async fn helpers_are_available_and_do_not_panic() { - let (tx_raw, _rx) = unbounded_channel::(); +async fn configured_pet_load_is_deferred_until_after_construction() { + let (tx_raw, mut rx) = unbounded_channel::(); let tx = AppEventSender::new(tx_raw); - let cfg = test_config().await; + let mut cfg = test_config().await; + cfg.tui_pet = Some(crate::pets::DEFAULT_PET_ID.to_string()); + crate::pets::write_test_pack(&cfg.codex_home); let resolved_model = crate::legacy_core::test_support::get_model_offline(cfg.model.as_deref()); let session_telemetry = test_session_telemetry(&cfg, resolved_model.as_str()); let init = ChatWidgetInit { @@ -419,9 +430,21 @@ async fn helpers_are_available_and_do_not_panic() { terminal_title_invalid_items_warned: Arc::new(AtomicBool::new(false)), session_telemetry, }; - let mut w = ChatWidget::new_with_app_event(init); - // Basic construction sanity. - let _ = &mut w; + + let chat = ChatWidget::new_with_app_event(init); + + assert!(!chat.ambient_pet_image_enabled()); + let event = tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv()) + .await + .unwrap() + .unwrap(); + assert_matches!( + event, + AppEvent::ConfiguredPetLoaded { pet_id, result } => { + assert_eq!(pet_id, crate::pets::DEFAULT_PET_ID); + assert!(result.unwrap().is_some()); + } + ); } #[tokio::test] @@ -1352,6 +1375,223 @@ async fn ui_snapshots_small_heights_task_running() { } } +#[tokio::test] +#[serial] +async fn ambient_pet_stays_hidden_until_a_pet_is_selected() { + use ratatui::layout::Rect; + + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + chat.set_pet_image_support_for_tests(crate::pets::PetImageSupport::Supported( + crate::pets::ImageProtocol::Kitty, + )); + assert!(chat.ambient_pet.is_none()); + + crate::pets::write_test_pack(&chat.config.codex_home); + chat.set_tui_pet(Some("codex".to_string())); + + let area = Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 60, /*height*/ 20, + ); + let draw = chat + .ambient_pet_draw(area, area.bottom()) + .expect("ambient pet draw request"); + assert_eq!(draw.x, 51); + assert_eq!(draw.y, 14); + assert_eq!(draw.columns, 9); + assert_eq!(draw.rows, 5); + assert_eq!( + draw.y.saturating_add(draw.rows), + area.bottom().saturating_sub(/*rhs*/ 1) + ); + + handle_turn_started(&mut chat, "turn-1"); + handle_agent_reasoning_delta(&mut chat, "**Thinking**"); + let draw_with_status = chat + .ambient_pet_draw(area, area.bottom()) + .expect("ambient pet draw request with status"); + assert_eq!(draw_with_status.y, draw.y); + assert_eq!( + draw_with_status.y.saturating_add(draw_with_status.rows), + area.bottom().saturating_sub(/*rhs*/ 1) + ); +} + +#[tokio::test] +#[serial] +async fn ambient_pet_screen_bottom_anchor_uses_terminal_bottom() { + use codex_config::types::TuiPetAnchor; + use ratatui::layout::Rect; + + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + enable_test_ambient_pet(&mut chat); + + let terminal_area = Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 80, /*height*/ 24, + ); + let composer_bottom_y = 20; + let default_draw = chat + .ambient_pet_draw(terminal_area, composer_bottom_y) + .expect("composer-anchored pet draw request"); + assert_eq!(default_draw.y, 14); + + chat.config.tui_pet_anchor = TuiPetAnchor::ScreenBottom; + let screen_bottom_draw = chat + .ambient_pet_draw(terminal_area, composer_bottom_y) + .expect("screen-bottom anchored pet draw request"); + assert_eq!(screen_bottom_draw.y, 18); +} + +#[tokio::test] +#[serial] +async fn ambient_pet_can_be_disabled() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + + chat.set_tui_pet(Some(crate::pets::DISABLED_PET_ID.to_string())); + + assert!(chat.ambient_pet.is_none()); +} + +#[tokio::test] +#[serial] +async fn ambient_pet_reserves_history_wrap_width() { + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + enable_test_ambient_pet(&mut chat); + + assert_eq!(chat.history_wrap_width(/*width*/ 80), 69); + + chat.set_tui_pet(Some(crate::pets::DISABLED_PET_ID.to_string())); + + assert_eq!(chat.history_wrap_width(/*width*/ 80), 80); +} + +#[tokio::test] +#[serial] +async fn ambient_pet_reduces_stream_width_and_composer_text_width() { + use ratatui::Terminal; + + let (mut with_pet, _with_pet_rx, _with_pet_op_rx) = + make_chatwidget_manual(/*model_override*/ None).await; + enable_test_ambient_pet(&mut with_pet); + with_pet.last_rendered_width.set(Some(80)); + let stream_width_with_pet = with_pet.current_stream_width(/*reserved_cols*/ 2); + + let (mut disabled, _disabled_rx, _disabled_op_rx) = + make_chatwidget_manual(/*model_override*/ None).await; + disabled.set_tui_pet(Some(crate::pets::DISABLED_PET_ID.to_string())); + disabled.last_rendered_width.set(Some(80)); + let stream_width_without_pet = disabled.current_stream_width(/*reserved_cols*/ 2); + + assert_eq!( + stream_width_with_pet, + crate::width::usable_content_width(/*total_width*/ 69, /*reserved_cols*/ 2) + ); + assert_eq!( + stream_width_without_pet, + crate::width::usable_content_width(/*total_width*/ 80, /*reserved_cols*/ 2) + ); + assert!(stream_width_with_pet < stream_width_without_pet); + + let draft = + "Minim commodo esse elit Lorem exercitation elit ipsum proident labore. Esse culpa aliqua" + .to_string(); + with_pet + .bottom_pane + .set_composer_text(draft.clone(), Vec::new(), Vec::new()); + disabled + .bottom_pane + .set_composer_text(draft, Vec::new(), Vec::new()); + + let mut with_pet_terminal = + Terminal::new(TestBackend::new(/*width*/ 80, /*height*/ 6)).expect("create terminal"); + with_pet_terminal + .draw(|f| with_pet.render(f.area(), f.buffer_mut())) + .expect("draw pet-enabled chat"); + let mut disabled_terminal = + Terminal::new(TestBackend::new(/*width*/ 80, /*height*/ 6)).expect("create terminal"); + disabled_terminal + .draw(|f| disabled.render(f.area(), f.buffer_mut())) + .expect("draw disabled-pet chat"); + + let pet_row = buffer_row_containing(with_pet_terminal.backend().buffer(), "Minim") + .expect("pet-enabled composer row should render draft"); + let disabled_row = buffer_row_containing(disabled_terminal.backend().buffer(), "Minim") + .expect("disabled-pet composer row should render draft"); + + assert!(row_tail_is_blank(&pet_row, /*start_col*/ 69)); + assert!(!row_tail_is_blank(&disabled_row, /*start_col*/ 69)); +} + +fn buffer_row_containing(buffer: &ratatui::buffer::Buffer, text: &str) -> Option { + (0..buffer.area.height) + .map(|y| { + (0..buffer.area.width) + .map(|x| buffer.cell((x, y)).expect("cell should exist").symbol()) + .collect::() + }) + .find(|row| row.contains(text)) +} + +fn row_tail_is_blank(row: &str, start_col: usize) -> bool { + row.chars().skip(start_col).all(char::is_whitespace) +} + +#[tokio::test] +#[serial] +async fn ambient_pet_draw_uses_terminal_screen_area_not_short_inline_viewport() { + use ratatui::layout::Rect; + + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + enable_test_ambient_pet(&mut chat); + + assert!( + chat.ambient_pet_draw( + Rect::new( + /*x*/ 0, /*y*/ 21, /*width*/ 80, /*height*/ 3, + ), + /*composer_bottom_y*/ 24 + ) + .is_none(), + "a normal short inline viewport cannot fit the ambient pet" + ); + + let draw = chat + .ambient_pet_draw( + Rect::new( + /*x*/ 0, /*y*/ 0, /*width*/ 80, /*height*/ 24, + ), + /*composer_bottom_y*/ 24, + ) + .expect("full terminal screen has room for the ambient pet"); + assert_eq!(draw.x, 71); + assert_eq!(draw.y, 18); +} + +#[tokio::test] +#[serial] +async fn ambient_pet_hides_notification_text_overlay() { + use ratatui::Terminal; + use ratatui::backend::TestBackend; + + let (mut chat, _rx, _op_rx) = make_chatwidget_manual(/*model_override*/ None).await; + enable_test_ambient_pet(&mut chat); + for (kind, label) in [ + (crate::pets::PetNotificationKind::Running, "Running"), + (crate::pets::PetNotificationKind::Waiting, "Needs input"), + (crate::pets::PetNotificationKind::Review, "Ready"), + (crate::pets::PetNotificationKind::Failed, "Blocked"), + ] { + chat.set_ambient_pet_notification(kind, /*body*/ None); + let mut terminal = Terminal::new(TestBackend::new(60, 20)).expect("create terminal"); + terminal + .draw(|f| chat.render(f.area(), f.buffer_mut())) + .expect("draw ambient pet notification"); + assert!( + !normalized_backend_snapshot(terminal.backend()).contains(label), + "did not expect {label} notification text to render" + ); + } +} + // Snapshot test: status widget + approval modal active together // The modal takes precedence visually; this captures the layout with a running // task (status indicator active) while an approval request is shown. diff --git a/codex-rs/tui/src/lib.rs b/codex-rs/tui/src/lib.rs index e3e36ab48c..0246b53802 100644 --- a/codex-rs/tui/src/lib.rs +++ b/codex-rs/tui/src/lib.rs @@ -111,6 +111,7 @@ mod clipboard_paste; mod collaboration_modes; mod color; pub(crate) mod custom_terminal; +mod pets; pub use custom_terminal::Terminal; mod auto_review_denials; mod cwd_prompt; diff --git a/codex-rs/tui/src/pets/ambient.rs b/codex-rs/tui/src/pets/ambient.rs new file mode 100644 index 0000000000..3427f0a4c0 --- /dev/null +++ b/codex-rs/tui/src/pets/ambient.rs @@ -0,0 +1,528 @@ +//! Ambient terminal rendering for the Codex companion. +//! +//! Ambient pets reuse the same extracted image frames as the full-screen viewer +//! but are rendered through a different ownership split: ratatui still owns the +//! transcript/composer layout, while the sprite itself is emitted through the +//! terminal image protocol after the frame draw completes. +//! +//! This module therefore owns two separate contracts: +//! choosing which animation frame should be visible for the current semantic +//! pet state, and translating that frame into a precise on-screen image request +//! that does not overlap reserved bottom-pane space. It does not persist pet +//! selection or decide when modal/popover UI should suppress the sprite. + +#[cfg(test)] +use std::collections::HashMap; +use std::path::PathBuf; +use std::time::Duration; +use std::time::Instant; + +use anyhow::Context; +use anyhow::Result; +use ratatui::layout::Rect; + +use crate::tui::FrameRequester; + +use super::DEFAULT_PET_ID; +use super::frames; +use super::image_protocol::ImageProtocol; +use super::image_protocol::PetImageSupport; +#[cfg(not(test))] +use super::image_protocol::ProtocolSelection; +use super::model::Animation; +#[cfg(test)] +use super::model::AnimationFrame; +use super::model::Pet; + +const PET_TARGET_HEIGHT_PX: u16 = 75; +const PET_COMPOSER_GAP_PX: u16 = 10; +const TERMINAL_ROW_HEIGHT_PX: u16 = 15; + +const RUNNING_LIFETIME: Duration = Duration::from_secs(3 * 60); +const FAILED_LIFETIME: Duration = Duration::from_secs(60 * 60); +const WAITING_LIFETIME: Duration = Duration::from_secs(24 * 60 * 60); +const REVIEW_LIFETIME: Duration = Duration::from_secs(7 * 24 * 60 * 60); + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum PetNotificationKind { + Running, + Waiting, + Review, + Failed, +} + +impl PetNotificationKind { + fn animation_name(self) -> &'static str { + match self { + Self::Running => "running", + Self::Waiting => "waiting", + Self::Review => "review", + Self::Failed => "failed", + } + } + + fn label(self) -> &'static str { + match self { + Self::Running => "Running", + Self::Waiting => "Needs input", + Self::Review => "Ready", + Self::Failed => "Blocked", + } + } + + fn fallback_body(self) -> &'static str { + match self { + Self::Running => "Thinking", + Self::Waiting => "Needs input", + Self::Review => "Ready", + Self::Failed => "Blocked", + } + } + + fn lifetime(self) -> Duration { + match self { + Self::Running => RUNNING_LIFETIME, + Self::Waiting => WAITING_LIFETIME, + Self::Review => REVIEW_LIFETIME, + Self::Failed => FAILED_LIFETIME, + } + } +} + +#[derive(Debug, Clone)] +struct PetNotification { + kind: PetNotificationKind, + body: String, + updated_at: Instant, +} + +impl PetNotification { + fn new(kind: PetNotificationKind, body: Option) -> Self { + Self { + kind, + body: body.unwrap_or_else(|| kind.fallback_body().to_string()), + updated_at: Instant::now(), + } + } + + fn is_expired(&self, now: Instant) -> bool { + now.saturating_duration_since(self.updated_at) >= self.kind.lifetime() + } +} + +#[derive(Debug, Clone)] +pub(crate) struct AmbientPetDraw { + pub(crate) frame: PathBuf, + pub(crate) protocol: ImageProtocol, + pub(crate) x: u16, + pub(crate) y: u16, + pub(crate) clear_top_y: u16, + pub(crate) columns: u16, + pub(crate) rows: u16, + pub(crate) height_px: u16, + pub(crate) sixel_dir: PathBuf, +} + +#[derive(Debug)] +pub(crate) struct AmbientPet { + pet: Pet, + support: PetImageSupport, + frames: Vec, + sixel_dir: PathBuf, + frame_requester: FrameRequester, + notification: Option, + animation_started_at: Instant, + animations_enabled: bool, +} + +impl AmbientPet { + /// Load the active ambient pet and prepare its frame cache. + /// + /// This resolves the selected pet id, extracts per-frame PNGs into the + /// CODEX_HOME cache, and records the terminal protocol support snapshot used + /// for later draw requests. A caller that repeatedly recreates `AmbientPet` + /// instead of mutating one instance would lose animation timing continuity + /// and pay the frame-cache preparation cost more often than necessary. + pub(crate) fn load( + selected_pet: Option<&str>, + codex_home: &std::path::Path, + frame_requester: FrameRequester, + animations_enabled: bool, + ) -> Result { + let pet = Pet::load_with_codex_home( + selected_pet.unwrap_or(DEFAULT_PET_ID), + /*codex_home*/ Some(codex_home), + ) + .with_context(|| "load ambient pet")?; + let cache_dir = codex_home + .join("cache") + .join("tui-pets") + .join("frame-cache") + .join(&pet.id) + .join(pet.frame_cache_key()?); + let frame_dir = cache_dir.join("frames"); + let sixel_dir = cache_dir.join("sixel"); + let frames = frames::prepare_png_frames(&pet, &frame_dir)?; + Ok(Self { + pet, + support: default_image_support(), + frames, + sixel_dir, + frame_requester, + notification: None, + animation_started_at: Instant::now(), + animations_enabled, + }) + } + + pub(crate) fn set_notification(&mut self, kind: PetNotificationKind, body: Option) { + self.notification = Some(PetNotification::new(kind, body)); + self.animation_started_at = Instant::now(); + } + + pub(crate) fn image_enabled(&self) -> bool { + self.support.protocol().is_some() + } + + pub(crate) fn image_columns(&self) -> u16 { + self.image_size().columns + } + + #[cfg(test)] + pub(crate) fn set_image_support_for_tests(&mut self, support: PetImageSupport) { + self.support = support; + } + + pub(crate) fn schedule_next_frame(&self) { + if let Some(delay) = self.next_frame_delay() { + self.frame_requester.schedule_frame_in(delay); + } + } + + fn next_frame_delay(&self) -> Option { + if self.support.protocol().is_none() || !self.animations_enabled { + return None; + } + + current_animation_frame( + self.current_animation()?, + self.animation_started_at.elapsed(), + )? + .delay + } + + /// Build an image draw request for the ambient pet anchored above the composer. + /// + /// Returning `None` means "do not render the sprite this frame", typically + /// because the terminal protocol is unavailable or the current layout cannot + /// fit the image without overlapping reserved UI. Callers should not try to + /// partially clip the image themselves; that would desynchronize the image + /// protocol output from the TUI's notion of cleared rows. + pub(crate) fn draw_request( + &self, + area: Rect, + composer_bottom_y: u16, + ) -> Option { + let protocol = self.support.protocol()?; + let size = self.image_size(); + let notification = self.visible_notification(Instant::now()); + let notification_height = notification.map_or(0, notification_height); + let required_height = size.rows.saturating_add(notification_height); + let sprite_bottom_y = composer_bottom_y.saturating_sub(composer_gap_rows()); + if sprite_bottom_y < area.y.saturating_add(required_height) || area.width < size.columns { + return None; + } + + let x = area.x + area.width.saturating_sub(size.columns); + let y = sprite_bottom_y.saturating_sub(size.rows); + Some(AmbientPetDraw { + frame: self.current_frame_path()?, + protocol, + x, + y, + clear_top_y: area.y, + columns: size.columns, + rows: size.rows, + height_px: size.height_px, + sixel_dir: self.sixel_dir.clone(), + }) + } + + /// Build a centered preview draw request for the `/pets` picker side pane. + /// + /// The picker preview intentionally uses the first idle frame rather than + /// the live animation state so selection browsing stays stable and does not + /// require the full ambient animation lifecycle. + pub(crate) fn preview_draw_request(&self, area: Rect) -> Option { + let protocol = self.support.protocol()?; + let size = self.image_size(); + if area.width < size.columns || area.height < size.rows { + return None; + } + + let y = area.y + area.height.saturating_sub(size.rows) / 2; + Some(AmbientPetDraw { + frame: self.first_idle_frame_path()?, + protocol, + x: area.x + area.width.saturating_sub(size.columns) / 2, + y, + clear_top_y: y, + columns: size.columns, + rows: size.rows, + height_px: size.height_px, + sixel_dir: self.sixel_dir.clone(), + }) + } + + fn visible_notification(&self, now: Instant) -> Option<&PetNotification> { + self.notification + .as_ref() + .filter(|notification| !notification.is_expired(now)) + } + + fn current_animation(&self) -> Option<&Animation> { + let animation_name = self + .visible_notification(Instant::now()) + .map_or("idle", |notification| notification.kind.animation_name()); + let animation = self + .pet + .animations + .get(animation_name) + .or_else(|| self.pet.animations.get("idle"))?; + if animation.loop_start.is_none() { + let elapsed = self.animation_started_at.elapsed(); + if elapsed >= animation.total_duration() + && let Some(fallback) = self.pet.animations.get(&animation.fallback) + { + return Some(fallback); + } + } + Some(animation) + } + + fn current_frame_path(&self) -> Option { + let sprite_index = self + .current_animation() + .and_then(|animation| { + if self.animations_enabled { + current_animation_frame(animation, self.animation_started_at.elapsed()) + .map(|frame| frame.sprite_index) + } else { + animation.frames.first().map(|frame| frame.sprite_index) + } + }) + .unwrap_or(0); + self.frame_path_for_sprite_index(sprite_index) + } + + fn first_idle_frame_path(&self) -> Option { + let sprite_index = self + .pet + .animations + .get("idle") + .and_then(|animation| animation.frames.first()) + .map_or(0, |frame| frame.sprite_index); + self.frame_path_for_sprite_index(sprite_index) + } + + fn frame_path_for_sprite_index(&self, sprite_index: usize) -> Option { + self.frames + .get(sprite_index.min(self.frames.len().saturating_sub(1))) + .cloned() + } + + fn image_size(&self) -> ImageSize { + let rows = (f64::from(PET_TARGET_HEIGHT_PX) / f64::from(TERMINAL_ROW_HEIGHT_PX)) + .round() + .max(/*other*/ 1.0) as u16; + let aspect = f64::from(self.pet.frame_height) / f64::from(self.pet.frame_width) * 0.52; + let columns = (f64::from(rows) / aspect).round() as u16; + ImageSize { + columns: columns.max(1), + rows, + height_px: PET_TARGET_HEIGHT_PX, + } + } +} + +fn composer_gap_rows() -> u16 { + ((f64::from(PET_COMPOSER_GAP_PX) / f64::from(TERMINAL_ROW_HEIGHT_PX)).round() as u16) + .max(/*other*/ 1) +} + +#[cfg(not(test))] +fn default_image_support() -> PetImageSupport { + ProtocolSelection::Auto.resolve() +} + +#[cfg(test)] +fn default_image_support() -> PetImageSupport { + PetImageSupport::Unsupported(super::image_protocol::PetImageUnsupportedReason::Terminal) +} + +#[derive(Debug, Clone, Copy)] +struct ImageSize { + columns: u16, + rows: u16, + height_px: u16, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct AnimationFrameTick { + sprite_index: usize, + delay: Option, +} + +fn current_animation_frame(animation: &Animation, elapsed: Duration) -> Option { + if animation.frames.len() <= 1 { + return Some(AnimationFrameTick { + sprite_index: animation.frames.first()?.sprite_index, + delay: None, + }); + } + + let elapsed_nanos = elapsed.as_nanos(); + if let Some(loop_start) = animation + .loop_start + .filter(|idx| *idx < animation.frames.len()) + { + let total_nanos = animation.total_duration().as_nanos(); + let prefix_nanos = animation.frames[..loop_start] + .iter() + .map(|frame| frame.duration.as_nanos()) + .sum::(); + let loop_nanos = animation.frames[loop_start..] + .iter() + .map(|frame| frame.duration.as_nanos()) + .sum::(); + let effective_elapsed = if elapsed_nanos >= total_nanos && loop_nanos > 0 { + prefix_nanos + elapsed_nanos.saturating_sub(prefix_nanos) % loop_nanos + } else { + elapsed_nanos + }; + frame_at_elapsed(animation, effective_elapsed) + } else if elapsed_nanos >= animation.total_duration().as_nanos() { + Some(AnimationFrameTick { + sprite_index: animation.frames.last()?.sprite_index, + delay: None, + }) + } else { + frame_at_elapsed(animation, elapsed_nanos) + } +} + +fn frame_at_elapsed(animation: &Animation, elapsed_nanos: u128) -> Option { + let mut remaining_elapsed = elapsed_nanos; + for frame in &animation.frames { + let frame_nanos = frame.duration.as_nanos().max(/*other*/ 1); + if remaining_elapsed < frame_nanos { + return Some(AnimationFrameTick { + sprite_index: frame.sprite_index, + delay: Some(nanos_to_duration(frame_nanos - remaining_elapsed)), + }); + } + remaining_elapsed = remaining_elapsed.saturating_sub(frame_nanos); + } + + Some(AnimationFrameTick { + sprite_index: animation.frames.last()?.sprite_index, + delay: None, + }) +} + +fn nanos_to_duration(nanos: u128) -> Duration { + Duration::from_nanos(nanos.min(u128::from(u64::MAX)) as u64) +} + +fn notification_height(notification: &PetNotification) -> u16 { + if notification.body == notification.kind.label() { + 1 + } else { + 2 + } +} + +#[cfg(test)] +pub(crate) fn test_ambient_pet( + frame_requester: FrameRequester, + animations_enabled: bool, +) -> AmbientPet { + AmbientPet { + pet: Pet { + id: "test".to_string(), + display_name: "Test".to_string(), + description: String::new(), + spritesheet_path: PathBuf::from("spritesheet.webp"), + frame_width: 192, + frame_height: 208, + columns: 8, + rows: 9, + frame_count: 72, + animations: HashMap::from([("idle".to_string(), test_animation())]), + }, + support: PetImageSupport::Supported(ImageProtocol::Kitty), + frames: vec![PathBuf::from("frame-0.png"), PathBuf::from("frame-1.png")], + sixel_dir: PathBuf::new(), + frame_requester, + notification: None, + animation_started_at: Instant::now() + .checked_sub(Duration::from_millis(/*millis*/ 15)) + .unwrap(), + animations_enabled, + } +} + +#[cfg(test)] +fn test_animation() -> Animation { + Animation { + frames: vec![ + AnimationFrame { + sprite_index: 0, + duration: Duration::from_millis(/*millis*/ 10), + }, + AnimationFrame { + sprite_index: 1, + duration: Duration::from_millis(/*millis*/ 10), + }, + ], + loop_start: Some(/*loop_start*/ 0), + fallback: "idle".to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn notification_labels_match_codex_app_vocabulary() { + assert_eq!(PetNotificationKind::Running.label(), "Running"); + assert_eq!(PetNotificationKind::Waiting.label(), "Needs input"); + assert_eq!(PetNotificationKind::Review.label(), "Ready"); + assert_eq!(PetNotificationKind::Failed.label(), "Blocked"); + } + + #[test] + fn animation_frame_uses_per_frame_duration() { + let animation = test_animation(); + + assert_eq!( + current_animation_frame(&animation, Duration::from_millis(/*millis*/ 15)), + Some(AnimationFrameTick { + sprite_index: 1, + delay: Some(Duration::from_millis(/*millis*/ 5)), + }) + ); + } + + #[test] + fn reduced_motion_uses_stable_first_frame_and_schedules_no_follow_up() { + let pet = test_ambient_pet( + FrameRequester::test_dummy(), + /*animations_enabled*/ false, + ); + + assert_eq!(pet.current_frame_path(), Some(PathBuf::from("frame-0.png"))); + assert_eq!(pet.next_frame_delay(), None); + } +} diff --git a/codex-rs/tui/src/pets/asset_pack.rs b/codex-rs/tui/src/pets/asset_pack.rs new file mode 100644 index 0000000000..7035d1dea2 --- /dev/null +++ b/codex-rs/tui/src/pets/asset_pack.rs @@ -0,0 +1,190 @@ +//! Built-in pet asset acquisition and cache ownership. +//! +//! Unlike custom pets, built-in pets are not checked into the TUI package as +//! local spritesheets. The TUI resolves them from the public Codex pets CDN on +//! first use, verifies that the downloaded file has the expected spritesheet +//! geometry, and installs it into a versioned cache under CODEX_HOME. +//! +//! This module deliberately stops at "a validated spritesheet exists at this +//! path". Higher layers remain responsible for deciding when downloads are +//! allowed, when previews should block on them, and when a successfully loaded +//! built-in pet is safe to persist to config. + +use std::fs; +use std::io::Read; +use std::path::Path; +use std::path::PathBuf; +use std::time::Duration; + +use anyhow::Context; +use anyhow::Result; +use anyhow::bail; +use url::Url; +use uuid::Uuid; + +use super::catalog; + +const PET_PACK_VERSION: &str = "v1"; +const PET_PACK_DIR: &str = "cache/tui-pets"; +const PET_CDN_BASE_URL: &str = "https://persistent.oaistatic.com/codex/pets/v1"; +const PET_DOWNLOAD_TIMEOUT: Duration = Duration::from_secs(60); +const PET_MAX_DOWNLOAD_BYTES: u64 = 4 * 1024 * 1024; + +pub(crate) fn builtin_spritesheet_path(codex_home: &Path, file: &str) -> PathBuf { + pack_dir(codex_home).join("assets").join(file) +} + +/// Ensure that a built-in pet's spritesheet is present and structurally valid. +/// +/// The cache key is the CDN-facing filename, so updating a built-in pet means +/// publishing a new versioned filename rather than mutating an existing one in +/// place. If a cached file is missing or invalid, this downloads a fresh copy, +/// validates the decoded image dimensions, and installs it atomically. Callers +/// should treat any error here as "the asset is unavailable", not as a partial +/// install they can safely ignore. +pub(crate) fn ensure_builtin_pet(codex_home: &Path, pet: catalog::BuiltinPet) -> Result<()> { + let destination = builtin_spritesheet_path(codex_home, pet.spritesheet_file); + if validate_cached_spritesheet(&destination).is_ok() { + return Ok(()); + } + + let url = builtin_pet_url(pet)?; + let bytes = download_bytes_with_limit(&url, PET_MAX_DOWNLOAD_BYTES)?; + let parent = destination + .parent() + .context("pet spritesheet path should include an assets directory")?; + fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?; + + let staging = destination.with_file_name(format!( + ".{}.download-{}.webp", + pet.spritesheet_file, + Uuid::new_v4() + )); + fs::write(&staging, &bytes).with_context(|| format!("write {}", staging.display()))?; + if let Err(err) = validate_cached_spritesheet(&staging) { + let _ = fs::remove_file(&staging); + return Err(err); + } + + if install_downloaded_spritesheet(&staging, &destination).is_ok() { + return Ok(()); + } + + if validate_cached_spritesheet(&destination).is_ok() { + let _ = fs::remove_file(&staging); + return Ok(()); + } + + if destination.exists() { + fs::remove_file(&destination) + .with_context(|| format!("remove {}", destination.display()))?; + } + install_downloaded_spritesheet(&staging, &destination) +} + +fn builtin_pet_url(pet: catalog::BuiltinPet) -> Result { + let url = format!("{PET_CDN_BASE_URL}/{}", pet.spritesheet_file); + validate_download_url(&url)?; + Ok(url) +} + +fn pack_dir(codex_home: &Path) -> PathBuf { + codex_home.join(PET_PACK_DIR).join(PET_PACK_VERSION) +} + +fn download_bytes_with_limit(url: &str, max_bytes: u64) -> Result> { + validate_download_url(url)?; + let response = reqwest::blocking::Client::builder() + .timeout(PET_DOWNLOAD_TIMEOUT) + .build() + .context("build pet asset download client")? + .get(url) + .send() + .with_context(|| format!("download pet asset from {url}"))? + .error_for_status() + .with_context(|| format!("download pet asset from {url}"))?; + validate_download_url(response.url().as_str())?; + + if response.content_length().is_some_and(|len| len > max_bytes) { + bail!("pet asset download from {url} exceeded {max_bytes} bytes"); + } + + let mut bytes = Vec::new(); + response + .take(max_bytes.saturating_add(/*rhs*/ 1)) + .read_to_end(&mut bytes) + .with_context(|| format!("read pet asset download from {url}"))?; + if bytes.len() as u64 > max_bytes { + bail!("pet asset download from {url} exceeded {max_bytes} bytes"); + } + Ok(bytes) +} + +fn install_downloaded_spritesheet(staging: &Path, destination: &Path) -> Result<()> { + fs::rename(staging, destination).with_context(|| format!("install {}", destination.display())) +} + +fn validate_download_url(value: &str) -> Result<()> { + let url = Url::parse(value).with_context(|| format!("parse pet asset download URL {value}"))?; + if url.scheme() != "https" { + bail!("unsupported pet asset download URL scheme {}", url.scheme()); + } + Ok(()) +} + +fn validate_cached_spritesheet(path: &Path) -> Result<()> { + let (width, height) = + image::image_dimensions(path).with_context(|| format!("read {}", path.display()))?; + if width != catalog::SPRITESHEET_WIDTH || height != catalog::SPRITESHEET_HEIGHT { + bail!( + "invalid pet spritesheet dimensions for {}: expected {}x{}, got {}x{}", + path.display(), + catalog::SPRITESHEET_WIDTH, + catalog::SPRITESHEET_HEIGHT, + width, + height + ); + } + Ok(()) +} + +#[cfg(test)] +pub(crate) fn write_test_pack(codex_home: &Path) { + let assets_dir = pack_dir(codex_home).join("assets"); + fs::create_dir_all(&assets_dir).unwrap(); + for pet in catalog::BUILTIN_PETS { + let path = assets_dir.join(pet.spritesheet_file); + catalog::write_test_spritesheet(&path); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn builtin_pet_url_uses_public_cdn_path() { + let pet = catalog::builtin_pet("dewey").unwrap(); + + let url = builtin_pet_url(pet).unwrap(); + + assert_eq!( + url, + "https://persistent.oaistatic.com/codex/pets/v1/dewey-spritesheet-v4.webp" + ); + } + + #[test] + fn write_test_pack_installs_all_builtins() { + let dir = tempfile::tempdir().unwrap(); + + write_test_pack(dir.path()); + + for pet in catalog::BUILTIN_PETS { + let path = builtin_spritesheet_path(dir.path(), pet.spritesheet_file); + assert!(path.is_file()); + validate_cached_spritesheet(&path).unwrap(); + } + } +} diff --git a/codex-rs/tui/src/pets/catalog.rs b/codex-rs/tui/src/pets/catalog.rs new file mode 100644 index 0000000000..e1d5c0c788 --- /dev/null +++ b/codex-rs/tui/src/pets/catalog.rs @@ -0,0 +1,77 @@ +//! Built-in pet catalog ported from the Codex App avatar catalog. + +pub(super) const DEFAULT_FRAME_WIDTH: u32 = 192; +pub(super) const DEFAULT_FRAME_HEIGHT: u32 = 208; +pub(super) const DEFAULT_FRAME_COLUMNS: u32 = 8; +pub(super) const DEFAULT_FRAME_ROWS: u32 = 9; +pub(super) const SPRITESHEET_WIDTH: u32 = DEFAULT_FRAME_WIDTH * DEFAULT_FRAME_COLUMNS; +pub(super) const SPRITESHEET_HEIGHT: u32 = DEFAULT_FRAME_HEIGHT * DEFAULT_FRAME_ROWS; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) struct BuiltinPet { + pub(super) id: &'static str, + pub(super) display_name: &'static str, + pub(super) description: &'static str, + pub(super) spritesheet_file: &'static str, +} + +pub(super) const BUILTIN_PETS: &[BuiltinPet] = &[ + BuiltinPet { + id: "codex", + display_name: "Codex", + description: "The original Codex companion", + spritesheet_file: "codex-spritesheet-v4.webp", + }, + BuiltinPet { + id: "dewey", + display_name: "Dewey", + description: "A tidy duck for calm workspace days", + spritesheet_file: "dewey-spritesheet-v4.webp", + }, + BuiltinPet { + id: "fireball", + display_name: "Fireball", + description: "Hot path energy for fast iteration", + spritesheet_file: "fireball-spritesheet-v4.webp", + }, + BuiltinPet { + id: "rocky", + display_name: "Rocky", + description: "A steady rock when the diff gets large", + spritesheet_file: "rocky-spritesheet-v4.webp", + }, + BuiltinPet { + id: "seedy", + display_name: "Seedy", + description: "Small green shoots for new ideas", + spritesheet_file: "seedy-spritesheet-v4.webp", + }, + BuiltinPet { + id: "stacky", + display_name: "Stacky", + description: "A balanced stack for deep work", + spritesheet_file: "stacky-spritesheet-v4.webp", + }, + BuiltinPet { + id: "bsod", + display_name: "BSOD", + description: "A tiny blue-screen gremlin", + spritesheet_file: "bsod-spritesheet-v4.webp", + }, + BuiltinPet { + id: "null-signal", + display_name: "Null Signal", + description: "Quiet signal from the void", + spritesheet_file: "null-signal-spritesheet-v4.webp", + }, +]; + +pub(super) fn builtin_pet(id: &str) -> Option { + BUILTIN_PETS.iter().copied().find(|pet| pet.id == id) +} + +#[cfg(test)] +pub(super) fn write_test_spritesheet(path: &std::path::Path) { + let image = image::RgbaImage::new(SPRITESHEET_WIDTH, SPRITESHEET_HEIGHT); + image.save(path).unwrap(); +} diff --git a/codex-rs/tui/src/pets/frames.rs b/codex-rs/tui/src/pets/frames.rs new file mode 100644 index 0000000000..4c65c900ad --- /dev/null +++ b/codex-rs/tui/src/pets/frames.rs @@ -0,0 +1,116 @@ +use std::fs; +use std::path::Path; +use std::path::PathBuf; + +use anyhow::Context; +use anyhow::Result; +use image::GenericImageView; + +use super::model::Pet; + +pub(super) fn prepare_png_frames(pet: &Pet, frame_dir: &Path) -> Result> { + fs::create_dir_all(frame_dir).with_context(|| format!("create {}", frame_dir.display()))?; + + let expected: Vec = (0..pet.frame_count()) + .map(|index| frame_dir.join(format!("frame_{index:03}.png"))) + .collect(); + + let complete = expected.iter().all(|path| path.exists()); + if !complete { + for stale in glob_frame_files(frame_dir)? { + let _ = fs::remove_file(stale); + } + + let spritesheet = image::open(&pet.spritesheet_path) + .with_context(|| format!("read {}", pet.spritesheet_path.display()))?; + for row in 0..pet.rows { + for column in 0..pet.columns { + let index = row + .checked_mul(pet.columns) + .and_then(|row_offset| row_offset.checked_add(column)) + .context("pet frame index overflow")?; + let index = usize::try_from(index).context("pet frame index does not fit usize")?; + let path = expected + .get(index) + .context("pet frame index exceeds expected frame count")?; + let x = column + .checked_mul(pet.frame_width) + .context("pet frame x offset overflow")?; + let y = row + .checked_mul(pet.frame_height) + .context("pet frame y offset overflow")?; + let frame = spritesheet.try_view(x, y, pet.frame_width, pet.frame_height)?; + frame + .to_image() + .save_with_format(path, image::ImageFormat::Png) + .with_context(|| format!("write {}", path.display()))?; + } + } + } + + Ok(expected) +} + +fn glob_frame_files(frame_dir: &Path) -> Result> { + if !frame_dir.exists() { + return Ok(Vec::new()); + } + + let mut paths = Vec::new(); + for entry in fs::read_dir(frame_dir).with_context(|| format!("read {}", frame_dir.display()))? { + let path = entry?.path(); + if path + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.starts_with("frame_") && name.ends_with(".png")) + { + paths.push(path); + } + } + Ok(paths) +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use image::ImageBuffer; + use image::Rgba; + + use super::*; + + #[test] + fn prepare_png_frames_slices_spritesheet_without_external_command() { + let dir = tempfile::tempdir().unwrap(); + let spritesheet_path = dir.path().join("spritesheet.png"); + let spritesheet: ImageBuffer, Vec> = ImageBuffer::from_fn(2, 1, |x, _| { + if x == 0 { + Rgba([255, 0, 0, 255]) + } else { + Rgba([0, 255, 0, 255]) + } + }); + spritesheet.save(&spritesheet_path).unwrap(); + + let frames = prepare_png_frames( + &Pet { + id: "tiny".to_string(), + display_name: "Tiny".to_string(), + description: String::new(), + spritesheet_path, + frame_width: 1, + frame_height: 1, + columns: 2, + rows: 1, + frame_count: 2, + animations: HashMap::new(), + }, + &dir.path().join("frames"), + ) + .unwrap(); + + assert_eq!(frames.len(), 2); + assert!(frames[0].exists()); + assert!(frames[1].exists()); + } +} diff --git a/codex-rs/tui/src/pets/image_protocol.rs b/codex-rs/tui/src/pets/image_protocol.rs new file mode 100644 index 0000000000..25a20c3528 --- /dev/null +++ b/codex-rs/tui/src/pets/image_protocol.rs @@ -0,0 +1,591 @@ +use std::env; +use std::fs; +use std::path::Path; +use std::path::PathBuf; +use std::str::FromStr; + +use anyhow::Context; +use anyhow::Result; +use anyhow::bail; +use base64::Engine as _; +use base64::engine::general_purpose; +use codex_terminal_detection::Multiplexer; +use codex_terminal_detection::TerminalInfo; +use codex_terminal_detection::TerminalName; +use codex_terminal_detection::terminal_info; +use image::imageops::FilterType; + +use super::sixel; + +const ESC: &str = "\x1b"; +const ST: &str = "\x1b\\"; +const KITTY_CHUNK_SIZE: usize = 4096; +const SIXEL_CACHE_VERSION: &str = "v2"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ImageProtocol { + Kitty, + KittyLocalFile, + Sixel, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum PetImageSupport { + Supported(ImageProtocol), + Unsupported(PetImageUnsupportedReason), +} + +impl PetImageSupport { + pub(crate) fn protocol(self) -> Option { + match self { + Self::Supported(protocol) => Some(protocol), + Self::Unsupported(_) => None, + } + } + + pub(crate) fn unsupported_message(self) -> Option<&'static str> { + match self { + Self::Supported(_) => None, + Self::Unsupported(reason) => Some(reason.message()), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum PetImageUnsupportedReason { + Tmux, + Zellij, + Terminal, +} + +impl PetImageUnsupportedReason { + fn message(self) -> &'static str { + match self { + Self::Tmux => { + "Pets are disabled in tmux. Terminal images don’t stay pane-local in tmux and can corrupt scrollback or move between panes. Run Codex outside tmux to use pets." + } + Self::Zellij => { + "Pets are disabled in Zellij. Terminal images don’t stay reliably pane-local in Zellij. Run Codex outside Zellij to use pets." + } + Self::Terminal => { + "Pets aren’t available in this terminal. Terminal pets need image support, and this terminal environment doesn’t expose a supported image protocol. Try a terminal with Kitty graphics or Sixel support, or run Codex outside tmux." + } + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProtocolSelection { + Auto, + Kitty, + Sixel, +} + +impl ProtocolSelection { + pub(crate) fn resolve(self) -> PetImageSupport { + match self { + Self::Kitty => PetImageSupport::Supported(ImageProtocol::Kitty), + Self::Sixel => PetImageSupport::Supported(ImageProtocol::Sixel), + Self::Auto => detect_pet_image_support(), + } + } +} + +impl FromStr for ProtocolSelection { + type Err = anyhow::Error; + + fn from_str(value: &str) -> Result { + match value { + "auto" => Ok(Self::Auto), + "kitty" => Ok(Self::Kitty), + "sixel" => Ok(Self::Sixel), + other => bail!("unknown protocol {other}; expected auto, kitty, or sixel"), + } + } +} + +pub(crate) fn detect_pet_image_support() -> PetImageSupport { + if env::var_os("TMUX").is_some() || env::var_os("TMUX_PANE").is_some() { + return PetImageSupport::Unsupported(PetImageUnsupportedReason::Tmux); + } + + if env::var_os("ZELLIJ").is_some() + || env::var_os("ZELLIJ_SESSION_NAME").is_some() + || env::var_os("ZELLIJ_VERSION").is_some() + { + return PetImageSupport::Unsupported(PetImageUnsupportedReason::Zellij); + } + + if env::var_os("KITTY_WINDOW_ID").is_some() { + return PetImageSupport::Supported(ImageProtocol::Kitty); + } + + if env::var_os("WEZTERM_EXECUTABLE").is_some() || env::var_os("WEZTERM_VERSION").is_some() { + return PetImageSupport::Supported(ImageProtocol::Kitty); + } + + pet_image_support_for_terminal(&terminal_info()) +} + +fn pet_image_support_for_terminal(info: &TerminalInfo) -> PetImageSupport { + match info.multiplexer { + Some(Multiplexer::Tmux { .. }) => { + return PetImageSupport::Unsupported(PetImageUnsupportedReason::Tmux); + } + Some(Multiplexer::Zellij {}) => { + return PetImageSupport::Unsupported(PetImageUnsupportedReason::Zellij); + } + None => {} + } + + if supports_iterm2_kitty_graphics(info) { + return PetImageSupport::Supported(ImageProtocol::KittyLocalFile); + } + + if supports_kitty_graphics(info) { + return PetImageSupport::Supported(ImageProtocol::Kitty); + } + + if supports_sixel(info) { + return PetImageSupport::Supported(ImageProtocol::Sixel); + } + + PetImageSupport::Unsupported(PetImageUnsupportedReason::Terminal) +} + +fn supports_iterm2_kitty_graphics(info: &TerminalInfo) -> bool { + matches!(info.name, TerminalName::Iterm2) + || terminal_field_contains(info.term_program.as_deref(), "iterm") +} + +fn supports_kitty_graphics(info: &TerminalInfo) -> bool { + matches!( + info.name, + TerminalName::Ghostty | TerminalName::Kitty | TerminalName::WezTerm + ) || terminal_field_contains(info.term.as_deref(), "kitty") + || terminal_field_contains(info.term.as_deref(), "ghostty") + || terminal_field_contains(info.term.as_deref(), "wezterm") + || terminal_field_contains(info.term_program.as_deref(), "kitty") + || terminal_field_contains(info.term_program.as_deref(), "ghostty") + || terminal_field_contains(info.term_program.as_deref(), "wezterm") +} + +fn supports_sixel(info: &TerminalInfo) -> bool { + matches!(info.name, TerminalName::WindowsTerminal) + || terminal_field_contains(info.term.as_deref(), "sixel") + || terminal_field_contains(info.term.as_deref(), "mlterm") + || terminal_field_contains(info.term.as_deref(), "foot") +} + +fn terminal_field_contains(value: Option<&str>, needle: &str) -> bool { + value.is_some_and(|value| value.to_ascii_lowercase().contains(needle)) +} + +pub fn kitty_delete_image(image_id: u32) -> String { + wrap_for_tmux_if_needed(&format!("{ESC}_Ga=d,d=I,i={image_id},q=2;{ST}")) +} + +pub fn kitty_transmit_png_with_id( + path: &Path, + columns: u16, + rows: u16, + image_id: Option, +) -> Result { + let png = fs::read(path).with_context(|| format!("read {}", path.display()))?; + let payload = general_purpose::STANDARD.encode(png); + let chunks = payload + .as_bytes() + .chunks(KITTY_CHUNK_SIZE) + .collect::>(); + + let mut command = String::new(); + for (index, chunk) in chunks.iter().enumerate() { + let chunk = std::str::from_utf8(chunk).context("base64 payload is not valid UTF-8")?; + let has_more = index + 1 < chunks.len(); + let more_flag = u8::from(has_more); + if index == 0 { + let image_id = kitty_image_id_arg(image_id); + command.push_str(&format!( + "{ESC}_Ga=T,t=d,f=100,c={columns},r={rows},q=2{image_id},m={more_flag};{chunk}{ST}", + )); + } else { + command.push_str(&format!("{ESC}_Gm={more_flag};{chunk}{ST}")); + } + } + + Ok(wrap_for_tmux_if_needed(&command)) +} + +pub fn kitty_transmit_png_file_with_id( + path: &Path, + columns: u16, + rows: u16, + image_id: Option, +) -> Result { + let path = path + .canonicalize() + .with_context(|| format!("canonicalize {}", path.display()))?; + let payload = general_purpose::STANDARD.encode(path.to_string_lossy().as_bytes()); + let image_id = kitty_image_id_arg(image_id); + let command = format!("{ESC}_Ga=T,t=f,f=100,c={columns},r={rows},q=2{image_id};{payload}{ST}"); + + Ok(wrap_for_tmux_if_needed(&command)) +} + +fn kitty_image_id_arg(image_id: Option) -> String { + image_id + .map(|image_id| format!(",i={image_id}")) + .unwrap_or_default() +} + +fn wrap_for_tmux_if_needed(command: &str) -> String { + if env::var_os("TMUX").is_none() { + return command.to_string(); + } + + let escaped = command.replace(ESC, "\x1b\x1b"); + format!("{ESC}Ptmux;{escaped}{ST}") +} + +pub fn sixel_frame(frame_path: &Path, cache_dir: &Path, height_px: u16) -> Result { + fs::create_dir_all(cache_dir).with_context(|| format!("create {}", cache_dir.display()))?; + + let stem = frame_path + .file_stem() + .and_then(|stem| stem.to_str()) + .context("frame path has no valid file stem")?; + let path = cache_dir.join(format!("{stem}_h{height_px}_{SIXEL_CACHE_VERSION}.six")); + if path.exists() { + return Ok(path); + } + + let frame = + image::open(frame_path).with_context(|| format!("read {}", frame_path.display()))?; + let height = u32::from(height_px).max(1); + let width = ((u64::from(frame.width()) * u64::from(height)) / u64::from(frame.height())) + .try_into() + .unwrap_or(u32::MAX) + .max(1); + let rgba = frame.resize(width, height, FilterType::Lanczos3).to_rgba8(); + let (width, height) = rgba.dimensions(); + let sixel = sixel::encode_rgba(&rgba.into_raw(), width, height)?; + + fs::write(&path, sixel).with_context(|| format!("write {}", path.display()))?; + Ok(path) +} + +#[cfg(test)] +mod tests { + use serial_test::serial; + + use super::*; + + struct EnvVarGuard { + name: &'static str, + previous: Option, + } + + impl EnvVarGuard { + fn new(name: &'static str, value: Option<&str>) -> Self { + let previous = env::var_os(name); + match value { + Some(value) => unsafe { env::set_var(name, value) }, + None => unsafe { env::remove_var(name) }, + } + Self { name, previous } + } + } + + impl Drop for EnvVarGuard { + fn drop(&mut self) { + match self.previous.take() { + Some(value) => unsafe { env::set_var(self.name, value) }, + None => unsafe { env::remove_var(self.name) }, + } + } + } + + #[test] + #[serial] + fn kitty_png_transmission_encodes_inline_data() { + let _guard = EnvVarGuard::new("TMUX", /*value*/ None); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("frame.png"); + fs::write(&path, b"png").unwrap(); + + let command = kitty_transmit_png_with_id( + &path, /*columns*/ 4, /*rows*/ 3, /*image_id*/ None, + ) + .unwrap(); + + assert!(command.starts_with("\x1b_Ga=T,t=d,f=100,c=4,r=3,q=2,m=0;")); + assert!(command.contains("cG5n")); + assert!(command.ends_with("\x1b\\")); + } + + #[test] + #[serial] + fn tmux_passthrough_wraps_and_escapes_control_sequence() { + let _guard = EnvVarGuard::new("TMUX", Some("session")); + assert_eq!( + wrap_for_tmux_if_needed("\x1b_Gx;\x1b\\"), + "\x1bPtmux;\x1b\x1b_Gx;\x1b\x1b\\\x1b\\" + ); + } + + #[test] + fn parses_protocol_selection() { + assert_eq!( + "auto".parse::().unwrap(), + ProtocolSelection::Auto + ); + assert_eq!( + "kitty".parse::().unwrap(), + ProtocolSelection::Kitty + ); + assert_eq!( + "sixel".parse::().unwrap(), + ProtocolSelection::Sixel + ); + } + + #[test] + #[serial] + fn auto_protocol_is_disabled_inside_tmux() { + let _guard = EnvVarGuard::new("TMUX", Some("session")); + + assert_eq!( + ProtocolSelection::Auto.resolve(), + PetImageSupport::Unsupported(PetImageUnsupportedReason::Tmux) + ); + } + + #[test] + #[serial] + fn explicit_protocol_still_resolves_inside_tmux() { + let _guard = EnvVarGuard::new("TMUX", Some("session")); + + assert_eq!( + ProtocolSelection::Kitty.resolve(), + PetImageSupport::Supported(ImageProtocol::Kitty) + ); + assert_eq!( + ProtocolSelection::Sixel.resolve(), + PetImageSupport::Supported(ImageProtocol::Sixel) + ); + } + + #[test] + fn pet_image_support_prefers_multiplexer_safety() { + assert_eq!( + pet_image_support_for_terminal(&terminal_info_for_test( + TerminalName::Ghostty, + Some(Multiplexer::Tmux { version: None }), + Some("Ghostty"), + /*term*/ None, + )), + PetImageSupport::Unsupported(PetImageUnsupportedReason::Tmux) + ); + assert_eq!( + pet_image_support_for_terminal(&terminal_info_for_test( + TerminalName::Kitty, + Some(Multiplexer::Zellij {}), + Some("kitty"), + /*term*/ None, + )), + PetImageSupport::Unsupported(PetImageUnsupportedReason::Zellij) + ); + } + + #[test] + fn pet_image_support_detects_iterm2_kitty_file_graphics() { + for info in [ + terminal_info_for_test( + TerminalName::Iterm2, + /*multiplexer*/ None, + Some("iTerm.app"), + /*term*/ None, + ), + terminal_info_for_test( + TerminalName::Unknown, + /*multiplexer*/ None, + Some("iTerm.app"), + Some("xterm-256color"), + ), + ] { + assert_eq!( + pet_image_support_for_terminal(&info), + PetImageSupport::Supported(ImageProtocol::KittyLocalFile) + ); + } + } + + #[test] + fn pet_image_support_detects_kitty_graphics_terminals() { + for info in [ + terminal_info_for_test( + TerminalName::Ghostty, + /*multiplexer*/ None, + Some("Ghostty"), + /*term*/ None, + ), + terminal_info_for_test( + TerminalName::Kitty, + /*multiplexer*/ None, + Some("kitty"), + /*term*/ None, + ), + terminal_info_for_test( + TerminalName::WezTerm, + /*multiplexer*/ None, + Some("WezTerm"), + /*term*/ None, + ), + terminal_info_for_test( + TerminalName::Unknown, + /*multiplexer*/ None, + /*term_program*/ None, + Some("xterm-kitty"), + ), + terminal_info_for_test( + TerminalName::Unknown, + /*multiplexer*/ None, + /*term_program*/ None, + Some("wezterm"), + ), + terminal_info_for_test( + TerminalName::Unknown, + /*multiplexer*/ None, + Some("WezTerm"), + Some("xterm-256color"), + ), + ] { + assert_eq!( + pet_image_support_for_terminal(&info), + PetImageSupport::Supported(ImageProtocol::Kitty) + ); + } + } + + #[test] + fn pet_image_support_detects_sixel_terminals() { + for info in [ + terminal_info_for_test( + TerminalName::Unknown, + /*multiplexer*/ None, + /*term_program*/ None, + Some("xterm-sixel"), + ), + terminal_info_for_test( + TerminalName::Unknown, + /*multiplexer*/ None, + /*term_program*/ None, + Some("foot"), + ), + terminal_info_for_test( + TerminalName::Unknown, + /*multiplexer*/ None, + /*term_program*/ None, + Some("mlterm"), + ), + terminal_info_for_test( + TerminalName::WindowsTerminal, + /*multiplexer*/ None, + Some("WindowsTerminal"), + Some("xterm-256color"), + ), + ] { + assert_eq!( + pet_image_support_for_terminal(&info), + PetImageSupport::Supported(ImageProtocol::Sixel) + ); + } + } + + #[test] + #[serial] + fn wezterm_env_uses_kitty_graphics_for_ambient_pets() { + let _tmux = EnvVarGuard::new("TMUX", /*value*/ None); + let _tmux_pane = EnvVarGuard::new("TMUX_PANE", /*value*/ None); + let _zellij = EnvVarGuard::new("ZELLIJ", /*value*/ None); + let _zellij_session = EnvVarGuard::new("ZELLIJ_SESSION_NAME", /*value*/ None); + let _zellij_version = EnvVarGuard::new("ZELLIJ_VERSION", /*value*/ None); + let _kitty = EnvVarGuard::new("KITTY_WINDOW_ID", /*value*/ None); + let _wezterm = EnvVarGuard::new("WEZTERM_VERSION", Some("20240203")); + let _wezterm_executable = EnvVarGuard::new("WEZTERM_EXECUTABLE", /*value*/ None); + + assert_eq!( + detect_pet_image_support(), + PetImageSupport::Supported(ImageProtocol::Kitty) + ); + } + + #[test] + fn pet_image_support_rejects_unknown_terminals() { + assert_eq!( + pet_image_support_for_terminal(&terminal_info_for_test( + TerminalName::Unknown, + /*multiplexer*/ None, + /*term_program*/ None, + Some("xterm-256color"), + )), + PetImageSupport::Unsupported(PetImageUnsupportedReason::Terminal) + ); + } + + fn terminal_info_for_test( + name: TerminalName, + multiplexer: Option, + term_program: Option<&str>, + term: Option<&str>, + ) -> TerminalInfo { + TerminalInfo { + name, + term_program: term_program.map(str::to_string), + version: /*version*/ None, + term: term.map(str::to_string), + multiplexer, + } + } + + #[test] + fn sixel_frame_encodes_without_external_crate() { + let dir = tempfile::tempdir().unwrap(); + let frame_path = dir.path().join("frame.png"); + let rgba = image::RgbaImage::from_pixel(1, 1, image::Rgba([255, 0, 0, 255])); + rgba.save(&frame_path).unwrap(); + + let sixel_path = + sixel_frame(&frame_path, &dir.path().join("sixel"), /*height_px*/ 1).unwrap(); + let sixel = fs::read_to_string(sixel_path).unwrap(); + + assert!(sixel.starts_with("\x1bP9;1;0q\"1;1;1;1")); + assert!(sixel.contains("#224;2;100;0;0")); + assert!(sixel.contains("#224@")); + assert!(sixel.ends_with("\x1b\\")); + } + + #[test] + #[serial] + fn kitty_file_png_transmission_encodes_local_file_reference() { + let _guard = EnvVarGuard::new("TMUX", /*value*/ None); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("frame.png"); + fs::write(&path, b"png").unwrap(); + + let command = kitty_transmit_png_file_with_id( + &path, + /*columns*/ 4, + /*rows*/ 3, + /*image_id*/ Some(7), + ) + .unwrap(); + let path = path.canonicalize().unwrap(); + let payload = general_purpose::STANDARD.encode(path.to_string_lossy().as_bytes()); + + assert_eq!( + command, + format!("\x1b_Ga=T,t=f,f=100,c=4,r=3,q=2,i=7;{payload}\x1b\\") + ); + } +} diff --git a/codex-rs/tui/src/pets/mod.rs b/codex-rs/tui/src/pets/mod.rs new file mode 100644 index 0000000000..74d8d37380 --- /dev/null +++ b/codex-rs/tui/src/pets/mod.rs @@ -0,0 +1,468 @@ +//! Ambient terminal pets configured from the /pets slash command. +//! +//! The TUI treats built-in and custom pets differently on purpose: +//! built-in pets are versioned application assets fetched on demand into a +//! managed CODEX_HOME cache, while custom pets remain entirely user-owned data +//! under `$CODEX_HOME/pets//pet.json` or legacy avatar directories. +//! +//! This module owns the TUI-facing contracts around that split: +//! resolving a selected pet id, preparing frames for terminal image protocols, +//! rendering the ambient sprite and picker preview, and preserving enough +//! metadata for `/pets` to behave like a first-class configuration surface. +//! It does not own config persistence or popup orchestration; callers must +//! ensure a built-in asset exists before loading it and must persist the final +//! selection only after the load succeeds. + +use std::io::Write; + +mod ambient; +mod asset_pack; +mod catalog; +mod frames; +mod image_protocol; +mod model; +mod picker; +mod preview; +mod sixel; + +use anyhow::Context; +use anyhow::Result; + +pub(crate) use ambient::AmbientPet; +pub(crate) use ambient::AmbientPetDraw; +pub(crate) use ambient::PetNotificationKind; +#[cfg(test)] +pub(crate) use ambient::test_ambient_pet; +pub(crate) use asset_pack::builtin_spritesheet_path; +#[cfg(test)] +pub(crate) use asset_pack::write_test_pack; +#[cfg(test)] +pub(crate) use image_protocol::ImageProtocol; +pub(crate) use image_protocol::PetImageSupport; +#[cfg(test)] +pub(crate) use image_protocol::PetImageUnsupportedReason; +#[cfg(not(test))] +pub(crate) use image_protocol::detect_pet_image_support; +pub(crate) use picker::PET_PICKER_VIEW_ID; +pub(crate) use picker::build_pet_picker_params; +pub(crate) use preview::PetPickerPreviewState; + +pub(crate) const DEFAULT_PET_ID: &str = "codex"; +pub(crate) const DISABLED_PET_ID: &str = "disabled"; + +/// Ensure that a selected built-in pet has a locally cached spritesheet. +/// +/// Custom pets are intentionally a no-op here because their source of truth is +/// already local. Callers should invoke this before loading a built-in pet for +/// preview or selection; skipping it would make first-use preview and +/// persistence failures depend on deeper image-loading errors instead of the +/// asset-fetch boundary. +pub(crate) fn ensure_builtin_pack_for_pet( + pet_id: &str, + codex_home: &std::path::Path, +) -> Result<()> { + if let Some(pet) = catalog::builtin_pet(pet_id) { + asset_pack::ensure_builtin_pet(codex_home, pet)?; + } + Ok(()) +} + +#[derive(Debug)] +pub(crate) enum PetImageRenderError { + Terminal(std::io::Error), + Asset(anyhow::Error), +} + +impl std::fmt::Display for PetImageRenderError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Terminal(err) => write!(f, "terminal image write failed: {err}"), + Self::Asset(err) => write!(f, "pet image asset unavailable: {err}"), + } + } +} + +impl std::error::Error for PetImageRenderError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match self { + Self::Terminal(err) => Some(err), + Self::Asset(err) => Some(err.as_ref()), + } + } +} + +impl From for PetImageRenderError { + fn from(err: std::io::Error) -> Self { + Self::Terminal(err) + } +} + +pub(crate) fn render_ambient_pet_image( + writer: &mut impl Write, + state: &mut PetImageRenderState, + request: Option, +) -> std::result::Result<(), PetImageRenderError> { + render_pet_image(writer, state, /*image_id*/ 0xC0DE, request) +} + +pub(crate) fn render_pet_picker_preview_image( + writer: &mut impl Write, + state: &mut PetImageRenderState, + request: Option, +) -> std::result::Result<(), PetImageRenderError> { + render_pet_image(writer, state, /*image_id*/ 0xC0DF, request) +} + +#[derive(Debug, Default)] +pub(crate) struct PetImageRenderState { + last_sixel_clear_area: Option, + last_protocol: Option, +} + +fn render_pet_image( + writer: &mut impl Write, + state: &mut PetImageRenderState, + image_id: u32, + request: Option, +) -> std::result::Result<(), PetImageRenderError> { + use crossterm::cursor::MoveTo; + use crossterm::cursor::RestorePosition; + use crossterm::cursor::SavePosition; + use crossterm::queue; + use image_protocol::ImageProtocol; + + let Some(request) = request else { + if state.last_protocol.take().is_some_and(is_kitty_protocol) { + write!(writer, "{}", image_protocol::kitty_delete_image(image_id))?; + } + if let Some(area) = state.last_sixel_clear_area.take() { + queue!(writer, SavePosition)?; + clear_sixel_area(writer, area)?; + queue!(writer, RestorePosition)?; + } + writer.flush()?; + return Ok(()); + }; + + if state.last_protocol.take().is_some_and(is_kitty_protocol) + || is_kitty_protocol(request.protocol) + { + write!(writer, "{}", image_protocol::kitty_delete_image(image_id))?; + } + state.last_protocol = Some(request.protocol); + + let payload = match request.protocol { + ImageProtocol::Kitty => AmbientPetPayload::Text( + image_protocol::kitty_transmit_png_with_id( + &request.frame, + request.columns, + request.rows, + Some(image_id), + ) + .map_err(PetImageRenderError::Asset)?, + ), + ImageProtocol::KittyLocalFile => AmbientPetPayload::Text( + image_protocol::kitty_transmit_png_file_with_id( + &request.frame, + request.columns, + request.rows, + Some(image_id), + ) + .map_err(PetImageRenderError::Asset)?, + ), + ImageProtocol::Sixel => { + let path = + image_protocol::sixel_frame(&request.frame, &request.sixel_dir, request.height_px) + .map_err(PetImageRenderError::Asset)?; + let sixel = std::fs::read(&path) + .with_context(|| format!("read {}", path.display())) + .map_err(PetImageRenderError::Asset)?; + AmbientPetPayload::Bytes(sixel) + } + }; + + queue!(writer, SavePosition)?; + let current_sixel_clear_area = if matches!(request.protocol, ImageProtocol::Sixel) { + Some(SixelClearArea::from(&request)) + } else { + None + }; + if let Some(previous_area) = state.last_sixel_clear_area.take() + && Some(previous_area) != current_sixel_clear_area + { + clear_sixel_area(writer, previous_area)?; + } + if let Some(area) = current_sixel_clear_area { + clear_sixel_area(writer, area)?; + state.last_sixel_clear_area = Some(area); + } + queue!(writer, MoveTo(request.x, request.y))?; + match payload { + AmbientPetPayload::Text(payload) => write!(writer, "{payload}")?, + AmbientPetPayload::Bytes(payload) => writer.write_all(&payload)?, + } + queue!(writer, RestorePosition)?; + writer.flush()?; + Ok(()) +} + +enum AmbientPetPayload { + Text(String), + Bytes(Vec), +} + +fn is_kitty_protocol(protocol: image_protocol::ImageProtocol) -> bool { + matches!( + protocol, + image_protocol::ImageProtocol::Kitty | image_protocol::ImageProtocol::KittyLocalFile + ) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct SixelClearArea { + x: u16, + clear_top_y: u16, + clear_bottom_y: u16, + columns: u16, +} + +impl From<&AmbientPetDraw> for SixelClearArea { + fn from(request: &AmbientPetDraw) -> Self { + Self { + x: request.x, + clear_top_y: request.clear_top_y, + clear_bottom_y: request.y.saturating_add(request.rows), + columns: request.columns, + } + } +} + +fn clear_sixel_area(writer: &mut impl Write, area: SixelClearArea) -> std::io::Result<()> { + use crossterm::cursor::MoveTo; + use crossterm::queue; + + let blank = " ".repeat(area.columns.into()); + for row in area.clear_top_y..area.clear_bottom_y { + queue!(writer, MoveTo(area.x, row))?; + write!(writer, "{blank}")?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use std::error::Error as _; + use std::io; + use std::path::PathBuf; + + use super::image_protocol::ImageProtocol; + use super::*; + + #[test] + fn ambient_pet_image_restores_cursor_after_drawing() { + let dir = tempfile::tempdir().unwrap(); + let frame = dir.path().join("frame.png"); + std::fs::write(&frame, b"png").unwrap(); + let request = AmbientPetDraw { + frame, + protocol: ImageProtocol::Kitty, + x: 2, + y: 3, + clear_top_y: 3, + columns: 4, + rows: 5, + height_px: 75, + sixel_dir: PathBuf::new(), + }; + let mut output = Vec::new(); + let mut state = PetImageRenderState::default(); + + render_ambient_pet_image(&mut output, &mut state, Some(request)).unwrap(); + + let output = String::from_utf8(output).unwrap(); + let save = output.find("\x1b7").expect("saves cursor position"); + let move_to = output.find("\x1b[4;3H").expect("moves to pet position"); + let image = output.find("cG5n").expect("writes image payload"); + let restore = output.find("\x1b8").expect("restores cursor position"); + assert!(save < move_to); + assert!(move_to < image); + assert!(image < restore); + } + + #[test] + fn kitty_pet_image_clear_deletes_without_moving_cursor() { + let dir = tempfile::tempdir().unwrap(); + let frame = dir.path().join("frame.png"); + std::fs::write(&frame, b"png").unwrap(); + let request = AmbientPetDraw { + frame, + protocol: ImageProtocol::Kitty, + x: 2, + y: 3, + clear_top_y: 3, + columns: 4, + rows: 5, + height_px: 75, + sixel_dir: PathBuf::new(), + }; + let mut output = Vec::new(); + let mut state = PetImageRenderState::default(); + + render_ambient_pet_image(&mut output, &mut state, Some(request)).unwrap(); + output.clear(); + render_ambient_pet_image(&mut output, &mut state, /*request*/ None).unwrap(); + + let output = String::from_utf8(output).unwrap(); + assert!(output.contains("Ga=d,d=I,i=49374,q=2;")); + assert!(!output.contains("\x1b7")); + assert!(!output.contains("\x1b[")); + assert!(!output.contains("\x1b8")); + } + + #[test] + fn kitty_local_file_pet_image_uses_file_reference_without_inline_payload() { + let dir = tempfile::tempdir().unwrap(); + let frame = dir.path().join("frame.png"); + std::fs::write(&frame, b"png").unwrap(); + let request = AmbientPetDraw { + frame, + protocol: ImageProtocol::KittyLocalFile, + x: 2, + y: 3, + clear_top_y: 3, + columns: 4, + rows: 2, + height_px: 75, + sixel_dir: PathBuf::new(), + }; + let mut output = Vec::new(); + let mut state = PetImageRenderState::default(); + + render_ambient_pet_image(&mut output, &mut state, Some(request)).unwrap(); + + let output = String::from_utf8(output).unwrap(); + assert!(output.contains("a=d,d=I,i=49374,q=2;")); + assert!(output.contains("\x1b[4;3H")); + assert!(output.contains("a=T,t=f,f=100,c=4,r=2,q=2,i=49374;")); + assert!(!output.contains("cG5n")); + assert!(output.contains("\x1b8")); + } + + #[test] + fn sixel_pet_image_clears_cell_area_before_redrawing() { + let dir = tempfile::tempdir().unwrap(); + let frame = dir.path().join("frame.png"); + std::fs::write(&frame, b"png").unwrap(); + let sixel_dir = dir.path().join("sixel"); + std::fs::create_dir(&sixel_dir).unwrap(); + let sixel_frame = sixel_dir.join("frame_h75_v2.six"); + std::fs::write(&sixel_frame, b"fake-sixel").unwrap(); + let request = AmbientPetDraw { + frame, + protocol: ImageProtocol::Sixel, + x: 2, + y: 3, + clear_top_y: 1, + columns: 4, + rows: 2, + height_px: 75, + sixel_dir, + }; + let mut output = Vec::new(); + let mut state = PetImageRenderState::default(); + + render_ambient_pet_image(&mut output, &mut state, Some(request)).unwrap(); + + let output = String::from_utf8(output).unwrap(); + assert!(output.contains("\x1b[2;3H \x1b[3;3H \x1b[4;3H \x1b[5;3H \x1b[4;3H")); + assert!(output.contains("fake-sixel")); + assert!(output.contains("\x1b8")); + } + + #[test] + fn sixel_pet_image_clear_erases_last_drawn_area() { + let dir = tempfile::tempdir().unwrap(); + let frame = dir.path().join("frame.png"); + std::fs::write(&frame, b"png").unwrap(); + let sixel_dir = dir.path().join("sixel"); + std::fs::create_dir(&sixel_dir).unwrap(); + let sixel_frame = sixel_dir.join("frame_h75_v2.six"); + std::fs::write(&sixel_frame, b"fake-sixel").unwrap(); + let request = AmbientPetDraw { + frame, + protocol: ImageProtocol::Sixel, + x: 2, + y: 3, + clear_top_y: 1, + columns: 4, + rows: 2, + height_px: 75, + sixel_dir, + }; + let mut output = Vec::new(); + let mut state = PetImageRenderState::default(); + + render_ambient_pet_image(&mut output, &mut state, Some(request)).unwrap(); + output.clear(); + render_ambient_pet_image(&mut output, &mut state, /*request*/ None).unwrap(); + + let output = String::from_utf8(output).unwrap(); + assert!(!output.contains("Ga=d,d=I,i=49374,q=2;")); + assert!(output.contains("\x1b7")); + assert!(output.contains("\x1b[2;3H \x1b[3;3H \x1b[4;3H \x1b[5;3H ")); + assert!(output.contains("\x1b8")); + assert!(!output.contains("fake-sixel")); + } + + #[test] + fn missing_frame_is_an_asset_error() { + let dir = tempfile::tempdir().unwrap(); + let request = AmbientPetDraw { + frame: dir.path().join("missing.png"), + protocol: ImageProtocol::Kitty, + x: 2, + y: 3, + clear_top_y: 3, + columns: 4, + rows: 5, + height_px: 75, + sixel_dir: PathBuf::new(), + }; + let mut output = Vec::new(); + let mut state = PetImageRenderState::default(); + + let err = render_ambient_pet_image(&mut output, &mut state, Some(request)).unwrap_err(); + + assert!(matches!(err, PetImageRenderError::Asset(_))); + assert!(err.source().is_some()); + } + + #[test] + fn writer_failure_is_a_terminal_error() { + struct FailingWriter; + + impl io::Write for FailingWriter { + fn write(&mut self, _buf: &[u8]) -> io::Result { + Err(io::Error::new( + io::ErrorKind::BrokenPipe, + "test writer failed", + )) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } + } + + let mut writer = FailingWriter; + let mut state = PetImageRenderState { + last_protocol: Some(ImageProtocol::Kitty), + ..Default::default() + }; + + let err = render_ambient_pet_image(&mut writer, &mut state, /*request*/ None).unwrap_err(); + + assert!(matches!(err, PetImageRenderError::Terminal(_))); + assert!(err.source().is_some()); + } +} diff --git a/codex-rs/tui/src/pets/model.rs b/codex-rs/tui/src/pets/model.rs new file mode 100644 index 0000000000..aa02731ab7 --- /dev/null +++ b/codex-rs/tui/src/pets/model.rs @@ -0,0 +1,1036 @@ +//! Pet manifest loading and normalization. +//! +//! This module converts several user-facing selectors into one normalized +//! in-memory `Pet`: built-in catalog ids, custom pet ids under CODEX_HOME, +//! legacy avatar directories, and explicit filesystem paths used by tests or +//! local iteration. +//! +//! The key invariant is that every returned `Pet` points at a local +//! spritesheet path that already exists and has app-compatible dimensions. +//! Asset acquisition is intentionally out of scope here; callers must ensure a +//! built-in pet has been downloaded before asking the model layer to load it. + +use std::collections::HashMap; +use std::fs; +use std::path::Component; +use std::path::Path; +use std::path::PathBuf; +use std::time::Duration; + +use anyhow::Context; +use anyhow::Result; +use anyhow::bail; +use serde::Deserialize; +use sha2::Digest as _; +use sha2::Sha256; + +use super::catalog; + +const MAX_PET_FRAMES: usize = 256; +const MAX_ANIMATION_FPS: f64 = 60.0; + +#[derive(Debug, Clone)] +pub struct AnimationFrame { + pub sprite_index: usize, + pub duration: Duration, +} + +#[derive(Debug, Clone)] +pub struct Animation { + pub frames: Vec, + pub loop_start: Option, + pub fallback: String, +} + +impl Animation { + pub(super) fn total_duration(&self) -> Duration { + self.frames + .iter() + .map(|frame| frame.duration) + .sum::() + } +} + +/// One named animation track for a pet spritesheet. +/// +/// Tracks use sprite indices into the already-decoded frame grid plus a +/// fallback animation name for one-shot sequences. Callers should not assume +/// an animation loops just because it has multiple frames; `loop_start == None` +/// means the final frame eventually hands off to `fallback`. +#[derive(Debug, Clone)] +pub struct Pet { + pub id: String, + pub display_name: String, + pub description: String, + pub spritesheet_path: PathBuf, + pub frame_width: u32, + pub frame_height: u32, + pub columns: u32, + pub rows: u32, + pub frame_count: usize, + pub animations: HashMap, +} + +impl Pet { + /// Load a pet selector into a concrete local pet definition. + /// + /// Selectors may name a built-in catalog pet, a custom pet id, a legacy + /// avatar id, or an explicit path. This method assumes any built-in asset + /// has already been materialized into CODEX_HOME; if callers skip the + /// asset-fetch step, they will get a missing-spritesheet error here on + /// first use. + pub(super) fn load_with_codex_home(value: &str, codex_home: Option<&Path>) -> Result { + if path_like(value) { + return load_pet_path(value); + } + + if let Some(custom_id) = value.strip_prefix(CUSTOM_PET_PREFIX) { + return load_custom_pet(custom_id, codex_home); + } + + if let Some(builtin) = catalog::builtin_pet(value) { + return load_builtin_pet(builtin, codex_home); + } + + load_custom_pet(value, codex_home) + } + + pub fn frame_count(&self) -> usize { + self.frame_count + } + + pub(super) fn frame_cache_key(&self) -> Result { + let bytes = fs::read(&self.spritesheet_path) + .with_context(|| format!("read {}", self.spritesheet_path.display()))?; + let digest = Sha256::digest(&bytes); + Ok(format!( + "sha256-{digest:x}-{}x{}-{}x{}", + self.frame_width, self.frame_height, self.columns, self.rows + )) + } +} + +pub(super) const CUSTOM_PET_PREFIX: &str = "custom:"; + +#[derive(Debug, Deserialize)] +struct PetFile { + #[serde(default)] + id: Option, + #[serde(default, rename = "displayName")] + display_name: Option, + #[serde(default)] + description: Option, + #[serde(default, rename = "spritesheetPath")] + spritesheet_path: Option, + frame: Option, + #[serde(default)] + animations: HashMap, +} + +#[derive(Debug, Clone, Copy, Deserialize)] +struct FrameSpec { + width: u32, + height: u32, + columns: u32, + rows: u32, +} + +impl Default for FrameSpec { + fn default() -> Self { + Self { + width: catalog::DEFAULT_FRAME_WIDTH, + height: catalog::DEFAULT_FRAME_HEIGHT, + columns: catalog::DEFAULT_FRAME_COLUMNS, + rows: catalog::DEFAULT_FRAME_ROWS, + } + } +} + +pub(super) fn custom_pet_selector(id: &str) -> String { + format!("{CUSTOM_PET_PREFIX}{id}") +} + +#[derive(Debug, Deserialize)] +struct AnimationSpec { + #[serde(default)] + frames: Vec, + fps: Option, + #[serde(rename = "loop")] + loop_animation: Option, + #[serde(default)] + fallback: String, +} + +fn load_builtin_pet(pet: catalog::BuiltinPet, codex_home: Option<&Path>) -> Result { + let codex_home = codex_home.context("CODEX_HOME is not available")?; + let spritesheet_path = super::builtin_spritesheet_path(codex_home, pet.spritesheet_file); + if !spritesheet_path.exists() { + bail!("missing spritesheet {}", spritesheet_path.display()); + } + + Ok(Pet { + id: pet.id.to_string(), + display_name: pet.display_name.to_string(), + description: pet.description.to_string(), + spritesheet_path, + frame_width: catalog::DEFAULT_FRAME_WIDTH, + frame_height: catalog::DEFAULT_FRAME_HEIGHT, + columns: catalog::DEFAULT_FRAME_COLUMNS, + rows: catalog::DEFAULT_FRAME_ROWS, + frame_count: default_frame_count(), + animations: default_animations(), + }) +} + +fn load_custom_pet(value: &str, codex_home: Option<&Path>) -> Result { + let codex_home = codex_home.context("CODEX_HOME is not available")?; + let pet_dir = codex_home.join("pets").join(value); + if pet_dir.join("pet.json").is_file() { + return load_pet_manifest(&pet_dir, "pet.json", value, &custom_pet_cache_id(value)); + } + + let avatar_dir = codex_home.join("avatars").join(value); + if avatar_dir.join("avatar.json").is_file() { + return load_pet_manifest( + &avatar_dir, + "avatar.json", + value, + &custom_pet_cache_id(value), + ); + } + + bail!("unknown pet {value}") +} + +fn load_pet_path(value: &str) -> Result { + let path = expand_path(value)?; + let metadata = fs::metadata(&path).with_context(|| format!("pet path {}", path.display()))?; + let dir = if metadata.is_dir() { + path + } else { + path.parent() + .context("pet json path has no containing directory")? + .to_path_buf() + }; + let pet_dir = dir + .canonicalize() + .with_context(|| format!("resolve {}", dir.display()))?; + let manifest_file = if pet_dir.join("pet.json").is_file() { + "pet.json" + } else if pet_dir.join("avatar.json").is_file() { + "avatar.json" + } else { + bail!("missing pet.json or avatar.json in {}", pet_dir.display()); + }; + let fallback_id = pet_dir + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("pet"); + load_pet_manifest(&pet_dir, manifest_file, fallback_id, fallback_id) +} + +fn load_pet_manifest( + pet_dir: &Path, + manifest_file: &str, + fallback_id: &str, + cache_id: &str, +) -> Result { + let config_path = pet_dir.join(manifest_file); + let raw = fs::read_to_string(&config_path) + .with_context(|| format!("read {}", config_path.display()))?; + let file: PetFile = + serde_json::from_str(&raw).with_context(|| format!("parse {}", config_path.display()))?; + + let manifest_id = file + .id + .as_deref() + .map(str::trim) + .filter(|id| !id.is_empty()); + let display_name = file + .display_name + .as_deref() + .map(str::trim) + .filter(|name| !name.is_empty()) + .or(manifest_id) + .unwrap_or(fallback_id) + .to_string(); + let pet_id = if cache_id == fallback_id { + manifest_id.unwrap_or(fallback_id).to_string() + } else { + cache_id.to_string() + }; + let description = file + .description + .map(|description| description.trim().to_string()) + .unwrap_or_default(); + let spritesheet_path = resolve_spritesheet_path( + pet_dir, + file.spritesheet_path + .as_deref() + .map(str::trim) + .filter(|path| !path.is_empty()) + .unwrap_or("spritesheet.webp"), + )?; + if !spritesheet_path.exists() { + bail!("missing spritesheet {}", spritesheet_path.display()); + } + let (spritesheet_width, spritesheet_height) = + validate_app_spritesheet_dimensions(&spritesheet_path)?; + + let frame = file.frame.unwrap_or_default(); + let frame_count = validate_frame_spec(&frame, spritesheet_width, spritesheet_height)?; + Ok(Pet { + id: pet_id, + display_name, + description, + spritesheet_path, + frame_width: frame.width, + frame_height: frame.height, + columns: frame.columns, + rows: frame.rows, + frame_count, + animations: load_animations(file.animations, frame_count)?, + }) +} + +/// Resolve a manifest-relative spritesheet path while keeping it inside the pet directory. +/// +/// The manifest format intentionally supports only relative child paths. +/// Allowing absolute or parent-traversing paths here would let one custom pet +/// manifest reach outside its own directory and make cache/debug behavior +/// depend on unrelated local files. +fn resolve_spritesheet_path(pet_dir: &Path, spritesheet_path: &str) -> Result { + let path = Path::new(spritesheet_path); + if path.is_absolute() + || path + .components() + .any(|component| matches!(component, Component::ParentDir | Component::Prefix(_))) + { + bail!("spritesheet path must stay inside {}", pet_dir.display()); + } + Ok(pet_dir.join(path)) +} + +fn validate_app_spritesheet_dimensions(path: &Path) -> Result<(u32, u32)> { + let (width, height) = + image::image_dimensions(path).with_context(|| format!("read {}", path.display()))?; + if width != catalog::SPRITESHEET_WIDTH || height != catalog::SPRITESHEET_HEIGHT { + bail!( + "spritesheet must be {}x{} pixels", + catalog::SPRITESHEET_WIDTH, + catalog::SPRITESHEET_HEIGHT + ); + } + Ok((width, height)) +} + +fn validate_frame_spec( + frame: &FrameSpec, + spritesheet_width: u32, + spritesheet_height: u32, +) -> Result { + if frame.width == 0 || frame.height == 0 || frame.columns == 0 || frame.rows == 0 { + bail!("pet frame dimensions and grid counts must be non-zero"); + } + + let total_width = frame + .width + .checked_mul(frame.columns) + .context("pet frame grid width overflow")?; + let total_height = frame + .height + .checked_mul(frame.rows) + .context("pet frame grid height overflow")?; + if total_width != spritesheet_width || total_height != spritesheet_height { + bail!( + "pet frame grid must cover spritesheet exactly: expected {spritesheet_width}x{spritesheet_height}, got {total_width}x{total_height}" + ); + } + + let frame_count = frame + .columns + .checked_mul(frame.rows) + .context("pet frame count overflow")?; + let frame_count = usize::try_from(frame_count).context("pet frame count does not fit usize")?; + if frame_count > MAX_PET_FRAMES { + bail!("pet frame count {frame_count} exceeds maximum {MAX_PET_FRAMES}"); + } + Ok(frame_count) +} + +fn custom_pet_cache_id(id: &str) -> String { + format!("custom-{id}") +} + +fn path_like(value: &str) -> bool { + value == "." + || value == ".." + || value.starts_with("~/") + || value.starts_with("../") + || value.starts_with("./") + || Path::new(value).is_absolute() + || value.contains('/') + || value.contains('\\') +} + +fn expand_path(value: &str) -> Result { + if value == "~" || value.starts_with("~/") { + let home = std::env::var_os("HOME").context("HOME is not set")?; + if value == "~" { + return Ok(PathBuf::from(home)); + } + return Ok(PathBuf::from(home).join(&value[2..])); + } + + Ok(PathBuf::from(value)) +} + +fn load_animations( + specs: HashMap, + frame_count: usize, +) -> Result> { + let mut animations = default_animations(); + if specs.is_empty() { + validate_animation_indices(&animations, frame_count)?; + return Ok(animations); + } + + for (name, spec) in specs { + if spec.frames.is_empty() { + bail!("animation {name} must include at least one frame"); + } + for sprite_index in &spec.frames { + if *sprite_index >= frame_count { + bail!( + "animation {name} references sprite index {sprite_index}, but pet has {frame_count} frames" + ); + } + } + + let fps = match spec.fps { + Some(fps) if fps.is_finite() && fps > 0.0 && fps <= MAX_ANIMATION_FPS => fps, + Some(fps) => { + bail!( + "animation {name} fps must be finite and between 0 and {MAX_ANIMATION_FPS}, got {fps}" + ); + } + None => 8.0, + }; + let duration = Duration::from_secs_f64(1.0 / fps); + let fallback = if spec.fallback.is_empty() { + "idle".to_string() + } else { + spec.fallback + }; + let loop_start = spec + .loop_animation + .unwrap_or(/*default*/ true) + .then_some(/*loop_start*/ 0); + + animations.insert( + name, + Animation { + frames: spec + .frames + .into_iter() + .map(|sprite_index| AnimationFrame { + sprite_index, + duration, + }) + .collect(), + loop_start, + fallback, + }, + ); + } + + animations + .entry("idle".to_string()) + .or_insert_with(idle_animation); + validate_animation_indices(&animations, frame_count)?; + Ok(animations) +} + +fn validate_animation_indices( + animations: &HashMap, + frame_count: usize, +) -> Result<()> { + for (name, animation) in animations { + if animation.frames.is_empty() { + bail!("animation {name} must include at least one frame"); + } + for frame in &animation.frames { + if frame.sprite_index >= frame_count { + bail!( + "animation {name} references sprite index {}, but pet has {frame_count} frames", + frame.sprite_index + ); + } + } + if !animations.contains_key(&animation.fallback) { + bail!( + "animation {name} fallback {} does not exist", + animation.fallback + ); + } + } + Ok(()) +} + +fn default_frame_count() -> usize { + (catalog::DEFAULT_FRAME_COLUMNS * catalog::DEFAULT_FRAME_ROWS) as usize +} + +fn default_animations() -> HashMap { + [ + ("idle", idle_animation()), + ( + "running-right", + app_state_animation( + /*row_index*/ 1, /*frame_count*/ 8, /*frame_duration_ms*/ 120, + /*final_frame_duration_ms*/ 220, + ), + ), + ( + "running-left", + app_state_animation( + /*row_index*/ 2, /*frame_count*/ 8, /*frame_duration_ms*/ 120, + /*final_frame_duration_ms*/ 220, + ), + ), + ( + "waving", + app_state_animation( + /*row_index*/ 3, /*frame_count*/ 4, /*frame_duration_ms*/ 140, + /*final_frame_duration_ms*/ 280, + ), + ), + ( + "jumping", + app_state_animation( + /*row_index*/ 4, /*frame_count*/ 5, /*frame_duration_ms*/ 140, + /*final_frame_duration_ms*/ 280, + ), + ), + ( + "failed", + app_state_animation( + /*row_index*/ 5, /*frame_count*/ 8, /*frame_duration_ms*/ 140, + /*final_frame_duration_ms*/ 240, + ), + ), + ( + "waiting", + app_state_animation( + /*row_index*/ 6, /*frame_count*/ 6, /*frame_duration_ms*/ 150, + /*final_frame_duration_ms*/ 260, + ), + ), + ( + "running", + app_state_animation( + /*row_index*/ 7, /*frame_count*/ 6, /*frame_duration_ms*/ 120, + /*final_frame_duration_ms*/ 220, + ), + ), + ( + "review", + app_state_animation( + /*row_index*/ 8, /*frame_count*/ 6, /*frame_duration_ms*/ 150, + /*final_frame_duration_ms*/ 280, + ), + ), + ( + "move_right", + app_state_animation( + /*row_index*/ 1, /*frame_count*/ 8, /*frame_duration_ms*/ 120, + /*final_frame_duration_ms*/ 220, + ), + ), + ( + "move_left", + app_state_animation( + /*row_index*/ 2, /*frame_count*/ 8, /*frame_duration_ms*/ 120, + /*final_frame_duration_ms*/ 220, + ), + ), + ( + "wave", + app_state_animation( + /*row_index*/ 3, /*frame_count*/ 4, /*frame_duration_ms*/ 140, + /*final_frame_duration_ms*/ 280, + ), + ), + ( + "bounce", + app_state_animation( + /*row_index*/ 4, /*frame_count*/ 5, /*frame_duration_ms*/ 140, + /*final_frame_duration_ms*/ 280, + ), + ), + ( + "sad", + app_state_animation( + /*row_index*/ 5, /*frame_count*/ 8, /*frame_duration_ms*/ 140, + /*final_frame_duration_ms*/ 240, + ), + ), + ] + .into_iter() + .map(|(name, animation)| (name.to_string(), animation)) + .collect() +} + +fn idle_animation() -> Animation { + Animation { + frames: [(0, 1680), (1, 660), (2, 660), (3, 840), (4, 840), (5, 1920)] + .into_iter() + .map(|(sprite_index, duration_ms)| AnimationFrame { + sprite_index, + duration: Duration::from_millis(duration_ms), + }) + .collect(), + loop_start: Some(/*loop_start*/ 0), + fallback: "idle".to_string(), + } +} + +fn app_state_animation( + row_index: usize, + frame_count: usize, + frame_duration_ms: u64, + final_frame_duration_ms: u64, +) -> Animation { + let primary_frames = (0..frame_count) + .map(|column_index| AnimationFrame { + sprite_index: row_index * catalog::DEFAULT_FRAME_COLUMNS as usize + column_index, + duration: Duration::from_millis(if column_index == frame_count - 1 { + final_frame_duration_ms + } else { + frame_duration_ms + }), + }) + .collect::>(); + let primary_frame_count = primary_frames.len() * 3; + let frames = primary_frames + .iter() + .chain(primary_frames.iter()) + .chain(primary_frames.iter()) + .cloned() + .chain(idle_animation().frames) + .collect(); + Animation { + frames, + loop_start: Some(primary_frame_count), + fallback: "idle".to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn write_minimal_pet() -> tempfile::TempDir { + write_pet_manifest( + r#"{ + "id": "chefito", + "displayName": "Chefito", + "description": "A tiny recipe-loving chef", + "spritesheetPath": "spritesheet.webp" + }"#, + ) + } + + fn write_pet_manifest(manifest: &str) -> tempfile::TempDir { + let dir = tempfile::tempdir().unwrap(); + fs::write(dir.path().join("pet.json"), manifest).unwrap(); + catalog::write_test_spritesheet(&dir.path().join("spritesheet.webp")); + dir + } + + fn load_pet_from_dir(dir: &tempfile::TempDir) -> Pet { + Pet::load_with_codex_home(dir.path().to_str().unwrap(), /*codex_home*/ None).unwrap() + } + + fn load_pet_error_from_dir(dir: &tempfile::TempDir) -> anyhow::Error { + Pet::load_with_codex_home(dir.path().to_str().unwrap(), /*codex_home*/ None).unwrap_err() + } + + #[test] + fn load_builtin_pet_uses_app_catalog_storage() { + let codex_home = tempfile::tempdir().unwrap(); + super::super::asset_pack::write_test_pack(codex_home.path()); + + let pet = + Pet::load_with_codex_home("dewey", /*codex_home*/ Some(codex_home.path())).unwrap(); + + assert_eq!(pet.id, "dewey"); + assert_eq!(pet.display_name, "Dewey"); + assert_eq!(pet.description, "A tidy duck for calm workspace days"); + assert_eq!( + pet.spritesheet_path, + super::super::builtin_spritesheet_path(codex_home.path(), "dewey-spritesheet-v4.webp") + ); + assert_eq!(pet.frame_width, 192); + assert_eq!(pet.frame_height, 208); + assert_eq!(pet.columns, 8); + assert_eq!(pet.rows, 9); + } + + #[test] + fn app_idle_animation_uses_calm_loop() { + let animations = default_animations(); + let idle = &animations["idle"]; + + assert_eq!(sprite_indices(idle), vec![0, 1, 2, 3, 4, 5]); + assert_eq!(durations_ms(idle), vec![1680, 660, 660, 840, 840, 1920]); + assert_eq!(idle.loop_start, Some(/*loop_start*/ 0)); + } + + #[test] + fn app_running_animation_repeats_then_settles_into_idle() { + let animations = default_animations(); + let running = &animations["running"]; + let primary = vec![56, 57, 58, 59, 60, 61]; + + assert_eq!(sprite_indices(running)[0..6], primary); + assert_eq!(sprite_indices(running)[6..12], primary); + assert_eq!(sprite_indices(running)[12..18], primary); + assert_eq!( + sprite_indices(running)[18..], + sprite_indices(&animations["idle"]) + ); + assert_eq!( + durations_ms(running)[0..6], + vec![120, 120, 120, 120, 120, 220] + ); + assert_eq!(running.loop_start, Some(/*loop_start*/ 18)); + } + + #[test] + fn app_notification_states_use_expected_rows() { + let animations = default_animations(); + + assert_eq!( + sprite_indices(&animations["waiting"])[0..6], + vec![48, 49, 50, 51, 52, 53] + ); + assert_eq!( + sprite_indices(&animations["review"])[0..6], + vec![64, 65, 66, 67, 68, 69] + ); + assert_eq!( + sprite_indices(&animations["failed"])[0..8], + vec![40, 41, 42, 43, 44, 45, 46, 47] + ); + } + + #[test] + fn custom_animation_specs_keep_manifest_fps_and_loop_shape() { + let animations = load_animations( + HashMap::from([( + "custom".to_string(), + AnimationSpec { + frames: vec![1, 2], + fps: Some(/*fps*/ 2.0), + loop_animation: Some(/*loop_animation*/ false), + fallback: "idle".to_string(), + }, + )]), + default_frame_count(), + ) + .unwrap(); + let custom = &animations["custom"]; + + assert_eq!(sprite_indices(custom), vec![1, 2]); + assert_eq!(durations_ms(custom), vec![500, 500]); + assert_eq!(custom.loop_start, None); + assert_eq!(custom.fallback, "idle"); + } + + #[test] + fn load_pet_directory_uses_app_pet_manifest_defaults() { + let dir = write_minimal_pet(); + + let pet = load_pet_from_dir(&dir); + + assert_eq!(pet.id, "chefito"); + assert_eq!(pet.display_name, "Chefito"); + assert_eq!(pet.frame_width, 192); + assert_eq!(pet.frame_height, 208); + assert_eq!(pet.columns, 8); + assert_eq!(pet.rows, 9); + assert_eq!(pet.frame_count(), 72); + assert!(!pet.animations["idle"].frames.is_empty()); + } + + #[test] + fn frame_cache_key_changes_with_spritesheet_contents() { + let dir = write_minimal_pet(); + let spritesheet_path = dir.path().join("spritesheet.webp"); + let pet = load_pet_from_dir(&dir); + let first_key = pet.frame_cache_key().unwrap(); + + let image = image::RgbaImage::from_pixel( + catalog::SPRITESHEET_WIDTH, + catalog::SPRITESHEET_HEIGHT, + image::Rgba([1, 2, 3, 255]), + ); + image.save(&spritesheet_path).unwrap(); + let pet = load_pet_from_dir(&dir); + + assert_ne!(pet.frame_cache_key().unwrap(), first_key); + } + + #[test] + fn frame_cache_key_changes_with_frame_spec() { + let default_dir = write_minimal_pet(); + let default_pet = load_pet_from_dir(&default_dir); + let custom_dir = write_pet_manifest( + r#"{ + "displayName": "Tall", + "spritesheetPath": "spritesheet.webp", + "frame": { "width": 384, "height": 104, "columns": 4, "rows": 18 } + }"#, + ); + let custom_pet = load_pet_from_dir(&custom_dir); + + assert_ne!( + custom_pet.frame_cache_key().unwrap(), + default_pet.frame_cache_key().unwrap() + ); + } + + #[test] + fn load_pet_json_path_uses_containing_directory() { + let dir = write_minimal_pet(); + + let pet = Pet::load_with_codex_home( + dir.path().join("pet.json").to_str().unwrap(), + /*codex_home*/ None, + ) + .unwrap(); + let expected = dir.path().join("spritesheet.webp").canonicalize().unwrap(); + + assert_eq!(pet.spritesheet_path, expected); + } + + #[test] + fn custom_pet_selector_loads_codex_home_pet_manifest() { + let dir = write_minimal_pet(); + let codex_home = tempfile::tempdir().unwrap(); + let pet_dir = codex_home.path().join("pets").join("chefito"); + fs::create_dir_all(&pet_dir).unwrap(); + fs::copy(dir.path().join("pet.json"), pet_dir.join("pet.json")).unwrap(); + fs::copy( + dir.path().join("spritesheet.webp"), + pet_dir.join("spritesheet.webp"), + ) + .unwrap(); + + let pet = Pet::load_with_codex_home( + &custom_pet_selector("chefito"), + /*codex_home*/ Some(codex_home.path()), + ) + .unwrap(); + + assert_eq!(pet.id, "custom-chefito"); + assert_eq!(pet.spritesheet_path, pet_dir.join("spritesheet.webp"),); + } + + #[test] + fn custom_pet_selector_falls_back_to_legacy_avatar_manifest() { + let dir = write_minimal_pet(); + let codex_home = tempfile::tempdir().unwrap(); + let avatar_dir = codex_home.path().join("avatars").join("legacy"); + fs::create_dir_all(&avatar_dir).unwrap(); + fs::copy(dir.path().join("pet.json"), avatar_dir.join("avatar.json")).unwrap(); + fs::copy( + dir.path().join("spritesheet.webp"), + avatar_dir.join("spritesheet.webp"), + ) + .unwrap(); + + let pet = Pet::load_with_codex_home( + &custom_pet_selector("legacy"), + /*codex_home*/ Some(codex_home.path()), + ) + .unwrap(); + + assert_eq!(pet.id, "custom-legacy"); + assert_eq!(pet.display_name, "Chefito"); + } + + #[test] + fn custom_pet_rejects_spritesheet_path_escape() { + let codex_home = tempfile::tempdir().unwrap(); + let pet_dir = codex_home.path().join("pets").join("escape"); + fs::create_dir_all(&pet_dir).unwrap(); + fs::write( + pet_dir.join("pet.json"), + r#"{ + "displayName": "Escape", + "spritesheetPath": "../spritesheet.webp" + }"#, + ) + .unwrap(); + + let err = Pet::load_with_codex_home( + &custom_pet_selector("escape"), + /*codex_home*/ Some(codex_home.path()), + ) + .unwrap_err(); + + assert!( + err.to_string() + .contains("spritesheet path must stay inside") + ); + } + + #[test] + fn custom_pet_rejects_zero_frame_dimensions() { + let dir = write_pet_manifest( + r#"{ + "displayName": "Zero", + "spritesheetPath": "spritesheet.webp", + "frame": { "width": 0, "height": 208, "columns": 8, "rows": 9 } + }"#, + ); + + let err = load_pet_error_from_dir(&dir); + + assert!( + err.to_string() + .contains("pet frame dimensions and grid counts must be non-zero") + ); + } + + #[test] + fn custom_pet_rejects_frame_grid_that_does_not_cover_spritesheet() { + let dir = write_pet_manifest( + r#"{ + "displayName": "Short", + "spritesheetPath": "spritesheet.webp", + "frame": { "width": 192, "height": 208, "columns": 7, "rows": 9 } + }"#, + ); + + let err = load_pet_error_from_dir(&dir); + + assert!( + err.to_string() + .contains("pet frame grid must cover spritesheet exactly") + ); + } + + #[test] + fn custom_pet_rejects_excessive_frame_count() { + let dir = write_pet_manifest( + r#"{ + "displayName": "Dense", + "spritesheetPath": "spritesheet.webp", + "frame": { "width": 8, "height": 8, "columns": 192, "rows": 234 } + }"#, + ); + + let err = load_pet_error_from_dir(&dir); + + assert!(err.to_string().contains("exceeds maximum")); + } + + #[test] + fn custom_pet_rejects_empty_animation_frames() { + let dir = write_pet_manifest( + r#"{ + "displayName": "Empty", + "spritesheetPath": "spritesheet.webp", + "animations": { + "idle": { "frames": [] } + } + }"#, + ); + + let err = load_pet_error_from_dir(&dir); + + assert!( + err.to_string() + .contains("animation idle must include at least one frame") + ); + } + + #[test] + fn custom_pet_rejects_animation_frame_outside_grid() { + let dir = write_pet_manifest( + r#"{ + "displayName": "Outside", + "spritesheetPath": "spritesheet.webp", + "animations": { + "idle": { "frames": [72] } + } + }"#, + ); + + let err = load_pet_error_from_dir(&dir); + + assert!( + err.to_string() + .contains("animation idle references sprite index 72") + ); + } + + #[test] + fn custom_pet_rejects_invalid_animation_fps() { + let dir = write_pet_manifest( + r#"{ + "displayName": "Fast", + "spritesheetPath": "spritesheet.webp", + "animations": { + "idle": { "frames": [0], "fps": 120.0 } + } + }"#, + ); + + let err = load_pet_error_from_dir(&dir); + + assert!( + err.to_string() + .contains("animation idle fps must be finite and between") + ); + } + + #[test] + fn custom_pet_rejects_animation_fallback_to_missing_animation() { + let dir = write_pet_manifest( + r#"{ + "displayName": "Fallback", + "spritesheetPath": "spritesheet.webp", + "animations": { + "wave": { "frames": [1], "loop": false, "fallback": "missing" } + } + }"#, + ); + + let err = load_pet_error_from_dir(&dir); + + assert!( + err.to_string() + .contains("animation wave fallback missing does not exist") + ); + } + + fn sprite_indices(animation: &Animation) -> Vec { + animation + .frames + .iter() + .map(|frame| frame.sprite_index) + .collect() + } + + fn durations_ms(animation: &Animation) -> Vec { + animation + .frames + .iter() + .map(|frame| frame.duration.as_millis()) + .collect() + } +} diff --git a/codex-rs/tui/src/pets/picker.rs b/codex-rs/tui/src/pets/picker.rs new file mode 100644 index 0000000000..935d230b93 --- /dev/null +++ b/codex-rs/tui/src/pets/picker.rs @@ -0,0 +1,324 @@ +//! Builds the `/pets` picker dialog for the TUI. +//! +//! The picker deliberately merges three sources into one list: +//! built-in catalog pets, a synthetic "disable" entry, and user-managed custom +//! pets. It does not load preview images itself; instead it emits selection +//! change events so the surrounding chat widget can coordinate async asset +//! downloads, preview loading, and final config persistence. + +use std::collections::HashMap; +use std::fs; +use std::path::Path; + +use crate::app_event::AppEvent; +use crate::bottom_pane::SelectionAction; +use crate::bottom_pane::SelectionItem; +use crate::bottom_pane::SelectionViewParams; +use crate::bottom_pane::SideContentWidth; +use crate::bottom_pane::popup_consts::standard_popup_hint_line; + +use super::DEFAULT_PET_ID; +use super::DISABLED_PET_ID; +use super::catalog; +use super::model::CUSTOM_PET_PREFIX; +use super::model::Pet; +use super::model::custom_pet_selector; +use super::preview::PetPickerPreviewState; + +pub(crate) const PET_PICKER_VIEW_ID: &str = "pet-picker"; +const PET_PICKER_PREVIEW_WIDTH: u16 = 30; + +#[derive(Debug, Clone, PartialEq, Eq)] +struct PetPickerEntry { + selector: String, + legacy_selector: Option, + display_name: String, + description: Option, +} + +/// Build the selection popup parameters for `/pets`. +/// +/// The picker preselects `DEFAULT_PET_ID` when no pet is configured so the UI +/// has a sensible starting point without implying that Codex is already the +/// active ambient pet. Callers should treat the returned actions as the only +/// supported mutation path; bypassing them would skip preview-loading and +/// selection-specific event wiring. +pub(crate) fn build_pet_picker_params( + current_pet: Option<&str>, + codex_home: &Path, + preview_state: PetPickerPreviewState, +) -> SelectionViewParams { + let preferred_pet = current_pet.unwrap_or(DEFAULT_PET_ID); + let mut entries = available_pet_entries(codex_home); + entries.sort_by(|left, right| left.display_name.cmp(&right.display_name)); + if let Some(disabled_idx) = entries + .iter() + .position(|entry| entry.selector == DISABLED_PET_ID) + { + let disabled_entry = entries.remove(disabled_idx); + entries.insert(0, disabled_entry); + } + + let mut initial_selected_idx = None; + let preview_pet_ids = entries + .iter() + .map(|entry| entry.selector.clone()) + .collect::>(); + let on_selection_changed: crate::bottom_pane::OnSelectionChangedCallback = Some(Box::new( + move |idx: usize, tx: &crate::app_event_sender::AppEventSender| { + if let Some(pet_id) = preview_pet_ids.get(idx) { + tx.send(AppEvent::PetPreviewRequested { + pet_id: pet_id.clone(), + }); + } + }, + )); + + let items = entries + .into_iter() + .enumerate() + .map(|(idx, entry)| { + let is_current = current_pet.is_some_and(|current_pet| { + current_pet == entry.selector + || entry.legacy_selector.as_deref() == Some(current_pet) + }); + if preferred_pet == entry.selector + || entry.legacy_selector.as_deref() == Some(preferred_pet) + { + initial_selected_idx = Some(idx); + } + let pet_id = entry.selector.clone(); + let search_value = if pet_id == DISABLED_PET_ID { + "disable disabled hide hidden off none".to_string() + } else { + entry.selector + }; + let actions: Vec = if pet_id == DISABLED_PET_ID { + vec![Box::new(|tx| { + tx.send(AppEvent::PetDisabled); + })] + } else { + vec![Box::new(move |tx| { + tx.send(AppEvent::PetSelected { + pet_id: pet_id.clone(), + }); + })] + }; + SelectionItem { + name: entry.display_name, + description: entry.description, + is_current, + dismiss_on_select: true, + search_value: Some(search_value), + actions, + ..Default::default() + } + }) + .collect(); + + SelectionViewParams { + view_id: Some(PET_PICKER_VIEW_ID), + title: Some("Select Pet".to_string()), + subtitle: Some("Choose a pet to wake in the terminal.".to_string()), + footer_hint: Some(standard_popup_hint_line()), + items, + is_searchable: true, + search_placeholder: Some("Type to filter pets...".to_string()), + initial_selected_idx, + side_content: Box::new(preview_state.renderable()), + side_content_width: SideContentWidth::Fixed(PET_PICKER_PREVIEW_WIDTH), + side_content_min_width: 28, + stacked_side_content: Some(Box::new(())), + preserve_side_content_bg: true, + on_selection_changed, + ..Default::default() + } +} + +fn available_pet_entries(codex_home: &Path) -> Vec { + let mut entries = catalog::BUILTIN_PETS + .iter() + .map(|pet| PetPickerEntry { + selector: pet.id.to_string(), + legacy_selector: None, + display_name: pet.display_name.to_string(), + description: Some(pet.description.to_string()), + }) + .collect::>(); + entries.push(PetPickerEntry { + selector: DISABLED_PET_ID.to_string(), + legacy_selector: None, + display_name: "Disable terminal pets".to_string(), + description: None, + }); + entries.extend(custom_pet_entries(codex_home)); + entries +} + +fn custom_pet_entries(codex_home: &Path) -> Vec { + let mut entries_by_selector = HashMap::new(); + for (directory_name, manifest_file) in [("avatars", "avatar.json"), ("pets", "pet.json")] { + let Ok(children) = fs::read_dir(codex_home.join(directory_name)) else { + continue; + }; + for child in children.flatten() { + let path = child.path(); + if !path.join(manifest_file).is_file() { + continue; + } + let Some(id) = path.file_name().and_then(|name| name.to_str()) else { + continue; + }; + if id == DISABLED_PET_ID || id.starts_with(CUSTOM_PET_PREFIX) { + continue; + } + let selector = custom_pet_selector(id); + let Ok(pet) = + Pet::load_with_codex_home(&selector, /*codex_home*/ Some(codex_home)) + else { + continue; + }; + entries_by_selector.insert( + selector.clone(), + PetPickerEntry { + selector, + legacy_selector: Some(id.to_string()), + display_name: pet.display_name, + description: (!pet.description.is_empty()).then_some(pet.description), + }, + ); + } + } + + entries_by_selector.into_values().collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn write_pet(dir: &Path, folder_name: &str, display_name: &str) { + let pet_dir = dir.join("pets").join(folder_name); + fs::create_dir_all(&pet_dir).unwrap(); + fs::write( + pet_dir.join("pet.json"), + format!( + r#"{{ + "id": "{folder_name}", + "displayName": "{display_name}", + "description": "custom pet", + "spritesheetPath": "spritesheet.webp" + }}"# + ), + ) + .unwrap(); + catalog::write_test_spritesheet(&pet_dir.join("spritesheet.webp")); + } + + fn write_legacy_avatar(dir: &Path, folder_name: &str, display_name: &str) { + let avatar_dir = dir.join("avatars").join(folder_name); + fs::create_dir_all(&avatar_dir).unwrap(); + fs::write( + avatar_dir.join("avatar.json"), + format!( + r#"{{ + "displayName": "{display_name}", + "description": "legacy custom pet", + "spritesheetPath": "spritesheet.webp" + }}"# + ), + ) + .unwrap(); + catalog::write_test_spritesheet(&avatar_dir.join("spritesheet.webp")); + } + + #[test] + fn picker_lists_app_bundled_and_custom_pets() { + let codex_home = tempfile::tempdir().unwrap(); + write_pet(codex_home.path(), "chefito", "Chefito"); + + let params = build_pet_picker_params( + Some("chefito"), + codex_home.path(), + PetPickerPreviewState::default(), + ); + + assert_eq!( + params + .items + .iter() + .map(|item| item.name.as_str()) + .collect::>(), + vec![ + "Disable terminal pets", + "BSOD", + "Chefito", + "Codex", + "Dewey", + "Fireball", + "Null Signal", + "Rocky", + "Seedy", + "Stacky", + ], + ); + assert_eq!(params.initial_selected_idx, Some(2)); + assert_eq!( + params.items[2].search_value.as_deref(), + Some("custom:chefito") + ); + } + + #[test] + fn picker_preselects_codex_without_marking_it_current_when_no_pet_is_configured() { + let codex_home = tempfile::tempdir().unwrap(); + let params = build_pet_picker_params( + /*current_pet*/ None, + codex_home.path(), + PetPickerPreviewState::default(), + ); + + assert_eq!(params.initial_selected_idx, Some(2)); + assert_eq!(params.items[2].name, "Codex"); + assert!(!params.items[2].is_current); + } + + #[test] + fn picker_marks_disabled_pet_as_current() { + let codex_home = tempfile::tempdir().unwrap(); + let params = build_pet_picker_params( + Some(DISABLED_PET_ID), + codex_home.path(), + PetPickerPreviewState::default(), + ); + + assert_eq!(params.initial_selected_idx, Some(0)); + assert_eq!(params.items[0].name, "Disable terminal pets"); + assert_eq!(params.items[0].description, None); + assert!(params.items[0].is_current); + assert_eq!( + params.items[0].search_value.as_deref(), + Some("disable disabled hide hidden off none") + ); + } + + #[test] + fn picker_imports_legacy_avatar_manifests() { + let codex_home = tempfile::tempdir().unwrap(); + write_legacy_avatar(codex_home.path(), "legacy", "Legacy"); + + let params = build_pet_picker_params( + Some("custom:legacy"), + codex_home.path(), + PetPickerPreviewState::default(), + ); + let legacy = params + .items + .iter() + .find(|item| item.name == "Legacy") + .unwrap(); + + assert!(legacy.is_current); + assert_eq!(legacy.search_value.as_deref(), Some("custom:legacy")); + } +} diff --git a/codex-rs/tui/src/pets/preview.rs b/codex-rs/tui/src/pets/preview.rs new file mode 100644 index 0000000000..260362ac4d --- /dev/null +++ b/codex-rs/tui/src/pets/preview.rs @@ -0,0 +1,164 @@ +//! Shared preview-state model for the `/pets` side pane. +//! +//! The preview pane is intentionally small and stateful: the selection popup +//! renders it synchronously, while async preview loading updates this state +//! from outside the widget tree. Keeping the state in a mutex-backed object lets +//! the picker remember the last preview area for out-of-band image rendering +//! without requiring the rest of the popup machinery to know about pet images. + +use std::sync::Arc; +use std::sync::Mutex; + +use ratatui::buffer::Buffer; +use ratatui::layout::Alignment; +use ratatui::layout::Rect; +use ratatui::style::Stylize; +use ratatui::text::Line; +use ratatui::widgets::Paragraph; +use ratatui::widgets::Widget; + +use crate::render::renderable::Renderable; + +#[derive(Debug, Clone, Default)] +pub(crate) struct PetPickerPreviewState { + inner: Arc>, +} + +impl PetPickerPreviewState { + /// Return a renderable wrapper for the picker side pane. + /// + /// The wrapper is cheap to clone and intentionally shares interior state + /// with the controller so selection-change callbacks can update the visible + /// loading/error/ready state without rebuilding the popup. + pub(crate) fn renderable(&self) -> PetPickerPreviewRenderable { + PetPickerPreviewRenderable { + inner: Arc::clone(&self.inner), + } + } + + pub(crate) fn set_loading(&self) { + self.update(|inner| { + inner.status = PetPickerPreviewStatus::Loading; + }); + } + + pub(crate) fn set_disabled(&self) { + self.update(|inner| { + inner.status = PetPickerPreviewStatus::Disabled; + }); + } + + pub(crate) fn set_ready(&self) { + self.update(|inner| { + inner.status = PetPickerPreviewStatus::Ready; + }); + } + + pub(crate) fn set_error(&self, message: String) { + self.update(|inner| { + inner.status = PetPickerPreviewStatus::Error { message }; + }); + } + + pub(crate) fn clear(&self) { + self.update(|inner| { + inner.status = PetPickerPreviewStatus::Hidden; + inner.last_area = None; + }); + } + + pub(crate) fn area(&self) -> Option { + self.inner.lock().ok().and_then(|inner| inner.last_area) + } + + fn update(&self, f: impl FnOnce(&mut PetPickerPreviewInner)) { + if let Ok(mut inner) = self.inner.lock() { + f(&mut inner); + } + } +} + +#[derive(Debug, Default)] +struct PetPickerPreviewInner { + status: PetPickerPreviewStatus, + last_area: Option, +} + +#[derive(Debug, Default)] +enum PetPickerPreviewStatus { + #[default] + Hidden, + Loading, + Disabled, + Ready, + Error { + message: String, + }, +} + +pub(crate) struct PetPickerPreviewRenderable { + inner: Arc>, +} + +impl Renderable for PetPickerPreviewRenderable { + fn render(&self, area: Rect, buf: &mut Buffer) { + let (title, body) = { + let Ok(mut inner) = self.inner.lock() else { + return; + }; + inner.last_area = Some(area); + match &inner.status { + PetPickerPreviewStatus::Hidden => return, + PetPickerPreviewStatus::Loading => ("Loading preview...", None), + PetPickerPreviewStatus::Disabled => ( + "Terminal pets disabled", + Some("No pet will be shown.".to_string()), + ), + PetPickerPreviewStatus::Ready => return, + PetPickerPreviewStatus::Error { message } => { + ("Preview unavailable", Some(message.clone())) + } + } + }; + + let text_height = if body.is_some() { 2 } else { 1 }; + let text_area = centered_text_area(area, text_height); + let mut lines = vec![Line::from(title.bold())]; + if let Some(body) = body { + lines.push(Line::from(body.dim())); + } + Paragraph::new(lines) + .alignment(Alignment::Center) + .render(text_area, buf); + } + + fn desired_height(&self, _width: u16) -> u16 { + 4 + } +} + +fn centered_text_area(area: Rect, height: u16) -> Rect { + let height = height.min(area.height); + let y = area.y + area.height.saturating_sub(height) / 2; + Rect::new(area.x, y, area.width, height) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn centered_text_area_centers_vertically() { + assert_eq!( + centered_text_area( + Rect::new( + /*x*/ 5, /*y*/ 10, /*width*/ 20, /*height*/ 8 + ), + /*height*/ 2 + ), + Rect::new( + /*x*/ 5, /*y*/ 13, /*width*/ 20, /*height*/ 2 + ) + ); + } +} diff --git a/codex-rs/tui/src/pets/sixel.rs b/codex-rs/tui/src/pets/sixel.rs new file mode 100644 index 0000000000..624fd7bc16 --- /dev/null +++ b/codex-rs/tui/src/pets/sixel.rs @@ -0,0 +1,315 @@ +//! Minimal Sixel encoder for pet sprites. +//! +//! This is intentionally not a general-purpose Sixel implementation. Pet frames +//! are already small RGBA images by the time they reach this module, so the +//! encoder uses deterministic RGB332 color reduction and transparent pixels are +//! simply omitted from the emitted color planes. + +use anyhow::Context; +use anyhow::Result; +use anyhow::bail; + +const ST: &[u8] = b"\x1b\\"; +const SIXEL_BAND_HEIGHT: u32 = 6; +const PALETTE_COLOR_COUNT: usize = 256; +const TRANSPARENT_ALPHA_THRESHOLD: u8 = 128; +const TRANSPARENT_BACKGROUND_DCS: &[u8] = b"\x1bP9;1;0q"; + +pub(crate) fn encode_rgba(rgba: &[u8], width: u32, height: u32) -> Result> { + if width == 0 || height == 0 { + bail!("sixel image dimensions must be non-zero"); + } + + let expected_len = pixel_count(width, height)? + .checked_mul(4) + .context("sixel RGBA buffer length overflow")?; + if rgba.len() != expected_len { + bail!( + "sixel RGBA buffer has {} bytes, expected {expected_len}", + rgba.len() + ); + } + + let palette = Palette::from_rgba(rgba); + let mut output = Vec::new(); + output.extend_from_slice(TRANSPARENT_BACKGROUND_DCS); + output.extend_from_slice(format!("\"1;1;{width};{height}").as_bytes()); + palette.write_definitions(&mut output); + write_pixels(&mut output, rgba, width, height, &palette)?; + output.extend_from_slice(ST); + Ok(output) +} + +fn write_pixels( + output: &mut Vec, + rgba: &[u8], + width: u32, + height: u32, + palette: &Palette, +) -> Result<()> { + let band_count = height.div_ceil(SIXEL_BAND_HEIGHT); + for band_index in 0..band_count { + let band_top = band_index * SIXEL_BAND_HEIGHT; + let colors = active_colors_for_band(rgba, width, height, band_top, palette)?; + for (position, color_index) in colors.iter().enumerate() { + output.extend_from_slice(format!("#{color_index}").as_bytes()); + let mut run_char = None; + let mut run_len = 0usize; + for x in 0..width { + let data = sixel_data_for_column(rgba, width, height, band_top, x, *color_index)?; + push_run(&mut run_char, &mut run_len, output, data); + } + flush_run(&mut run_char, &mut run_len, output); + if position + 1 < colors.len() { + output.push(b'$'); + } + } + + if band_index + 1 < band_count { + if colors.is_empty() { + output.push(b'-'); + } else { + output.extend_from_slice(b"$-"); + } + } + } + + Ok(()) +} + +fn active_colors_for_band( + rgba: &[u8], + width: u32, + height: u32, + band_top: u32, + palette: &Palette, +) -> Result> { + let mut active = [false; PALETTE_COLOR_COUNT]; + for y in band_top..height.min(band_top + SIXEL_BAND_HEIGHT) { + for x in 0..width { + if let Some(color_index) = color_index_at(rgba, width, x, y)? { + active[usize::from(color_index)] = true; + } + } + } + + Ok(palette + .indices() + .filter(|color_index| active[usize::from(*color_index)]) + .collect()) +} + +fn sixel_data_for_column( + rgba: &[u8], + width: u32, + height: u32, + band_top: u32, + x: u32, + color_index: u8, +) -> Result { + let mut mask = 0u8; + for bit in 0..SIXEL_BAND_HEIGHT { + let y = band_top + bit; + if y >= height { + continue; + } + + if color_index_at(rgba, width, x, y)? == Some(color_index) { + mask |= 1 << bit; + } + } + + Ok(b'?' + mask) +} + +fn color_index_at(rgba: &[u8], width: u32, x: u32, y: u32) -> Result> { + let pixel_index = pixel_offset(width, x, y)?; + let alpha = rgba[pixel_index + 3]; + if alpha < TRANSPARENT_ALPHA_THRESHOLD { + return Ok(None); + } + + Ok(Some(rgb332_index( + rgba[pixel_index], + rgba[pixel_index + 1], + rgba[pixel_index + 2], + ))) +} + +fn push_run(run_char: &mut Option, run_len: &mut usize, output: &mut Vec, byte: u8) { + match *run_char { + Some(current) if current == byte => { + *run_len += 1; + } + _ => { + flush_run(run_char, run_len, output); + *run_char = Some(byte); + *run_len = 1; + } + } +} + +fn flush_run(run_char: &mut Option, run_len: &mut usize, output: &mut Vec) { + let Some(byte) = run_char.take() else { + return; + }; + + if *run_len > 3 { + output.extend_from_slice(format!("!{}", *run_len).as_bytes()); + output.push(byte); + } else { + output.extend(std::iter::repeat_n(byte, *run_len)); + } + *run_len = 0; +} + +fn pixel_offset(width: u32, x: u32, y: u32) -> Result { + let pixel_index = u64::from(y) + .checked_mul(u64::from(width)) + .and_then(|row| row.checked_add(u64::from(x))) + .context("sixel pixel index overflow")?; + let byte_index = pixel_index + .checked_mul(4) + .context("sixel byte index overflow")?; + usize::try_from(byte_index).context("sixel byte index does not fit usize") +} + +fn pixel_count(width: u32, height: u32) -> Result { + let count = u64::from(width) + .checked_mul(u64::from(height)) + .context("sixel pixel count overflow")?; + usize::try_from(count).context("sixel pixel count does not fit usize") +} + +fn rgb332_index(red: u8, green: u8, blue: u8) -> u8 { + let red = red >> 5; + let green = green >> 5; + let blue = blue >> 6; + (red << 5) | (green << 2) | blue +} + +fn rgb332_color(index: u8) -> (u8, u8, u8) { + let red = index >> 5; + let green = (index >> 2) & 0b111; + let blue = index & 0b11; + ( + scale_bucket_to_byte(red, /*max*/ 7), + scale_bucket_to_byte(green, /*max*/ 7), + scale_bucket_to_byte(blue, /*max*/ 3), + ) +} + +fn scale_bucket_to_byte(bucket: u8, max: u8) -> u8 { + let value = (u16::from(bucket) * 255) / u16::from(max); + u8::try_from(value).unwrap_or(u8::MAX) +} + +fn byte_to_sixel_percent(value: u8) -> u8 { + let value = (u16::from(value) * 100) / 255; + u8::try_from(value).unwrap_or(100) +} + +struct Palette { + used: [bool; PALETTE_COLOR_COUNT], +} + +impl Palette { + fn from_rgba(rgba: &[u8]) -> Self { + let mut used = [false; PALETTE_COLOR_COUNT]; + for pixel in rgba.chunks_exact(4) { + if pixel[3] < TRANSPARENT_ALPHA_THRESHOLD { + continue; + } + + used[usize::from(rgb332_index(pixel[0], pixel[1], pixel[2]))] = true; + } + + Self { used } + } + + fn indices(&self) -> impl Iterator + '_ { + (0..=u8::MAX).filter(|index| self.used[usize::from(*index)]) + } + + fn write_definitions(&self, output: &mut Vec) { + for color_index in self.indices() { + let (red, green, blue) = rgb332_color(color_index); + output.extend_from_slice( + format!( + "#{color_index};2;{};{};{}", + byte_to_sixel_percent(red), + byte_to_sixel_percent(green), + byte_to_sixel_percent(blue) + ) + .as_bytes(), + ); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const EXPECTED_TRANSPARENT_BACKGROUND_DCS: &str = "\x1bP9;1;0q"; + + #[test] + fn encodes_red_pixel_with_palette_and_pixel_data() { + let sixel = encode_rgba(&[255, 0, 0, 255], /*width*/ 1, /*height*/ 1).unwrap(); + let sixel = String::from_utf8(sixel).unwrap(); + + assert_eq!( + sixel, + format!("{EXPECTED_TRANSPARENT_BACKGROUND_DCS}\"1;1;1;1#224;2;100;0;0#224@\x1b\\") + ); + } + + #[test] + fn transparent_pixels_do_not_emit_palette_or_pixel_data() { + let sixel = encode_rgba(&[255, 0, 0, 0], /*width*/ 1, /*height*/ 1).unwrap(); + let sixel = String::from_utf8(sixel).unwrap(); + + assert_eq!( + sixel, + format!("{EXPECTED_TRANSPARENT_BACKGROUND_DCS}\"1;1;1;1\x1b\\") + ); + } + + #[test] + fn multi_band_images_advance_to_next_sixel_band() { + let mut rgba = Vec::new(); + for _ in 0..7 { + rgba.extend_from_slice(&[255, 0, 0, 255]); + } + + let sixel = encode_rgba(&rgba, /*width*/ 1, /*height*/ 7).unwrap(); + let sixel = String::from_utf8(sixel).unwrap(); + + assert_eq!( + sixel, + format!( + "{EXPECTED_TRANSPARENT_BACKGROUND_DCS}\"1;1;1;7#224;2;100;0;0#224~$-#224@\x1b\\" + ) + ); + } + + #[test] + fn repeated_cells_use_sixel_run_length_encoding() { + let mut rgba = Vec::new(); + for _ in 0..4 { + rgba.extend_from_slice(&[255, 0, 0, 255]); + } + + let sixel = encode_rgba(&rgba, /*width*/ 4, /*height*/ 1).unwrap(); + let sixel = String::from_utf8(sixel).unwrap(); + + assert!(sixel.contains("#224!4@")); + } + + #[test] + fn rejects_mismatched_rgba_buffer_length() { + let err = encode_rgba(&[255, 0, 0], /*width*/ 1, /*height*/ 1).unwrap_err(); + + assert_eq!(err.to_string(), "sixel RGBA buffer has 3 bytes, expected 4"); + } +} diff --git a/codex-rs/tui/src/resume_picker.rs b/codex-rs/tui/src/resume_picker.rs index be89655d7c..5d5e888390 100644 --- a/codex-rs/tui/src/resume_picker.rs +++ b/codex-rs/tui/src/resume_picker.rs @@ -5738,6 +5738,7 @@ session_picker_view = "dense" name: None, turns: vec![codex_app_server_protocol::Turn { id: String::from("turn-1"), + items_view: codex_app_server_protocol::TurnItemsView::Full, items: vec![ ThreadItem::UserMessage { id: String::from("user-1"), @@ -5757,7 +5758,6 @@ session_picker_view = "dense" text: String::from("1. Do the thing"), }, ], - items_view: codex_app_server_protocol::TurnItemsView::Full, status: codex_app_server_protocol::TurnStatus::Completed, error: None, started_at: None, @@ -5805,12 +5805,12 @@ session_picker_view = "dense" name: None, turns: vec![codex_app_server_protocol::Turn { id: String::from("turn-1"), + items_view: codex_app_server_protocol::TurnItemsView::Full, items: vec![ThreadItem::Reasoning { id: String::from("reasoning-1"), summary: Vec::new(), content: vec![String::from("private raw chain of thought")], }], - items_view: codex_app_server_protocol::TurnItemsView::Full, status: codex_app_server_protocol::TurnStatus::Completed, error: None, started_at: None, @@ -5862,12 +5862,12 @@ session_picker_view = "dense" name: None, turns: vec![codex_app_server_protocol::Turn { id: String::from("turn-1"), + items_view: codex_app_server_protocol::TurnItemsView::Full, items: vec![ThreadItem::Reasoning { id: String::from("reasoning-1"), summary: vec![String::from("public summary")], content: vec![String::from("raw reasoning content")], }], - items_view: codex_app_server_protocol::TurnItemsView::Full, status: codex_app_server_protocol::TurnStatus::Completed, error: None, started_at: None, diff --git a/codex-rs/tui/src/session_log.rs b/codex-rs/tui/src/session_log.rs index 4b06a69b2f..b1f533a050 100644 --- a/codex-rs/tui/src/session_log.rs +++ b/codex-rs/tui/src/session_log.rs @@ -169,6 +169,33 @@ pub(crate) fn log_inbound_app_event(event: &AppEvent) { }); LOGGER.write_json_line(value); } + AppEvent::PetPreviewLoaded { request_id, result } => { + let value = json!({ + "ts": now_ts(), + "dir": "to_tui", + "kind": "app_event", + "variant": "PetPreviewLoaded", + "request_id": request_id, + "ok": result.is_ok(), + }); + LOGGER.write_json_line(value); + } + AppEvent::PetSelectionLoaded { + request_id, + pet_id, + result, + } => { + let value = json!({ + "ts": now_ts(), + "dir": "to_tui", + "kind": "app_event", + "variant": "PetSelectionLoaded", + "request_id": request_id, + "pet_id": pet_id, + "ok": result.is_ok(), + }); + LOGGER.write_json_line(value); + } // Noise or control flow – record variant only other => { let value = json!({ diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs index cce0bc38df..beaaa7f319 100644 --- a/codex-rs/tui/src/slash_command.rs +++ b/codex-rs/tui/src/slash_command.rs @@ -48,6 +48,8 @@ pub enum SlashCommand { Title, Statusline, Theme, + #[strum(to_string = "pets", serialize = "pet")] + Pets, Mcp, Apps, Plugins, @@ -98,6 +100,7 @@ impl SlashCommand { SlashCommand::Title => "configure which items appear in the terminal title", SlashCommand::Statusline => "configure which items appear in the status line", SlashCommand::Theme => "choose a syntax highlighting theme", + SlashCommand::Pets => "choose or hide the terminal pet", SlashCommand::Ps => "list background terminals", SlashCommand::Stop => "stop all background terminals", SlashCommand::MemoryDrop => "DO NOT USE", @@ -151,6 +154,7 @@ impl SlashCommand { | SlashCommand::Keymap | SlashCommand::Mcp | SlashCommand::Raw + | SlashCommand::Pets | SlashCommand::Side | SlashCommand::Resume | SlashCommand::SandboxReadRoot @@ -222,7 +226,7 @@ impl SlashCommand { SlashCommand::Settings => true, SlashCommand::Collab => true, SlashCommand::Agent | SlashCommand::MultiAgents => true, - SlashCommand::Theme => false, + SlashCommand::Theme | SlashCommand::Pets => false, } } @@ -261,6 +265,12 @@ mod tests { assert_eq!(SlashCommand::from_str("clean"), Ok(SlashCommand::Stop)); } + #[test] + fn pet_alias_parses_to_pets_command() { + assert_eq!(SlashCommand::Pets.command(), "pets"); + assert_eq!(SlashCommand::from_str("pet"), Ok(SlashCommand::Pets)); + } + #[test] fn certain_commands_are_available_during_task() { assert!(SlashCommand::Goal.available_during_task()); diff --git a/codex-rs/tui/src/tui.rs b/codex-rs/tui/src/tui.rs index 06417e31ea..9255d188c4 100644 --- a/codex-rs/tui/src/tui.rs +++ b/codex-rs/tui/src/tui.rs @@ -75,6 +75,14 @@ fn should_emit_notification(condition: NotificationCondition, terminal_focused: } } +impl Drop for Tui { + fn drop(&mut self) { + if let Err(err) = self.clear_ambient_pet_image() { + tracing::debug!(error = %err, "failed to clear ambient pet image on TUI drop"); + } + } +} + #[cfg(test)] mod tests { use std::io::Write as _; @@ -420,6 +428,8 @@ pub struct Tui { event_broker: Arc, pub(crate) terminal: Terminal, pending_history_lines: Vec, + ambient_pet_image_state: crate::pets::PetImageRenderState, + pet_picker_preview_image_state: crate::pets::PetImageRenderState, alt_saved_viewport: Option, #[cfg(unix)] suspend_context: SuspendContext, @@ -475,6 +485,8 @@ impl Tui { event_broker: Arc::new(EventBroker::new()), terminal, pending_history_lines: vec![], + ambient_pet_image_state: crate::pets::PetImageRenderState::default(), + pet_picker_preview_image_state: crate::pets::PetImageRenderState::default(), alt_saved_viewport: None, #[cfg(unix)] suspend_context: SuspendContext::new(), @@ -863,6 +875,50 @@ impl Tui { })? } + pub fn draw_ambient_pet_image( + &mut self, + request: Option, + ) -> std::result::Result<(), crate::pets::PetImageRenderError> { + let terminal = &mut self.terminal; + let state = &mut self.ambient_pet_image_state; + stdout().sync_update(|_| { + match crate::pets::render_ambient_pet_image(terminal.backend_mut(), state, request) { + Ok(()) => Ok(Ok(())), + Err(crate::pets::PetImageRenderError::Terminal(err)) => Err(err), + Err(err @ crate::pets::PetImageRenderError::Asset(_)) => Ok(Err(err)), + } + })?? + } + + pub fn draw_pet_picker_preview_image( + &mut self, + request: Option, + ) -> std::result::Result<(), crate::pets::PetImageRenderError> { + let terminal = &mut self.terminal; + let state = &mut self.pet_picker_preview_image_state; + stdout().sync_update(|_| { + match crate::pets::render_pet_picker_preview_image( + terminal.backend_mut(), + state, + request, + ) { + Ok(()) => Ok(Ok(())), + Err(crate::pets::PetImageRenderError::Terminal(err)) => Err(err), + Err(err @ crate::pets::PetImageRenderError::Asset(_)) => Ok(Err(err)), + } + })?? + } + + pub fn clear_ambient_pet_image( + &mut self, + ) -> std::result::Result<(), crate::pets::PetImageRenderError> { + crate::pets::render_ambient_pet_image( + self.terminal.backend_mut(), + &mut self.ambient_pet_image_state, + /*request*/ None, + ) + } + /// Draw a frame using the resize-reflow viewport and history insertion rules. /// /// This is the feature-gated counterpart to `draw`. It intentionally skips From c4cb89817236d89bc15b66ad8cdb43d533c54162 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Tue, 12 May 2026 06:51:09 -0700 Subject: [PATCH 5/6] core: box multi-agent handler futures --- codex-rs/core/src/agent/control.rs | 31 +- .../handlers/multi_agents/close_agent.rs | 157 ++++---- .../handlers/multi_agents/resume_agent.rs | 201 +++++----- .../src/tools/handlers/multi_agents/spawn.rs | 303 +++++++-------- .../src/tools/handlers/multi_agents_tests.rs | 3 +- .../handlers/multi_agents_v2/close_agent.rs | 179 ++++----- .../tools/handlers/multi_agents_v2/spawn.rs | 348 +++++++++--------- 7 files changed, 623 insertions(+), 599 deletions(-) diff --git a/codex-rs/core/src/agent/control.rs b/codex-rs/core/src/agent/control.rs index 079ee61f01..34ac0d5af0 100644 --- a/codex-rs/core/src/agent/control.rs +++ b/codex-rs/core/src/agent/control.rs @@ -233,32 +233,31 @@ impl AgentControl { // The same `AgentControl` is sent to spawn the thread. let new_thread = match (session_source, options.fork_mode.as_ref()) { (Some(session_source), Some(_)) => { - self.spawn_forked_thread( + Box::pin(self.spawn_forked_thread( &state, config, session_source, &options, inherited_shell_snapshot, inherited_exec_policy, - ) + )) .await? } (Some(session_source), None) => { - state - .spawn_new_thread_with_source( - config.clone(), - self.clone(), - session_source, - /*thread_source*/ Some(ThreadSource::Subagent), - /*persist_extended_history*/ false, - /*metrics_service_name*/ None, - inherited_shell_snapshot, - inherited_exec_policy, - options.environments.clone(), - ) - .await? + Box::pin(state.spawn_new_thread_with_source( + config, + self.clone(), + session_source, + /*thread_source*/ Some(ThreadSource::Subagent), + /*persist_extended_history*/ false, + /*metrics_service_name*/ None, + inherited_shell_snapshot, + inherited_exec_policy, + options.environments.clone(), + )) + .await? } - (None, _) => state.spawn_new_thread(config.clone(), self.clone()).await?, + (None, _) => Box::pin(state.spawn_new_thread(config, self.clone())).await?, }; agent_metadata.agent_id = Some(new_thread.thread_id); reservation.commit(agent_metadata.clone()); diff --git a/codex-rs/core/src/tools/handlers/multi_agents/close_agent.rs b/codex-rs/core/src/tools/handlers/multi_agents/close_agent.rs index e4788dd9b8..489315c507 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents/close_agent.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents/close_agent.rs @@ -20,84 +20,89 @@ impl ToolHandler for Handler { matches!(payload, ToolPayload::Function { .. }) } - async fn handle(&self, invocation: ToolInvocation) -> Result { - let ToolInvocation { - session, - turn, - payload, - call_id, - .. - } = invocation; - let arguments = function_arguments(payload)?; - let args: CloseAgentArgs = parse_arguments(&arguments)?; - let agent_id = parse_agent_id_target(&args.target)?; - let receiver_agent = session - .services - .agent_control - .get_agent_metadata(agent_id) - .unwrap_or_default(); - session - .send_event( - &turn, - CollabCloseBeginEvent { - call_id: call_id.clone(), - started_at_ms: now_unix_timestamp_ms(), - sender_thread_id: session.conversation_id, - receiver_thread_id: agent_id, + fn handle( + &self, + invocation: ToolInvocation, + ) -> impl std::future::Future> + Send { + Box::pin(async move { + let ToolInvocation { + session, + turn, + payload, + call_id, + .. + } = invocation; + let arguments = function_arguments(payload)?; + let args: CloseAgentArgs = parse_arguments(&arguments)?; + let agent_id = parse_agent_id_target(&args.target)?; + let receiver_agent = session + .services + .agent_control + .get_agent_metadata(agent_id) + .unwrap_or_default(); + session + .send_event( + &turn, + CollabCloseBeginEvent { + call_id: call_id.clone(), + started_at_ms: now_unix_timestamp_ms(), + sender_thread_id: session.conversation_id, + receiver_thread_id: agent_id, + } + .into(), + ) + .await; + let status = match session + .services + .agent_control + .subscribe_status(agent_id) + .await + { + Ok(mut status_rx) => status_rx.borrow_and_update().clone(), + Err(err) => { + let status = session.services.agent_control.get_status(agent_id).await; + session + .send_event( + &turn, + CollabCloseEndEvent { + call_id: call_id.clone(), + completed_at_ms: now_unix_timestamp_ms(), + sender_thread_id: session.conversation_id, + receiver_thread_id: agent_id, + receiver_agent_nickname: receiver_agent.agent_nickname.clone(), + receiver_agent_role: receiver_agent.agent_role.clone(), + status, + } + .into(), + ) + .await; + return Err(collab_agent_error(agent_id, err)); } - .into(), - ) - .await; - let status = match session - .services - .agent_control - .subscribe_status(agent_id) - .await - { - Ok(mut status_rx) => status_rx.borrow_and_update().clone(), - Err(err) => { - let status = session.services.agent_control.get_status(agent_id).await; - session - .send_event( - &turn, - CollabCloseEndEvent { - call_id: call_id.clone(), - completed_at_ms: now_unix_timestamp_ms(), - sender_thread_id: session.conversation_id, - receiver_thread_id: agent_id, - receiver_agent_nickname: receiver_agent.agent_nickname.clone(), - receiver_agent_role: receiver_agent.agent_role.clone(), - status, - } - .into(), - ) - .await; - return Err(collab_agent_error(agent_id, err)); - } - }; - let result = Box::pin(session.services.agent_control.close_agent(agent_id)) - .await - .map_err(|err| collab_agent_error(agent_id, err)) - .map(|_| ()); - session - .send_event( - &turn, - CollabCloseEndEvent { - call_id, - completed_at_ms: now_unix_timestamp_ms(), - sender_thread_id: session.conversation_id, - receiver_thread_id: agent_id, - receiver_agent_nickname: receiver_agent.agent_nickname, - receiver_agent_role: receiver_agent.agent_role, - status: status.clone(), - } - .into(), - ) - .await; - result?; + }; + let result = Box::pin(session.services.agent_control.close_agent(agent_id)) + .await + .map_err(|err| collab_agent_error(agent_id, err)) + .map(|_| ()); + session + .send_event( + &turn, + CollabCloseEndEvent { + call_id, + completed_at_ms: now_unix_timestamp_ms(), + sender_thread_id: session.conversation_id, + receiver_thread_id: agent_id, + receiver_agent_nickname: receiver_agent.agent_nickname, + receiver_agent_role: receiver_agent.agent_role, + status: status.clone(), + } + .into(), + ) + .await; + result?; - Ok(CloseAgentResult { - previous_status: status, + Ok(CloseAgentResult { + previous_status: status, + }) }) } } diff --git a/codex-rs/core/src/tools/handlers/multi_agents/resume_agent.rs b/codex-rs/core/src/tools/handlers/multi_agents/resume_agent.rs index fa77bc37bb..6225bed414 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents/resume_agent.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents/resume_agent.rs @@ -22,111 +22,116 @@ impl ToolHandler for Handler { matches!(payload, ToolPayload::Function { .. }) } - async fn handle(&self, invocation: ToolInvocation) -> Result { - let ToolInvocation { - session, - turn, - payload, - call_id, - .. - } = invocation; - let arguments = function_arguments(payload)?; - let args: ResumeAgentArgs = parse_arguments(&arguments)?; - let receiver_thread_id = ThreadId::from_string(&args.id).map_err(|err| { - FunctionCallError::RespondToModel(format!("invalid agent id {}: {err:?}", args.id)) - })?; - let receiver_agent = session - .services - .agent_control - .get_agent_metadata(receiver_thread_id) - .unwrap_or_default(); - let child_depth = next_thread_spawn_depth(&turn.session_source); - let max_depth = turn.config.agent_max_depth; - if exceeds_thread_spawn_depth_limit(child_depth, max_depth) { - return Err(FunctionCallError::RespondToModel( - "Agent depth limit reached. Solve the task yourself.".to_string(), - )); - } + fn handle( + &self, + invocation: ToolInvocation, + ) -> impl std::future::Future> + Send { + Box::pin(async move { + let ToolInvocation { + session, + turn, + payload, + call_id, + .. + } = invocation; + let arguments = function_arguments(payload)?; + let args: ResumeAgentArgs = parse_arguments(&arguments)?; + let receiver_thread_id = ThreadId::from_string(&args.id).map_err(|err| { + FunctionCallError::RespondToModel(format!("invalid agent id {}: {err:?}", args.id)) + })?; + let receiver_agent = session + .services + .agent_control + .get_agent_metadata(receiver_thread_id) + .unwrap_or_default(); + let child_depth = next_thread_spawn_depth(&turn.session_source); + let max_depth = turn.config.agent_max_depth; + if exceeds_thread_spawn_depth_limit(child_depth, max_depth) { + return Err(FunctionCallError::RespondToModel( + "Agent depth limit reached. Solve the task yourself.".to_string(), + )); + } - session - .send_event( - &turn, - CollabResumeBeginEvent { - call_id: call_id.clone(), - started_at_ms: now_unix_timestamp_ms(), - sender_thread_id: session.conversation_id, + session + .send_event( + &turn, + CollabResumeBeginEvent { + call_id: call_id.clone(), + started_at_ms: now_unix_timestamp_ms(), + sender_thread_id: session.conversation_id, + receiver_thread_id, + receiver_agent_nickname: receiver_agent.agent_nickname.clone(), + receiver_agent_role: receiver_agent.agent_role.clone(), + } + .into(), + ) + .await; + + let mut status = session + .services + .agent_control + .get_status(receiver_thread_id) + .await; + let (receiver_agent, error) = if matches!(status, AgentStatus::NotFound) { + match Box::pin(try_resume_closed_agent( + &session, + &turn, receiver_thread_id, - receiver_agent_nickname: receiver_agent.agent_nickname.clone(), - receiver_agent_role: receiver_agent.agent_role.clone(), - } - .into(), - ) - .await; - - let mut status = session - .services - .agent_control - .get_status(receiver_thread_id) - .await; - let (receiver_agent, error) = if matches!(status, AgentStatus::NotFound) { - match Box::pin(try_resume_closed_agent( - &session, - &turn, - receiver_thread_id, - child_depth, - )) - .await - { - Ok(()) => { - status = session - .services - .agent_control - .get_status(receiver_thread_id) - .await; - ( - session + child_depth, + )) + .await + { + Ok(()) => { + status = session .services .agent_control - .get_agent_metadata(receiver_thread_id) - .unwrap_or(receiver_agent), - None, - ) - } - Err(err) => { - status = session - .services - .agent_control - .get_status(receiver_thread_id) - .await; - (receiver_agent, Some(err)) + .get_status(receiver_thread_id) + .await; + ( + session + .services + .agent_control + .get_agent_metadata(receiver_thread_id) + .unwrap_or(receiver_agent), + None, + ) + } + Err(err) => { + status = session + .services + .agent_control + .get_status(receiver_thread_id) + .await; + (receiver_agent, Some(err)) + } } + } else { + (receiver_agent, None) + }; + session + .send_event( + &turn, + CollabResumeEndEvent { + call_id, + completed_at_ms: now_unix_timestamp_ms(), + sender_thread_id: session.conversation_id, + receiver_thread_id, + receiver_agent_nickname: receiver_agent.agent_nickname, + receiver_agent_role: receiver_agent.agent_role, + status: status.clone(), + } + .into(), + ) + .await; + + if let Some(err) = error { + return Err(err); } - } else { - (receiver_agent, None) - }; - session - .send_event( - &turn, - CollabResumeEndEvent { - call_id, - completed_at_ms: now_unix_timestamp_ms(), - sender_thread_id: session.conversation_id, - receiver_thread_id, - receiver_agent_nickname: receiver_agent.agent_nickname, - receiver_agent_role: receiver_agent.agent_role, - status: status.clone(), - } - .into(), - ) - .await; + turn.session_telemetry + .counter("codex.multi_agent.resume", /*inc*/ 1, &[]); - if let Some(err) = error { - return Err(err); - } - turn.session_telemetry - .counter("codex.multi_agent.resume", /*inc*/ 1, &[]); - - Ok(ResumeAgentResult { status }) + Ok(ResumeAgentResult { status }) + }) } } diff --git a/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs b/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs index 0317658955..8159c3344f 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents/spawn.rs @@ -37,157 +37,162 @@ impl ToolHandler for Handler { matches!(payload, ToolPayload::Function { .. }) } - async fn handle(&self, invocation: ToolInvocation) -> Result { - let ToolInvocation { - session, - turn, - payload, - call_id, - .. - } = invocation; - let arguments = function_arguments(payload)?; - let args: SpawnAgentArgs = parse_arguments(&arguments)?; - let role_name = args - .agent_type - .as_deref() - .map(str::trim) - .filter(|role| !role.is_empty()); - let input_items = parse_collab_input(args.message, args.items)?; - let prompt = render_input_preview(&input_items); - let session_source = turn.session_source.clone(); - let child_depth = next_thread_spawn_depth(&session_source); - let max_depth = turn.config.agent_max_depth; - if exceeds_thread_spawn_depth_limit(child_depth, max_depth) { - return Err(FunctionCallError::RespondToModel( - "Agent depth limit reached. Solve the task yourself.".to_string(), - )); - } - session - .send_event( - &turn, - CollabAgentSpawnBeginEvent { - call_id: call_id.clone(), - started_at_ms: now_unix_timestamp_ms(), - sender_thread_id: session.conversation_id, - prompt: prompt.clone(), - model: args.model.clone().unwrap_or_default(), - reasoning_effort: args.reasoning_effort.unwrap_or_default(), - } - .into(), - ) - .await; - let mut config = - build_agent_spawn_config(&session.get_base_instructions().await, turn.as_ref())?; - if args.fork_context { - reject_full_fork_spawn_overrides( - role_name, - args.model.as_deref(), - args.reasoning_effort, - )?; - } else { - apply_requested_spawn_agent_model_overrides( - &session, - turn.as_ref(), - &mut config, - args.model.as_deref(), - args.reasoning_effort, - ) - .await?; - apply_role_to_config(&mut config, role_name) - .await - .map_err(FunctionCallError::RespondToModel)?; - } - apply_spawn_agent_runtime_overrides(&mut config, turn.as_ref())?; - apply_spawn_agent_overrides(&mut config, child_depth); - - let result = Box::pin(session.services.agent_control.spawn_agent_with_metadata( - config, - input_items, - Some(thread_spawn_source( - session.conversation_id, - &turn.session_source, - child_depth, - role_name, - /*task_name*/ None, - )?), - SpawnAgentOptions { - fork_parent_spawn_call_id: args.fork_context.then(|| call_id.clone()), - fork_mode: args.fork_context.then_some(SpawnAgentForkMode::FullHistory), - environments: Some(turn.environments.to_selections()), - }, - )) - .await - .map_err(collab_spawn_error); - let (new_thread_id, new_agent_metadata, status) = match &result { - Ok(spawned_agent) => ( - Some(spawned_agent.thread_id), - Some(spawned_agent.metadata.clone()), - spawned_agent.status.clone(), - ), - Err(_) => (None, None, AgentStatus::NotFound), - }; - let agent_snapshot = match new_thread_id { - Some(thread_id) => { - session - .services - .agent_control - .get_agent_config_snapshot(thread_id) - .await + fn handle( + &self, + invocation: ToolInvocation, + ) -> impl std::future::Future> + Send { + Box::pin(async move { + let ToolInvocation { + session, + turn, + payload, + call_id, + .. + } = invocation; + let arguments = function_arguments(payload)?; + let args: SpawnAgentArgs = parse_arguments(&arguments)?; + let role_name = args + .agent_type + .as_deref() + .map(str::trim) + .filter(|role| !role.is_empty()); + let input_items = parse_collab_input(args.message, args.items)?; + let prompt = render_input_preview(&input_items); + let session_source = turn.session_source.clone(); + let child_depth = next_thread_spawn_depth(&session_source); + let max_depth = turn.config.agent_max_depth; + if exceeds_thread_spawn_depth_limit(child_depth, max_depth) { + return Err(FunctionCallError::RespondToModel( + "Agent depth limit reached. Solve the task yourself.".to_string(), + )); } - None => None, - }; - let (_new_agent_path, new_agent_nickname, new_agent_role) = - match (&agent_snapshot, new_agent_metadata) { - (Some(snapshot), _) => ( - snapshot.session_source.get_agent_path().map(String::from), - snapshot.session_source.get_nickname(), - snapshot.session_source.get_agent_role(), - ), - (None, Some(metadata)) => ( - metadata.agent_path.map(String::from), - metadata.agent_nickname, - metadata.agent_role, - ), - (None, None) => (None, None, None), - }; - let effective_model = agent_snapshot - .as_ref() - .map(|snapshot| snapshot.model.clone()) - .unwrap_or_else(|| args.model.clone().unwrap_or_default()); - let effective_reasoning_effort = agent_snapshot - .as_ref() - .and_then(|snapshot| snapshot.reasoning_effort) - .unwrap_or(args.reasoning_effort.unwrap_or_default()); - let nickname = new_agent_nickname.clone(); - session - .send_event( - &turn, - CollabAgentSpawnEndEvent { - call_id, - completed_at_ms: now_unix_timestamp_ms(), - sender_thread_id: session.conversation_id, - new_thread_id, - new_agent_nickname, - new_agent_role, - prompt, - model: effective_model, - reasoning_effort: effective_reasoning_effort, - status, - } - .into(), - ) - .await; - let new_thread_id = result?.thread_id; - let role_tag = role_name.unwrap_or(DEFAULT_ROLE_NAME); - turn.session_telemetry.counter( - "codex.multi_agent.spawn", - /*inc*/ 1, - &[("role", role_tag)], - ); + session + .send_event( + &turn, + CollabAgentSpawnBeginEvent { + call_id: call_id.clone(), + started_at_ms: now_unix_timestamp_ms(), + sender_thread_id: session.conversation_id, + prompt: prompt.clone(), + model: args.model.clone().unwrap_or_default(), + reasoning_effort: args.reasoning_effort.unwrap_or_default(), + } + .into(), + ) + .await; + let mut config = + build_agent_spawn_config(&session.get_base_instructions().await, turn.as_ref())?; + if args.fork_context { + reject_full_fork_spawn_overrides( + role_name, + args.model.as_deref(), + args.reasoning_effort, + )?; + } else { + apply_requested_spawn_agent_model_overrides( + &session, + turn.as_ref(), + &mut config, + args.model.as_deref(), + args.reasoning_effort, + ) + .await?; + apply_role_to_config(&mut config, role_name) + .await + .map_err(FunctionCallError::RespondToModel)?; + } + apply_spawn_agent_runtime_overrides(&mut config, turn.as_ref())?; + apply_spawn_agent_overrides(&mut config, child_depth); - Ok(SpawnAgentResult { - agent_id: new_thread_id.to_string(), - nickname, + let result = Box::pin(session.services.agent_control.spawn_agent_with_metadata( + config, + input_items, + Some(thread_spawn_source( + session.conversation_id, + &turn.session_source, + child_depth, + role_name, + /*task_name*/ None, + )?), + SpawnAgentOptions { + fork_parent_spawn_call_id: args.fork_context.then(|| call_id.clone()), + fork_mode: args.fork_context.then_some(SpawnAgentForkMode::FullHistory), + environments: Some(turn.environments.to_selections()), + }, + )) + .await + .map_err(collab_spawn_error); + let (new_thread_id, new_agent_metadata, status) = match &result { + Ok(spawned_agent) => ( + Some(spawned_agent.thread_id), + Some(spawned_agent.metadata.clone()), + spawned_agent.status.clone(), + ), + Err(_) => (None, None, AgentStatus::NotFound), + }; + let agent_snapshot = match new_thread_id { + Some(thread_id) => { + session + .services + .agent_control + .get_agent_config_snapshot(thread_id) + .await + } + None => None, + }; + let (_new_agent_path, new_agent_nickname, new_agent_role) = + match (&agent_snapshot, new_agent_metadata) { + (Some(snapshot), _) => ( + snapshot.session_source.get_agent_path().map(String::from), + snapshot.session_source.get_nickname(), + snapshot.session_source.get_agent_role(), + ), + (None, Some(metadata)) => ( + metadata.agent_path.map(String::from), + metadata.agent_nickname, + metadata.agent_role, + ), + (None, None) => (None, None, None), + }; + let effective_model = agent_snapshot + .as_ref() + .map(|snapshot| snapshot.model.clone()) + .unwrap_or_else(|| args.model.clone().unwrap_or_default()); + let effective_reasoning_effort = agent_snapshot + .as_ref() + .and_then(|snapshot| snapshot.reasoning_effort) + .unwrap_or(args.reasoning_effort.unwrap_or_default()); + let nickname = new_agent_nickname.clone(); + session + .send_event( + &turn, + CollabAgentSpawnEndEvent { + call_id, + completed_at_ms: now_unix_timestamp_ms(), + sender_thread_id: session.conversation_id, + new_thread_id, + new_agent_nickname, + new_agent_role, + prompt, + model: effective_model, + reasoning_effort: effective_reasoning_effort, + status, + } + .into(), + ) + .await; + let new_thread_id = result?.thread_id; + let role_tag = role_name.unwrap_or(DEFAULT_ROLE_NAME); + turn.session_telemetry.counter( + "codex.multi_agent.spawn", + /*inc*/ 1, + &[("role", role_tag)], + ); + + Ok(SpawnAgentResult { + agent_id: new_thread_id.to_string(), + nickname, + }) }) } } diff --git a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs index b030cb010c..d1360186a0 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_tests.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_tests.rs @@ -3179,10 +3179,11 @@ async fn tool_handlers_cascade_close_and_resume_and_keep_explicitly_closed_subtr let parent_thread_id = parent.thread_id; let parent_session = parent.thread.codex.session.clone(); + let child_turn = parent_session.new_default_turn().await; let child_spawn_output = SpawnAgentHandler::default() .handle(invocation( parent_session.clone(), - parent_session.new_default_turn().await, + child_turn, "spawn_agent", function_payload(json!({"message": "hello child"})), )) diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/close_agent.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/close_agent.rs index 6f2984c338..dc7221fc12 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2/close_agent.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/close_agent.rs @@ -20,96 +20,101 @@ impl ToolHandler for Handler { matches!(payload, ToolPayload::Function { .. }) } - async fn handle(&self, invocation: ToolInvocation) -> Result { - let ToolInvocation { - session, - turn, - payload, - call_id, - .. - } = invocation; - let arguments = function_arguments(payload)?; - let args: CloseAgentArgs = parse_arguments(&arguments)?; - let agent_id = resolve_agent_target(&session, &turn, &args.target).await?; - let receiver_agent = session - .services - .agent_control - .get_agent_metadata(agent_id) - .unwrap_or_default(); - if receiver_agent - .agent_path - .as_ref() - .is_some_and(AgentPath::is_root) - { - return Err(FunctionCallError::RespondToModel( - "root is not a spawned agent".to_string(), - )); - } - session - .send_event( - &turn, - CollabCloseBeginEvent { - call_id: call_id.clone(), - started_at_ms: now_unix_timestamp_ms(), - sender_thread_id: session.conversation_id, - receiver_thread_id: agent_id, - } - .into(), - ) - .await; - let status = match session - .services - .agent_control - .subscribe_status(agent_id) - .await - { - Ok(mut status_rx) => status_rx.borrow_and_update().clone(), - Err(err) => { - let status = session.services.agent_control.get_status(agent_id).await; - session - .send_event( - &turn, - CollabCloseEndEvent { - call_id: call_id.clone(), - completed_at_ms: now_unix_timestamp_ms(), - sender_thread_id: session.conversation_id, - receiver_thread_id: agent_id, - receiver_agent_nickname: receiver_agent.agent_nickname.clone(), - receiver_agent_role: receiver_agent.agent_role.clone(), - status, - } - .into(), - ) - .await; - return Err(collab_agent_error(agent_id, err)); + fn handle( + &self, + invocation: ToolInvocation, + ) -> impl std::future::Future> + Send { + Box::pin(async move { + let ToolInvocation { + session, + turn, + payload, + call_id, + .. + } = invocation; + let arguments = function_arguments(payload)?; + let args: CloseAgentArgs = parse_arguments(&arguments)?; + let agent_id = resolve_agent_target(&session, &turn, &args.target).await?; + let receiver_agent = session + .services + .agent_control + .get_agent_metadata(agent_id) + .unwrap_or_default(); + if receiver_agent + .agent_path + .as_ref() + .is_some_and(AgentPath::is_root) + { + return Err(FunctionCallError::RespondToModel( + "root is not a spawned agent".to_string(), + )); } - }; - let result = session - .services - .agent_control - .close_agent(agent_id) - .await - .map_err(|err| collab_agent_error(agent_id, err)) - .map(|_| ()); - session - .send_event( - &turn, - CollabCloseEndEvent { - call_id, - completed_at_ms: now_unix_timestamp_ms(), - sender_thread_id: session.conversation_id, - receiver_thread_id: agent_id, - receiver_agent_nickname: receiver_agent.agent_nickname, - receiver_agent_role: receiver_agent.agent_role, - status: status.clone(), + session + .send_event( + &turn, + CollabCloseBeginEvent { + call_id: call_id.clone(), + started_at_ms: now_unix_timestamp_ms(), + sender_thread_id: session.conversation_id, + receiver_thread_id: agent_id, + } + .into(), + ) + .await; + let status = match session + .services + .agent_control + .subscribe_status(agent_id) + .await + { + Ok(mut status_rx) => status_rx.borrow_and_update().clone(), + Err(err) => { + let status = session.services.agent_control.get_status(agent_id).await; + session + .send_event( + &turn, + CollabCloseEndEvent { + call_id: call_id.clone(), + completed_at_ms: now_unix_timestamp_ms(), + sender_thread_id: session.conversation_id, + receiver_thread_id: agent_id, + receiver_agent_nickname: receiver_agent.agent_nickname.clone(), + receiver_agent_role: receiver_agent.agent_role.clone(), + status, + } + .into(), + ) + .await; + return Err(collab_agent_error(agent_id, err)); } - .into(), - ) - .await; - result?; + }; + let result = session + .services + .agent_control + .close_agent(agent_id) + .await + .map_err(|err| collab_agent_error(agent_id, err)) + .map(|_| ()); + session + .send_event( + &turn, + CollabCloseEndEvent { + call_id, + completed_at_ms: now_unix_timestamp_ms(), + sender_thread_id: session.conversation_id, + receiver_thread_id: agent_id, + receiver_agent_nickname: receiver_agent.agent_nickname, + receiver_agent_role: receiver_agent.agent_role, + status: status.clone(), + } + .into(), + ) + .await; + result?; - Ok(CloseAgentResult { - previous_status: status, + Ok(CloseAgentResult { + previous_status: status, + }) }) } } diff --git a/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs b/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs index 6642c5e4b0..289e7fbf4e 100644 --- a/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs +++ b/codex-rs/core/src/tools/handlers/multi_agents_v2/spawn.rs @@ -39,188 +39,192 @@ impl ToolHandler for Handler { matches!(payload, ToolPayload::Function { .. }) } - async fn handle(&self, invocation: ToolInvocation) -> Result { - let ToolInvocation { - session, - turn, - payload, - call_id, - .. - } = invocation; - let arguments = function_arguments(payload)?; - let args: SpawnAgentArgs = parse_arguments(&arguments)?; - let fork_mode = args.fork_mode()?; - let role_name = args - .agent_type - .as_deref() - .map(str::trim) - .filter(|role| !role.is_empty()); + fn handle( + &self, + invocation: ToolInvocation, + ) -> impl std::future::Future> + Send { + Box::pin(async move { + let ToolInvocation { + session, + turn, + payload, + call_id, + .. + } = invocation; + let arguments = function_arguments(payload)?; + let args: SpawnAgentArgs = parse_arguments(&arguments)?; + let fork_mode = args.fork_mode()?; + let role_name = args + .agent_type + .as_deref() + .map(str::trim) + .filter(|role| !role.is_empty()); - let initial_operation = parse_collab_input(Some(args.message), /*items*/ None)?; - let prompt = render_input_preview(&initial_operation); + let initial_operation = parse_collab_input(Some(args.message), /*items*/ None)?; + let prompt = render_input_preview(&initial_operation); - let session_source = turn.session_source.clone(); - let child_depth = next_thread_spawn_depth(&session_source); - session - .send_event( - &turn, - CollabAgentSpawnBeginEvent { - call_id: call_id.clone(), - started_at_ms: now_unix_timestamp_ms(), - sender_thread_id: session.conversation_id, - prompt: prompt.clone(), - model: args.model.clone().unwrap_or_default(), - reasoning_effort: args.reasoning_effort.unwrap_or_default(), - } - .into(), - ) - .await; - let mut config = - build_agent_spawn_config(&session.get_base_instructions().await, turn.as_ref())?; - if matches!(fork_mode, Some(SpawnAgentForkMode::FullHistory)) { - reject_full_fork_spawn_overrides( - role_name, - args.model.as_deref(), - args.reasoning_effort, - )?; - } else { - apply_requested_spawn_agent_model_overrides( - &session, - turn.as_ref(), - &mut config, - args.model.as_deref(), - args.reasoning_effort, - ) - .await?; - apply_role_to_config(&mut config, role_name) - .await - .map_err(FunctionCallError::RespondToModel)?; - } - apply_spawn_agent_runtime_overrides(&mut config, turn.as_ref())?; - apply_spawn_agent_overrides(&mut config, child_depth); - - let spawn_source = thread_spawn_source( - session.conversation_id, - &turn.session_source, - child_depth, - role_name, - Some(args.task_name.clone()), - )?; - let result = session - .services - .agent_control - .spawn_agent_with_metadata( - config, - match (spawn_source.get_agent_path(), initial_operation) { - (Some(recipient), Op::UserInput { items, .. }) - if items - .iter() - .all(|item| matches!(item, UserInput::Text { .. })) => - { - Op::InterAgentCommunication { - communication: InterAgentCommunication::new( - turn.session_source - .get_agent_path() - .unwrap_or_else(AgentPath::root), - recipient, - Vec::new(), - prompt.clone(), - /*trigger_turn*/ true, - ), - } + let session_source = turn.session_source.clone(); + let child_depth = next_thread_spawn_depth(&session_source); + session + .send_event( + &turn, + CollabAgentSpawnBeginEvent { + call_id: call_id.clone(), + started_at_ms: now_unix_timestamp_ms(), + sender_thread_id: session.conversation_id, + prompt: prompt.clone(), + model: args.model.clone().unwrap_or_default(), + reasoning_effort: args.reasoning_effort.unwrap_or_default(), } - (_, initial_operation) => initial_operation, - }, - Some(spawn_source), - SpawnAgentOptions { - fork_parent_spawn_call_id: fork_mode.as_ref().map(|_| call_id.clone()), - fork_mode, - environments: Some(turn.environments.to_selections()), - }, + .into(), + ) + .await; + let mut config = + build_agent_spawn_config(&session.get_base_instructions().await, turn.as_ref())?; + if matches!(fork_mode, Some(SpawnAgentForkMode::FullHistory)) { + reject_full_fork_spawn_overrides( + role_name, + args.model.as_deref(), + args.reasoning_effort, + )?; + } else { + apply_requested_spawn_agent_model_overrides( + &session, + turn.as_ref(), + &mut config, + args.model.as_deref(), + args.reasoning_effort, + ) + .await?; + apply_role_to_config(&mut config, role_name) + .await + .map_err(FunctionCallError::RespondToModel)?; + } + apply_spawn_agent_runtime_overrides(&mut config, turn.as_ref())?; + apply_spawn_agent_overrides(&mut config, child_depth); + + let spawn_source = thread_spawn_source( + session.conversation_id, + &turn.session_source, + child_depth, + role_name, + Some(args.task_name.clone()), + )?; + let result = Box::pin( + session.services.agent_control.spawn_agent_with_metadata( + config, + match (spawn_source.get_agent_path(), initial_operation) { + (Some(recipient), Op::UserInput { items, .. }) + if items + .iter() + .all(|item| matches!(item, UserInput::Text { .. })) => + { + Op::InterAgentCommunication { + communication: InterAgentCommunication::new( + turn.session_source + .get_agent_path() + .unwrap_or_else(AgentPath::root), + recipient, + Vec::new(), + prompt.clone(), + /*trigger_turn*/ true, + ), + } + } + (_, initial_operation) => initial_operation, + }, + Some(spawn_source), + SpawnAgentOptions { + fork_parent_spawn_call_id: fork_mode.as_ref().map(|_| call_id.clone()), + fork_mode, + environments: Some(turn.environments.to_selections()), + }, + ), ) .await .map_err(collab_spawn_error); - let (new_thread_id, new_agent_metadata, status) = match &result { - Ok(spawned_agent) => ( - Some(spawned_agent.thread_id), - Some(spawned_agent.metadata.clone()), - spawned_agent.status.clone(), - ), - Err(_) => (None, None, AgentStatus::NotFound), - }; - let agent_snapshot = match new_thread_id { - Some(thread_id) => { - session - .services - .agent_control - .get_agent_config_snapshot(thread_id) - .await - } - None => None, - }; - let (new_agent_path, new_agent_nickname, new_agent_role) = - match (&agent_snapshot, new_agent_metadata) { - (Some(snapshot), _) => ( - snapshot.session_source.get_agent_path().map(String::from), - snapshot.session_source.get_nickname(), - snapshot.session_source.get_agent_role(), + let (new_thread_id, new_agent_metadata, status) = match &result { + Ok(spawned_agent) => ( + Some(spawned_agent.thread_id), + Some(spawned_agent.metadata.clone()), + spawned_agent.status.clone(), ), - (None, Some(metadata)) => ( - metadata.agent_path.map(String::from), - metadata.agent_nickname, - metadata.agent_role, - ), - (None, None) => (None, None, None), + Err(_) => (None, None, AgentStatus::NotFound), }; - let effective_model = agent_snapshot - .as_ref() - .map(|snapshot| snapshot.model.clone()) - .unwrap_or_else(|| args.model.clone().unwrap_or_default()); - let effective_reasoning_effort = agent_snapshot - .as_ref() - .and_then(|snapshot| snapshot.reasoning_effort) - .unwrap_or(args.reasoning_effort.unwrap_or_default()); - let nickname = new_agent_nickname.clone(); - session - .send_event( - &turn, - CollabAgentSpawnEndEvent { - call_id, - completed_at_ms: now_unix_timestamp_ms(), - sender_thread_id: session.conversation_id, - new_thread_id, - new_agent_nickname, - new_agent_role, - prompt, - model: effective_model, - reasoning_effort: effective_reasoning_effort, - status, + let agent_snapshot = match new_thread_id { + Some(thread_id) => { + session + .services + .agent_control + .get_agent_config_snapshot(thread_id) + .await } - .into(), - ) - .await; - let _ = result?; - let role_tag = role_name.unwrap_or(DEFAULT_ROLE_NAME); - turn.session_telemetry.counter( - "codex.multi_agent.spawn", - /*inc*/ 1, - &[("role", role_tag)], - ); - let task_name = new_agent_path.ok_or_else(|| { - FunctionCallError::RespondToModel( - "spawned agent is missing a canonical task name".to_string(), - ) - })?; + None => None, + }; + let (new_agent_path, new_agent_nickname, new_agent_role) = + match (&agent_snapshot, new_agent_metadata) { + (Some(snapshot), _) => ( + snapshot.session_source.get_agent_path().map(String::from), + snapshot.session_source.get_nickname(), + snapshot.session_source.get_agent_role(), + ), + (None, Some(metadata)) => ( + metadata.agent_path.map(String::from), + metadata.agent_nickname, + metadata.agent_role, + ), + (None, None) => (None, None, None), + }; + let effective_model = agent_snapshot + .as_ref() + .map(|snapshot| snapshot.model.clone()) + .unwrap_or_else(|| args.model.clone().unwrap_or_default()); + let effective_reasoning_effort = agent_snapshot + .as_ref() + .and_then(|snapshot| snapshot.reasoning_effort) + .unwrap_or(args.reasoning_effort.unwrap_or_default()); + let nickname = new_agent_nickname.clone(); + session + .send_event( + &turn, + CollabAgentSpawnEndEvent { + call_id, + completed_at_ms: now_unix_timestamp_ms(), + sender_thread_id: session.conversation_id, + new_thread_id, + new_agent_nickname, + new_agent_role, + prompt, + model: effective_model, + reasoning_effort: effective_reasoning_effort, + status, + } + .into(), + ) + .await; + let _ = result?; + let role_tag = role_name.unwrap_or(DEFAULT_ROLE_NAME); + turn.session_telemetry.counter( + "codex.multi_agent.spawn", + /*inc*/ 1, + &[("role", role_tag)], + ); + let task_name = new_agent_path.ok_or_else(|| { + FunctionCallError::RespondToModel( + "spawned agent is missing a canonical task name".to_string(), + ) + })?; - let hide_agent_metadata = turn.config.multi_agent_v2.hide_spawn_agent_metadata; - if hide_agent_metadata { - Ok(SpawnAgentResult::HiddenMetadata { task_name }) - } else { - Ok(SpawnAgentResult::WithNickname { - task_name, - nickname, - }) - } + let hide_agent_metadata = turn.config.multi_agent_v2.hide_spawn_agent_metadata; + if hide_agent_metadata { + Ok(SpawnAgentResult::HiddenMetadata { task_name }) + } else { + Ok(SpawnAgentResult::WithNickname { + task_name, + nickname, + }) + } + }) } } From 1915975876322b7dc7056742d316511ba8322998 Mon Sep 17 00:00:00 2001 From: Michael Bolin Date: Tue, 12 May 2026 06:51:09 -0700 Subject: [PATCH 6/6] permissions: move workspace roots onto thread state --- codex-rs/Cargo.lock | 3 + .../analytics/src/analytics_client_tests.rs | 4 +- codex-rs/analytics/src/client_tests.rs | 12 +- .../schema/json/ClientRequest.json | 74 +-- .../codex_app_server_protocol.schemas.json | 113 +--- .../codex_app_server_protocol.v2.schemas.json | 113 +--- .../schema/json/v2/CommandExecParams.json | 7 - .../schema/json/v2/ThreadForkParams.json | 62 +-- .../schema/json/v2/ThreadForkResponse.json | 367 +------------ .../schema/json/v2/ThreadResumeParams.json | 62 +-- .../schema/json/v2/ThreadResumeResponse.json | 367 +------------ .../schema/json/v2/ThreadStartParams.json | 59 -- .../schema/json/v2/ThreadStartResponse.json | 367 +------------ .../schema/json/v2/TurnStartParams.json | 68 +-- .../typescript/v2/ActivePermissionProfile.ts | 8 +- .../v2/ActivePermissionProfileModification.ts | 6 - .../v2/PermissionProfileModificationParams.ts | 6 - .../v2/PermissionProfileSelectionParams.ts | 6 - .../schema/typescript/v2/SandboxPolicy.ts | 3 +- .../schema/typescript/v2/ThreadForkParams.ts | 7 +- .../typescript/v2/ThreadForkResponse.ts | 3 +- .../typescript/v2/ThreadResumeParams.ts | 7 +- .../typescript/v2/ThreadResumeResponse.ts | 3 +- .../typescript/v2/ThreadStartResponse.ts | 3 +- .../schema/typescript/v2/TurnStartParams.ts | 4 +- .../schema/typescript/v2/index.ts | 3 - .../src/protocol/common.rs | 4 +- .../src/protocol/v2/permissions.rs | 130 ++--- .../src/protocol/v2/tests.rs | 50 +- .../src/protocol/v2/thread.rs | 83 +-- .../src/protocol/v2/turn.rs | 18 +- codex-rs/app-server/README.md | 26 +- codex-rs/app-server/src/in_process.rs | 43 +- codex-rs/app-server/src/lib.rs | 2 +- codex-rs/app-server/src/message_processor.rs | 68 +-- .../src/message_processor_tracing_tests.rs | 1 + codex-rs/app-server/src/request_processors.rs | 10 +- .../command_exec_processor.rs | 31 +- .../legacy_sandbox_compat.rs | 272 ++++++++++ .../request_processors/thread_lifecycle.rs | 6 +- .../request_processors/thread_processor.rs | 513 +++++++++++++++++- .../thread_processor_tests.rs | 97 ++++ .../src/request_processors/thread_summary.rs | 22 +- .../src/request_processors/turn_processor.rs | 132 ++++- codex-rs/app-server/tests/common/rollout.rs | 2 + .../tests/suite/conversation_summary.rs | 1 + .../app-server/tests/suite/v2/skills_list.rs | 1 + .../app-server/tests/suite/v2/thread_list.rs | 1 + .../app-server/tests/suite/v2/thread_read.rs | 1 + .../tests/suite/v2/thread_resume.rs | 118 ++++ .../tests/suite/v2/thread_unarchive.rs | 1 + .../app-server/tests/suite/v2/turn_start.rs | 249 ++++++++- .../tests/suite/v2/turn_start_zsh_fork.rs | 11 +- codex-rs/cli/src/debug_sandbox.rs | 20 +- codex-rs/config/src/config_requirements.rs | 4 - codex-rs/config/src/config_toml.rs | 4 +- codex-rs/core/src/codex_thread.rs | 36 +- .../core/src/config/config_loader_tests.rs | 6 +- codex-rs/core/src/config/config_tests.rs | 105 ++-- codex-rs/core/src/config/mod.rs | 196 ++++--- codex-rs/core/src/config/permissions.rs | 4 +- .../src/context/permissions_instructions.rs | 33 +- .../context/permissions_instructions_tests.rs | 42 +- .../sandbox_mode/workspace_write.md | 2 +- .../core/src/context_manager/history_tests.rs | 2 + codex-rs/core/src/context_manager/updates.rs | 11 +- codex-rs/core/src/exec.rs | 62 ++- codex-rs/core/src/exec_tests.rs | 62 ++- codex-rs/core/src/guardian/review_session.rs | 10 +- codex-rs/core/src/guardian/tests.rs | 8 +- .../core/src/personality_migration_tests.rs | 1 + codex-rs/core/src/rollout.rs | 4 + codex-rs/core/src/safety_tests.rs | 21 +- codex-rs/core/src/session/handlers.rs | 4 + codex-rs/core/src/session/mod.rs | 9 +- codex-rs/core/src/session/review.rs | 1 + .../session/rollout_reconstruction_tests.rs | 16 + codex-rs/core/src/session/session.rs | 91 +++- codex-rs/core/src/session/tests.rs | 122 ++++- codex-rs/core/src/session/turn_context.rs | 20 +- .../src/tools/handlers/apply_patch_tests.rs | 2 - .../src/tools/handlers/multi_agents_common.rs | 1 + .../src/tools/handlers/multi_agents_tests.rs | 2 +- codex-rs/core/src/tools/orchestrator.rs | 3 + .../core/src/tools/runtimes/apply_patch.rs | 1 + .../src/tools/runtimes/apply_patch_tests.rs | 2 + codex-rs/core/src/tools/runtimes/mod_tests.rs | 1 + .../tools/runtimes/shell/unix_escalation.rs | 6 +- codex-rs/core/src/tools/sandboxing.rs | 2 + codex-rs/core/tests/common/test_codex.rs | 2 + codex-rs/core/tests/suite/approvals.rs | 4 - codex-rs/core/tests/suite/exec.rs | 1 + .../core/tests/suite/permissions_messages.rs | 10 +- .../core/tests/suite/personality_migration.rs | 2 + codex-rs/core/tests/suite/prompt_caching.rs | 9 +- .../core/tests/suite/request_permissions.rs | 1 - codex-rs/core/tests/suite/resume_warning.rs | 2 + codex-rs/core/tests/suite/sqlite_state.rs | 1 + codex-rs/core/tests/suite/unified_exec.rs | 19 +- codex-rs/core/tests/suite/user_shell_cmd.rs | 5 +- codex-rs/core/tests/suite/windows_sandbox.rs | 2 + codex-rs/exec-server/src/fs_sandbox.rs | 1 + codex-rs/exec/Cargo.toml | 1 + .../src/event_processor_with_human_output.rs | 90 +-- ...event_processor_with_human_output_tests.rs | 81 --- codex-rs/exec/src/lib.rs | 120 ++-- codex-rs/exec/src/lib_tests.rs | 183 ++++++- .../tests/event_processor_with_json_output.rs | 1 + codex-rs/exec/tests/suite/sandbox.rs | 20 +- codex-rs/file-system/src/lib.rs | 22 +- .../linux-sandbox/tests/suite/landlock.rs | 2 + codex-rs/mcp-server/src/outgoing_message.rs | 5 + codex-rs/memories/write/src/phase2.rs | 33 +- codex-rs/protocol/src/models.rs | 38 +- codex-rs/protocol/src/permissions.rs | 140 ++--- codex-rs/protocol/src/protocol.rs | 312 ++++++++++- codex-rs/rollout/Cargo.toml | 1 + codex-rs/rollout/src/config.rs | 17 + codex-rs/rollout/src/metadata_tests.rs | 3 + codex-rs/rollout/src/recorder.rs | 1 + codex-rs/rollout/src/recorder_tests.rs | 4 + codex-rs/rollout/src/session_index_tests.rs | 1 + codex-rs/rollout/src/tests.rs | 1 + codex-rs/sandboxing/src/manager.rs | 49 +- codex-rs/sandboxing/src/manager_tests.rs | 42 ++ codex-rs/sandboxing/src/seatbelt_tests.rs | 118 ++-- codex-rs/state/src/extract.rs | 8 + codex-rs/state/src/runtime/threads.rs | 2 + codex-rs/thread-manager-sample/src/main.rs | 16 +- codex-rs/thread-store/Cargo.toml | 1 + .../thread-store/src/local/create_thread.rs | 1 + .../thread-store/src/local/list_threads.rs | 1 + .../thread-store/src/local/live_writer.rs | 1 + codex-rs/thread-store/src/local/mod.rs | 2 + .../src/local/update_thread_metadata.rs | 1 + codex-rs/thread-store/src/types.rs | 4 + codex-rs/tui/src/app/startup_prompts.rs | 2 +- codex-rs/tui/src/app/thread_routing.rs | 1 - codex-rs/tui/src/app_server_session.rs | 277 +++++----- codex-rs/tui/src/chatwidget.rs | 10 +- .../tui/src/chatwidget/status_surfaces.rs | 4 +- codex-rs/tui/src/history_cell.rs | 2 +- codex-rs/tui/src/lib.rs | 3 +- codex-rs/tui/src/permission_compat.rs | 95 ---- codex-rs/tui/src/status/card.rs | 88 +-- codex-rs/tui/src/status/tests.rs | 131 +++-- codex-rs/utils/sandbox-summary/Cargo.toml | 2 +- .../sandbox-summary/src/config_summary.rs | 26 +- codex-rs/utils/sandbox-summary/src/lib.rs | 2 +- .../sandbox-summary/src/sandbox_summary.rs | 308 ++++++++--- codex-rs/windows-sandbox-rs/Cargo.toml | 2 +- codex-rs/windows-sandbox-rs/src/allow.rs | 78 +-- codex-rs/windows-sandbox-rs/src/audit.rs | 9 +- .../windows-sandbox-rs/src/elevated_impl.rs | 1 - codex-rs/windows-sandbox-rs/src/lib.rs | 18 +- codex-rs/windows-sandbox-rs/src/setup.rs | 58 +- codex-rs/windows-sandbox-rs/src/spawn_prep.rs | 1 - 157 files changed, 3848 insertions(+), 3197 deletions(-) delete mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/ActivePermissionProfileModification.ts delete mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfileModificationParams.ts delete mode 100644 codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfileSelectionParams.ts create mode 100644 codex-rs/app-server/src/request_processors/legacy_sandbox_compat.rs delete mode 100644 codex-rs/tui/src/permission_compat.rs diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 30d4bf3640..771da80417 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -2698,6 +2698,7 @@ dependencies = [ "codex-utils-cargo-bin", "codex-utils-cli", "codex-utils-oss", + "codex-utils-sandbox-summary", "core_test_support", "libc", "opentelemetry", @@ -3495,6 +3496,7 @@ dependencies = [ "codex-otel", "codex-protocol", "codex-state", + "codex-utils-absolute-path", "codex-utils-path", "codex-utils-string", "pretty_assertions", @@ -3687,6 +3689,7 @@ dependencies = [ "codex-protocol", "codex-rollout", "codex-state", + "codex-utils-absolute-path", "pretty_assertions", "serde", "serde_json", diff --git a/codex-rs/analytics/src/analytics_client_tests.rs b/codex-rs/analytics/src/analytics_client_tests.rs index 5b4a72229b..e51e2bbeb8 100644 --- a/codex-rs/analytics/src/analytics_client_tests.rs +++ b/codex-rs/analytics/src/analytics_client_tests.rs @@ -201,11 +201,11 @@ fn sample_thread_start_response( model_provider: "openai".to_string(), service_tier: None, cwd: test_path_buf("/tmp").abs(), + workspace_roots: Vec::new(), instruction_sources: Vec::new(), approval_policy: AppServerAskForApproval::OnFailure, approvals_reviewer: AppServerApprovalsReviewer::User, sandbox: AppServerSandboxPolicy::DangerFullAccess, - permission_profile: None, active_permission_profile: None, reasoning_effort: None, }) @@ -257,11 +257,11 @@ fn sample_thread_resume_response_with_source( model_provider: "openai".to_string(), service_tier: None, cwd: test_path_buf("/tmp").abs(), + workspace_roots: Vec::new(), instruction_sources: Vec::new(), approval_policy: AppServerAskForApproval::OnFailure, approvals_reviewer: AppServerApprovalsReviewer::User, sandbox: AppServerSandboxPolicy::DangerFullAccess, - permission_profile: None, active_permission_profile: None, reasoning_effort: None, }) diff --git a/codex-rs/analytics/src/client_tests.rs b/codex-rs/analytics/src/client_tests.rs index 71c46d808a..c06372d1e7 100644 --- a/codex-rs/analytics/src/client_tests.rs +++ b/codex-rs/analytics/src/client_tests.rs @@ -12,7 +12,6 @@ use codex_app_server_protocol::ApprovalsReviewer as AppServerApprovalsReviewer; use codex_app_server_protocol::AskForApproval as AppServerAskForApproval; use codex_app_server_protocol::ClientRequest; use codex_app_server_protocol::ClientResponsePayload; -use codex_app_server_protocol::PermissionProfile as AppServerPermissionProfile; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::SandboxPolicy as AppServerSandboxPolicy; use codex_app_server_protocol::SessionSource as AppServerSessionSource; @@ -29,7 +28,6 @@ use codex_app_server_protocol::TurnStartResponse; use codex_app_server_protocol::TurnStatus as AppServerTurnStatus; use codex_app_server_protocol::TurnSteerParams; use codex_app_server_protocol::TurnSteerResponse; -use codex_protocol::models::PermissionProfile as CorePermissionProfile; use codex_utils_absolute_path::test_support::PathBufExt; use codex_utils_absolute_path::test_support::test_path_buf; use std::collections::HashSet; @@ -142,10 +140,6 @@ fn sample_thread(thread_id: &str) -> Thread { } } -fn sample_permission_profile() -> AppServerPermissionProfile { - CorePermissionProfile::Disabled.into() -} - fn sample_thread_start_response() -> ClientResponsePayload { ClientResponsePayload::ThreadStart(ThreadStartResponse { thread: sample_thread("thread-1"), @@ -153,11 +147,11 @@ fn sample_thread_start_response() -> ClientResponsePayload { model_provider: "openai".to_string(), service_tier: None, cwd: test_path_buf("/tmp").abs(), + workspace_roots: Vec::new(), instruction_sources: Vec::new(), approval_policy: AppServerAskForApproval::OnFailure, approvals_reviewer: AppServerApprovalsReviewer::User, sandbox: AppServerSandboxPolicy::DangerFullAccess, - permission_profile: Some(sample_permission_profile()), active_permission_profile: None, reasoning_effort: None, }) @@ -170,11 +164,11 @@ fn sample_thread_resume_response() -> ClientResponsePayload { model_provider: "openai".to_string(), service_tier: None, cwd: test_path_buf("/tmp").abs(), + workspace_roots: Vec::new(), instruction_sources: Vec::new(), approval_policy: AppServerAskForApproval::OnFailure, approvals_reviewer: AppServerApprovalsReviewer::User, sandbox: AppServerSandboxPolicy::DangerFullAccess, - permission_profile: Some(sample_permission_profile()), active_permission_profile: None, reasoning_effort: None, }) @@ -187,11 +181,11 @@ fn sample_thread_fork_response() -> ClientResponsePayload { model_provider: "openai".to_string(), service_tier: None, cwd: test_path_buf("/tmp").abs(), + workspace_roots: Vec::new(), instruction_sources: Vec::new(), approval_policy: AppServerAskForApproval::OnFailure, approvals_reviewer: AppServerApprovalsReviewer::User, sandbox: AppServerSandboxPolicy::DangerFullAccess, - permission_profile: Some(sample_permission_profile()), active_permission_profile: None, reasoning_effort: None, }) diff --git a/codex-rs/app-server-protocol/schema/json/ClientRequest.json b/codex-rs/app-server-protocol/schema/json/ClientRequest.json index 6351993046..ebf0656cd7 100644 --- a/codex-rs/app-server-protocol/schema/json/ClientRequest.json +++ b/codex-rs/app-server-protocol/schema/json/ClientRequest.json @@ -1850,31 +1850,6 @@ } ] }, - "PermissionProfileModificationParams": { - "oneOf": [ - { - "description": "Additional concrete directory that should be writable.", - "properties": { - "path": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": { - "enum": [ - "additionalWritableRoot" - ], - "title": "AdditionalWritableRootPermissionProfileModificationParamsType", - "type": "string" - } - }, - "required": [ - "path", - "type" - ], - "title": "AdditionalWritableRootPermissionProfileModificationParams", - "type": "object" - } - ] - }, "PermissionProfileNetworkPermissions": { "properties": { "enabled": { @@ -1886,40 +1861,6 @@ ], "type": "object" }, - "PermissionProfileSelectionParams": { - "oneOf": [ - { - "description": "Select a named built-in or user-defined profile and optionally apply bounded modifications that Codex knows how to validate.", - "properties": { - "id": { - "type": "string" - }, - "modifications": { - "items": { - "$ref": "#/definitions/PermissionProfileModificationParams" - }, - "type": [ - "array", - "null" - ] - }, - "type": { - "enum": [ - "profile" - ], - "title": "ProfilePermissionProfileSelectionParamsType", - "type": "string" - } - }, - "required": [ - "id", - "type" - ], - "title": "ProfilePermissionProfileSelectionParams", - "type": "object" - } - ] - }, "Personality": { "enum": [ "none", @@ -3133,13 +3074,6 @@ ], "title": "WorkspaceWriteSandboxPolicyType", "type": "string" - }, - "writableRoots": { - "default": [], - "items": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": "array" } }, "required": [ @@ -3406,7 +3340,8 @@ { "type": "null" } - ] + ], + "description": "Deprecated for fork. When present, the server treats this as a compatibility spelling for selecting a matching named permissions profile." }, "serviceTier": { "type": [ @@ -3817,7 +3752,8 @@ { "type": "null" } - ] + ], + "description": "Deprecated for resume. When present, the server treats this as a compatibility spelling for selecting a matching named permissions profile." }, "serviceTier": { "type": [ @@ -4197,7 +4133,7 @@ "type": "null" } ], - "description": "Override the sandbox policy for this turn and subsequent turns." + "description": "Deprecated for turns. When present, the server treats this as a compatibility spelling for selecting a matching named permissions profile." }, "serviceTier": { "description": "Override the service tier for this turn and subsequent turns.", 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 9934e36940..c15c195d9b 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 @@ -5583,14 +5583,6 @@ "id": { "description": "Identifier from `default_permissions` or the implicit built-in default, such as `:workspace` or a user-defined `[permissions.]` profile.", "type": "string" - }, - "modifications": { - "default": [], - "description": "Bounded user-requested modifications applied on top of the named profile, if any.", - "items": { - "$ref": "#/definitions/v2/ActivePermissionProfileModification" - }, - "type": "array" } }, "required": [ @@ -5598,31 +5590,6 @@ ], "type": "object" }, - "ActivePermissionProfileModification": { - "oneOf": [ - { - "description": "Additional concrete directory that should be writable.", - "properties": { - "path": { - "$ref": "#/definitions/v2/AbsolutePathBuf" - }, - "type": { - "enum": [ - "additionalWritableRoot" - ], - "title": "AdditionalWritableRootActivePermissionProfileModificationType", - "type": "string" - } - }, - "required": [ - "path", - "type" - ], - "title": "AdditionalWritableRootActivePermissionProfileModification", - "type": "object" - } - ] - }, "AddCreditsNudgeCreditType": { "enum": [ "credits", @@ -11710,31 +11677,6 @@ } ] }, - "PermissionProfileModificationParams": { - "oneOf": [ - { - "description": "Additional concrete directory that should be writable.", - "properties": { - "path": { - "$ref": "#/definitions/v2/AbsolutePathBuf" - }, - "type": { - "enum": [ - "additionalWritableRoot" - ], - "title": "AdditionalWritableRootPermissionProfileModificationParamsType", - "type": "string" - } - }, - "required": [ - "path", - "type" - ], - "title": "AdditionalWritableRootPermissionProfileModificationParams", - "type": "object" - } - ] - }, "PermissionProfileNetworkPermissions": { "properties": { "enabled": { @@ -11746,40 +11688,6 @@ ], "type": "object" }, - "PermissionProfileSelectionParams": { - "oneOf": [ - { - "description": "Select a named built-in or user-defined profile and optionally apply bounded modifications that Codex knows how to validate.", - "properties": { - "id": { - "type": "string" - }, - "modifications": { - "items": { - "$ref": "#/definitions/v2/PermissionProfileModificationParams" - }, - "type": [ - "array", - "null" - ] - }, - "type": { - "enum": [ - "profile" - ], - "title": "ProfilePermissionProfileSelectionParamsType", - "type": "string" - } - }, - "required": [ - "id", - "type" - ], - "title": "ProfilePermissionProfileSelectionParams", - "type": "object" - } - ] - }, "Personality": { "enum": [ "none", @@ -14403,13 +14311,6 @@ ], "title": "WorkspaceWriteSandboxPolicyType", "type": "string" - }, - "writableRoots": { - "default": [], - "items": { - "$ref": "#/definitions/v2/AbsolutePathBuf" - }, - "type": "array" } }, "required": [ @@ -15397,7 +15298,8 @@ { "type": "null" } - ] + ], + "description": "Deprecated for fork. When present, the server treats this as a compatibility spelling for selecting a matching named permissions profile." }, "serviceTier": { "type": [ @@ -15473,7 +15375,7 @@ "$ref": "#/definitions/v2/SandboxPolicy" } ], - "description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `permissionProfile` when they need exact runtime permissions." + "description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `activePermissionProfile` and `workspaceRoots`." }, "serviceTier": { "type": [ @@ -16900,7 +16802,8 @@ { "type": "null" } - ] + ], + "description": "Deprecated for resume. When present, the server treats this as a compatibility spelling for selecting a matching named permissions profile." }, "serviceTier": { "type": [ @@ -16965,7 +16868,7 @@ "$ref": "#/definitions/v2/SandboxPolicy" } ], - "description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `permissionProfile` when they need exact runtime permissions." + "description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `activePermissionProfile` and `workspaceRoots`." }, "serviceTier": { "type": [ @@ -17273,7 +17176,7 @@ "$ref": "#/definitions/v2/SandboxPolicy" } ], - "description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `permissionProfile` when they need exact runtime permissions." + "description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `activePermissionProfile` and `workspaceRoots`." }, "serviceTier": { "type": [ @@ -17942,7 +17845,7 @@ "type": "null" } ], - "description": "Override the sandbox policy for this turn and subsequent turns." + "description": "Deprecated for turns. When present, the server treats this as a compatibility spelling for selecting a matching named permissions profile." }, "serviceTier": { "description": "Override the service tier for this turn and subsequent turns.", 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 1926b22091..7dcc773714 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 @@ -143,14 +143,6 @@ "id": { "description": "Identifier from `default_permissions` or the implicit built-in default, such as `:workspace` or a user-defined `[permissions.]` profile.", "type": "string" - }, - "modifications": { - "default": [], - "description": "Bounded user-requested modifications applied on top of the named profile, if any.", - "items": { - "$ref": "#/definitions/ActivePermissionProfileModification" - }, - "type": "array" } }, "required": [ @@ -158,31 +150,6 @@ ], "type": "object" }, - "ActivePermissionProfileModification": { - "oneOf": [ - { - "description": "Additional concrete directory that should be writable.", - "properties": { - "path": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": { - "enum": [ - "additionalWritableRoot" - ], - "title": "AdditionalWritableRootActivePermissionProfileModificationType", - "type": "string" - } - }, - "required": [ - "path", - "type" - ], - "title": "AdditionalWritableRootActivePermissionProfileModification", - "type": "object" - } - ] - }, "AddCreditsNudgeCreditType": { "enum": [ "credits", @@ -8259,31 +8226,6 @@ } ] }, - "PermissionProfileModificationParams": { - "oneOf": [ - { - "description": "Additional concrete directory that should be writable.", - "properties": { - "path": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": { - "enum": [ - "additionalWritableRoot" - ], - "title": "AdditionalWritableRootPermissionProfileModificationParamsType", - "type": "string" - } - }, - "required": [ - "path", - "type" - ], - "title": "AdditionalWritableRootPermissionProfileModificationParams", - "type": "object" - } - ] - }, "PermissionProfileNetworkPermissions": { "properties": { "enabled": { @@ -8295,40 +8237,6 @@ ], "type": "object" }, - "PermissionProfileSelectionParams": { - "oneOf": [ - { - "description": "Select a named built-in or user-defined profile and optionally apply bounded modifications that Codex knows how to validate.", - "properties": { - "id": { - "type": "string" - }, - "modifications": { - "items": { - "$ref": "#/definitions/PermissionProfileModificationParams" - }, - "type": [ - "array", - "null" - ] - }, - "type": { - "enum": [ - "profile" - ], - "title": "ProfilePermissionProfileSelectionParamsType", - "type": "string" - } - }, - "required": [ - "id", - "type" - ], - "title": "ProfilePermissionProfileSelectionParams", - "type": "object" - } - ] - }, "Personality": { "enum": [ "none", @@ -10952,13 +10860,6 @@ ], "title": "WorkspaceWriteSandboxPolicyType", "type": "string" - }, - "writableRoots": { - "default": [], - "items": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": "array" } }, "required": [ @@ -13221,7 +13122,8 @@ { "type": "null" } - ] + ], + "description": "Deprecated for fork. When present, the server treats this as a compatibility spelling for selecting a matching named permissions profile." }, "serviceTier": { "type": [ @@ -13297,7 +13199,7 @@ "$ref": "#/definitions/SandboxPolicy" } ], - "description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `permissionProfile` when they need exact runtime permissions." + "description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `activePermissionProfile` and `workspaceRoots`." }, "serviceTier": { "type": [ @@ -14724,7 +14626,8 @@ { "type": "null" } - ] + ], + "description": "Deprecated for resume. When present, the server treats this as a compatibility spelling for selecting a matching named permissions profile." }, "serviceTier": { "type": [ @@ -14789,7 +14692,7 @@ "$ref": "#/definitions/SandboxPolicy" } ], - "description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `permissionProfile` when they need exact runtime permissions." + "description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `activePermissionProfile` and `workspaceRoots`." }, "serviceTier": { "type": [ @@ -15097,7 +15000,7 @@ "$ref": "#/definitions/SandboxPolicy" } ], - "description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `permissionProfile` when they need exact runtime permissions." + "description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `activePermissionProfile` and `workspaceRoots`." }, "serviceTier": { "type": [ @@ -15766,7 +15669,7 @@ "type": "null" } ], - "description": "Override the sandbox policy for this turn and subsequent turns." + "description": "Deprecated for turns. When present, the server treats this as a compatibility spelling for selecting a matching named permissions profile." }, "serviceTier": { "description": "Override the service tier for this turn and subsequent turns.", 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 f29483862c..6a1098fda4 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/CommandExecParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/CommandExecParams.json @@ -441,13 +441,6 @@ ], "title": "WorkspaceWriteSandboxPolicyType", "type": "string" - }, - "writableRoots": { - "default": [], - "items": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": "array" } }, "required": [ 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 29d67403cd..3b3a81bb92 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkParams.json @@ -64,65 +64,6 @@ } ] }, - "PermissionProfileModificationParams": { - "oneOf": [ - { - "description": "Additional concrete directory that should be writable.", - "properties": { - "path": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": { - "enum": [ - "additionalWritableRoot" - ], - "title": "AdditionalWritableRootPermissionProfileModificationParamsType", - "type": "string" - } - }, - "required": [ - "path", - "type" - ], - "title": "AdditionalWritableRootPermissionProfileModificationParams", - "type": "object" - } - ] - }, - "PermissionProfileSelectionParams": { - "oneOf": [ - { - "description": "Select a named built-in or user-defined profile and optionally apply bounded modifications that Codex knows how to validate.", - "properties": { - "id": { - "type": "string" - }, - "modifications": { - "items": { - "$ref": "#/definitions/PermissionProfileModificationParams" - }, - "type": [ - "array", - "null" - ] - }, - "type": { - "enum": [ - "profile" - ], - "title": "ProfilePermissionProfileSelectionParamsType", - "type": "string" - } - }, - "required": [ - "id", - "type" - ], - "title": "ProfilePermissionProfileSelectionParams", - "type": "object" - } - ] - }, "SandboxMode": { "enum": [ "read-only", @@ -212,7 +153,8 @@ { "type": "null" } - ] + ], + "description": "Deprecated for fork. When present, the server treats this as a compatibility spelling for selecting a matching named permissions profile." }, "serviceTier": { "type": [ 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 6e74ab4ac8..98e00a1a1a 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadForkResponse.json @@ -18,14 +18,6 @@ "id": { "description": "Identifier from `default_permissions` or the implicit built-in default, such as `:workspace` or a user-defined `[permissions.]` profile.", "type": "string" - }, - "modifications": { - "default": [], - "description": "Bounded user-requested modifications applied on top of the named profile, if any.", - "items": { - "$ref": "#/definitions/ActivePermissionProfileModification" - }, - "type": "array" } }, "required": [ @@ -33,31 +25,6 @@ ], "type": "object" }, - "ActivePermissionProfileModification": { - "oneOf": [ - { - "description": "Additional concrete directory that should be writable.", - "properties": { - "path": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": { - "enum": [ - "additionalWritableRoot" - ], - "title": "AdditionalWritableRootActivePermissionProfileModificationType", - "type": "string" - } - }, - "required": [ - "path", - "type" - ], - "title": "AdditionalWritableRootActivePermissionProfileModification", - "type": "object" - } - ] - }, "AgentPath": { "type": "string" }, @@ -503,202 +470,6 @@ ], "type": "string" }, - "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": [ - "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" - } - ] - }, "FileUpdateChange": { "properties": { "diff": { @@ -937,135 +708,6 @@ } ] }, - "PermissionProfile": { - "oneOf": [ - { - "description": "Codex owns sandbox construction for this profile.", - "properties": { - "fileSystem": { - "$ref": "#/definitions/PermissionProfileFileSystemPermissions" - }, - "network": { - "$ref": "#/definitions/PermissionProfileNetworkPermissions" - }, - "type": { - "enum": [ - "managed" - ], - "title": "ManagedPermissionProfileType", - "type": "string" - } - }, - "required": [ - "fileSystem", - "network", - "type" - ], - "title": "ManagedPermissionProfile", - "type": "object" - }, - { - "description": "Do not apply an outer sandbox.", - "properties": { - "type": { - "enum": [ - "disabled" - ], - "title": "DisabledPermissionProfileType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "DisabledPermissionProfile", - "type": "object" - }, - { - "description": "Filesystem isolation is enforced by an external caller.", - "properties": { - "network": { - "$ref": "#/definitions/PermissionProfileNetworkPermissions" - }, - "type": { - "enum": [ - "external" - ], - "title": "ExternalPermissionProfileType", - "type": "string" - } - }, - "required": [ - "network", - "type" - ], - "title": "ExternalPermissionProfile", - "type": "object" - } - ] - }, - "PermissionProfileFileSystemPermissions": { - "oneOf": [ - { - "properties": { - "entries": { - "items": { - "$ref": "#/definitions/FileSystemSandboxEntry" - }, - "type": "array" - }, - "globScanMaxDepth": { - "format": "uint", - "minimum": 1.0, - "type": [ - "integer", - "null" - ] - }, - "type": { - "enum": [ - "restricted" - ], - "title": "RestrictedPermissionProfileFileSystemPermissionsType", - "type": "string" - } - }, - "required": [ - "entries", - "type" - ], - "title": "RestrictedPermissionProfileFileSystemPermissions", - "type": "object" - }, - { - "properties": { - "type": { - "enum": [ - "unrestricted" - ], - "title": "UnrestrictedPermissionProfileFileSystemPermissionsType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "UnrestrictedPermissionProfileFileSystemPermissions", - "type": "object" - } - ] - }, - "PermissionProfileNetworkPermissions": { - "properties": { - "enabled": { - "type": "boolean" - } - }, - "required": [ - "enabled" - ], - "type": "object" - }, "ReasoningEffort": { "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", "enum": [ @@ -1160,13 +802,6 @@ ], "title": "WorkspaceWriteSandboxPolicyType", "type": "string" - }, - "writableRoots": { - "default": [], - "items": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": "array" } }, "required": [ @@ -2605,7 +2240,7 @@ "$ref": "#/definitions/SandboxPolicy" } ], - "description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `permissionProfile` when they need exact runtime permissions." + "description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `activePermissionProfile` and `workspaceRoots`." }, "serviceTier": { "type": [ 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 5f07fe0149..69b12a365c 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeParams.json @@ -298,65 +298,6 @@ } ] }, - "PermissionProfileModificationParams": { - "oneOf": [ - { - "description": "Additional concrete directory that should be writable.", - "properties": { - "path": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": { - "enum": [ - "additionalWritableRoot" - ], - "title": "AdditionalWritableRootPermissionProfileModificationParamsType", - "type": "string" - } - }, - "required": [ - "path", - "type" - ], - "title": "AdditionalWritableRootPermissionProfileModificationParams", - "type": "object" - } - ] - }, - "PermissionProfileSelectionParams": { - "oneOf": [ - { - "description": "Select a named built-in or user-defined profile and optionally apply bounded modifications that Codex knows how to validate.", - "properties": { - "id": { - "type": "string" - }, - "modifications": { - "items": { - "$ref": "#/definitions/PermissionProfileModificationParams" - }, - "type": [ - "array", - "null" - ] - }, - "type": { - "enum": [ - "profile" - ], - "title": "ProfilePermissionProfileSelectionParamsType", - "type": "string" - } - }, - "required": [ - "id", - "type" - ], - "title": "ProfilePermissionProfileSelectionParams", - "type": "object" - } - ] - }, "Personality": { "enum": [ "none", @@ -1091,7 +1032,8 @@ { "type": "null" } - ] + ], + "description": "Deprecated for resume. When present, the server treats this as a compatibility spelling for selecting a matching named permissions profile." }, "serviceTier": { "type": [ 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 727b7a3fb2..49c7402bbf 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadResumeResponse.json @@ -18,14 +18,6 @@ "id": { "description": "Identifier from `default_permissions` or the implicit built-in default, such as `:workspace` or a user-defined `[permissions.]` profile.", "type": "string" - }, - "modifications": { - "default": [], - "description": "Bounded user-requested modifications applied on top of the named profile, if any.", - "items": { - "$ref": "#/definitions/ActivePermissionProfileModification" - }, - "type": "array" } }, "required": [ @@ -33,31 +25,6 @@ ], "type": "object" }, - "ActivePermissionProfileModification": { - "oneOf": [ - { - "description": "Additional concrete directory that should be writable.", - "properties": { - "path": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": { - "enum": [ - "additionalWritableRoot" - ], - "title": "AdditionalWritableRootActivePermissionProfileModificationType", - "type": "string" - } - }, - "required": [ - "path", - "type" - ], - "title": "AdditionalWritableRootActivePermissionProfileModification", - "type": "object" - } - ] - }, "AgentPath": { "type": "string" }, @@ -503,202 +470,6 @@ ], "type": "string" }, - "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": [ - "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" - } - ] - }, "FileUpdateChange": { "properties": { "diff": { @@ -937,135 +708,6 @@ } ] }, - "PermissionProfile": { - "oneOf": [ - { - "description": "Codex owns sandbox construction for this profile.", - "properties": { - "fileSystem": { - "$ref": "#/definitions/PermissionProfileFileSystemPermissions" - }, - "network": { - "$ref": "#/definitions/PermissionProfileNetworkPermissions" - }, - "type": { - "enum": [ - "managed" - ], - "title": "ManagedPermissionProfileType", - "type": "string" - } - }, - "required": [ - "fileSystem", - "network", - "type" - ], - "title": "ManagedPermissionProfile", - "type": "object" - }, - { - "description": "Do not apply an outer sandbox.", - "properties": { - "type": { - "enum": [ - "disabled" - ], - "title": "DisabledPermissionProfileType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "DisabledPermissionProfile", - "type": "object" - }, - { - "description": "Filesystem isolation is enforced by an external caller.", - "properties": { - "network": { - "$ref": "#/definitions/PermissionProfileNetworkPermissions" - }, - "type": { - "enum": [ - "external" - ], - "title": "ExternalPermissionProfileType", - "type": "string" - } - }, - "required": [ - "network", - "type" - ], - "title": "ExternalPermissionProfile", - "type": "object" - } - ] - }, - "PermissionProfileFileSystemPermissions": { - "oneOf": [ - { - "properties": { - "entries": { - "items": { - "$ref": "#/definitions/FileSystemSandboxEntry" - }, - "type": "array" - }, - "globScanMaxDepth": { - "format": "uint", - "minimum": 1.0, - "type": [ - "integer", - "null" - ] - }, - "type": { - "enum": [ - "restricted" - ], - "title": "RestrictedPermissionProfileFileSystemPermissionsType", - "type": "string" - } - }, - "required": [ - "entries", - "type" - ], - "title": "RestrictedPermissionProfileFileSystemPermissions", - "type": "object" - }, - { - "properties": { - "type": { - "enum": [ - "unrestricted" - ], - "title": "UnrestrictedPermissionProfileFileSystemPermissionsType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "UnrestrictedPermissionProfileFileSystemPermissions", - "type": "object" - } - ] - }, - "PermissionProfileNetworkPermissions": { - "properties": { - "enabled": { - "type": "boolean" - } - }, - "required": [ - "enabled" - ], - "type": "object" - }, "ReasoningEffort": { "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", "enum": [ @@ -1160,13 +802,6 @@ ], "title": "WorkspaceWriteSandboxPolicyType", "type": "string" - }, - "writableRoots": { - "default": [], - "items": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": "array" } }, "required": [ @@ -2605,7 +2240,7 @@ "$ref": "#/definitions/SandboxPolicy" } ], - "description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `permissionProfile` when they need exact runtime permissions." + "description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `activePermissionProfile` and `workspaceRoots`." }, "serviceTier": { "type": [ 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 9a60049a61..99b25490ab 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartParams.json @@ -90,65 +90,6 @@ ], "type": "object" }, - "PermissionProfileModificationParams": { - "oneOf": [ - { - "description": "Additional concrete directory that should be writable.", - "properties": { - "path": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": { - "enum": [ - "additionalWritableRoot" - ], - "title": "AdditionalWritableRootPermissionProfileModificationParamsType", - "type": "string" - } - }, - "required": [ - "path", - "type" - ], - "title": "AdditionalWritableRootPermissionProfileModificationParams", - "type": "object" - } - ] - }, - "PermissionProfileSelectionParams": { - "oneOf": [ - { - "description": "Select a named built-in or user-defined profile and optionally apply bounded modifications that Codex knows how to validate.", - "properties": { - "id": { - "type": "string" - }, - "modifications": { - "items": { - "$ref": "#/definitions/PermissionProfileModificationParams" - }, - "type": [ - "array", - "null" - ] - }, - "type": { - "enum": [ - "profile" - ], - "title": "ProfilePermissionProfileSelectionParamsType", - "type": "string" - } - }, - "required": [ - "id", - "type" - ], - "title": "ProfilePermissionProfileSelectionParams", - "type": "object" - } - ] - }, "Personality": { "enum": [ "none", 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 bf03f0fb55..7c246aa92f 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json +++ b/codex-rs/app-server-protocol/schema/json/v2/ThreadStartResponse.json @@ -18,14 +18,6 @@ "id": { "description": "Identifier from `default_permissions` or the implicit built-in default, such as `:workspace` or a user-defined `[permissions.]` profile.", "type": "string" - }, - "modifications": { - "default": [], - "description": "Bounded user-requested modifications applied on top of the named profile, if any.", - "items": { - "$ref": "#/definitions/ActivePermissionProfileModification" - }, - "type": "array" } }, "required": [ @@ -33,31 +25,6 @@ ], "type": "object" }, - "ActivePermissionProfileModification": { - "oneOf": [ - { - "description": "Additional concrete directory that should be writable.", - "properties": { - "path": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": { - "enum": [ - "additionalWritableRoot" - ], - "title": "AdditionalWritableRootActivePermissionProfileModificationType", - "type": "string" - } - }, - "required": [ - "path", - "type" - ], - "title": "AdditionalWritableRootActivePermissionProfileModification", - "type": "object" - } - ] - }, "AgentPath": { "type": "string" }, @@ -503,202 +470,6 @@ ], "type": "string" }, - "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": [ - "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" - } - ] - }, "FileUpdateChange": { "properties": { "diff": { @@ -937,135 +708,6 @@ } ] }, - "PermissionProfile": { - "oneOf": [ - { - "description": "Codex owns sandbox construction for this profile.", - "properties": { - "fileSystem": { - "$ref": "#/definitions/PermissionProfileFileSystemPermissions" - }, - "network": { - "$ref": "#/definitions/PermissionProfileNetworkPermissions" - }, - "type": { - "enum": [ - "managed" - ], - "title": "ManagedPermissionProfileType", - "type": "string" - } - }, - "required": [ - "fileSystem", - "network", - "type" - ], - "title": "ManagedPermissionProfile", - "type": "object" - }, - { - "description": "Do not apply an outer sandbox.", - "properties": { - "type": { - "enum": [ - "disabled" - ], - "title": "DisabledPermissionProfileType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "DisabledPermissionProfile", - "type": "object" - }, - { - "description": "Filesystem isolation is enforced by an external caller.", - "properties": { - "network": { - "$ref": "#/definitions/PermissionProfileNetworkPermissions" - }, - "type": { - "enum": [ - "external" - ], - "title": "ExternalPermissionProfileType", - "type": "string" - } - }, - "required": [ - "network", - "type" - ], - "title": "ExternalPermissionProfile", - "type": "object" - } - ] - }, - "PermissionProfileFileSystemPermissions": { - "oneOf": [ - { - "properties": { - "entries": { - "items": { - "$ref": "#/definitions/FileSystemSandboxEntry" - }, - "type": "array" - }, - "globScanMaxDepth": { - "format": "uint", - "minimum": 1.0, - "type": [ - "integer", - "null" - ] - }, - "type": { - "enum": [ - "restricted" - ], - "title": "RestrictedPermissionProfileFileSystemPermissionsType", - "type": "string" - } - }, - "required": [ - "entries", - "type" - ], - "title": "RestrictedPermissionProfileFileSystemPermissions", - "type": "object" - }, - { - "properties": { - "type": { - "enum": [ - "unrestricted" - ], - "title": "UnrestrictedPermissionProfileFileSystemPermissionsType", - "type": "string" - } - }, - "required": [ - "type" - ], - "title": "UnrestrictedPermissionProfileFileSystemPermissions", - "type": "object" - } - ] - }, - "PermissionProfileNetworkPermissions": { - "properties": { - "enabled": { - "type": "boolean" - } - }, - "required": [ - "enabled" - ], - "type": "object" - }, "ReasoningEffort": { "description": "See https://platform.openai.com/docs/guides/reasoning?api-mode=responses#get-started-with-reasoning", "enum": [ @@ -1160,13 +802,6 @@ ], "title": "WorkspaceWriteSandboxPolicyType", "type": "string" - }, - "writableRoots": { - "default": [], - "items": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": "array" } }, "required": [ @@ -2605,7 +2240,7 @@ "$ref": "#/definitions/SandboxPolicy" } ], - "description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `permissionProfile` when they need exact runtime permissions." + "description": "Legacy sandbox policy retained for compatibility. Experimental clients should prefer `activePermissionProfile` and `workspaceRoots`." }, "serviceTier": { "type": [ 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 1ef33d4301..7d8ba1e0b4 100644 --- a/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json +++ b/codex-rs/app-server-protocol/schema/json/v2/TurnStartParams.json @@ -114,65 +114,6 @@ ], "type": "string" }, - "PermissionProfileModificationParams": { - "oneOf": [ - { - "description": "Additional concrete directory that should be writable.", - "properties": { - "path": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": { - "enum": [ - "additionalWritableRoot" - ], - "title": "AdditionalWritableRootPermissionProfileModificationParamsType", - "type": "string" - } - }, - "required": [ - "path", - "type" - ], - "title": "AdditionalWritableRootPermissionProfileModificationParams", - "type": "object" - } - ] - }, - "PermissionProfileSelectionParams": { - "oneOf": [ - { - "description": "Select a named built-in or user-defined profile and optionally apply bounded modifications that Codex knows how to validate.", - "properties": { - "id": { - "type": "string" - }, - "modifications": { - "items": { - "$ref": "#/definitions/PermissionProfileModificationParams" - }, - "type": [ - "array", - "null" - ] - }, - "type": { - "enum": [ - "profile" - ], - "title": "ProfilePermissionProfileSelectionParamsType", - "type": "string" - } - }, - "required": [ - "id", - "type" - ], - "title": "ProfilePermissionProfileSelectionParams", - "type": "object" - } - ] - }, "Personality": { "enum": [ "none", @@ -295,13 +236,6 @@ ], "title": "WorkspaceWriteSandboxPolicyType", "type": "string" - }, - "writableRoots": { - "default": [], - "items": { - "$ref": "#/definitions/AbsolutePathBuf" - }, - "type": "array" } }, "required": [ @@ -576,7 +510,7 @@ "type": "null" } ], - "description": "Override the sandbox policy for this turn and subsequent turns." + "description": "Deprecated for turns. When present, the server treats this as a compatibility spelling for selecting a matching named permissions profile." }, "serviceTier": { "description": "Override the service tier for this turn and subsequent turns.", diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ActivePermissionProfile.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ActivePermissionProfile.ts index cbc8c6ef0a..73f9efcab5 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ActivePermissionProfile.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ActivePermissionProfile.ts @@ -1,7 +1,6 @@ // GENERATED CODE! DO NOT MODIFY BY HAND! // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { ActivePermissionProfileModification } from "./ActivePermissionProfileModification"; export type ActivePermissionProfile = { /** @@ -13,9 +12,4 @@ id: string, * Parent profile identifier once permissions profiles support * inheritance. This is currently always `null`. */ -extends: string | null, -/** - * Bounded user-requested modifications applied on top of the named - * profile, if any. - */ -modifications: Array, }; +extends: string | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ActivePermissionProfileModification.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ActivePermissionProfileModification.ts deleted file mode 100644 index 1cbee6868a..0000000000 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ActivePermissionProfileModification.ts +++ /dev/null @@ -1,6 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { AbsolutePathBuf } from "../AbsolutePathBuf"; - -export type ActivePermissionProfileModification = { "type": "additionalWritableRoot", path: AbsolutePathBuf, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfileModificationParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfileModificationParams.ts deleted file mode 100644 index c619edcea8..0000000000 --- a/codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfileModificationParams.ts +++ /dev/null @@ -1,6 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { AbsolutePathBuf } from "../AbsolutePathBuf"; - -export type PermissionProfileModificationParams = { "type": "additionalWritableRoot", path: AbsolutePathBuf, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfileSelectionParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfileSelectionParams.ts deleted file mode 100644 index a415bd0028..0000000000 --- a/codex-rs/app-server-protocol/schema/typescript/v2/PermissionProfileSelectionParams.ts +++ /dev/null @@ -1,6 +0,0 @@ -// GENERATED CODE! DO NOT MODIFY BY HAND! - -// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { PermissionProfileModificationParams } from "./PermissionProfileModificationParams"; - -export type PermissionProfileSelectionParams = { "type": "profile", id: string, modifications?: Array | null, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/SandboxPolicy.ts b/codex-rs/app-server-protocol/schema/typescript/v2/SandboxPolicy.ts index 5575701ff2..1715d7710e 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/SandboxPolicy.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/SandboxPolicy.ts @@ -1,7 +1,6 @@ // GENERATED CODE! DO NOT MODIFY BY HAND! // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. -import type { AbsolutePathBuf } from "../AbsolutePathBuf"; import type { NetworkAccess } from "./NetworkAccess"; -export type SandboxPolicy = { "type": "dangerFullAccess" } | { "type": "readOnly", networkAccess: boolean, } | { "type": "externalSandbox", networkAccess: NetworkAccess, } | { "type": "workspaceWrite", writableRoots: Array, networkAccess: boolean, excludeTmpdirEnvVar: boolean, excludeSlashTmp: boolean, }; +export type SandboxPolicy = { "type": "dangerFullAccess" } | { "type": "readOnly", networkAccess: boolean, } | { "type": "externalSandbox", networkAccess: NetworkAccess, } | { "type": "workspaceWrite", networkAccess: boolean, excludeTmpdirEnvVar: boolean, excludeSlashTmp: boolean, }; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkParams.ts index 6076a4bb14..167a6f1465 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkParams.ts @@ -23,7 +23,12 @@ model?: string | null, modelProvider?: string | null, serviceTier?: string | nul * Override where approval requests are routed for review on this thread * and subsequent turns. */ -approvalsReviewer?: ApprovalsReviewer | null, sandbox?: SandboxMode | null, config?: { [key in string]?: JsonValue } | null, baseInstructions?: string | null, developerInstructions?: string | null, ephemeral?: boolean, /** +approvalsReviewer?: ApprovalsReviewer | null, /** + * Deprecated for fork. When present, the server treats this as a + * compatibility spelling for selecting a matching named permissions + * profile. + */ +sandbox?: SandboxMode | null, config?: { [key in string]?: JsonValue } | null, baseInstructions?: string | null, developerInstructions?: string | null, ephemeral?: boolean, /** * Optional client-supplied analytics source classification for this forked thread. */ threadSource?: ThreadSource | null}; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkResponse.ts index c44533ec1a..acada51444 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkResponse.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadForkResponse.ts @@ -16,7 +16,6 @@ instructionSources: Array, approvalPolicy: AskForApproval, /** */ approvalsReviewer: ApprovalsReviewer, /** * Legacy sandbox policy retained for compatibility. Experimental clients - * should prefer `permissionProfile` when they need exact runtime - * permissions. + * should prefer `activePermissionProfile` and `workspaceRoots`. */ sandbox: SandboxPolicy, reasoningEffort: ReasoningEffort | null}; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeParams.ts index 6d1dbdca4f..feae3906ba 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeParams.ts @@ -25,4 +25,9 @@ model?: string | null, modelProvider?: string | null, serviceTier?: string | nul * Override where approval requests are routed for review on this thread * and subsequent turns. */ -approvalsReviewer?: ApprovalsReviewer | null, sandbox?: SandboxMode | null, config?: { [key in string]?: JsonValue } | null, baseInstructions?: string | null, developerInstructions?: string | null, personality?: Personality | null}; +approvalsReviewer?: ApprovalsReviewer | null, /** + * Deprecated for resume. When present, the server treats this as a + * compatibility spelling for selecting a matching named permissions + * profile. + */ +sandbox?: SandboxMode | null, config?: { [key in string]?: JsonValue } | null, baseInstructions?: string | null, developerInstructions?: string | null, personality?: Personality | null}; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeResponse.ts index f91756c7c6..fc9a2008f3 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeResponse.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadResumeResponse.ts @@ -16,7 +16,6 @@ instructionSources: Array, approvalPolicy: AskForApproval, /** */ approvalsReviewer: ApprovalsReviewer, /** * Legacy sandbox policy retained for compatibility. Experimental clients - * should prefer `permissionProfile` when they need exact runtime - * permissions. + * should prefer `activePermissionProfile` and `workspaceRoots`. */ sandbox: SandboxPolicy, reasoningEffort: ReasoningEffort | null}; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadStartResponse.ts b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadStartResponse.ts index 9573bd7dee..ea219decbd 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/ThreadStartResponse.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/ThreadStartResponse.ts @@ -16,7 +16,6 @@ instructionSources: Array, approvalPolicy: AskForApproval, /** */ approvalsReviewer: ApprovalsReviewer, /** * Legacy sandbox policy retained for compatibility. Experimental clients - * should prefer `permissionProfile` when they need exact runtime - * permissions. + * should prefer `activePermissionProfile` and `workspaceRoots`. */ sandbox: SandboxPolicy, reasoningEffort: ReasoningEffort | null}; diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts b/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts index b04919d86b..e6daf0b557 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/TurnStartParams.ts @@ -21,7 +21,9 @@ approvalPolicy?: AskForApproval | null, /** * subsequent turns. */ approvalsReviewer?: ApprovalsReviewer | null, /** - * Override the sandbox policy for this turn and subsequent turns. + * Deprecated for turns. When present, the server treats this as a + * compatibility spelling for selecting a matching named permissions + * profile. */ sandboxPolicy?: SandboxPolicy | null, /** * Override the model for this turn and subsequent turns. diff --git a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts index a6b961366e..de91837849 100644 --- a/codex-rs/app-server-protocol/schema/typescript/v2/index.ts +++ b/codex-rs/app-server-protocol/schema/typescript/v2/index.ts @@ -5,7 +5,6 @@ export type { AccountLoginCompletedNotification } from "./AccountLoginCompletedN export type { AccountRateLimitsUpdatedNotification } from "./AccountRateLimitsUpdatedNotification"; export type { AccountUpdatedNotification } from "./AccountUpdatedNotification"; export type { ActivePermissionProfile } from "./ActivePermissionProfile"; -export type { ActivePermissionProfileModification } from "./ActivePermissionProfileModification"; export type { AddCreditsNudgeCreditType } from "./AddCreditsNudgeCreditType"; export type { AddCreditsNudgeEmailStatus } from "./AddCreditsNudgeEmailStatus"; export type { AdditionalFileSystemPermissions } from "./AdditionalFileSystemPermissions"; @@ -257,9 +256,7 @@ export type { PatchChangeKind } from "./PatchChangeKind"; export type { PermissionGrantScope } from "./PermissionGrantScope"; export type { PermissionProfile } from "./PermissionProfile"; export type { PermissionProfileFileSystemPermissions } from "./PermissionProfileFileSystemPermissions"; -export type { PermissionProfileModificationParams } from "./PermissionProfileModificationParams"; export type { PermissionProfileNetworkPermissions } from "./PermissionProfileNetworkPermissions"; -export type { PermissionProfileSelectionParams } from "./PermissionProfileSelectionParams"; export type { PermissionsRequestApprovalParams } from "./PermissionsRequestApprovalParams"; export type { PermissionsRequestApprovalResponse } from "./PermissionsRequestApprovalResponse"; export type { PlanDeltaNotification } from "./PlanDeltaNotification"; diff --git a/codex-rs/app-server-protocol/src/protocol/common.rs b/codex-rs/app-server-protocol/src/protocol/common.rs index ae00b08b73..636f37f95e 100644 --- a/codex-rs/app-server-protocol/src/protocol/common.rs +++ b/codex-rs/app-server-protocol/src/protocol/common.rs @@ -2272,11 +2272,11 @@ mod tests { model_provider: "openai".to_string(), service_tier: None, cwd, + workspace_roots: vec![], instruction_sources: vec![absolute_path("/tmp/AGENTS.md")], approval_policy: v2::AskForApproval::OnFailure, approvals_reviewer: v2::ApprovalsReviewer::User, sandbox: v2::SandboxPolicy::DangerFullAccess, - permission_profile: None, active_permission_profile: None, reasoning_effort: None, }, @@ -2316,13 +2316,13 @@ mod tests { "modelProvider": "openai", "serviceTier": null, "cwd": absolute_path_string("tmp"), + "workspaceRoots": [], "instructionSources": [absolute_path_string("tmp/AGENTS.md")], "approvalPolicy": "on-failure", "approvalsReviewer": "user", "sandbox": { "type": "dangerFullAccess" }, - "permissionProfile": null, "activePermissionProfile": null, "reasoningEffort": null } diff --git a/codex-rs/app-server-protocol/src/protocol/v2/permissions.rs b/codex-rs/app-server-protocol/src/protocol/v2/permissions.rs index 86614a6aeb..919b2542cd 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/permissions.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/permissions.rs @@ -5,7 +5,6 @@ use codex_protocol::approvals::NetworkApprovalProtocol as CoreNetworkApprovalPro use codex_protocol::approvals::NetworkPolicyAmendment as CoreNetworkPolicyAmendment; use codex_protocol::approvals::NetworkPolicyRuleAction as CoreNetworkPolicyRuleAction; use codex_protocol::models::ActivePermissionProfile as CoreActivePermissionProfile; -use codex_protocol::models::ActivePermissionProfileModification as CoreActivePermissionProfileModification; use codex_protocol::models::AdditionalPermissionProfile as CoreAdditionalPermissionProfile; use codex_protocol::models::FileSystemPermissions as CoreFileSystemPermissions; use codex_protocol::models::ManagedFileSystemPermissions as CoreManagedFileSystemPermissions; @@ -437,41 +436,6 @@ pub struct ActivePermissionProfile { /// inheritance. This is currently always `null`. #[serde(default)] pub extends: Option, - /// Bounded user-requested modifications applied on top of the named - /// profile, if any. - #[serde(default)] - pub modifications: Vec, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(tag = "type", rename_all = "camelCase")] -#[ts(tag = "type")] -#[ts(export_to = "v2/")] -pub enum ActivePermissionProfileModification { - /// Additional concrete directory that should be writable. - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - AdditionalWritableRoot { path: AbsolutePathBuf }, -} - -impl From for ActivePermissionProfileModification { - fn from(value: CoreActivePermissionProfileModification) -> Self { - match value { - CoreActivePermissionProfileModification::AdditionalWritableRoot { path } => { - Self::AdditionalWritableRoot { path } - } - } - } -} - -impl From for CoreActivePermissionProfileModification { - fn from(value: ActivePermissionProfileModification) -> Self { - match value { - ActivePermissionProfileModification::AdditionalWritableRoot { path } => { - Self::AdditionalWritableRoot { path } - } - } - } } impl From for ActivePermissionProfile { @@ -479,11 +443,6 @@ impl From for ActivePermissionProfile { Self { id: value.id, extends: value.extends, - modifications: value - .modifications - .into_iter() - .map(ActivePermissionProfileModification::from) - .collect(), } } } @@ -493,42 +452,10 @@ impl From for CoreActivePermissionProfile { Self { id: value.id, extends: value.extends, - modifications: value - .modifications - .into_iter() - .map(CoreActivePermissionProfileModification::from) - .collect(), } } } -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(tag = "type", rename_all = "camelCase")] -#[ts(tag = "type")] -#[ts(export_to = "v2/")] -pub enum PermissionProfileSelectionParams { - /// Select a named built-in or user-defined profile and optionally apply - /// bounded modifications that Codex knows how to validate. - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - Profile { - id: String, - #[ts(optional = nullable)] - modifications: Option>, - }, -} - -#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] -#[serde(tag = "type", rename_all = "camelCase")] -#[ts(tag = "type")] -#[ts(export_to = "v2/")] -pub enum PermissionProfileModificationParams { - /// Additional concrete directory that should be writable. - #[serde(rename_all = "camelCase")] - #[ts(rename_all = "camelCase")] - AdditionalWritableRoot { path: AbsolutePathBuf }, -} - #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)] #[serde(rename_all = "camelCase")] #[ts(export_to = "v2/")] @@ -607,14 +534,16 @@ pub enum SandboxPolicy { #[serde(rename_all = "camelCase")] #[ts(rename_all = "camelCase")] WorkspaceWrite { - #[serde(default)] - writable_roots: Vec, #[serde(default)] network_access: bool, #[serde(default)] exclude_tmpdir_env_var: bool, #[serde(default)] exclude_slash_tmp: bool, + #[serde(default, skip_serializing)] + #[schemars(skip)] + #[ts(skip)] + legacy_writable_roots: Vec, }, } @@ -690,10 +619,10 @@ impl<'de> Deserialize<'de> for SandboxPolicy { )); } Ok(SandboxPolicy::WorkspaceWrite { - writable_roots, network_access, exclude_tmpdir_env_var, exclude_slash_tmp, + legacy_writable_roots: writable_roots, }) } } @@ -720,18 +649,60 @@ impl SandboxPolicy { } } SandboxPolicy::WorkspaceWrite { - writable_roots, network_access, exclude_tmpdir_env_var, exclude_slash_tmp, + legacy_writable_roots: _, } => codex_protocol::protocol::SandboxPolicy::WorkspaceWrite { - writable_roots: writable_roots.clone(), network_access: *network_access, exclude_tmpdir_env_var: *exclude_tmpdir_env_var, exclude_slash_tmp: *exclude_slash_tmp, }, } } + + pub fn legacy_writable_roots(&self) -> &[AbsolutePathBuf] { + match self { + SandboxPolicy::WorkspaceWrite { + legacy_writable_roots, + .. + } => legacy_writable_roots, + SandboxPolicy::DangerFullAccess + | SandboxPolicy::ReadOnly { .. } + | SandboxPolicy::ExternalSandbox { .. } => &[], + } + } + + pub fn to_permission_profile_for_cwd(&self, cwd: &std::path::Path) -> CorePermissionProfile { + match self { + SandboxPolicy::WorkspaceWrite { + legacy_writable_roots, + .. + } if legacy_writable_roots.is_empty() => { + CorePermissionProfile::from_legacy_sandbox_policy_for_cwd(&self.to_core(), cwd) + } + SandboxPolicy::WorkspaceWrite { + network_access, + exclude_tmpdir_env_var, + exclude_slash_tmp, + legacy_writable_roots, + } => CorePermissionProfile::workspace_write_with( + legacy_writable_roots, + if *network_access { + CoreNetworkSandboxPolicy::Enabled + } else { + CoreNetworkSandboxPolicy::Restricted + }, + *exclude_tmpdir_env_var, + *exclude_slash_tmp, + ), + SandboxPolicy::DangerFullAccess + | SandboxPolicy::ReadOnly { .. } + | SandboxPolicy::ExternalSandbox { .. } => { + CorePermissionProfile::from_legacy_sandbox_policy_for_cwd(&self.to_core(), cwd) + } + } + } } impl From for SandboxPolicy { @@ -752,15 +723,14 @@ impl From for SandboxPolicy { } } codex_protocol::protocol::SandboxPolicy::WorkspaceWrite { - writable_roots, network_access, exclude_tmpdir_env_var, exclude_slash_tmp, } => SandboxPolicy::WorkspaceWrite { - writable_roots, network_access, exclude_tmpdir_env_var, exclude_slash_tmp, + legacy_writable_roots: Vec::new(), }, } } diff --git a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs index 73b22cecb4..7091e1808d 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs @@ -2019,17 +2019,16 @@ fn mcp_server_elicitation_response_serializes_nullable_content() { #[test] fn sandbox_policy_round_trips_workspace_write_access() { let v2_policy = SandboxPolicy::WorkspaceWrite { - writable_roots: vec![], network_access: true, exclude_tmpdir_env_var: false, exclude_slash_tmp: false, + legacy_writable_roots: Vec::new(), }; let core_policy = v2_policy.to_core(); assert_eq!( core_policy, codex_protocol::protocol::SandboxPolicy::WorkspaceWrite { - writable_roots: vec![], network_access: true, exclude_tmpdir_env_var: false, exclude_slash_tmp: false, @@ -2060,10 +2059,9 @@ fn sandbox_policy_deserializes_legacy_read_only_full_access_field() { #[test] fn sandbox_policy_deserializes_legacy_workspace_write_full_access_field() { - let writable_root = absolute_path("/workspace"); let policy = serde_json::from_value::(json!({ "type": "workspaceWrite", - "writableRoots": [writable_root], + "writableRoots": [], "readOnlyAccess": { "type": "fullAccess" }, @@ -2075,14 +2073,38 @@ fn sandbox_policy_deserializes_legacy_workspace_write_full_access_field() { assert_eq!( policy, SandboxPolicy::WorkspaceWrite { - writable_roots: vec![absolute_path("/workspace")], network_access: true, exclude_tmpdir_env_var: true, exclude_slash_tmp: true, + legacy_writable_roots: Vec::new(), } ); } +#[test] +fn sandbox_policy_deserializes_legacy_workspace_write_writable_roots_field() { + let writable_root = absolute_path("/workspace"); + let policy = serde_json::from_value::(json!({ + "type": "workspaceWrite", + "writableRoots": [writable_root], + "networkAccess": false, + "excludeTmpdirEnvVar": false, + "excludeSlashTmp": false + })) + .expect("workspace-write policy should accept legacy writableRoots field"); + assert_eq!(policy.legacy_writable_roots(), &[writable_root]); + + assert_eq!( + serde_json::to_value(policy).expect("policy should serialize"), + json!({ + "type": "workspaceWrite", + "networkAccess": false, + "excludeTmpdirEnvVar": false, + "excludeSlashTmp": false + }) + ); +} + #[test] fn sandbox_policy_rejects_legacy_read_only_restricted_access_field() { let err = serde_json::from_value::(json!({ @@ -3389,9 +3411,6 @@ fn thread_lifecycle_responses_default_missing_optional_fields() { assert_eq!(start.instruction_sources, Vec::::new()); assert_eq!(resume.instruction_sources, Vec::::new()); assert_eq!(fork.instruction_sources, Vec::::new()); - assert_eq!(start.permission_profile, None); - assert_eq!(resume.permission_profile, None); - assert_eq!(fork.permission_profile, None); assert_eq!(start.active_permission_profile, None); assert_eq!(resume.active_permission_profile, None); assert_eq!(fork.active_permission_profile, None); @@ -3419,6 +3438,7 @@ fn turn_start_params_preserve_explicit_null_service_tier() { responsesapi_client_metadata: None, environments: None, cwd: None, + workspace_roots: None, approval_policy: None, approvals_reviewer: None, sandbox_policy: None, @@ -3436,6 +3456,20 @@ fn turn_start_params_preserve_explicit_null_service_tier() { assert_eq!(serialized_without_override.get("serviceTier"), None); } +#[test] +fn turn_start_permissions_uses_profile_id_string_shape() { + let params: TurnStartParams = serde_json::from_value(json!({ + "threadId": "thread-1", + "input": [], + "permissions": ":workspace" + })) + .expect("turn start params should deserialize"); + assert_eq!(params.permissions, Some(":workspace".to_string())); + + let serialized = serde_json::to_value(¶ms).expect("params should serialize"); + assert_eq!(serialized["permissions"], json!(":workspace")); +} + #[test] fn turn_start_params_round_trip_environments() { let cwd = test_absolute_path(); diff --git a/codex-rs/app-server-protocol/src/protocol/v2/thread.rs b/codex-rs/app-server-protocol/src/protocol/v2/thread.rs index 458722b3a2..726ec0ac80 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/thread.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/thread.rs @@ -1,8 +1,6 @@ use super::ActivePermissionProfile; use super::ApprovalsReviewer; use super::AskForApproval; -use super::PermissionProfile; -use super::PermissionProfileSelectionParams; use super::SandboxMode; use super::SandboxPolicy; use super::Thread; @@ -107,6 +105,11 @@ pub struct ThreadStartParams { pub service_tier: Option>, #[ts(optional = nullable)] pub cwd: Option, + /// Optional workspace roots for this thread. Omitted uses the server's + /// configured roots, usually seeded from `cwd`. + #[experimental("thread/start.workspaceRoots")] + #[ts(optional = nullable)] + pub workspace_roots: Option>, #[experimental(nested)] #[ts(optional = nullable)] pub approval_policy: Option, @@ -116,12 +119,11 @@ pub struct ThreadStartParams { pub approvals_reviewer: Option, #[ts(optional = nullable)] pub sandbox: Option, - /// Named profile selection for this thread. Cannot be combined with - /// `sandbox`. Use bounded `modifications` for supported turn/thread - /// adjustments instead of replacing the full permissions profile. + /// Named permissions profile id for this new thread's initial permissions. + /// Cannot be combined with `sandbox`. #[experimental("thread/start.permissions")] #[ts(optional = nullable)] - pub permissions: Option, + pub permissions: Option, #[ts(optional = nullable)] pub config: Option>, #[ts(optional = nullable)] @@ -195,6 +197,11 @@ pub struct ThreadStartResponse { pub model_provider: String, pub service_tier: Option, pub cwd: AbsolutePathBuf, + /// Workspace roots used to realize symbolic `:project_roots` permission + /// entries for this thread. + #[experimental("thread/start.workspaceRoots")] + #[serde(default)] + pub workspace_roots: Vec, /// Instruction source files currently loaded for this thread. #[serde(default)] pub instruction_sources: Vec, @@ -203,14 +210,8 @@ pub struct ThreadStartResponse { /// Reviewer currently used for approval requests on this thread. pub approvals_reviewer: ApprovalsReviewer, /// Legacy sandbox policy retained for compatibility. Experimental clients - /// should prefer `permissionProfile` when they need exact runtime - /// permissions. + /// should prefer `activePermissionProfile` and `workspaceRoots`. pub sandbox: SandboxPolicy, - /// Full active permissions for this thread. `activePermissionProfile` - /// carries display/provenance metadata for this runtime profile. - #[experimental("thread/start.permissionProfile")] - #[serde(default)] - pub permission_profile: Option, /// Named or implicit built-in profile that produced the active /// permissions, when known. #[experimental("thread/start.activePermissionProfile")] @@ -264,6 +265,11 @@ pub struct ThreadResumeParams { pub service_tier: Option>, #[ts(optional = nullable)] pub cwd: Option, + /// Optional replacement workspace roots for the resumed thread. Omitted + /// preserves the persisted or configured roots. + #[experimental("thread/resume.workspaceRoots")] + #[ts(optional = nullable)] + pub workspace_roots: Option>, #[experimental(nested)] #[ts(optional = nullable)] pub approval_policy: Option, @@ -271,14 +277,16 @@ pub struct ThreadResumeParams { /// and subsequent turns. #[ts(optional = nullable)] pub approvals_reviewer: Option, + /// Deprecated for resume. When present, the server treats this as a + /// compatibility spelling for selecting a matching named permissions + /// profile. #[ts(optional = nullable)] pub sandbox: Option, - /// Named profile selection for the resumed thread. Cannot be combined - /// with `sandbox`. Use bounded `modifications` for supported thread - /// adjustments instead of replacing the full permissions profile. + /// Named permissions profile id for the resumed thread. Cannot be combined + /// with `sandbox`. #[experimental("thread/resume.permissions")] #[ts(optional = nullable)] - pub permissions: Option, + pub permissions: Option, #[ts(optional = nullable)] pub config: Option>, #[ts(optional = nullable)] @@ -310,6 +318,11 @@ pub struct ThreadResumeResponse { pub model_provider: String, pub service_tier: Option, pub cwd: AbsolutePathBuf, + /// Workspace roots used to realize symbolic `:project_roots` permission + /// entries for this thread. + #[experimental("thread/resume.workspaceRoots")] + #[serde(default)] + pub workspace_roots: Vec, /// Instruction source files currently loaded for this thread. #[serde(default)] pub instruction_sources: Vec, @@ -318,14 +331,8 @@ pub struct ThreadResumeResponse { /// Reviewer currently used for approval requests on this thread. pub approvals_reviewer: ApprovalsReviewer, /// Legacy sandbox policy retained for compatibility. Experimental clients - /// should prefer `permissionProfile` when they need exact runtime - /// permissions. + /// should prefer `activePermissionProfile` and `workspaceRoots`. pub sandbox: SandboxPolicy, - /// Full active permissions for this thread. `activePermissionProfile` - /// carries display/provenance metadata for this runtime profile. - #[experimental("thread/resume.permissionProfile")] - #[serde(default)] - pub permission_profile: Option, /// Named or implicit built-in profile that produced the active /// permissions, when known. #[experimental("thread/resume.activePermissionProfile")] @@ -370,6 +377,11 @@ pub struct ThreadForkParams { pub service_tier: Option>, #[ts(optional = nullable)] pub cwd: Option, + /// Optional replacement workspace roots for the forked thread. Omitted + /// preserves the source thread roots when available. + #[experimental("thread/fork.workspaceRoots")] + #[ts(optional = nullable)] + pub workspace_roots: Option>, #[experimental(nested)] #[ts(optional = nullable)] pub approval_policy: Option, @@ -377,14 +389,16 @@ pub struct ThreadForkParams { /// and subsequent turns. #[ts(optional = nullable)] pub approvals_reviewer: Option, + /// Deprecated for fork. When present, the server treats this as a + /// compatibility spelling for selecting a matching named permissions + /// profile. #[ts(optional = nullable)] pub sandbox: Option, - /// Named profile selection for the forked thread. Cannot be combined with - /// `sandbox`. Use bounded `modifications` for supported thread - /// adjustments instead of replacing the full permissions profile. + /// Named permissions profile id for the forked thread. Cannot be combined + /// with `sandbox`. #[experimental("thread/fork.permissions")] #[ts(optional = nullable)] - pub permissions: Option, + pub permissions: Option, #[ts(optional = nullable)] pub config: Option>, #[ts(optional = nullable)] @@ -419,6 +433,11 @@ pub struct ThreadForkResponse { pub model_provider: String, pub service_tier: Option, pub cwd: AbsolutePathBuf, + /// Workspace roots used to realize symbolic `:project_roots` permission + /// entries for this thread. + #[experimental("thread/fork.workspaceRoots")] + #[serde(default)] + pub workspace_roots: Vec, /// Instruction source files currently loaded for this thread. #[serde(default)] pub instruction_sources: Vec, @@ -427,14 +446,8 @@ pub struct ThreadForkResponse { /// Reviewer currently used for approval requests on this thread. pub approvals_reviewer: ApprovalsReviewer, /// Legacy sandbox policy retained for compatibility. Experimental clients - /// should prefer `permissionProfile` when they need exact runtime - /// permissions. + /// should prefer `activePermissionProfile` and `workspaceRoots`. pub sandbox: SandboxPolicy, - /// Full active permissions for this thread. `activePermissionProfile` - /// carries display/provenance metadata for this runtime profile. - #[experimental("thread/fork.permissionProfile")] - #[serde(default)] - pub permission_profile: Option, /// Named or implicit built-in profile that produced the active /// permissions, when known. #[experimental("thread/fork.activePermissionProfile")] diff --git a/codex-rs/app-server-protocol/src/protocol/v2/turn.rs b/codex-rs/app-server-protocol/src/protocol/v2/turn.rs index 61a09bfbf5..7dff5363c9 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2/turn.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2/turn.rs @@ -1,6 +1,5 @@ use super::ApprovalsReviewer; use super::AskForApproval; -use super::PermissionProfileSelectionParams; use super::SandboxPolicy; use super::Turn; use codex_experimental_api_macros::ExperimentalApi; @@ -64,6 +63,11 @@ pub struct TurnStartParams { /// Override the working directory for this turn and subsequent turns. #[ts(optional = nullable)] pub cwd: Option, + /// Replace the workspace roots for this turn and subsequent turns. Omitted + /// preserves the current roots. + #[experimental("turn/start.workspaceRoots")] + #[ts(optional = nullable)] + pub workspace_roots: Option>, /// Override the approval policy for this turn and subsequent turns. #[experimental(nested)] #[ts(optional = nullable)] @@ -72,16 +76,16 @@ pub struct TurnStartParams { /// subsequent turns. #[ts(optional = nullable)] pub approvals_reviewer: Option, - /// Override the sandbox policy for this turn and subsequent turns. + /// Deprecated for turns. When present, the server treats this as a + /// compatibility spelling for selecting a matching named permissions + /// profile. #[ts(optional = nullable)] pub sandbox_policy: Option, - /// Select a named permissions profile for this turn and subsequent turns. - /// Cannot be combined with `sandboxPolicy`. Use bounded `modifications` - /// for supported turn adjustments instead of replacing the full - /// permissions profile. + /// Select a named permissions profile id for this turn and subsequent + /// turns. Cannot be combined with `sandboxPolicy`. #[experimental("turn/start.permissions")] #[ts(optional = nullable)] - pub permissions: Option, + pub permissions: Option, /// Override the model for this turn and subsequent turns. #[ts(optional = nullable)] pub model: Option, diff --git a/codex-rs/app-server/README.md b/codex-rs/app-server/README.md index c167627257..b05a06938c 100644 --- a/codex-rs/app-server/README.md +++ b/codex-rs/app-server/README.md @@ -67,7 +67,7 @@ Use the thread APIs to create, list, or archive conversations. Drive a conversat - Initialize once per connection: Immediately after opening a transport connection, send an `initialize` request with your client metadata, then emit an `initialized` notification. Any other request on that connection before this handshake gets rejected. - Start (or resume) a thread: Call `thread/start` to open a fresh conversation. The response returns the thread object and you’ll also get a `thread/started` notification. If you’re continuing an existing conversation, call `thread/resume` with its ID instead. If you want to branch from an existing conversation, call `thread/fork` to create a new thread id with copied history. Like `thread/start`, `thread/fork` also accepts `ephemeral: true` for an in-memory temporary thread. The returned `thread.ephemeral` flag tells you whether the session is intentionally in-memory only; when it is `true`, `thread.path` is `null`. -- Begin a turn: To send user input, call `turn/start` with the target `threadId` and the user's input. Optional fields let you override model, cwd, sandbox policy or experimental `permissions` profile selection, approval policy, approvals reviewer, etc. This immediately returns the new turn object. The app-server emits `turn/started` when that turn actually begins running. +- Begin a turn: To send user input, call `turn/start` with the target `threadId` and the user's input. Optional fields let you override model, cwd, workspace roots, approval policy, approvals reviewer, and the active permission profile name. The permission profile value associated with the thread is not mutable through `turn/start`. This immediately returns the new turn object. The app-server emits `turn/started` when that turn actually begins running. - Stream events: After `turn/start`, keep reading JSON-RPC notifications on stdout. You’ll see `item/started`, `item/completed`, deltas like `item/agentMessage/delta`, tool progress, etc. These represent streaming model output plus any side effects (commands, tool calls, reasoning notes). - Finish the turn: When the model is done (or the turn is interrupted via making the `turn/interrupt` call), the server sends `turn/completed` with the final turn state and token usage. @@ -122,9 +122,9 @@ Example with notification opt-out: ## API Overview - `thread/start` — create a new thread; emits `thread/started` (including the current `thread.status`) and auto-subscribes you to turn/item events for that thread. When the request includes a `cwd` and the resolved sandbox is `workspace-write` or full access, app-server also marks that project as trusted in the user `config.toml`. Pass `sessionStartSource: "clear"` when starting a replacement thread after clearing the current session so `SessionStart` hooks receive `source: "clear"` instead of the default `"startup"`. For permissions, prefer experimental `permissions` profile selection; the legacy `sandbox` shorthand is still accepted but cannot be combined with `permissions`. Experimental `environments` selects the sticky execution environments for turns on the thread; omit it to use the server default, pass `[]` to disable environments, or pass explicit environment ids with per-environment `cwd`. -- `thread/resume` — reopen an existing thread by id so subsequent `turn/start` calls append to it. Accepts the same permission override rules as `thread/start`. -- `thread/fork` — fork an existing thread into a new thread id by copying the stored history; if the source thread is currently mid-turn, the fork records the same interruption marker as `turn/interrupt` instead of inheriting an unmarked partial turn suffix. The returned `thread.forkedFromId` points at the source thread when known. Accepts `ephemeral: true` for an in-memory temporary fork, emits `thread/started` (including the current `thread.status`), and auto-subscribes you to turn/item events for the new thread. Experimental clients can pass `excludeTurns: true` when they plan to page fork history via `thread/turns/list` instead of receiving the full turn array immediately. Accepts the same permission override rules as `thread/start`. -- `thread/start`, `thread/resume`, and `thread/fork` responses include the legacy `sandbox` compatibility projection. Experimental clients can read response `permissionProfile` for the exact active runtime permissions and `activePermissionProfile` for the named or implicit built-in profile identity/provenance when known. +- `thread/resume` — reopen an existing thread by id so subsequent `turn/start` calls append to it. Omitted permission fields preserve the stored permission profile; clients may replace `workspaceRoots` or select a server-defined profile by id with `permissions`, but they cannot send arbitrary `PermissionProfile` bodies. The legacy `sandbox` shorthand is accepted as a compatibility spelling only when it maps to a named permissions profile and cannot be combined with `permissions`. +- `thread/fork` — fork an existing thread into a new thread id by copying the stored history; if the source thread is currently mid-turn, the fork records the same interruption marker as `turn/interrupt` instead of inheriting an unmarked partial turn suffix. The returned `thread.forkedFromId` points at the source thread when known. Accepts `ephemeral: true` for an in-memory temporary fork, emits `thread/started` (including the current `thread.status`), and auto-subscribes you to turn/item events for the new thread. Experimental clients can pass `excludeTurns: true` when they plan to page fork history via `thread/turns/list` instead of receiving the full turn array immediately. Like resume, omitted permission fields preserve the source permission profile while allowing explicit `workspaceRoots`, named `permissions` profile selection, or legacy `sandbox` compatibility selection. +- `thread/start`, `thread/resume`, and `thread/fork` responses include the legacy `sandbox` compatibility projection. Experimental clients can read response `activePermissionProfile` for the named or implicit built-in profile identity/provenance when known; the full `PermissionProfile` value is not exposed by thread lifecycle responses. - `thread/list` — page through stored rollouts; supports cursor-based pagination and optional `modelProviders`, `sourceKinds`, `archived`, `cwd`, and `searchTerm` filters. Each returned `thread` includes `status` (`ThreadStatus`), defaulting to `notLoaded` when the thread is not currently loaded. - `thread/loaded/list` — list the thread ids currently loaded in memory. - `thread/read` — read a stored thread by id without resuming it; optionally include turns via `includeTurns`. The returned `thread` includes `status` (`ThreadStatus`), defaulting to `notLoaded` when the thread is not currently loaded. @@ -147,7 +147,7 @@ Example with notification opt-out: - `thread/shellCommand` — run a user-initiated `!` shell command against a thread; this runs unsandboxed with full access rather than inheriting the thread sandbox policy. Returns `{}` immediately while progress streams through standard turn/item notifications and any active turn receives the formatted output in its message stream. - `thread/backgroundTerminals/clean` — terminate all running background terminals for a thread (experimental; requires `capabilities.experimentalApi`); returns `{}` when the cleanup request is accepted. - `thread/rollback` — drop the last N turns from the agent’s in-memory context and persist a rollback marker in the rollout so future resumes see the pruned history; returns the updated `thread` (with `turns` populated) on success. -- `turn/start` — add user input to a thread and begin Codex generation; responds with the initial `turn` object and streams `turn/started`, `item/*`, and `turn/completed` notifications. Prefer experimental `permissions` profile selection for permission overrides; the legacy `sandboxPolicy` field is still accepted but cannot be combined with `permissions`. For `collaborationMode`, `settings.developer_instructions: null` means "use built-in instructions for the selected mode". +- `turn/start` — add user input to a thread and begin Codex generation; responds with the initial `turn` object and streams `turn/started`, `item/*`, and `turn/completed` notifications. It can update `workspaceRoots` and use experimental `permissions` to select a server-defined profile by id, but it cannot replace the thread's permission profile with arbitrary values. The legacy `sandboxPolicy` field is accepted as a compatibility spelling only when it is a no-op or maps to a named permissions profile. For `collaborationMode`, `settings.developer_instructions: null` means "use built-in instructions for the selected mode". - `thread/inject_items` — append raw Responses API items to a loaded thread’s model-visible history without starting a user turn; returns `{}` on success. - `turn/steer` — add user input to an already in-flight regular turn without starting a new turn; returns the active `turnId` that accepted the input. Review and manual compaction turns reject `turn/steer`. - `turn/interrupt` — request cancellation of an in-flight turn by `(thread_id, turn_id)`; success is an empty `{}` response and the turn finishes with `status: "interrupted"`. @@ -222,10 +222,11 @@ Start a fresh thread when you need a new Codex conversation. // current config settings. "model": "gpt-5.1-codex", "cwd": "/Users/me/project", + "workspaceRoots": ["/Users/me/project"], "approvalPolicy": "never", "sandbox": "workspaceWrite", // Prefer experimental profile selection: - // "permissions": { "type": "profile", "id": ":workspace" } + // "permissions": ":workspace" // Do not send both "sandbox" and "permissions". "personality": "friendly", "serviceName": "my_app_server_client", // optional metrics tag (`service_name`) @@ -259,7 +260,7 @@ Start a fresh thread when you need a new Codex conversation. Valid `personality` values are `"friendly"`, `"pragmatic"`, and `"none"`. When `"none"` is selected, the personality placeholder is replaced with an empty string. -To continue a stored session, call `thread/resume` with the `thread.id` you previously recorded. The response shape matches `thread/start`. When the stored session includes persisted token usage, the server emits `thread/tokenUsage/updated` immediately after the response so clients can render restored usage before the next turn starts. You can also pass the same configuration overrides supported by `thread/start`, including `approvalsReviewer`. +To continue a stored session, call `thread/resume` with the `thread.id` you previously recorded. The response shape matches `thread/start`. When the stored session includes persisted token usage, the server emits `thread/tokenUsage/updated` immediately after the response so clients can render restored usage before the next turn starts. You can pass non-permission configuration overrides such as `approvalsReviewer`; omitted permission fields preserve the stored permission profile. Use `workspaceRoots` to replace the thread root list, and use `permissions` to select a server-defined profile by id. Older clients may still send `sandbox` when it maps to the same named profile selection. By default, `thread/resume` includes the reconstructed turn history in `thread.turns`. Experimental clients can pass `excludeTurns: true` to return only thread metadata and live resume state, then call `thread/turns/list` separately if they want to page the turn history over the network. In that mode the server also skips replaying restored `thread/tokenUsage/updated`, which avoids rebuilding turns just to attribute historical usage. @@ -627,19 +628,14 @@ You can optionally specify config overrides on the new turn. If specified, these "input": [ { "type": "text", "text": "Run tests" } ], // Below are optional config overrides "cwd": "/Users/me/project", + "workspaceRoots": ["/Users/me/project", "/Users/me/project/packages/api"], // Experimental: turn-scoped environment selection. "environments": [ { "environmentId": "local", "cwd": "/Users/me/project" } ], "approvalPolicy": "unlessTrusted", - "sandboxPolicy": { - "type": "workspaceWrite", - "writableRoots": ["/Users/me/project"], - "networkAccess": true - }, - // Prefer experimental profile selection: - // "permissions": { "type": "profile", "id": ":workspace" } - // Do not send both "sandboxPolicy" and "permissions". + // Optional: select a named permission profile for this and later turns. + // "permissions": ":workspace" "model": "gpt-5.1-codex", "effort": "medium", "summary": "concise", diff --git a/codex-rs/app-server/src/in_process.rs b/codex-rs/app-server/src/in_process.rs index d812888e62..9ac7e3498f 100644 --- a/codex-rs/app-server/src/in_process.rs +++ b/codex-rs/app-server/src/in_process.rs @@ -194,18 +194,24 @@ pub struct InProcessClientSender { } impl InProcessClientSender { - pub async fn request(&self, request: ClientRequest) -> IoResult { + pub fn request( + &self, + request: ClientRequest, + ) -> impl std::future::Future> + '_ { let (response_tx, response_rx) = oneshot::channel(); - self.try_send_client_message(InProcessClientMessage::Request { + let send_result = self.try_send_client_message(InProcessClientMessage::Request { request: Box::new(request), response_tx, - })?; - response_rx.await.map_err(|err| { - IoError::new( - ErrorKind::BrokenPipe, - format!("in-process request response channel closed: {err}"), - ) - }) + }); + async move { + send_result?; + response_rx.await.map_err(|err| { + IoError::new( + ErrorKind::BrokenPipe, + format!("in-process request response channel closed: {err}"), + ) + }) + } } pub fn notify(&self, notification: ClientNotification) -> IoResult<()> { @@ -266,8 +272,11 @@ impl InProcessClientHandle { /// request IDs unique among concurrent requests; reusing an in-flight ID /// produces an `INVALID_REQUEST` response and can make request routing /// ambiguous in the caller. - pub async fn request(&self, request: ClientRequest) -> IoResult { - self.client.request(request).await + pub fn request( + &self, + request: ClientRequest, + ) -> impl std::future::Future> + '_ { + self.client.request(request) } /// Sends a typed client notification into the in-process runtime. @@ -443,14 +452,13 @@ async fn start_uninitialized(args: InProcessStartArgs) -> IoResult { let was_initialized = session.initialized(); - processor - .process_client_request( + Box::pin(processor.process_client_request( IN_PROCESS_CONNECTION_ID, - *request, + request, Arc::clone(&session), &outbound_initialized, - ) - .await; + )) + .await; let opted_out_notification_methods_snapshot = session.opted_out_notification_methods(); let experimental_api_enabled = @@ -521,7 +529,6 @@ async fn start_uninitialized(args: InProcessStartArgs) -> IoResult { match message { Some(InProcessClientMessage::Request { request, response_tx }) => { - let request = *request; let request_id = request.id().clone(); match pending_request_responses.entry(request_id.clone()) { Entry::Vacant(entry) => { @@ -535,7 +542,7 @@ async fn start_uninitialized(args: InProcessStartArgs) -> IoResult {} Err(mpsc::error::TrySendError::Full(_)) => { if let Some(response_tx) = diff --git a/codex-rs/app-server/src/lib.rs b/codex-rs/app-server/src/lib.rs index f655f65b91..4ec50dc20f 100644 --- a/codex-rs/app-server/src/lib.rs +++ b/codex-rs/app-server/src/lib.rs @@ -585,7 +585,7 @@ pub async fn run_main_with_transport_options( }); } if let Some(warning) = - codex_core::config::system_bwrap_warning(config.permissions.permission_profile.get()) + codex_core::config::system_bwrap_warning(config.permissions.permission_profile_ref()) { config_warnings.push(ConfigWarningNotification { summary: warning, diff --git a/codex-rs/app-server/src/message_processor.rs b/codex-rs/app-server/src/message_processor.rs index 867cd64c99..0394ba6ca0 100644 --- a/codex-rs/app-server/src/message_processor.rs +++ b/codex-rs/app-server/src/message_processor.rs @@ -1,5 +1,6 @@ use std::collections::HashSet; use std::future::Future; +use std::pin::Pin; use std::sync::Arc; use std::sync::OnceLock; use std::sync::atomic::AtomicBool; @@ -528,7 +529,7 @@ impl MessageProcessor { Self::run_request_with_context( Arc::clone(&self.outgoing), request_context.clone(), - async { + Box::pin(async { let codex_request = serde_json::to_value(&request) .map_err(|err| invalid_request(format!("Invalid request: {err}"))) .and_then(|request_json| { @@ -541,13 +542,13 @@ impl MessageProcessor { // session state into outbound state and sending initialize notifications to // this specific connection. Passing `None` avoids marking the connection // ready too early from inside the shared request handler. - self.handle_client_request( + Box::pin(self.handle_client_request( request_id.clone(), codex_request, Arc::clone(&session), /*outbound_initialized*/ None, request_context.clone(), - ) + )) .await } Err(error) => Err(error), @@ -555,7 +556,7 @@ impl MessageProcessor { if let Err(error) = result { self.outgoing.send_error(request_id.clone(), error).await; } - }, + }), ) .await; } @@ -567,7 +568,7 @@ impl MessageProcessor { pub(crate) async fn process_client_request( self: &Arc, connection_id: ConnectionId, - request: ClientRequest, + request: Box, session: Arc, outbound_initialized: &AtomicBool, ) { @@ -575,8 +576,11 @@ impl MessageProcessor { connection_id, request_id: request.id().clone(), }; - let request_span = - crate::app_server_tracing::typed_request_span(&request, connection_id, &session); + let request_span = crate::app_server_tracing::typed_request_span( + request.as_ref(), + connection_id, + &session, + ); let request_context = RequestContext::new(request_id.clone(), request_span, /*parent_trace*/ None); tracing::trace!( @@ -587,23 +591,22 @@ impl MessageProcessor { Self::run_request_with_context( Arc::clone(&self.outgoing), request_context.clone(), - async { + Box::pin(async { // In-process clients do not have the websocket transport loop that performs // post-initialize bookkeeping, so they still finalize outbound readiness in // the shared request handler. - let result = self - .handle_client_request( - request_id.clone(), - request, - Arc::clone(&session), - Some(outbound_initialized), - request_context.clone(), - ) - .await; + let result = Box::pin(self.handle_client_request( + request_id.clone(), + *request, + Arc::clone(&session), + Some(outbound_initialized), + request_context.clone(), + )) + .await; if let Err(error) = result { self.outgoing.send_error(request_id.clone(), error).await; } - }, + }), ) .await; } @@ -621,13 +624,11 @@ impl MessageProcessor { tracing::info!("<- typed notification: {:?}", notification); } - async fn run_request_with_context( + async fn run_request_with_context( outgoing: Arc, request_context: RequestContext, - request_fut: F, - ) where - F: Future, - { + request_fut: Pin + Send + '_>>, + ) { outgoing .register_request_context(request_context.clone()) .await; @@ -765,12 +766,12 @@ impl MessageProcessor { return Ok(()); } - self.dispatch_initialized_client_request( + Box::pin(self.dispatch_initialized_client_request( connection_request_id, codex_request, session, request_context, - ) + )) .await } @@ -808,15 +809,14 @@ impl MessageProcessor { rpc_gate, async move { let processor_for_request = Arc::clone(&processor); - let result = processor_for_request - .handle_initialized_client_request( - connection_request_id, - codex_request, - request_context, - app_server_client_name, - client_version, - ) - .await; + let result = Box::pin(processor_for_request.handle_initialized_client_request( + connection_request_id, + codex_request, + request_context, + app_server_client_name, + client_version, + )) + .await; if let Err(error) = result { processor.outgoing.send_error(error_request_id, error).await; } diff --git a/codex-rs/app-server/src/message_processor_tracing_tests.rs b/codex-rs/app-server/src/message_processor_tracing_tests.rs index 516e042301..5e243dcdd9 100644 --- a/codex-rs/app-server/src/message_processor_tracing_tests.rs +++ b/codex-rs/app-server/src/message_processor_tracing_tests.rs @@ -658,6 +658,7 @@ async fn turn_start_jsonrpc_span_parents_core_turn_spans() -> Result<()> { }], responsesapi_client_metadata: None, cwd: None, + workspace_roots: None, approval_policy: None, sandbox_policy: None, permissions: None, diff --git a/codex-rs/app-server/src/request_processors.rs b/codex-rs/app-server/src/request_processors.rs index f24dcaa34f..a0b73e369d 100644 --- a/codex-rs/app-server/src/request_processors.rs +++ b/codex-rs/app-server/src/request_processors.rs @@ -103,8 +103,6 @@ use codex_app_server_protocol::MockExperimentalMethodParams; use codex_app_server_protocol::MockExperimentalMethodResponse; use codex_app_server_protocol::ModelListParams; use codex_app_server_protocol::ModelListResponse; -use codex_app_server_protocol::PermissionProfileModificationParams; -use codex_app_server_protocol::PermissionProfileSelectionParams; use codex_app_server_protocol::PluginDetail; use codex_app_server_protocol::PluginInstallParams; use codex_app_server_protocol::PluginInstallResponse; @@ -354,6 +352,8 @@ use codex_protocol::error::CodexErr; use codex_protocol::error::Result as CodexResult; #[cfg(test)] use codex_protocol::items::TurnItem; +use codex_protocol::models::ActivePermissionProfile; +use codex_protocol::models::PermissionProfile; use codex_protocol::models::ResponseItem; use codex_protocol::permissions::FileSystemSandboxPolicy; use codex_protocol::protocol::AgentStatus; @@ -431,6 +431,11 @@ use uuid::Uuid; #[cfg(test)] use codex_app_server_protocol::ServerRequest; +struct ResolvedPermissionProfileSelection { + permission_profile: PermissionProfile, + active_permission_profile: ActivePermissionProfile, +} + mod account_processor; mod apps_processor; mod catalog_processor; @@ -442,6 +447,7 @@ mod feedback_processor; mod fs_processor; mod git_processor; mod initialize_processor; +mod legacy_sandbox_compat; mod marketplace_processor; mod mcp_processor; mod plugins; diff --git a/codex-rs/app-server/src/request_processors/command_exec_processor.rs b/codex-rs/app-server/src/request_processors/command_exec_processor.rs index 3236a67627..f3729b2aeb 100644 --- a/codex-rs/app-server/src/request_processors/command_exec_processor.rs +++ b/codex-rs/app-server/src/request_processors/command_exec_processor.rs @@ -164,7 +164,7 @@ impl CommandExecRequestProcessor { let started_network_proxy = match self.config.permissions.network.as_ref() { Some(spec) => match spec .start_proxy( - self.config.permissions.permission_profile.get(), + self.config.permissions.permission_profile_ref(), /*policy_decider*/ None, /*blocked_request_observer*/ None, managed_network_requirements_enabled, @@ -200,7 +200,8 @@ impl CommandExecRequestProcessor { } else { ExecCapturePolicy::ShellTool }; - let sandbox_cwd = if permission_profile.is_some() { + let has_request_permission_profile = permission_profile.is_some(); + let sandbox_cwd = if has_request_permission_profile { cwd.clone() } else { self.config.cwd.clone() @@ -243,28 +244,20 @@ impl CommandExecRequestProcessor { ); self.config .permissions - .permission_profile + .permission_profile_constraint() .can_set(&effective_permission_profile) .map_err(|err| invalid_request(format!("invalid permission profile: {err}")))?; effective_permission_profile - } else if let Some(policy) = sandbox_policy.map(|policy| policy.to_core()) { + } else if let Some(policy) = sandbox_policy { + let legacy_policy = policy.to_core(); self.config .permissions - .can_set_legacy_sandbox_policy(&policy, &sandbox_cwd) + .can_set_legacy_sandbox_policy(&legacy_policy, &sandbox_cwd) .map_err(|err| invalid_request(format!("invalid sandbox policy: {err}")))?; - let file_system_sandbox_policy = - codex_protocol::permissions::FileSystemSandboxPolicy::from_legacy_sandbox_policy_for_cwd(&policy, &sandbox_cwd); - let network_sandbox_policy = - codex_protocol::permissions::NetworkSandboxPolicy::from(&policy); - let permission_profile = - codex_protocol::models::PermissionProfile::from_runtime_permissions_with_enforcement( - codex_protocol::models::SandboxEnforcement::from_legacy_sandbox_policy(&policy), - &file_system_sandbox_policy, - network_sandbox_policy, - ); + let permission_profile = policy.to_permission_profile_for_cwd(&sandbox_cwd); self.config .permissions - .permission_profile + .permission_profile_constraint() .can_set(&permission_profile) .map_err(|err| invalid_request(format!("invalid sandbox policy: {err}")))?; permission_profile @@ -283,10 +276,16 @@ impl CommandExecRequestProcessor { None => None, }; + let workspace_roots = if has_request_permission_profile { + vec![sandbox_cwd.clone()] + } else { + self.config.workspace_roots.clone() + }; let exec_request = codex_core::exec::build_exec_request( exec_params, &effective_permission_profile, &sandbox_cwd, + &workspace_roots, &codex_linux_sandbox_exe, use_legacy_landlock, ) diff --git a/codex-rs/app-server/src/request_processors/legacy_sandbox_compat.rs b/codex-rs/app-server/src/request_processors/legacy_sandbox_compat.rs new file mode 100644 index 0000000000..409d28583f --- /dev/null +++ b/codex-rs/app-server/src/request_processors/legacy_sandbox_compat.rs @@ -0,0 +1,272 @@ +use super::*; + +const WORKSPACE_PERMISSION_PROFILE_ID: &str = ":workspace"; +const READ_ONLY_PERMISSION_PROFILE_ID: &str = ":read-only"; +const DANGER_NO_SANDBOX_PERMISSION_PROFILE_ID: &str = ":danger-no-sandbox"; + +pub(super) struct CurrentPermissionProfile<'a> { + pub(super) permission_profile: &'a PermissionProfile, + pub(super) workspace_roots: &'a [AbsolutePathBuf], +} + +pub(super) struct LegacySandboxProfileSelection { + pub(super) permissions: String, + pub(super) workspace_roots: Option>, + expected_enforcement: codex_protocol::models::SandboxEnforcement, + expected_network: codex_protocol::permissions::NetworkSandboxPolicy, +} + +pub(super) enum LegacySandboxResolution { + Noop { + workspace_roots: Option>, + }, + Selection(LegacySandboxProfileSelection), +} + +pub(super) fn resolve_legacy_sandbox_profile_selection( + sandbox_policy: &codex_app_server_protocol::SandboxPolicy, + current: Option>, + cwd: &AbsolutePathBuf, + explicit_workspace_roots: Option<&[AbsolutePathBuf]>, + field_name: &str, +) -> Result { + let legacy_workspace_roots = + workspace_roots_from_implicit_legacy_sandbox(cwd, sandbox_policy, explicit_workspace_roots); + if let Some(current_match) = legacy_sandbox_current_match( + sandbox_policy, + current, + cwd, + explicit_workspace_roots, + legacy_workspace_roots.as_deref(), + ) { + return Ok(LegacySandboxResolution::Noop { + workspace_roots: current_match.workspace_roots, + }); + } + + let expected_network = + codex_protocol::permissions::NetworkSandboxPolicy::from(&sandbox_policy.to_core()); + match sandbox_policy { + codex_app_server_protocol::SandboxPolicy::DangerFullAccess => Ok( + LegacySandboxResolution::Selection(LegacySandboxProfileSelection { + permissions: DANGER_NO_SANDBOX_PERMISSION_PROFILE_ID.to_string(), + workspace_roots: None, + expected_enforcement: codex_protocol::models::SandboxEnforcement::Disabled, + expected_network, + }), + ), + codex_app_server_protocol::SandboxPolicy::ReadOnly { network_access: _ } => Ok( + LegacySandboxResolution::Selection(LegacySandboxProfileSelection { + permissions: READ_ONLY_PERMISSION_PROFILE_ID.to_string(), + workspace_roots: None, + expected_enforcement: codex_protocol::models::SandboxEnforcement::Managed, + expected_network, + }), + ), + codex_app_server_protocol::SandboxPolicy::WorkspaceWrite { .. } => Ok( + LegacySandboxResolution::Selection(LegacySandboxProfileSelection { + permissions: WORKSPACE_PERMISSION_PROFILE_ID.to_string(), + workspace_roots: legacy_workspace_roots, + expected_enforcement: codex_protocol::models::SandboxEnforcement::Managed, + expected_network, + }), + ), + codex_app_server_protocol::SandboxPolicy::ExternalSandbox { .. } => { + Err(invalid_request(format!( + "`{field_name}` externalSandbox cannot be mapped to a named permissions profile" + ))) + } + } +} + +pub(super) fn sandbox_policy_from_legacy_mode( + sandbox_mode: SandboxMode, +) -> codex_app_server_protocol::SandboxPolicy { + match sandbox_mode { + SandboxMode::ReadOnly => codex_protocol::protocol::SandboxPolicy::new_read_only_policy(), + SandboxMode::WorkspaceWrite => { + codex_protocol::protocol::SandboxPolicy::new_workspace_write_policy() + } + SandboxMode::DangerFullAccess => codex_protocol::protocol::SandboxPolicy::DangerFullAccess, + } + .into() +} + +pub(super) fn validate_legacy_sandbox_profile_selection( + legacy_selection: &LegacySandboxProfileSelection, + resolved_selection: &ResolvedPermissionProfileSelection, + field_name: &str, +) -> Result<(), JSONRPCErrorError> { + let permission_profile = &resolved_selection.permission_profile; + if permission_profile.enforcement() != legacy_selection.expected_enforcement { + return Err(invalid_request(format!( + "`{field_name}` does not match permissions profile `{}`", + legacy_selection.permissions + ))); + } + if permission_profile.network_sandbox_policy() != legacy_selection.expected_network { + return Err(invalid_request(format!( + "`{field_name}` network access does not match permissions profile `{}`", + legacy_selection.permissions + ))); + } + Ok(()) +} + +pub(super) fn resolve_cwd_against_fallback( + cwd: Option<&Path>, + fallback_cwd: &AbsolutePathBuf, +) -> AbsolutePathBuf { + match cwd { + Some(cwd) => { + if let Ok(path) = AbsolutePathBuf::try_from(cwd) { + path + } else { + AbsolutePathBuf::resolve_path_against_base(cwd, fallback_cwd.as_path()) + } + } + None => fallback_cwd.clone(), + } +} + +struct LegacySandboxCurrentMatch { + workspace_roots: Option>, +} + +fn legacy_sandbox_current_match( + sandbox_policy: &codex_app_server_protocol::SandboxPolicy, + current: Option>, + cwd: &AbsolutePathBuf, + explicit_workspace_roots: Option<&[AbsolutePathBuf]>, + legacy_workspace_roots: Option<&[AbsolutePathBuf]>, +) -> Option { + let current = current?; + + let projection_workspace_roots = explicit_workspace_roots + .or(legacy_workspace_roots) + .unwrap_or(current.workspace_roots); + let materialized_profile = current + .permission_profile + .materialize_project_roots_with_workspace_roots(projection_workspace_roots); + let file_system_policy = materialized_profile.file_system_sandbox_policy(); + let active_sandbox = codex_sandboxing::compatibility_sandbox_policy_for_permission_profile( + &materialized_profile, + &file_system_policy, + materialized_profile.network_sandbox_policy(), + cwd.as_path(), + ); + if active_sandbox != sandbox_policy.to_core() { + return None; + } + + let workspace_roots = legacy_workspace_roots + .filter(|roots| *roots != current.workspace_roots) + .map(<[AbsolutePathBuf]>::to_vec); + + Some(LegacySandboxCurrentMatch { workspace_roots }) +} + +fn workspace_roots_from_implicit_legacy_sandbox( + cwd: &AbsolutePathBuf, + sandbox_policy: &codex_app_server_protocol::SandboxPolicy, + explicit_workspace_roots: Option<&[AbsolutePathBuf]>, +) -> Option> { + if explicit_workspace_roots.is_some() + || !matches!( + sandbox_policy, + codex_app_server_protocol::SandboxPolicy::WorkspaceWrite { .. } + ) + { + None + } else { + Some(workspace_roots_from_legacy_sandbox(cwd, sandbox_policy)) + } +} + +fn workspace_roots_from_legacy_sandbox( + cwd: &AbsolutePathBuf, + sandbox_policy: &codex_app_server_protocol::SandboxPolicy, +) -> Vec { + let mut roots = Vec::with_capacity(1 + sandbox_policy.legacy_writable_roots().len()); + push_unique_root(&mut roots, cwd.clone()); + for root in sandbox_policy.legacy_writable_roots() { + push_unique_root(&mut roots, root.clone()); + } + roots +} + +fn push_unique_root(roots: &mut Vec, root: AbsolutePathBuf) { + if !roots.iter().any(|existing| existing == &root) { + roots.push(root); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn abs_test_path(name: &str) -> AbsolutePathBuf { + AbsolutePathBuf::from_absolute_path(std::env::temp_dir().join(name)) + .expect("temp dir path should be absolute") + } + + fn workspace_write_policy() -> codex_app_server_protocol::SandboxPolicy { + codex_app_server_protocol::SandboxPolicy::WorkspaceWrite { + network_access: false, + exclude_tmpdir_env_var: false, + exclude_slash_tmp: false, + legacy_writable_roots: Vec::new(), + } + } + + #[test] + fn legacy_workspace_sandbox_updates_roots_when_current_profile_matches_new_cwd() { + let old_root = abs_test_path("codex-old-workspace-root"); + let cwd = abs_test_path("codex-new-workspace-root"); + let policy = workspace_write_policy(); + + let resolution = resolve_legacy_sandbox_profile_selection( + &policy, + Some(CurrentPermissionProfile { + permission_profile: &PermissionProfile::workspace_write(), + workspace_roots: std::slice::from_ref(&old_root), + }), + &cwd, + /*explicit_workspace_roots*/ None, + "sandboxPolicy", + ) + .expect("legacy sandbox should resolve"); + + match resolution { + LegacySandboxResolution::Noop { + workspace_roots: Some(workspace_roots), + } => assert_eq!(workspace_roots, vec![cwd]), + _ => panic!("expected workspace-roots-only resolution, got unexpected selection"), + } + } + + #[test] + fn legacy_workspace_sandbox_is_noop_when_current_workspace_roots_match() { + let cwd = abs_test_path("codex-current-workspace-root"); + let policy = workspace_write_policy(); + + let resolution = resolve_legacy_sandbox_profile_selection( + &policy, + Some(CurrentPermissionProfile { + permission_profile: &PermissionProfile::workspace_write(), + workspace_roots: std::slice::from_ref(&cwd), + }), + &cwd, + /*explicit_workspace_roots*/ None, + "sandboxPolicy", + ) + .expect("legacy sandbox should resolve"); + + match resolution { + LegacySandboxResolution::Noop { + workspace_roots: None, + } => {} + _ => panic!("expected no-op resolution, got unexpected workspace roots or selection"), + } + } +} diff --git a/codex-rs/app-server/src/request_processors/thread_lifecycle.rs b/codex-rs/app-server/src/request_processors/thread_lifecycle.rs index 7dab206d85..0483a31e3e 100644 --- a/codex-rs/app-server/src/request_processors/thread_lifecycle.rs +++ b/codex-rs/app-server/src/request_processors/thread_lifecycle.rs @@ -604,11 +604,13 @@ pub(super) async fn handle_pending_thread_resume_request( permission_profile, active_permission_profile, cwd, + workspace_roots, reasoning_effort, .. } = pending.config_snapshot; let instruction_sources = pending.instruction_sources; - let sandbox = thread_response_sandbox_policy(&permission_profile, cwd.as_path()); + let sandbox = + thread_response_sandbox_policy(&permission_profile, &workspace_roots, cwd.as_path()); let active_permission_profile = thread_response_active_permission_profile(active_permission_profile); let session_id = conversation.session_configured().session_id.to_string(); @@ -624,7 +626,7 @@ pub(super) async fn handle_pending_thread_resume_request( approval_policy: approval_policy.into(), approvals_reviewer: approvals_reviewer.into(), sandbox, - permission_profile: Some(permission_profile.into()), + workspace_roots, active_permission_profile, reasoning_effort, }; diff --git a/codex-rs/app-server/src/request_processors/thread_processor.rs b/codex-rs/app-server/src/request_processors/thread_processor.rs index 9b5cef069d..8def9af207 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor.rs @@ -1,3 +1,4 @@ +use super::legacy_sandbox_compat::*; use super::*; use crate::error_code::method_not_found; @@ -17,6 +18,119 @@ struct ThreadListFilters { use_state_db_only: bool, } +#[derive(Clone)] +struct PersistedThreadPermissionState { + permission_profile: PermissionProfile, + active_permission_profile: Option, + workspace_roots: Vec, +} + +fn absolute_path_from_history_path( + path: &Path, + base: Option<&AbsolutePathBuf>, +) -> Option { + if let Ok(path) = AbsolutePathBuf::try_from(path) { + Some(path) + } else if let Some(base) = base { + Some(AbsolutePathBuf::resolve_path_against_base( + path, + base.as_path(), + )) + } else { + AbsolutePathBuf::relative_to_current_dir(path).ok() + } +} + +fn roots_or_cwd( + roots: Vec, + cwd: Option<&AbsolutePathBuf>, +) -> Vec { + if roots.is_empty() { + cwd.cloned().into_iter().collect() + } else { + roots + } +} + +fn effective_cwd_for_legacy_sandbox( + request_cwd: Option<&str>, + history_cwd: Option<&Path>, + persisted_permission_state: Option<&PersistedThreadPermissionState>, +) -> Option { + let history_cwd = + history_cwd.and_then(|cwd| absolute_path_from_history_path(cwd, /*base*/ None)); + request_cwd + .and_then(|cwd| absolute_path_from_history_path(Path::new(cwd), history_cwd.as_ref())) + .or_else(|| history_cwd.clone()) + .or_else(|| { + persisted_permission_state + .and_then(|state| state.workspace_roots.first()) + .cloned() + }) +} + +fn persisted_thread_permission_state( + history: &InitialHistory, + fallback_cwd: Option<&Path>, + fallback_sandbox_policy: Option<&codex_protocol::protocol::SandboxPolicy>, +) -> Option { + let mut cwd = + fallback_cwd.and_then(|cwd| absolute_path_from_history_path(cwd, /*base*/ None)); + let mut workspace_roots = None; + let mut permission_profile = None; + let mut active_permission_profile = None; + + for item in history.get_rollout_items() { + match item { + RolloutItem::SessionMeta(meta_line) => { + cwd = absolute_path_from_history_path(meta_line.meta.cwd.as_path(), cwd.as_ref()) + .or(cwd); + workspace_roots = Some(roots_or_cwd(meta_line.meta.workspace_roots, cwd.as_ref())); + } + RolloutItem::TurnContext(context) => { + cwd = absolute_path_from_history_path(context.cwd.as_path(), cwd.as_ref()).or(cwd); + workspace_roots = Some(context.workspace_roots); + let context_cwd = cwd + .as_ref() + .map(AbsolutePathBuf::as_path) + .unwrap_or(context.cwd.as_path()); + permission_profile = Some(context.permission_profile.unwrap_or_else(|| { + PermissionProfile::from_legacy_sandbox_policy_for_cwd( + &context.sandbox_policy, + context_cwd, + ) + })); + if context.active_permission_profile.is_some() { + active_permission_profile = context.active_permission_profile; + } + } + RolloutItem::EventMsg(EventMsg::SessionConfigured(event)) => { + cwd = Some(event.cwd.clone()); + workspace_roots = Some(event.workspace_roots); + permission_profile = Some(event.permission_profile); + active_permission_profile = event.active_permission_profile; + } + RolloutItem::ResponseItem(_) | RolloutItem::Compacted(_) | RolloutItem::EventMsg(_) => { + } + } + } + + if permission_profile.is_none() { + let cwd = cwd.as_ref()?; + let fallback_sandbox_policy = fallback_sandbox_policy?; + permission_profile = Some(PermissionProfile::from_legacy_sandbox_policy_for_cwd( + fallback_sandbox_policy, + cwd.as_path(), + )); + } + + Some(PersistedThreadPermissionState { + permission_profile: permission_profile?, + active_permission_profile, + workspace_roots: workspace_roots.unwrap_or_else(|| cwd.into_iter().collect()), + }) +} + fn collect_resume_override_mismatches( request: &ThreadResumeParams, config_snapshot: &ThreadConfigSnapshot, @@ -98,12 +212,6 @@ fn collect_resume_override_mismatches( )); } } - if request.permissions.is_some() { - mismatch_details.push(format!( - "permissions override was provided and ignored while running; active={:?}", - config_snapshot.active_permission_profile - )); - } if let Some(requested_personality) = request.personality.as_ref() && config_snapshot.personality.as_ref() != Some(requested_personality) { @@ -802,6 +910,7 @@ impl ThreadRequestProcessor { model_provider, service_tier, cwd, + workspace_roots, approval_policy, approvals_reviewer, sandbox, @@ -835,6 +944,7 @@ impl ThreadRequestProcessor { model_provider, service_tier, cwd, + workspace_roots, approval_policy, approvals_reviewer, sandbox, @@ -975,7 +1085,7 @@ impl ThreadRequestProcessor { let requested_permissions_trust_project = requested_permissions_trust_project(&typesafe_overrides, config.cwd.as_path()); let effective_permissions_trust_project = permission_profile_trusts_project( - &config.permissions.permission_profile(), + config.permissions.permission_profile_ref(), config.cwd.as_path(), ); @@ -1160,6 +1270,7 @@ impl ThreadRequestProcessor { let sandbox = thread_response_sandbox_policy( &config_snapshot.permission_profile, + &config_snapshot.workspace_roots, config_snapshot.cwd.as_path(), ); let active_permission_profile = @@ -1175,7 +1286,7 @@ impl ThreadRequestProcessor { approval_policy: config_snapshot.approval_policy.into(), approvals_reviewer: config_snapshot.approvals_reviewer.into(), sandbox, - permission_profile: Some(config_snapshot.permission_profile.into()), + workspace_roots: config_snapshot.workspace_roots, active_permission_profile, reasoning_effort: config_snapshot.reasoning_effort, }; @@ -1212,10 +1323,11 @@ impl ThreadRequestProcessor { model_provider: Option, service_tier: Option>, cwd: Option, + workspace_roots: Option>, approval_policy: Option, approvals_reviewer: Option, sandbox: Option, - permissions: Option, + permissions: Option, base_instructions: Option, developer_instructions: Option, personality: Option, @@ -1225,6 +1337,8 @@ impl ThreadRequestProcessor { model_provider, service_tier, cwd: cwd.map(PathBuf::from), + workspace_roots: workspace_roots + .map(|roots| roots.into_iter().map(|root| root.to_path_buf()).collect()), approval_policy: approval_policy .map(codex_app_server_protocol::AskForApproval::to_core), approvals_reviewer: approvals_reviewer @@ -1241,6 +1355,47 @@ impl ThreadRequestProcessor { overrides } + async fn validate_active_permission_profile_selection( + &self, + permissions: String, + request_overrides: Option>, + cwd: Option, + fallback_cwd: Option, + ) -> Result { + let mut overrides = ConfigOverrides { + cwd, + codex_linux_sandbox_exe: self.arg0_paths.codex_linux_sandbox_exe.clone(), + main_execve_wrapper_exe: self.arg0_paths.main_execve_wrapper_exe.clone(), + ..Default::default() + }; + apply_permission_profile_selection_to_config_overrides(&mut overrides, Some(permissions)); + let config = self + .config_manager + .load_for_cwd(request_overrides, overrides, fallback_cwd) + .await + .map_err(|err| config_load_error(&err))?; + if let Some(warning) = config.startup_warnings.iter().find(|warning| { + warning.contains("Configured value for `permission_profile` is disallowed") + }) { + return Err(invalid_request(format!( + "invalid permission profile selection: {warning}" + ))); + } + let active_permission_profile = + config + .permissions + .active_permission_profile() + .ok_or_else(|| { + invalid_request( + "permission profile selection did not resolve to a named profile", + ) + })?; + Ok(ResolvedPermissionProfileSelection { + permission_profile: config.permissions.permission_profile(), + active_permission_profile, + }) + } + fn parse_environment_selections( &self, environments: Option>, @@ -2381,6 +2536,7 @@ impl ThreadRequestProcessor { model_provider, service_tier, cwd, + workspace_roots, approval_policy, approvals_reviewer, sandbox, @@ -2393,6 +2549,7 @@ impl ThreadRequestProcessor { persist_extended_history: _persist_extended_history, } = params; let include_turns = !exclude_turns; + let mut workspace_roots = workspace_roots; let (thread_history, resume_source_thread) = match if let Some(history) = history { self.resume_thread_from_history(history.as_slice()) @@ -2411,19 +2568,147 @@ impl ThreadRequestProcessor { }; let history_cwd = thread_history.session_cwd(); + let persisted_permission_state = persisted_thread_permission_state( + &thread_history, + history_cwd.as_deref(), + resume_source_thread + .as_ref() + .map(|thread| &thread.sandbox_policy), + ); + let permission_profile_selection = if let Some(permissions) = permissions { + match self + .validate_active_permission_profile_selection( + permissions, + request_overrides.clone(), + cwd.clone().map(PathBuf::from), + history_cwd.clone(), + ) + .await + { + Ok(selection) => Some(selection), + Err(error) => { + self.outgoing.send_error(request_id, error).await; + return Ok(()); + } + } + } else if let Some(sandbox_mode) = sandbox { + let sandbox_policy = sandbox_policy_from_legacy_mode(sandbox_mode); + let Some(effective_cwd) = effective_cwd_for_legacy_sandbox( + cwd.as_deref(), + history_cwd.as_deref(), + persisted_permission_state.as_ref(), + ) else { + self.outgoing + .send_error( + request_id, + invalid_request("`sandbox` requires a cwd to resolve legacy permissions"), + ) + .await; + return Ok(()); + }; + match resolve_legacy_sandbox_profile_selection( + &sandbox_policy, + persisted_permission_state + .as_ref() + .map(|state| CurrentPermissionProfile { + permission_profile: &state.permission_profile, + workspace_roots: &state.workspace_roots, + }), + &effective_cwd, + workspace_roots.as_deref(), + "sandbox", + ) { + Ok(LegacySandboxResolution::Noop { + workspace_roots: legacy_workspace_roots, + }) => { + if workspace_roots.is_none() { + workspace_roots = legacy_workspace_roots; + } + None + } + Ok(LegacySandboxResolution::Selection(legacy_selection)) => { + if workspace_roots.is_none() { + workspace_roots = legacy_selection.workspace_roots.clone(); + } + match self + .validate_active_permission_profile_selection( + legacy_selection.permissions.clone(), + request_overrides.clone(), + cwd.clone().map(PathBuf::from), + history_cwd.clone(), + ) + .await + .and_then(|selection| { + validate_legacy_sandbox_profile_selection( + &legacy_selection, + &selection, + "sandbox", + ) + .map(|()| selection) + }) { + Ok(selection) => Some(selection), + Err(error) => { + self.outgoing.send_error(request_id, error).await; + return Ok(()); + } + } + } + Err(error) => { + self.outgoing.send_error(request_id, error).await; + return Ok(()); + } + } + } else { + None + }; + let active_permission_profile = permission_profile_selection + .as_ref() + .map(|selection| selection.active_permission_profile.clone()) + .or_else(|| { + persisted_permission_state + .as_ref() + .and_then(|state| state.active_permission_profile.clone()) + }); + let expected_active_permission_profile_source = permission_profile_selection + .as_ref() + .map(|selection| selection.permission_profile.clone()) + .or_else(|| { + persisted_permission_state + .as_ref() + .map(|state| state.permission_profile.clone()) + }); + let workspace_roots_were_explicit = workspace_roots.is_some(); let mut typesafe_overrides = self.build_thread_config_overrides( model, model_provider, service_tier, cwd, + workspace_roots, approval_policy, approvals_reviewer, - sandbox, - permissions, + /*sandbox*/ None, + /*permissions*/ None, base_instructions, developer_instructions, personality, ); + typesafe_overrides.permission_profile = expected_active_permission_profile_source.clone(); + if !workspace_roots_were_explicit { + if let Some(persisted_permission_state) = persisted_permission_state.as_ref() { + typesafe_overrides.workspace_roots = Some( + persisted_permission_state + .workspace_roots + .iter() + .map(codex_utils_absolute_path::AbsolutePathBuf::to_path_buf) + .collect(), + ); + } else if let Some(root) = history_cwd + .as_deref() + .and_then(|cwd| absolute_path_from_history_path(cwd, /*base*/ None)) + { + typesafe_overrides.workspace_roots = Some(vec![root.to_path_buf()]); + } + } self.load_and_apply_persisted_resume_metadata( &thread_history, &mut request_overrides, @@ -2432,7 +2717,7 @@ impl ThreadRequestProcessor { .await; // Derive a Config using the same logic as new conversation, honoring overrides if provided. - let config = match self + let mut config = match self .config_manager .load_for_cwd(request_overrides, typesafe_overrides, history_cwd) .await @@ -2444,6 +2729,12 @@ impl ThreadRequestProcessor { return Ok(()); } }; + config + .permissions + .set_active_permission_profile_for_current_profile( + active_permission_profile, + expected_active_permission_profile_source.as_ref(), + ); let instruction_sources = Self::instruction_sources_from_config(&config).await; let response_history = thread_history.clone(); @@ -2537,6 +2828,7 @@ impl ThreadRequestProcessor { let config_snapshot = codex_thread.config_snapshot().await; let sandbox = thread_response_sandbox_policy( &config_snapshot.permission_profile, + &config_snapshot.workspace_roots, config_snapshot.cwd.as_path(), ); let active_permission_profile = thread_response_active_permission_profile( @@ -2557,7 +2849,7 @@ impl ThreadRequestProcessor { approval_policy: session_configured.approval_policy.into(), approvals_reviewer: session_configured.approvals_reviewer.into(), sandbox, - permission_profile: Some(config_snapshot.permission_profile.into()), + workspace_roots: config_snapshot.workspace_roots, active_permission_profile, reasoning_effort: session_configured.reasoning_effort, }; @@ -2711,6 +3003,94 @@ impl ThreadRequestProcessor { ) .await?; + let config_snapshot = existing_thread.config_snapshot().await; + let mut workspace_roots = params.workspace_roots.clone(); + let permission_profile_selection = if let Some(permissions) = params.permissions.clone() + { + Some( + self.validate_active_permission_profile_selection( + permissions, + /*request_overrides*/ None, + /*cwd*/ None, + Some(config_snapshot.cwd.to_path_buf()), + ) + .await?, + ) + } else if let Some(sandbox_mode) = params.sandbox { + let sandbox_policy = sandbox_policy_from_legacy_mode(sandbox_mode); + match resolve_legacy_sandbox_profile_selection( + &sandbox_policy, + Some(CurrentPermissionProfile { + permission_profile: &config_snapshot.permission_profile, + workspace_roots: &config_snapshot.workspace_roots, + }), + &config_snapshot.cwd, + workspace_roots.as_deref(), + "sandbox", + )? { + LegacySandboxResolution::Noop { + workspace_roots: legacy_workspace_roots, + } => { + if workspace_roots.is_none() { + workspace_roots = legacy_workspace_roots; + } + None + } + LegacySandboxResolution::Selection(legacy_selection) => { + if workspace_roots.is_none() { + workspace_roots = legacy_selection.workspace_roots.clone(); + } + let selection = self + .validate_active_permission_profile_selection( + legacy_selection.permissions.clone(), + /*request_overrides*/ None, + /*cwd*/ None, + Some(config_snapshot.cwd.to_path_buf()), + ) + .await?; + validate_legacy_sandbox_profile_selection( + &legacy_selection, + &selection, + "sandbox", + )?; + Some(selection) + } + } + } else { + None + }; + if workspace_roots.is_some() || permission_profile_selection.is_some() { + existing_thread + .update_turn_context_overrides(CodexThreadTurnContextOverrides { + cwd: None, + workspace_roots: workspace_roots.map(|roots| { + roots + .into_iter() + .map(AbsolutePathBuf::into_path_buf) + .collect() + }), + approval_policy: None, + approvals_reviewer: None, + sandbox_policy: None, + permission_profile: permission_profile_selection + .as_ref() + .map(|selection| selection.permission_profile.clone()), + active_permission_profile: permission_profile_selection + .map(|selection| selection.active_permission_profile), + windows_sandbox_level: None, + model: None, + effort: None, + summary: None, + service_tier: None, + collaboration_mode: None, + personality: None, + }) + .await + .map_err(|err| { + invalid_request(format!("invalid thread resume override: {err}")) + })?; + } + let config_snapshot = existing_thread.config_snapshot().await; let mismatch_details = collect_resume_override_mismatches(params, &config_snapshot); if !mismatch_details.is_empty() { @@ -3017,6 +3397,7 @@ impl ThreadRequestProcessor { model_provider, service_tier, cwd, + workspace_roots, approval_policy, approvals_reviewer, sandbox, @@ -3077,26 +3458,123 @@ impl ThreadRequestProcessor { } else { Some(cli_overrides) }; + let fork_history = InitialHistory::Forked(history_items.clone()); + let persisted_permission_state = persisted_thread_permission_state( + &fork_history, + history_cwd.as_deref(), + Some(&source_thread.sandbox_policy), + ) + .ok_or_else(|| { + invalid_request("thread history is missing persisted permission configuration") + })?; + let mut workspace_roots = workspace_roots; + let permission_profile_selection = if let Some(permissions) = permissions { + Some( + self.validate_active_permission_profile_selection( + permissions, + request_overrides.clone(), + cwd.clone().map(PathBuf::from), + history_cwd.clone(), + ) + .await?, + ) + } else if let Some(sandbox_mode) = sandbox { + let sandbox_policy = sandbox_policy_from_legacy_mode(sandbox_mode); + let effective_cwd = effective_cwd_for_legacy_sandbox( + cwd.as_deref(), + history_cwd.as_deref(), + Some(&persisted_permission_state), + ) + .ok_or_else(|| { + invalid_request("`sandbox` requires a cwd to resolve legacy permissions") + })?; + match resolve_legacy_sandbox_profile_selection( + &sandbox_policy, + Some(CurrentPermissionProfile { + permission_profile: &persisted_permission_state.permission_profile, + workspace_roots: &persisted_permission_state.workspace_roots, + }), + &effective_cwd, + workspace_roots.as_deref(), + "sandbox", + )? { + LegacySandboxResolution::Noop { + workspace_roots: legacy_workspace_roots, + } => { + if workspace_roots.is_none() { + workspace_roots = legacy_workspace_roots; + } + None + } + LegacySandboxResolution::Selection(legacy_selection) => { + if workspace_roots.is_none() { + workspace_roots = legacy_selection.workspace_roots.clone(); + } + let selection = self + .validate_active_permission_profile_selection( + legacy_selection.permissions.clone(), + request_overrides.clone(), + cwd.clone().map(PathBuf::from), + history_cwd.clone(), + ) + .await?; + validate_legacy_sandbox_profile_selection( + &legacy_selection, + &selection, + "sandbox", + )?; + Some(selection) + } + } + } else { + None + }; + let active_permission_profile = permission_profile_selection + .as_ref() + .map(|selection| selection.active_permission_profile.clone()) + .or_else(|| persisted_permission_state.active_permission_profile.clone()); + let expected_active_permission_profile_source = permission_profile_selection + .as_ref() + .map(|selection| selection.permission_profile.clone()) + .or_else(|| Some(persisted_permission_state.permission_profile.clone())); + let workspace_roots_were_explicit = workspace_roots.is_some(); let mut typesafe_overrides = self.build_thread_config_overrides( model, model_provider, service_tier, cwd, + workspace_roots, approval_policy, approvals_reviewer, - sandbox, - permissions, + /*sandbox*/ None, + /*permissions*/ None, base_instructions, developer_instructions, /*personality*/ None, ); + typesafe_overrides.permission_profile = expected_active_permission_profile_source.clone(); + if !workspace_roots_were_explicit { + typesafe_overrides.workspace_roots = Some( + persisted_permission_state + .workspace_roots + .iter() + .map(codex_utils_absolute_path::AbsolutePathBuf::to_path_buf) + .collect(), + ); + } typesafe_overrides.ephemeral = ephemeral.then_some(true); // Derive a Config using the same logic as new conversation, honoring overrides if provided. - let config = self + let mut config = self .config_manager .load_for_cwd(request_overrides, typesafe_overrides, history_cwd) .await .map_err(|err| config_load_error(&err))?; + config + .permissions + .set_active_permission_profile_for_current_profile( + active_permission_profile, + expected_active_permission_profile_source.as_ref(), + ); let fallback_model_provider = config.model_provider_id.clone(); let instruction_sources = Self::instruction_sources_from_config(&config).await; @@ -3200,6 +3678,7 @@ impl ThreadRequestProcessor { let config_snapshot = forked_thread.config_snapshot().await; let sandbox = thread_response_sandbox_policy( &config_snapshot.permission_profile, + &config_snapshot.workspace_roots, config_snapshot.cwd.as_path(), ); let active_permission_profile = @@ -3215,7 +3694,7 @@ impl ThreadRequestProcessor { approval_policy: session_configured.approval_policy.into(), approvals_reviewer: session_configured.approvals_reviewer.into(), sandbox, - permission_profile: Some(config_snapshot.permission_profile.into()), + workspace_roots: config_snapshot.workspace_roots, active_permission_profile, reasoning_effort: session_configured.reasoning_effort, }; diff --git a/codex-rs/app-server/src/request_processors/thread_processor_tests.rs b/codex-rs/app-server/src/request_processors/thread_processor_tests.rs index 61a31f9c4d..0ebb428e39 100644 --- a/codex-rs/app-server/src/request_processors/thread_processor_tests.rs +++ b/codex-rs/app-server/src/request_processors/thread_processor_tests.rs @@ -495,6 +495,101 @@ mod thread_processor_behavior_tests { )); } + #[test] + fn persisted_thread_permission_state_uses_latest_turn_active_profile() { + let cwd = test_path_buf("/tmp/project").abs(); + let workspace_root = test_path_buf("/tmp/workspace").abs(); + let active_permission_profile = + codex_protocol::models::ActivePermissionProfile::new(":workspace"); + let permission_profile = codex_protocol::models::PermissionProfile::workspace_write(); + + let history = codex_protocol::protocol::InitialHistory::Forked(vec![ + codex_protocol::protocol::RolloutItem::TurnContext( + codex_protocol::protocol::TurnContextItem { + turn_id: Some("turn-1".to_string()), + trace_id: None, + cwd: cwd.to_path_buf(), + workspace_roots: vec![workspace_root.clone()], + current_date: None, + timezone: None, + approval_policy: AskForApproval::Never, + sandbox_policy: SandboxPolicy::new_workspace_write_policy(), + permission_profile: Some(permission_profile.clone()), + active_permission_profile: Some(active_permission_profile.clone()), + network: None, + file_system_sandbox_policy: None, + model: "gpt-5".to_string(), + personality: None, + collaboration_mode: None, + realtime_active: None, + effort: None, + summary: codex_protocol::config_types::ReasoningSummary::Auto, + user_instructions: None, + developer_instructions: None, + final_output_json_schema: None, + truncation_policy: None, + }, + ), + ]); + + let persisted = persisted_thread_permission_state( + &history, + Some(cwd.as_path()), + /*fallback_sandbox_policy*/ None, + ) + .expect("permission state should be reconstructed"); + + assert_eq!(persisted.permission_profile, permission_profile); + assert_eq!( + persisted.active_permission_profile, + Some(active_permission_profile) + ); + assert_eq!(persisted.workspace_roots, vec![workspace_root]); + } + + #[test] + fn persisted_thread_permission_state_preserves_empty_workspace_roots_from_event_roundtrip() { + let cwd = test_path_buf("/tmp/project").abs(); + let thread_id = ThreadId::from_string("67e55044-10b1-426f-9247-bb680e5fe0c8").unwrap(); + let permission_profile = codex_protocol::models::PermissionProfile::workspace_write(); + let event = codex_protocol::protocol::EventMsg::SessionConfigured( + codex_protocol::protocol::SessionConfiguredEvent { + session_id: thread_id.into(), + thread_id, + forked_from_id: None, + thread_source: None, + thread_name: None, + model: "gpt-5".to_string(), + model_provider_id: "mock_provider".to_string(), + service_tier: None, + approval_policy: AskForApproval::Never, + approvals_reviewer: codex_protocol::config_types::ApprovalsReviewer::User, + permission_profile: permission_profile.clone(), + active_permission_profile: None, + cwd: cwd.clone(), + workspace_roots: Vec::new(), + reasoning_effort: None, + initial_messages: None, + network_proxy: None, + rollout_path: None, + }, + ); + let event = serde_json::from_value(serde_json::to_value(event).unwrap()).unwrap(); + let history = codex_protocol::protocol::InitialHistory::Forked(vec![ + codex_protocol::protocol::RolloutItem::EventMsg(event), + ]); + + let persisted = persisted_thread_permission_state( + &history, + Some(cwd.as_path()), + /*fallback_sandbox_policy*/ None, + ) + .expect("permission state should be reconstructed"); + + assert_eq!(persisted.permission_profile, permission_profile); + assert_eq!(persisted.workspace_roots, Vec::::new()); + } + #[test] fn config_load_error_marks_cloud_requirements_failures_for_relogin() { let err = std::io::Error::other(CloudRequirementsLoadError::new( @@ -630,6 +725,7 @@ mod thread_processor_behavior_tests { model_provider: None, service_tier: Some(Some("priority".to_string())), cwd: None, + workspace_roots: None, approval_policy: None, approvals_reviewer: None, sandbox: None, @@ -650,6 +746,7 @@ mod thread_processor_behavior_tests { permission_profile: codex_protocol::models::PermissionProfile::Disabled, active_permission_profile: None, cwd, + workspace_roots: Vec::new(), ephemeral: false, reasoning_effort: None, personality: None, diff --git a/codex-rs/app-server/src/request_processors/thread_summary.rs b/codex-rs/app-server/src/request_processors/thread_summary.rs index 875bd3deaf..d6bb2f8c48 100644 --- a/codex-rs/app-server/src/request_processors/thread_summary.rs +++ b/codex-rs/app-server/src/request_processors/thread_summary.rs @@ -177,32 +177,26 @@ pub(super) fn thread_response_active_permission_profile( pub(super) fn apply_permission_profile_selection_to_config_overrides( overrides: &mut ConfigOverrides, - permissions: Option, + permissions: Option, ) { - let Some(PermissionProfileSelectionParams::Profile { id, modifications }) = permissions else { + let Some(id) = permissions else { return; }; overrides.default_permissions = Some(id); - overrides - .additional_writable_roots - .extend(modifications.unwrap_or_default().into_iter().map( - |modification| match modification { - PermissionProfileModificationParams::AdditionalWritableRoot { path } => { - path.to_path_buf() - } - }, - )); } pub(super) fn thread_response_sandbox_policy( permission_profile: &codex_protocol::models::PermissionProfile, + workspace_roots: &[AbsolutePathBuf], cwd: &Path, ) -> codex_app_server_protocol::SandboxPolicy { - let file_system_policy = permission_profile.file_system_sandbox_policy(); + let materialized_permission_profile = + permission_profile.materialize_project_roots_with_workspace_roots(workspace_roots); + let file_system_policy = materialized_permission_profile.file_system_sandbox_policy(); let sandbox_policy = codex_sandboxing::compatibility_sandbox_policy_for_permission_profile( - permission_profile, + &materialized_permission_profile, &file_system_policy, - permission_profile.network_sandbox_policy(), + materialized_permission_profile.network_sandbox_policy(), cwd, ); sandbox_policy.into() diff --git a/codex-rs/app-server/src/request_processors/turn_processor.rs b/codex-rs/app-server/src/request_processors/turn_processor.rs index d1dae4ef46..b71aef3767 100644 --- a/codex-rs/app-server/src/request_processors/turn_processor.rs +++ b/codex-rs/app-server/src/request_processors/turn_processor.rs @@ -1,3 +1,4 @@ +use super::legacy_sandbox_compat::*; use super::*; #[derive(Clone)] @@ -357,6 +358,7 @@ impl TurnRequestProcessor { let turn_has_input = !mapped_items.is_empty(); let has_any_overrides = params.cwd.is_some() + || params.workspace_roots.is_some() || params.approval_policy.is_some() || params.approvals_reviewer.is_some() || params.sandbox_policy.is_some() @@ -375,47 +377,70 @@ impl TurnRequestProcessor { } let cwd = params.cwd; + let mut workspace_roots = params.workspace_roots; let approval_policy = params.approval_policy.map(AskForApproval::to_core); let approvals_reviewer = params .approvals_reviewer .map(codex_app_server_protocol::ApprovalsReviewer::to_core); - let sandbox_policy = params.sandbox_policy.map(|p| p.to_core()); + let legacy_sandbox_policy = params.sandbox_policy; + let sandbox_policy = None; let (permission_profile, active_permission_profile) = if let Some(permissions) = params.permissions { let snapshot = thread.config_snapshot().await; - let mut overrides = ConfigOverrides { - cwd: cwd.clone(), - codex_linux_sandbox_exe: self.arg0_paths.codex_linux_sandbox_exe.clone(), - main_execve_wrapper_exe: self.arg0_paths.main_execve_wrapper_exe.clone(), - ..Default::default() - }; - apply_permission_profile_selection_to_config_overrides( - &mut overrides, - Some(permissions), - ); - let config = self - .config_manager - .load_for_cwd( - /*request_overrides*/ None, - overrides, + let selection = self + .validate_active_permission_profile_selection( + permissions, + cwd.clone(), Some(snapshot.cwd.to_path_buf()), ) - .await - .map_err(|err| config_load_error(&err))?; - // Startup config is allowed to fall back when requirements - // disallow a configured profile. An explicit turn request - // is different: reject it before accepting user input. - if let Some(warning) = config.startup_warnings.iter().find(|warning| { - warning.contains("Configured value for `permission_profile` is disallowed") - }) { - return Err(invalid_request(format!( - "invalid turn context override: {warning}" - ))); - } + .await?; ( - Some(config.permissions.permission_profile()), - config.permissions.active_permission_profile(), + Some(selection.permission_profile), + Some(selection.active_permission_profile), ) + } else if let Some(legacy_sandbox_policy) = legacy_sandbox_policy.as_ref() { + let snapshot = thread.config_snapshot().await; + let effective_cwd = resolve_cwd_against_fallback(cwd.as_deref(), &snapshot.cwd); + match resolve_legacy_sandbox_profile_selection( + legacy_sandbox_policy, + Some(CurrentPermissionProfile { + permission_profile: &snapshot.permission_profile, + workspace_roots: &snapshot.workspace_roots, + }), + &effective_cwd, + workspace_roots.as_deref(), + "sandboxPolicy", + )? { + LegacySandboxResolution::Noop { + workspace_roots: legacy_workspace_roots, + } => { + if workspace_roots.is_none() { + workspace_roots = legacy_workspace_roots; + } + (None, None) + } + LegacySandboxResolution::Selection(legacy_selection) => { + if workspace_roots.is_none() { + workspace_roots = legacy_selection.workspace_roots.clone(); + } + let selection = self + .validate_active_permission_profile_selection( + legacy_selection.permissions.clone(), + cwd.clone(), + Some(snapshot.cwd.to_path_buf()), + ) + .await?; + validate_legacy_sandbox_profile_selection( + &legacy_selection, + &selection, + "sandboxPolicy", + )?; + ( + Some(selection.permission_profile), + Some(selection.active_permission_profile), + ) + } + } } else { (None, None) }; @@ -432,6 +457,9 @@ impl TurnRequestProcessor { thread .validate_turn_context_overrides(CodexThreadTurnContextOverrides { cwd: cwd.clone(), + workspace_roots: workspace_roots + .clone() + .map(|roots| roots.into_iter().map(|root| root.to_path_buf()).collect()), approval_policy, approvals_reviewer, sandbox_policy: sandbox_policy.clone(), @@ -457,6 +485,7 @@ impl TurnRequestProcessor { final_output_json_schema: params.output_schema, responsesapi_client_metadata: params.responsesapi_client_metadata, cwd, + workspace_roots, approval_policy, approvals_reviewer, sandbox_policy, @@ -516,6 +545,49 @@ impl TurnRequestProcessor { Ok(TurnStartResponse { turn }) } + async fn validate_active_permission_profile_selection( + &self, + permissions: String, + cwd: Option, + fallback_cwd: Option, + ) -> Result { + let mut overrides = ConfigOverrides { + cwd, + codex_linux_sandbox_exe: self.arg0_paths.codex_linux_sandbox_exe.clone(), + main_execve_wrapper_exe: self.arg0_paths.main_execve_wrapper_exe.clone(), + ..Default::default() + }; + apply_permission_profile_selection_to_config_overrides(&mut overrides, Some(permissions)); + let config = self + .config_manager + .load_for_cwd(/*request_overrides*/ None, overrides, fallback_cwd) + .await + .map_err(|err| config_load_error(&err))?; + // Startup config is allowed to fall back when requirements disallow a + // configured profile. An explicit turn request is different: reject it + // before accepting user input. + if let Some(warning) = config.startup_warnings.iter().find(|warning| { + warning.contains("Configured value for `permission_profile` is disallowed") + }) { + return Err(invalid_request(format!( + "invalid turn context override: {warning}" + ))); + } + let active_permission_profile = + config + .permissions + .active_permission_profile() + .ok_or_else(|| { + invalid_request( + "permission profile selection did not resolve to a named profile", + ) + })?; + Ok(ResolvedPermissionProfileSelection { + permission_profile: config.permissions.permission_profile(), + active_permission_profile, + }) + } + async fn thread_inject_items_response_inner( &self, params: ThreadInjectItemsParams, diff --git a/codex-rs/app-server/tests/common/rollout.rs b/codex-rs/app-server/tests/common/rollout.rs index 6b2a9a0abe..6c113297d7 100644 --- a/codex-rs/app-server/tests/common/rollout.rs +++ b/codex-rs/app-server/tests/common/rollout.rs @@ -135,6 +135,7 @@ pub fn create_fake_rollout_with_source( forked_from_id: None, timestamp: meta_rfc3339.to_string(), cwd: PathBuf::from("/"), + workspace_roots: Vec::new(), originator: "codex".to_string(), cli_version: "0.0.0".to_string(), source, @@ -219,6 +220,7 @@ pub fn create_fake_rollout_with_text_elements( forked_from_id: None, timestamp: meta_rfc3339.to_string(), cwd: PathBuf::from("/"), + workspace_roots: Vec::new(), originator: "codex".to_string(), cli_version: "0.0.0".to_string(), source: SessionSource::Cli, diff --git a/codex-rs/app-server/tests/suite/conversation_summary.rs b/codex-rs/app-server/tests/suite/conversation_summary.rs index 754d1f9467..e692ed95f2 100644 --- a/codex-rs/app-server/tests/suite/conversation_summary.rs +++ b/codex-rs/app-server/tests/suite/conversation_summary.rs @@ -132,6 +132,7 @@ async fn get_conversation_summary_by_thread_id_reads_pathless_store_thread() -> cwd: None, model_provider: "test-provider".to_string(), memory_mode: ThreadMemoryMode::Disabled, + workspace_roots: Vec::new(), }, event_persistence_mode: ThreadEventPersistenceMode::default(), }) diff --git a/codex-rs/app-server/tests/suite/v2/skills_list.rs b/codex-rs/app-server/tests/suite/v2/skills_list.rs index 416b2515ad..9254019d85 100644 --- a/codex-rs/app-server/tests/suite/v2/skills_list.rs +++ b/codex-rs/app-server/tests/suite/v2/skills_list.rs @@ -618,6 +618,7 @@ async fn skills_changed_notification_is_emitted_after_skill_change() -> Result<( approvals_reviewer: None, sandbox: None, permissions: None, + workspace_roots: None, config: None, service_name: None, base_instructions: None, diff --git a/codex-rs/app-server/tests/suite/v2/thread_list.rs b/codex-rs/app-server/tests/suite/v2/thread_list.rs index 80254d8f47..c8872c47bf 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_list.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_list.rs @@ -610,6 +610,7 @@ sqlite = true codex_home: codex_home.path().to_path_buf(), sqlite_home: codex_home.path().to_path_buf(), cwd: codex_home.path().to_path_buf(), + workspace_roots: Vec::new(), model_provider_id: "mock_provider".to_string(), generate_memories: false, }; diff --git a/codex-rs/app-server/tests/suite/v2/thread_read.rs b/codex-rs/app-server/tests/suite/v2/thread_read.rs index 52420c0c80..c1fca44a44 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_read.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_read.rs @@ -1288,6 +1288,7 @@ async fn seed_pathless_store_thread( dynamic_tools: Vec::new(), metadata: ThreadPersistenceMetadata { cwd: None, + workspace_roots: Vec::new(), model_provider: "test-provider".to_string(), memory_mode: ThreadMemoryMode::Disabled, }, diff --git a/codex-rs/app-server/tests/suite/v2/thread_resume.rs b/codex-rs/app-server/tests/suite/v2/thread_resume.rs index 64dfe0beb7..3ce6df7bf7 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs @@ -26,6 +26,7 @@ use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::PatchApplyStatus; use codex_app_server_protocol::PatchChangeKind; use codex_app_server_protocol::RequestId; +use codex_app_server_protocol::SandboxMode; use codex_app_server_protocol::ServerNotification; use codex_app_server_protocol::ServerRequest; use codex_app_server_protocol::SessionSource; @@ -1474,6 +1475,7 @@ stream_max_retries = 0 forked_from_id: None, timestamp: "2025-01-05T12:00:00Z".to_string(), cwd: repo_path.clone(), + workspace_roots: Vec::new(), originator: "codex".to_string(), cli_version: "0.0.0".to_string(), source: RolloutSessionSource::Cli, @@ -2235,6 +2237,122 @@ async fn thread_resume_rejoins_running_thread_even_with_override_mismatch() -> R Ok(()) } +#[tokio::test] +async fn thread_resume_running_applies_workspace_roots_and_active_profile_name() -> Result<()> { + let server = responses::start_mock_server().await; + let first_response = responses::sse_response(responses::sse(vec![ + responses::ev_response_created("resp-1"), + responses::ev_assistant_message("msg-1", "Done"), + responses::ev_completed("resp-1"), + ])); + let second_response = responses::sse_response(responses::sse(vec![ + responses::ev_response_created("resp-2"), + responses::ev_assistant_message("msg-2", "Done"), + responses::ev_completed("resp-2"), + ])) + .set_delay(std::time::Duration::from_millis(500)); + let _response_mock = + responses::mount_response_sequence(&server, vec![first_response, second_response]).await; + let codex_home = TempDir::new()?; + create_config_toml(codex_home.path(), &server.uri())?; + let workspace_root = codex_home.path().join("replacement-root"); + std::fs::create_dir_all(&workspace_root)?; + let workspace_root = AbsolutePathBuf::from_absolute_path(workspace_root.canonicalize()?)?; + + let mut primary = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, primary.initialize()).await??; + + let start_id = primary + .send_thread_start_request(ThreadStartParams { + model: Some("gpt-5.4".to_string()), + ..Default::default() + }) + .await?; + let start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + primary.read_stream_until_response_message(RequestId::Integer(start_id)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(start_resp)?; + + let seed_turn_id = primary + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![UserInput::Text { + text: "seed history".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + primary.read_stream_until_response_message(RequestId::Integer(seed_turn_id)), + ) + .await??; + timeout( + DEFAULT_READ_TIMEOUT, + primary.read_stream_until_notification_message("turn/completed"), + ) + .await??; + primary.clear_message_buffer(); + + let running_turn_id = primary + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![UserInput::Text { + text: "keep running".to_string(), + text_elements: Vec::new(), + }], + ..Default::default() + }) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + primary.read_stream_until_response_message(RequestId::Integer(running_turn_id)), + ) + .await??; + timeout( + DEFAULT_READ_TIMEOUT, + primary.read_stream_until_notification_message("turn/started"), + ) + .await??; + + let resume_id = primary + .send_thread_resume_request(ThreadResumeParams { + thread_id: thread.id.clone(), + workspace_roots: Some(vec![workspace_root.clone()]), + sandbox: Some(SandboxMode::WorkspaceWrite), + ..Default::default() + }) + .await?; + let resume_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + primary.read_stream_until_response_message(RequestId::Integer(resume_id)), + ) + .await??; + let ThreadResumeResponse { + workspace_roots, + active_permission_profile, + .. + } = to_response::(resume_resp)?; + assert_eq!(workspace_roots, vec![workspace_root]); + assert_eq!( + active_permission_profile + .as_ref() + .map(|profile| profile.id.as_str()), + Some(":workspace") + ); + + timeout( + DEFAULT_READ_TIMEOUT, + primary.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + Ok(()) +} + #[tokio::test] async fn thread_resume_can_skip_turns_when_thread_is_running() -> Result<()> { let server = responses::start_mock_server().await; diff --git a/codex-rs/app-server/tests/suite/v2/thread_unarchive.rs b/codex-rs/app-server/tests/suite/v2/thread_unarchive.rs index 5b421dcec5..b851af545c 100644 --- a/codex-rs/app-server/tests/suite/v2/thread_unarchive.rs +++ b/codex-rs/app-server/tests/suite/v2/thread_unarchive.rs @@ -218,6 +218,7 @@ async fn thread_unarchive_preserves_pathless_store_metadata() -> Result<()> { cwd: None, model_provider: "test-provider".to_string(), memory_mode: ThreadMemoryMode::Disabled, + workspace_roots: Vec::new(), }, event_persistence_mode: ThreadEventPersistenceMode::default(), }) diff --git a/codex-rs/app-server/tests/suite/v2/turn_start.rs b/codex-rs/app-server/tests/suite/v2/turn_start.rs index 524b795b81..51ade13b6c 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start.rs @@ -34,7 +34,6 @@ use codex_app_server_protocol::JSONRPCNotification; use codex_app_server_protocol::JSONRPCResponse; use codex_app_server_protocol::PatchApplyStatus; use codex_app_server_protocol::PatchChangeKind; -use codex_app_server_protocol::PermissionProfileSelectionParams; use codex_app_server_protocol::RequestId; use codex_app_server_protocol::ServerRequest; use codex_app_server_protocol::ServerRequestResolvedNotification; @@ -291,7 +290,9 @@ async fn turn_start_emits_thread_scoped_warning_notification_for_trimmed_skills( write_test_skill(codex_home.path(), "alpha-skill")?; write_test_skill(codex_home.path(), "beta-skill")?; - let mut mcp = McpProcess::new(codex_home.path()).await?; + let home_dir = codex_home.path().display().to_string(); + let mut mcp = + McpProcess::new_with_env(codex_home.path(), &[("HOME", Some(home_dir.as_str()))]).await?; timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; let thread_req = mcp @@ -779,10 +780,7 @@ async fn turn_start_rejects_invalid_permission_selection_before_starting_turn() text: "Hello".to_string(), text_elements: Vec::new(), }], - permissions: Some(PermissionProfileSelectionParams::Profile { - id: ":danger-no-sandbox".to_string(), - modifications: None, - }), + permissions: Some(":danger-no-sandbox".to_string()), ..Default::default() }) .await?; @@ -812,6 +810,87 @@ async fn turn_start_rejects_invalid_permission_selection_before_starting_turn() Ok(()) } +#[tokio::test] +async fn turn_start_rejects_unknown_permission_selection_before_starting_turn() -> Result<()> { + let codex_home = TempDir::new()?; + create_config_toml( + codex_home.path(), + "http://localhost/unused", + "never", + &BTreeMap::from([(Feature::Personality, true)]), + )?; + let config_toml = codex_home.path().join("config.toml"); + let config_contents = std::fs::read_to_string(&config_toml)?; + let config_contents = config_contents.replace( + "model_provider = \"mock_provider\"\n\n[features]", + r#"model_provider = "mock_provider" + +default_permissions = "workspace" + +[permissions.workspace.filesystem] +":minimal" = "read" + +[permissions.workspace.filesystem.":project_roots"] +"." = "write" + +[features] +"#, + ); + std::fs::write(config_toml, config_contents)?; + + let mut mcp = McpProcess::new(codex_home.path()).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let thread_req = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let thread_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(thread_req)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(thread_resp)?; + let turn_req = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id, + input: vec![V2UserInput::Text { + text: "Hello".to_string(), + text_elements: Vec::new(), + }], + permissions: Some("missing-profile".to_string()), + ..Default::default() + }) + .await?; + let err: JSONRPCError = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_error_message(RequestId::Integer(turn_req)), + ) + .await??; + + assert_eq!(err.error.code, INVALID_REQUEST_ERROR_CODE); + assert!( + err.error + .message + .contains("default_permissions refers to undefined profile `missing-profile`"), + "unexpected error message: {}", + err.error.message + ); + let turn_started = tokio::time::timeout( + std::time::Duration::from_millis(250), + mcp.read_stream_until_notification_message("turn/started"), + ) + .await; + assert!( + turn_started.is_err(), + "did not expect a turn/started notification after rejected permissions selection" + ); + + Ok(()) +} + #[tokio::test] async fn turn_start_rejects_unknown_environment_before_starting_turn() -> Result<()> { let server = create_mock_responses_server_repeating_assistant("Done").await; @@ -1657,7 +1736,6 @@ async fn turn_start_exec_approval_toggle_v2() -> Result<()> { text_elements: Vec::new(), }], approval_policy: Some(codex_app_server_protocol::AskForApproval::Never), - sandbox_policy: Some(codex_app_server_protocol::SandboxPolicy::DangerFullAccess), model: Some("mock-model".to_string()), effort: Some(ReasoningEffort::Medium), summary: Some(ReasoningSummary::Auto), @@ -1825,7 +1903,7 @@ async fn turn_start_exec_approval_decline_v2() -> Result<()> { } #[tokio::test] -async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { +async fn turn_start_updates_cwd_without_replacing_workspace_roots_v2() -> Result<()> { skip_if_no_network!(Ok(())); let tmp = TempDir::new()?; @@ -1854,12 +1932,14 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { )?, create_final_assistant_message_sse_response("done second")?, ]; - let server = create_mock_responses_server_sequence(responses).await; - create_config_toml( + let server = responses::start_mock_server().await; + let response_mock = responses::mount_sse_sequence(&server, responses).await; + create_config_toml_with_sandbox( &codex_home, &server.uri(), "untrusted", &BTreeMap::default(), + "read-only", )?; let mut mcp = McpProcess::new(&codex_home).await?; @@ -1879,7 +1959,7 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { .await??; let ThreadStartResponse { thread, .. } = to_response::(start_resp)?; - // first turn with workspace-write sandbox and first_cwd + // first turn with first_cwd as the thread's workspace root let first_turn = mcp .send_turn_start_request(TurnStartParams { environments: None, @@ -1890,15 +1970,11 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { }], responsesapi_client_metadata: None, cwd: Some(first_cwd.clone()), + workspace_roots: Some(vec![first_cwd.clone().try_into()?]), approval_policy: Some(codex_app_server_protocol::AskForApproval::Never), approvals_reviewer: None, - sandbox_policy: Some(codex_app_server_protocol::SandboxPolicy::WorkspaceWrite { - writable_roots: vec![first_cwd.try_into()?], - network_access: false, - exclude_tmpdir_env_var: false, - exclude_slash_tmp: false, - }), - permissions: None, + sandbox_policy: None, + permissions: Some(":workspace".to_string()), model: Some("mock-model".to_string()), effort: Some(ReasoningEffort::Medium), summary: Some(ReasoningSummary::Auto), @@ -1920,7 +1996,8 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { .await??; mcp.clear_message_buffer(); - // second turn with workspace-write and second_cwd, ensure exec begins in second_cwd + // second turn changes cwd only; workspace roots stay on first_cwd while + // exec begins in second_cwd. let second_turn = mcp .send_turn_start_request(TurnStartParams { environments: None, @@ -1931,9 +2008,10 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { }], responsesapi_client_metadata: None, cwd: Some(second_cwd.clone()), + workspace_roots: None, approval_policy: Some(codex_app_server_protocol::AskForApproval::Never), approvals_reviewer: None, - sandbox_policy: Some(codex_app_server_protocol::SandboxPolicy::DangerFullAccess), + sandbox_policy: None, permissions: None, model: Some("mock-model".to_string()), effort: Some(ReasoningEffort::Medium), @@ -1981,6 +2059,22 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { assert_eq!(command, expected_command); assert_eq!(status, CommandExecutionStatus::InProgress); + let requests = response_mock.requests(); + assert!( + requests.len() >= 3, + "expected at least 3 model requests, got {}", + requests.len() + ); + let second_turn_developer_text = requests[2].message_input_texts("developer").join("\n"); + let first_cwd_name = first_cwd + .file_name() + .expect("first cwd should have a final path component") + .to_string_lossy(); + assert!( + second_turn_developer_text.contains(first_cwd_name.as_ref()), + "second turn developer instructions should retain first_cwd as a workspace root; got {second_turn_developer_text:?}" + ); + timeout( DEFAULT_READ_TIMEOUT, mcp.read_stream_until_notification_message("turn/completed"), @@ -1990,6 +2084,110 @@ async fn turn_start_updates_sandbox_and_cwd_between_turns_v2() -> Result<()> { Ok(()) } +#[tokio::test] +async fn turn_start_legacy_workspace_sandbox_updates_workspace_roots_for_cwd() -> Result<()> { + let tmp = TempDir::new()?; + let codex_home = tmp.path().join("codex_home"); + std::fs::create_dir(&codex_home)?; + let first_cwd = tmp.path().join("turn1"); + let second_cwd = tmp.path().join("turn2"); + std::fs::create_dir(&first_cwd)?; + std::fs::create_dir(&second_cwd)?; + + let server = responses::start_mock_server().await; + let response_mock = responses::mount_sse_sequence( + &server, + vec![ + create_final_assistant_message_sse_response("done first")?, + create_final_assistant_message_sse_response("done second")?, + ], + ) + .await; + create_config_toml(&codex_home, &server.uri(), "never", &BTreeMap::default())?; + + let mut mcp = McpProcess::new(&codex_home).await?; + timeout(DEFAULT_READ_TIMEOUT, mcp.initialize()).await??; + + let start_id = mcp + .send_thread_start_request(ThreadStartParams { + model: Some("mock-model".to_string()), + ..Default::default() + }) + .await?; + let start_resp: JSONRPCResponse = timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(start_id)), + ) + .await??; + let ThreadStartResponse { thread, .. } = to_response::(start_resp)?; + + let first_turn = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id.clone(), + input: vec![V2UserInput::Text { + text: "first turn".to_string(), + text_elements: Vec::new(), + }], + cwd: Some(first_cwd.clone()), + workspace_roots: Some(vec![first_cwd.clone().try_into()?]), + permissions: Some(":workspace".to_string()), + ..Default::default() + }) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(first_turn)), + ) + .await??; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let second_turn = mcp + .send_turn_start_request(TurnStartParams { + thread_id: thread.id, + input: vec![V2UserInput::Text { + text: "second turn".to_string(), + text_elements: Vec::new(), + }], + cwd: Some(second_cwd.clone()), + sandbox_policy: Some(codex_app_server_protocol::SandboxPolicy::WorkspaceWrite { + network_access: false, + exclude_tmpdir_env_var: false, + exclude_slash_tmp: false, + legacy_writable_roots: Vec::new(), + }), + ..Default::default() + }) + .await?; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_response_message(RequestId::Integer(second_turn)), + ) + .await??; + timeout( + DEFAULT_READ_TIMEOUT, + mcp.read_stream_until_notification_message("turn/completed"), + ) + .await??; + + let requests = response_mock.requests(); + assert_eq!(requests.len(), 2); + let second_turn_developer_text = requests[1].message_input_texts("developer").join("\n"); + let second_cwd_name = second_cwd + .file_name() + .expect("second cwd should have a final path component") + .to_string_lossy(); + assert!( + second_turn_developer_text.contains(second_cwd_name.as_ref()), + "legacy sandboxPolicy should rebind workspace roots to second_cwd; got {second_turn_developer_text:?}" + ); + + Ok(()) +} + #[tokio::test] async fn turn_start_resolves_sticky_thread_local_environment_and_turn_overrides() -> Result<()> { let tmp = TempDir::new()?; @@ -3306,7 +3504,6 @@ async fn command_execution_notifications_include_process_id() -> Result<()> { text: "run a command".to_string(), text_elements: Vec::new(), }], - sandbox_policy: Some(codex_app_server_protocol::SandboxPolicy::DangerFullAccess), ..Default::default() }) .await?; @@ -3402,7 +3599,8 @@ async fn command_execution_notifications_include_process_id() -> Result<()> { } #[tokio::test] -async fn turn_start_with_elevated_override_does_not_persist_project_trust() -> Result<()> { +async fn turn_start_accepts_legacy_sandbox_policy_and_does_not_persist_project_trust() -> Result<()> +{ let responses = vec![create_final_assistant_message_sse_response("Done")?]; let server = create_mock_responses_server_sequence_unchecked(responses).await; @@ -3436,7 +3634,12 @@ async fn turn_start_with_elevated_override_does_not_persist_project_trust() -> R .send_turn_start_request(TurnStartParams { thread_id: thread.id, cwd: Some(workspace.path().to_path_buf()), - sandbox_policy: Some(codex_app_server_protocol::SandboxPolicy::DangerFullAccess), + sandbox_policy: Some(codex_app_server_protocol::SandboxPolicy::WorkspaceWrite { + network_access: false, + exclude_tmpdir_env_var: false, + exclude_slash_tmp: false, + legacy_writable_roots: Vec::new(), + }), input: vec![V2UserInput::Text { text: "Hello".to_string(), text_elements: Vec::new(), diff --git a/codex-rs/app-server/tests/suite/v2/turn_start_zsh_fork.rs b/codex-rs/app-server/tests/suite/v2/turn_start_zsh_fork.rs index 31247418e5..c20eb58883 100644 --- a/codex-rs/app-server/tests/suite/v2/turn_start_zsh_fork.rs +++ b/codex-rs/app-server/tests/suite/v2/turn_start_zsh_fork.rs @@ -104,6 +104,7 @@ async fn turn_start_shell_zsh_fork_executes_command_v2() -> Result<()> { .send_thread_start_request(ThreadStartParams { model: Some("mock-model".to_string()), cwd: Some(workspace.to_string_lossy().into_owned()), + sandbox: Some(codex_app_server_protocol::SandboxMode::DangerFullAccess), ..Default::default() }) .await?; @@ -123,7 +124,6 @@ async fn turn_start_shell_zsh_fork_executes_command_v2() -> Result<()> { }], cwd: Some(workspace.clone()), approval_policy: Some(codex_app_server_protocol::AskForApproval::Never), - sandbox_policy: Some(codex_app_server_protocol::SandboxPolicy::DangerFullAccess), model: Some("mock-model".to_string()), effort: Some(codex_protocol::openai_models::ReasoningEffort::Medium), summary: Some(codex_protocol::config_types::ReasoningSummary::Auto), @@ -515,6 +515,7 @@ async fn turn_start_shell_zsh_fork_subcommand_decline_marks_parent_declined_v2() .send_thread_start_request(ThreadStartParams { model: Some("mock-model".to_string()), cwd: Some(workspace.to_string_lossy().into_owned()), + sandbox: Some(codex_app_server_protocol::SandboxMode::WorkspaceWrite), ..Default::default() }) .await?; @@ -533,13 +534,9 @@ async fn turn_start_shell_zsh_fork_subcommand_decline_marks_parent_declined_v2() text_elements: Vec::new(), }], cwd: Some(workspace.clone()), + workspace_roots: Some(vec![workspace.clone().try_into()?]), approval_policy: Some(codex_app_server_protocol::AskForApproval::UnlessTrusted), - sandbox_policy: Some(codex_app_server_protocol::SandboxPolicy::WorkspaceWrite { - writable_roots: vec![workspace.clone().try_into()?], - network_access: false, - exclude_tmpdir_env_var: false, - exclude_slash_tmp: false, - }), + sandbox_policy: None, model: Some("mock-model".to_string()), effort: Some(codex_protocol::openai_models::ReasoningEffort::Medium), summary: Some(codex_protocol::config_types::ReasoningSummary::Auto), diff --git a/codex-rs/cli/src/debug_sandbox.rs b/codex-rs/cli/src/debug_sandbox.rs index 2722f69849..895ac076fe 100644 --- a/codex-rs/cli/src/debug_sandbox.rs +++ b/codex-rs/cli/src/debug_sandbox.rs @@ -229,7 +229,7 @@ async fn run_command_under_sandbox( let network_proxy = match config.permissions.network.as_ref() { Some(spec) => Some( spec.start_proxy( - config.permissions.permission_profile.get(), + config.permissions.permission_profile_ref(), /*policy_decider*/ None, /*blocked_request_observer*/ None, managed_network_requirements_enabled, @@ -284,7 +284,7 @@ async fn run_command_under_sandbox( let args = create_linux_sandbox_command_args_for_permission_profile( command, cwd.as_path(), - &config.permissions.permission_profile(), + config.permissions.permission_profile_ref(), sandbox_policy_cwd.as_path(), use_legacy_landlock, allow_network_for_proxy(managed_network_requirements_enabled), @@ -769,6 +769,16 @@ mod tests { Ok(()) } + fn workspace_write_policy_for_codex_home( + codex_home: &TempDir, + ) -> codex_protocol::permissions::FileSystemSandboxPolicy { + let memories_root = AbsolutePathBuf::try_from(codex_home.path().join("memories")) + .expect("codex home tempdir should be absolute"); + codex_protocol::models::PermissionProfile::workspace_write() + .file_system_sandbox_policy() + .with_additional_legacy_workspace_writable_roots(std::slice::from_ref(&memories_root)) + } + #[tokio::test] async fn debug_sandbox_honors_active_permission_profiles() -> anyhow::Result<()> { let codex_home = TempDir::new()?; @@ -947,8 +957,7 @@ mod tests { assert_eq!( config.permissions.file_system_sandbox_policy(), - codex_protocol::models::PermissionProfile::workspace_write() - .file_system_sandbox_policy() + workspace_write_policy_for_codex_home(&codex_home) ); Ok(()) @@ -980,8 +989,7 @@ mod tests { assert_eq!( config.permissions.file_system_sandbox_policy(), - codex_protocol::models::PermissionProfile::workspace_write() - .file_system_sandbox_policy() + workspace_write_policy_for_codex_home(&codex_home) ); Ok(()) diff --git a/codex-rs/config/src/config_requirements.rs b/codex-rs/config/src/config_requirements.rs index 8bf3148ba8..2dccad1fca 100644 --- a/codex-rs/config/src/config_requirements.rs +++ b/codex-rs/config/src/config_requirements.rs @@ -2144,7 +2144,6 @@ allowed_approvals_reviewers = ["user"] let config: ConfigRequirementsToml = from_str(toml_str)?; let requirements: ConfigRequirements = with_unknown_source(config).try_into()?; - let root = if cfg!(windows) { "C:\\repo" } else { "/repo" }; assert!( requirements .permission_profile @@ -2154,7 +2153,6 @@ allowed_approvals_reviewers = ["user"] .is_ok() ); let workspace_write_policy = SandboxPolicy::WorkspaceWrite { - writable_roots: vec![AbsolutePathBuf::from_absolute_path(root)?], network_access: false, exclude_tmpdir_env_var: false, exclude_slash_tmp: false, @@ -2265,9 +2263,7 @@ allowed_approvals_reviewers = ["user"] ); let requirements = ConfigRequirements::try_from(requirements_with_sources)?; - let root = if cfg!(windows) { "C:\\repo" } else { "/repo" }; let workspace_write_policy = SandboxPolicy::WorkspaceWrite { - writable_roots: vec![AbsolutePathBuf::from_absolute_path(root)?], network_access: false, exclude_tmpdir_env_var: false, exclude_slash_tmp: false, diff --git a/codex-rs/config/src/config_toml.rs b/codex-rs/config/src/config_toml.rs index 989aab1691..0285f85349 100644 --- a/codex-rs/config/src/config_toml.rs +++ b/codex-rs/config/src/config_toml.rs @@ -757,7 +757,7 @@ impl ConfigToml { SandboxMode::ReadOnly => PermissionProfile::read_only(), SandboxMode::WorkspaceWrite => match self.sandbox_workspace_write.as_ref() { Some(SandboxWorkspaceWrite { - writable_roots, + writable_roots: _, network_access, exclude_tmpdir_env_var, exclude_slash_tmp, @@ -768,7 +768,7 @@ impl ConfigToml { NetworkSandboxPolicy::Restricted }; PermissionProfile::workspace_write_with( - writable_roots, + &[], network_policy, *exclude_tmpdir_env_var, *exclude_slash_tmp, diff --git a/codex-rs/core/src/codex_thread.rs b/codex-rs/core/src/codex_thread.rs index c110d30f66..0680429237 100644 --- a/codex-rs/core/src/codex_thread.rs +++ b/codex-rs/core/src/codex_thread.rs @@ -59,6 +59,7 @@ pub struct ThreadConfigSnapshot { pub permission_profile: PermissionProfile, pub active_permission_profile: Option, pub cwd: AbsolutePathBuf, + pub workspace_roots: Vec, pub ephemeral: bool, pub reasoning_effort: Option, pub personality: Option, @@ -68,11 +69,15 @@ pub struct ThreadConfigSnapshot { impl ThreadConfigSnapshot { pub fn sandbox_policy(&self) -> SandboxPolicy { - let file_system_sandbox_policy = self.permission_profile.file_system_sandbox_policy(); + let permission_profile = self + .permission_profile + .clone() + .materialize_project_roots_with_workspace_roots(&self.workspace_roots); + let file_system_sandbox_policy = permission_profile.file_system_sandbox_policy(); codex_sandboxing::compatibility_sandbox_policy_for_permission_profile( - &self.permission_profile, + &permission_profile, &file_system_sandbox_policy, - self.permission_profile.network_sandbox_policy(), + permission_profile.network_sandbox_policy(), self.cwd.as_path(), ) } @@ -82,6 +87,7 @@ impl ThreadConfigSnapshot { #[derive(Clone, Default)] pub struct CodexThreadTurnContextOverrides { pub cwd: Option, + pub workspace_roots: Option>, pub approval_policy: Option, pub approvals_reviewer: Option, pub sandbox_policy: Option, @@ -241,8 +247,26 @@ impl CodexThread { &self, overrides: CodexThreadTurnContextOverrides, ) -> ConstraintResult<()> { + let updates = self.turn_context_updates_from_overrides(overrides).await; + self.codex.session.validate_settings(&updates).await + } + + /// Apply persistent thread context overrides immediately. + pub async fn update_turn_context_overrides( + &self, + overrides: CodexThreadTurnContextOverrides, + ) -> ConstraintResult<()> { + let updates = self.turn_context_updates_from_overrides(overrides).await; + self.codex.session.update_settings(updates).await + } + + async fn turn_context_updates_from_overrides( + &self, + overrides: CodexThreadTurnContextOverrides, + ) -> SessionSettingsUpdate { let CodexThreadTurnContextOverrides { cwd, + workspace_roots, approval_policy, approvals_reviewer, sandbox_policy, @@ -266,8 +290,9 @@ impl CodexThread { .with_updates(model, effort, /*developer_instructions*/ None) }; - let updates = SessionSettingsUpdate { + SessionSettingsUpdate { cwd, + workspace_roots, approval_policy, approvals_reviewer, sandbox_policy, @@ -279,8 +304,7 @@ impl CodexThread { service_tier, personality, ..Default::default() - }; - self.codex.session.validate_settings(&updates).await + } } /// Use sparingly: this is intended to be removed soon. diff --git a/codex-rs/core/src/config/config_loader_tests.rs b/codex-rs/core/src/config/config_loader_tests.rs index 6296d13886..a3ae9a466f 100644 --- a/codex-rs/core/src/config/config_loader_tests.rs +++ b/codex-rs/core/src/config/config_loader_tests.rs @@ -527,9 +527,10 @@ writable_roots = ["~/code"] let expected_root = AbsolutePathBuf::from_absolute_path(home.join("code"))?; match &config.legacy_sandbox_policy() { - SandboxPolicy::WorkspaceWrite { writable_roots, .. } => { + SandboxPolicy::WorkspaceWrite { .. } => { assert_eq!( - writable_roots + config + .workspace_roots .iter() .filter(|root| **root == expected_root) .count(), @@ -593,7 +594,6 @@ allowed_sandbox_modes = ["read-only"] .permission_profile .can_set(&PermissionProfile::from_legacy_sandbox_policy( &SandboxPolicy::WorkspaceWrite { - writable_roots: Vec::new(), network_access: false, exclude_tmpdir_env_var: false, exclude_slash_tmp: false, diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs index 59ea489e3a..e02eaab6f8 100644 --- a/codex-rs/core/src/config/config_tests.rs +++ b/codex-rs/core/src/config/config_tests.rs @@ -68,7 +68,6 @@ use codex_model_provider_info::WireApi; use codex_models_manager::bundled_models_response; use codex_protocol::config_types::ServiceTier; use codex_protocol::models::ActivePermissionProfile; -use codex_protocol::models::ActivePermissionProfileModification; use codex_protocol::models::ManagedFileSystemPermissions; use codex_protocol::models::PermissionProfile; use codex_protocol::models::SandboxEnforcement; @@ -100,6 +99,14 @@ use std::path::Path; use std::time::Duration; use tempfile::TempDir; +fn materialized_file_system_sandbox_policy(config: &Config) -> FileSystemSandboxPolicy { + config + .permissions + .permission_profile() + .materialize_project_roots_with_workspace_roots(&config.workspace_roots) + .file_system_sandbox_policy() +} + fn stdio_mcp(command: &str) -> McpServerConfig { McpServerConfig { transport: McpServerTransportConfig::Stdio { @@ -1364,7 +1371,7 @@ async fn default_permissions_profile_populates_runtime_sandbox_policy() -> std:: }, FileSystemSandboxEntry { path: FileSystemPath::Path { - path: memories_root.clone(), + path: memories_root, }, access: FileSystemAccessMode::Write, }, @@ -1373,7 +1380,6 @@ async fn default_permissions_profile_populates_runtime_sandbox_policy() -> std:: assert_eq!( &config.legacy_sandbox_policy(), &SandboxPolicy::WorkspaceWrite { - writable_roots: vec![memories_root], network_access: false, exclude_tmpdir_env_var: true, exclude_slash_tmp: true, @@ -1550,7 +1556,6 @@ async fn permission_profile_override_applies_runtime_roots_to_legacy_projection( assert_eq!( &config.legacy_sandbox_policy(), &SandboxPolicy::WorkspaceWrite { - writable_roots: vec![memories_root], network_access: false, exclude_tmpdir_env_var: true, exclude_slash_tmp: true, @@ -1751,7 +1756,7 @@ async fn default_permissions_can_select_builtin_profile_without_permissions_tabl ) .await?; - let policy = config.permissions.file_system_sandbox_policy(); + let policy = materialized_file_system_sandbox_policy(&config); assert_eq!( config .permissions @@ -1772,7 +1777,7 @@ async fn default_permissions_can_select_builtin_profile_without_permissions_tabl } #[tokio::test] -async fn default_permissions_read_only_applies_additional_writable_roots_as_modifications() +async fn default_permissions_read_only_records_additional_writable_roots_as_workspace_roots() -> std::io::Result<()> { let codex_home = TempDir::new()?; let cwd = TempDir::new()?; @@ -1793,18 +1798,18 @@ async fn default_permissions_read_only_applies_additional_writable_roots_as_modi ) .await?; - let policy = config.permissions.file_system_sandbox_policy(); - assert!( - policy.can_write_path_with_cwd(extra_root.as_path(), cwd.path()), - "expected additional writable root to modify :read-only, policy: {policy:?}" + let policy = materialized_file_system_sandbox_policy(&config); + assert_eq!( + policy, + PermissionProfile::read_only().file_system_sandbox_policy() + ); + assert_eq!( + config.workspace_roots, + vec![cwd.path().abs(), extra_root.clone()] ); assert_eq!( config.permissions.active_permission_profile(), - Some( - ActivePermissionProfile::new(":read-only").with_modifications(vec![ - ActivePermissionProfileModification::AdditionalWritableRoot { path: extra_root }, - ]) - ) + Some(ActivePermissionProfile::new(":read-only")) ); Ok(()) } @@ -1835,7 +1840,7 @@ async fn explicit_builtin_workspace_profile_ignores_legacy_workspace_write_setti ) .await?; - let policy = config.permissions.file_system_sandbox_policy(); + let policy = materialized_file_system_sandbox_policy(&config); assert_eq!( config.permissions.network_sandbox_policy(), NetworkSandboxPolicy::Restricted @@ -1875,7 +1880,7 @@ async fn empty_config_defaults_to_builtin_profile_for_trusted_project() -> std:: ) .await?; - let policy = config.permissions.file_system_sandbox_policy(); + let policy = materialized_file_system_sandbox_policy(&config); assert_eq!( config .permissions @@ -1943,7 +1948,7 @@ async fn implicit_builtin_workspace_profile_preserves_sandbox_workspace_write_se ) .await?; - let policy = config.permissions.file_system_sandbox_policy(); + let policy = materialized_file_system_sandbox_policy(&config); assert!( policy.can_write_path_with_cwd(extra_root.as_path(), cwd.path()), "expected implicit :workspace to preserve sandbox_workspace_write.writable_roots, policy: {policy:?}" @@ -1960,12 +1965,11 @@ async fn implicit_builtin_workspace_profile_preserves_sandbox_workspace_write_se ); match config.legacy_sandbox_policy() { SandboxPolicy::WorkspaceWrite { - writable_roots, network_access, exclude_tmpdir_env_var, exclude_slash_tmp, } => { - assert!(writable_roots.contains(&extra_root)); + assert!(config.workspace_roots.contains(&extra_root)); assert!(network_access); assert!(exclude_tmpdir_env_var); assert!(!exclude_slash_tmp); @@ -2009,7 +2013,7 @@ async fn implicit_builtin_workspace_profile_preserves_add_dir_metadata_carveouts ) .await?; - let policy = config.permissions.file_system_sandbox_policy(); + let policy = materialized_file_system_sandbox_policy(&config); let extra_root = extra_root.path().abs(); assert!( policy.can_write_path_with_cwd(extra_root.as_path(), cwd.path()), @@ -2184,9 +2188,6 @@ async fn permissions_profiles_allow_direct_write_roots_outside_workspace_root() ) .await?; - let memories_root = AbsolutePathBuf::from_absolute_path(std::fs::canonicalize( - codex_home.path().join("memories"), - )?)?; assert!( config .permissions @@ -2196,7 +2197,6 @@ async fn permissions_profiles_allow_direct_write_roots_outside_workspace_root() assert_eq!( &config.legacy_sandbox_policy(), &SandboxPolicy::WorkspaceWrite { - writable_roots: vec![external_write_path, memories_root], network_access: false, exclude_tmpdir_env_var: true, exclude_slash_tmp: true, @@ -2871,7 +2871,6 @@ trust_level = "trusted" assert_eq!( resolution, SandboxPolicy::WorkspaceWrite { - writable_roots: vec![writable_root.clone()], network_access: false, exclude_tmpdir_env_var: true, exclude_slash_tmp: true, @@ -2911,7 +2910,6 @@ exclude_slash_tmp = true assert_eq!( resolution, SandboxPolicy::WorkspaceWrite { - writable_roots: vec![writable_root], network_access: false, exclude_tmpdir_env_var: true, exclude_slash_tmp: true, @@ -2975,13 +2973,21 @@ exclude_slash_tmp = true NetworkSandboxPolicy::from(&sandbox_policy), "case `{name}` should preserve network semantics from legacy config" ); - assert_eq!( - file_system_policy - .to_legacy_sandbox_policy(network_policy, cwd.path()) - .unwrap_or_else(|err| panic!("case `{name}` should round-trip: {err}")), - sandbox_policy, - "case `{name}` should preserve its legacy compatibility projection" - ); + let direct_legacy_projection = + file_system_policy.to_legacy_sandbox_policy(network_policy, cwd.path()); + if name == "workspace-write" && !cfg!(target_os = "windows") { + assert!( + direct_legacy_projection.is_err(), + "case `{name}` should require the compatibility projection for split workspace roots" + ); + } else { + assert_eq!( + direct_legacy_projection + .unwrap_or_else(|err| panic!("case `{name}` should round-trip: {err}")), + sandbox_policy, + "case `{name}` should preserve its legacy compatibility projection" + ); + } match name.as_str() { "danger-full-access" | "read-only" => { @@ -3023,14 +3029,19 @@ exclude_slash_tmp = true }) ); assert!( - file_system_policy + config.workspace_roots.contains(&extra_root), + "case `{name}` should store legacy writable roots as thread workspace roots" + ); + assert!( + materialized_file_system_sandbox_policy(&config) .entries .contains(&FileSystemSandboxEntry { path: FileSystemPath::Path { path: extra_root.clone(), }, access: FileSystemAccessMode::Write, - }) + }), + "case `{name}` should materialize workspace roots into runtime write access" ); for subpath in [".git", ".agents", ".codex"] { assert!( @@ -3773,14 +3784,15 @@ async fn add_dir_override_extends_workspace_writable_roots() -> std::io::Result< } } else { match &config.legacy_sandbox_policy() { - SandboxPolicy::WorkspaceWrite { writable_roots, .. } => { + SandboxPolicy::WorkspaceWrite { .. } => { assert_eq!( - writable_roots + config + .workspace_roots .iter() .filter(|root| **root == expected_backend) .count(), 1, - "expected single writable root entry for {}", + "expected single workspace root entry for {}", expected_backend.display() ); } @@ -3840,13 +3852,16 @@ async fn workspace_write_always_includes_memories_root_once() -> std::io::Result "expected memories root directory to exist at {}", memories_root.display() ); - let expected_memories_root = memories_root.abs(); + let expected_memories_root = + AbsolutePathBuf::from_absolute_path(std::fs::canonicalize(&memories_root)?)?; match &config.legacy_sandbox_policy() { - SandboxPolicy::WorkspaceWrite { writable_roots, .. } => { + SandboxPolicy::WorkspaceWrite { .. } => { + let writable_roots = materialized_file_system_sandbox_policy(&config) + .get_writable_roots_with_cwd(config.cwd.as_path()); assert_eq!( writable_roots .iter() - .filter(|root| **root == expected_memories_root) + .filter(|root| root.root == expected_memories_root) .count(), 1, "expected single writable root entry for {}", @@ -7294,6 +7309,7 @@ async fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> { user_instructions: None, notify: None, cwd: fixture.cwd(), + workspace_roots: vec![fixture.cwd()], cli_auth_credentials_store_mode: Default::default(), mcp_servers: Constrained::allow_any(HashMap::new()), mcp_oauth_credentials_store_mode: resolve_mcp_oauth_credentials_store_mode( @@ -7740,6 +7756,7 @@ async fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> { user_instructions: None, notify: None, cwd: fixture.cwd(), + workspace_roots: vec![fixture.cwd()], cli_auth_credentials_store_mode: Default::default(), mcp_servers: Constrained::allow_any(HashMap::new()), mcp_oauth_credentials_store_mode: resolve_mcp_oauth_credentials_store_mode( @@ -7900,6 +7917,7 @@ async fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> { user_instructions: None, notify: None, cwd: fixture.cwd(), + workspace_roots: vec![fixture.cwd()], cli_auth_credentials_store_mode: Default::default(), mcp_servers: Constrained::allow_any(HashMap::new()), mcp_oauth_credentials_store_mode: resolve_mcp_oauth_credentials_store_mode( @@ -8045,6 +8063,7 @@ async fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> { user_instructions: None, notify: None, cwd: fixture.cwd(), + workspace_roots: vec![fixture.cwd()], cli_auth_credentials_store_mode: Default::default(), mcp_servers: Constrained::allow_any(HashMap::new()), mcp_oauth_credentials_store_mode: resolve_mcp_oauth_credentials_store_mode( @@ -9052,7 +9071,7 @@ async fn requirements_web_search_mode_overrides_danger_full_access_default() -> assert_eq!( resolve_web_search_mode_for_turn( &config.web_search_mode, - &config.permissions.permission_profile(), + config.permissions.permission_profile_ref(), ), WebSearchMode::Cached, ); diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs index f5dda3fc38..b1a8e7ac76 100644 --- a/codex-rs/core/src/config/mod.rs +++ b/codex-rs/core/src/config/mod.rs @@ -89,7 +89,6 @@ use codex_protocol::config_types::WebSearchConfig; use codex_protocol::config_types::WebSearchMode; use codex_protocol::config_types::WindowsSandboxLevel; use codex_protocol::models::ActivePermissionProfile; -use codex_protocol::models::ActivePermissionProfileModification; use codex_protocol::models::PermissionProfile; use codex_protocol::models::SandboxEnforcement; use codex_protocol::openai_models::ModelsResponse; @@ -242,10 +241,10 @@ pub struct Permissions { pub approval_policy: Constrained, /// Canonical effective runtime permissions after config requirements and /// runtime readable-root additions have been applied. - pub permission_profile: Constrained, + permission_profile: Constrained, /// Named or implicit built-in profile selected by config, rather than an /// ad-hoc override. - pub active_permission_profile: Option, + active_permission_profile: Option, /// Effective network configuration applied to all spawned processes. pub network: Option, /// Whether the model may request a login shell for shell-based tools. @@ -267,6 +266,35 @@ pub struct Permissions { } impl Permissions { + /// Build permissions from the two constrained values that are required for + /// a minimal in-process configuration. + pub fn from_approval_and_profile( + approval_policy: Constrained, + permission_profile: Constrained, + ) -> Self { + Self { + approval_policy, + permission_profile, + active_permission_profile: None, + network: None, + allow_login_shell: true, + shell_environment_policy: ShellEnvironmentPolicy::default(), + windows_sandbox_mode: None, + windows_sandbox_private_desktop: true, + } + } + + /// Borrow the constrained canonical profile. This is for code that must + /// preserve or inspect the active requirements wrapper without mutating it. + pub fn permission_profile_constraint(&self) -> &Constrained { + &self.permission_profile + } + + /// Borrow the canonical effective runtime permissions without cloning. + pub fn permission_profile_ref(&self) -> &PermissionProfile { + self.permission_profile.get() + } + /// Effective runtime permissions after config requirements and runtime /// readable-root additions have been applied. pub fn permission_profile(&self) -> PermissionProfile { @@ -278,6 +306,45 @@ impl Permissions { self.active_permission_profile.clone() } + /// Replace the full constrained profile value and clear any profile-name + /// sidecar because the new constraint may no longer match that name. + pub fn replace_permission_profile_constraint( + &mut self, + permission_profile: Constrained, + ) { + self.permission_profile = permission_profile; + self.active_permission_profile = None; + } + + /// Replace the full constrained profile value and preserve the active + /// profile sidecar when the caller has already validated both together. + pub fn replace_permission_profile_constraint_with_active_profile( + &mut self, + permission_profile: Constrained, + active_permission_profile: Option, + ) { + self.permission_profile = permission_profile; + self.active_permission_profile = active_permission_profile; + } + + /// Record the active profile id only if it still describes the current + /// canonical profile. + pub fn set_active_permission_profile_for_current_profile( + &mut self, + active_permission_profile: Option, + expected_permission_profile: Option<&PermissionProfile>, + ) { + self.active_permission_profile = + match (active_permission_profile, expected_permission_profile) { + (Some(active_permission_profile), Some(expected_permission_profile)) + if self.permission_profile.get() == expected_permission_profile => + { + Some(active_permission_profile) + } + _ => None, + }; + } + /// Effective filesystem sandbox policy derived from the canonical profile. pub fn file_system_sandbox_policy(&self) -> FileSystemSandboxPolicy { self.permission_profile.get().file_system_sandbox_policy() @@ -576,6 +643,10 @@ pub struct Config { /// layer are resolved against this path. pub cwd: AbsolutePathBuf, + /// Absolute roots that define the writable project/workspace set for + /// symbolic `:project_roots` permission entries. + pub workspace_roots: Vec, + /// Preferred store for CLI auth credentials. /// file (default): Use a file in the Codex home directory. /// keyring: Use an OS-specific keyring service. @@ -1857,6 +1928,11 @@ fn apply_managed_filesystem_constraints( } } +fn dedupe_absolute_paths(paths: &mut Vec) { + let mut seen = HashSet::new(); + paths.retain(|path| seen.insert(path.clone())); +} + /// Optional overrides for user configuration (e.g., from CLI flags). #[derive(Default, Debug, Clone)] pub struct ConfigOverrides { @@ -1885,6 +1961,9 @@ pub struct ConfigOverrides { pub ephemeral: Option, /// Additional directories that should be treated as writable roots for this session. pub additional_writable_roots: Vec, + /// Explicit workspace roots for this session. When set, this is the full + /// root list rather than an additive override. + pub workspace_roots: Option>, } /// Resolves the OSS provider from CLI override, profile config, or global config. @@ -2166,6 +2245,7 @@ impl Config { tools_web_search_request: override_tools_web_search_request, ephemeral, additional_writable_roots, + workspace_roots: workspace_roots_override, } = overrides; if sandbox_mode.is_some() && permission_profile.is_some() { @@ -2254,11 +2334,10 @@ impl Config { } } }))?; - let mut additional_writable_roots: Vec = additional_writable_roots + let requested_additional_writable_roots: Vec = additional_writable_roots .into_iter() .map(|path| AbsolutePathBuf::resolve_path_against_base(path, resolved_cwd.as_path())) .collect(); - let requested_additional_writable_roots = additional_writable_roots.clone(); let repo_root = resolve_root_git_project_for_trust(fs, &resolved_cwd).await; let active_project = cfg .get_active_project( @@ -2300,12 +2379,7 @@ impl Config { }; let memories_root = memory_root(&codex_home); std::fs::create_dir_all(&memories_root)?; - if !additional_writable_roots - .iter() - .any(|existing| existing == &memories_root) - { - additional_writable_roots.push(memories_root); - } + let internal_writable_roots = vec![memories_root]; let profiles_are_active = default_permissions_override.is_some() || matches!( @@ -2315,6 +2389,31 @@ impl Config { || permission_config_syntax.is_none(); let using_implicit_builtin_profile = permission_config_syntax.is_none() && default_permissions.is_none(); + let should_seed_legacy_workspace_roots = + default_permissions.is_none() + && matches!( + permission_config_syntax, + None | Some(PermissionConfigSyntax::Legacy) + ); + let mut workspace_roots = match workspace_roots_override { + Some(workspace_roots) => workspace_roots + .into_iter() + .map(|path| { + AbsolutePathBuf::resolve_path_against_base(path, resolved_cwd.as_path()) + }) + .collect(), + None => { + let mut workspace_roots = vec![resolved_cwd.clone()]; + workspace_roots.extend(requested_additional_writable_roots.clone()); + if should_seed_legacy_workspace_roots + && let Some(sandbox_workspace_write) = cfg.sandbox_workspace_write.as_ref() + { + workspace_roots.extend(sandbox_workspace_write.writable_roots.clone()); + } + workspace_roots + } + }; + dedupe_absolute_paths(&mut workspace_roots); let ( mut configured_network_proxy_config, permission_profile, @@ -2351,10 +2450,7 @@ impl Config { ); if matches!(sandbox_policy, SandboxPolicy::WorkspaceWrite { .. }) { file_system_sandbox_policy = file_system_sandbox_policy - .with_additional_writable_roots( - resolved_cwd.as_path(), - &additional_writable_roots, - ); + .with_additional_legacy_workspace_writable_roots(&internal_writable_roots); permission_profile = PermissionProfile::from_runtime_permissions_with_enforcement( permission_profile.enforcement(), &file_system_sandbox_policy, @@ -2405,28 +2501,8 @@ impl Config { resolved_cwd.as_path(), ); if matches!(sandbox_policy, SandboxPolicy::WorkspaceWrite { .. }) { - file_system_sandbox_policy = if using_implicit_builtin_profile { - file_system_sandbox_policy - .with_additional_legacy_workspace_writable_roots( - &additional_writable_roots, - ) - } else { - file_system_sandbox_policy.with_additional_writable_roots( - resolved_cwd.as_path(), - &additional_writable_roots, - ) - }; - permission_profile = PermissionProfile::from_runtime_permissions( - &file_system_sandbox_policy, - network_sandbox_policy, - ); - } else if matches!(permission_profile, PermissionProfile::Managed { .. }) - && !requested_additional_writable_roots.is_empty() - { - file_system_sandbox_policy = file_system_sandbox_policy.with_additional_writable_roots( - resolved_cwd.as_path(), - &requested_additional_writable_roots, - ); + file_system_sandbox_policy = file_system_sandbox_policy + .with_additional_legacy_workspace_writable_roots(&internal_writable_roots); permission_profile = PermissionProfile::from_runtime_permissions( &file_system_sandbox_policy, network_sandbox_policy, @@ -2443,22 +2519,7 @@ impl Config { // when doing so would lose roots, network, or tmp settings. None } else { - let active_permission_profile = if !requested_additional_writable_roots.is_empty() - && matches!(permission_profile, PermissionProfile::Managed { .. }) - { - ActivePermissionProfile::new(default_permissions).with_modifications( - requested_additional_writable_roots - .iter() - .cloned() - .map(|path| { - ActivePermissionProfileModification::AdditionalWritableRoot { path } - }) - .collect(), - ) - } else { - ActivePermissionProfile::new(default_permissions) - }; - Some(active_permission_profile) + Some(ActivePermissionProfile::new(default_permissions)) }; ( configured_network_proxy_config, @@ -2497,25 +2558,25 @@ impl Config { } let (mut file_system_sandbox_policy, network_sandbox_policy) = permission_profile.to_runtime_permissions(); - // `additional_writable_roots` is a legacy workspace-write knob. It - // only applies when the derived managed profile has workspace-style - // write access to the project roots; read-only, disabled, external, - // and future non-workspace profiles must not silently grow extra - // write access. + // Internal writable roots only apply when the derived managed + // profile has workspace-style write access to the project roots; + // read-only, disabled, external, and future non-workspace profiles + // must not silently grow extra write access. + let materialized_file_system_sandbox_policy = permission_profile + .materialize_project_roots_with_workspace_roots(&workspace_roots) + .file_system_sandbox_policy(); if matches!(permission_profile.enforcement(), SandboxEnforcement::Managed) - && file_system_sandbox_policy.can_write_path_with_cwd( + && materialized_file_system_sandbox_policy.can_write_path_with_cwd( resolved_cwd.as_path(), resolved_cwd.as_path(), ) - && !file_system_sandbox_policy.has_full_disk_write_access() + && !materialized_file_system_sandbox_policy.has_full_disk_write_access() { - // Keep legacy behavior for extra writable roots while storing - // the result as the canonical permission profile. Explicit - // extra roots are concrete paths, so their metadata carveouts - // are also concrete rather than symbolic `:project_roots` - // entries. + // Keep Codex runtime write access while storing the result as + // the canonical permission profile. Workspace roots themselves + // are held separately on the thread. file_system_sandbox_policy = file_system_sandbox_policy - .with_additional_legacy_workspace_writable_roots(&additional_writable_roots); + .with_additional_legacy_workspace_writable_roots(&internal_writable_roots); permission_profile = PermissionProfile::from_runtime_permissions_with_enforcement( permission_profile.enforcement(), &file_system_sandbox_policy, @@ -3009,6 +3070,7 @@ impl Config { model_provider_id, model_provider, cwd: resolved_cwd, + workspace_roots, startup_warnings, permissions: Permissions { approval_policy: constrained_approval_policy.value, @@ -3298,7 +3360,7 @@ impl Config { pub fn managed_network_requirements_enabled(&self) -> bool { !matches!( - self.permissions.permission_profile.get(), + self.permissions.permission_profile_ref(), PermissionProfile::Disabled ) && self .config_layer_stack diff --git a/codex-rs/core/src/config/permissions.rs b/codex-rs/core/src/config/permissions.rs index 6b6021ad34..eb4da0d447 100644 --- a/codex-rs/core/src/config/permissions.rs +++ b/codex-rs/core/src/config/permissions.rs @@ -68,12 +68,12 @@ pub(crate) fn builtin_permission_profile( BUILT_IN_READ_ONLY_PROFILE => Some(PermissionProfile::read_only()), BUILT_IN_WORKSPACE_PROFILE => Some(match workspace_write { Some(SandboxWorkspaceWrite { - writable_roots, + writable_roots: _, network_access, exclude_tmpdir_env_var, exclude_slash_tmp, }) => PermissionProfile::workspace_write_with( - writable_roots, + &[], if *network_access { NetworkSandboxPolicy::Enabled } else { diff --git a/codex-rs/core/src/context/permissions_instructions.rs b/codex-rs/core/src/context/permissions_instructions.rs index 0ccd6c33a7..4f30c644c0 100644 --- a/codex-rs/core/src/context/permissions_instructions.rs +++ b/codex-rs/core/src/context/permissions_instructions.rs @@ -30,6 +30,8 @@ const SANDBOX_MODE_DANGER_FULL_ACCESS: &str = const SANDBOX_MODE_WORKSPACE_WRITE: &str = include_str!("prompts/permissions/sandbox_mode/workspace_write.md"); const SANDBOX_MODE_READ_ONLY: &str = include_str!("prompts/permissions/sandbox_mode/read_only.md"); +const MAX_WRITABLE_ROOTS_IN_PROMPT: usize = 8; +const MAX_WRITABLE_ROOT_LABEL_CHARS: usize = 160; static SANDBOX_MODE_DANGER_FULL_ACCESS_TEMPLATE: LazyLock