diff --git a/README.md b/README.md
index 5cc7fd4953..77c8d2199c 100644
--- a/README.md
+++ b/README.md
@@ -1,4 +1,3 @@
-
npm i -g @openai/codex
or brew install --cask codex
Codex CLI is a coding agent from OpenAI that runs locally on your computer.
@@ -14,7 +13,19 @@ If you want Codex in your code editor (VS Code, Cursor, Windsurf), | null, allowedSandboxModes: Array | null, allowedPermissions: Array | null, allowedWebSearchModes: Array | null, allowManagedHooksOnly: boolean | null, computerUse: ComputerUseRequirements | null, featureRequirements: { [key in string]?: boolean } | null, enforceResidency: ResidencyRequirement | null};
+export type ConfigRequirements = {allowedApprovalPolicies: Array | null, allowedSandboxModes: Array | null, allowedPermissions: Array | null, allowedWebSearchModes: Array | null, allowManagedHooksOnly: boolean | null, allowAppshots: boolean | null, computerUse: ComputerUseRequirements | null, featureRequirements: { [key in string]?: boolean } | null, enforceResidency: ResidencyRequirement | null};
diff --git a/codex-rs/app-server-protocol/src/protocol/thread_history.rs b/codex-rs/app-server-protocol/src/protocol/thread_history.rs
index 0fbb93f397..c0c7d55528 100644
--- a/codex-rs/app-server-protocol/src/protocol/thread_history.rs
+++ b/codex-rs/app-server-protocol/src/protocol/thread_history.rs
@@ -1393,6 +1393,7 @@ mod tests {
let events = vec![
EventMsg::TurnStarted(TurnStartedEvent {
turn_id: turn_id.to_string(),
+ trace_id: None,
started_at: None,
model_context_window: None,
collaboration_mode_kind: Default::default(),
@@ -1471,6 +1472,7 @@ mod tests {
let items = vec![
RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent {
turn_id: "turn-image".into(),
+ trace_id: None,
started_at: None,
model_context_window: None,
collaboration_mode_kind: Default::default(),
@@ -1806,6 +1808,7 @@ mod tests {
let events = vec![
EventMsg::TurnStarted(TurnStartedEvent {
turn_id: "turn-a".into(),
+ trace_id: None,
started_at: None,
model_context_window: None,
collaboration_mode_kind: Default::default(),
@@ -1866,6 +1869,7 @@ mod tests {
let events = vec![
EventMsg::TurnStarted(TurnStartedEvent {
turn_id: "turn-1".into(),
+ trace_id: None,
started_at: None,
model_context_window: None,
collaboration_mode_kind: Default::default(),
@@ -1978,6 +1982,7 @@ mod tests {
let events = vec![
EventMsg::TurnStarted(TurnStartedEvent {
turn_id: "turn-1".into(),
+ trace_id: None,
started_at: None,
model_context_window: None,
collaboration_mode_kind: Default::default(),
@@ -2043,6 +2048,7 @@ mod tests {
let events = vec![
EventMsg::TurnStarted(TurnStartedEvent {
turn_id: "turn-1".into(),
+ trace_id: None,
started_at: None,
model_context_window: None,
collaboration_mode_kind: Default::default(),
@@ -2109,6 +2115,7 @@ mod tests {
let events = vec![
EventMsg::TurnStarted(TurnStartedEvent {
turn_id: "turn-1".into(),
+ trace_id: None,
started_at: None,
model_context_window: None,
collaboration_mode_kind: Default::default(),
@@ -2199,6 +2206,7 @@ mod tests {
let events = vec![
EventMsg::TurnStarted(TurnStartedEvent {
turn_id: "turn-1".into(),
+ trace_id: None,
started_at: None,
model_context_window: None,
collaboration_mode_kind: Default::default(),
@@ -2283,6 +2291,7 @@ mod tests {
let events = vec![
EventMsg::TurnStarted(TurnStartedEvent {
turn_id: "turn-1".into(),
+ trace_id: None,
started_at: None,
model_context_window: None,
collaboration_mode_kind: Default::default(),
@@ -2347,6 +2356,7 @@ mod tests {
let events = vec![
EventMsg::TurnStarted(TurnStartedEvent {
turn_id: "turn-a".into(),
+ trace_id: None,
started_at: None,
model_context_window: None,
collaboration_mode_kind: Default::default(),
@@ -2367,6 +2377,7 @@ mod tests {
}),
EventMsg::TurnStarted(TurnStartedEvent {
turn_id: "turn-b".into(),
+ trace_id: None,
started_at: None,
model_context_window: None,
collaboration_mode_kind: Default::default(),
@@ -2441,6 +2452,7 @@ mod tests {
let events = vec![
EventMsg::TurnStarted(TurnStartedEvent {
turn_id: "turn-a".into(),
+ trace_id: None,
started_at: None,
model_context_window: None,
collaboration_mode_kind: Default::default(),
@@ -2461,6 +2473,7 @@ mod tests {
}),
EventMsg::TurnStarted(TurnStartedEvent {
turn_id: "turn-b".into(),
+ trace_id: None,
started_at: None,
model_context_window: None,
collaboration_mode_kind: Default::default(),
@@ -2530,6 +2543,7 @@ mod tests {
let events = vec![
EventMsg::TurnStarted(TurnStartedEvent {
turn_id: turn_id.to_string(),
+ trace_id: None,
started_at: None,
model_context_window: None,
collaboration_mode_kind: Default::default(),
@@ -2595,6 +2609,7 @@ mod tests {
let events = vec![
EventMsg::TurnStarted(TurnStartedEvent {
turn_id: turn_id.to_string(),
+ trace_id: None,
started_at: None,
model_context_window: None,
collaboration_mode_kind: Default::default(),
@@ -2660,6 +2675,7 @@ mod tests {
let events = vec![
EventMsg::TurnStarted(TurnStartedEvent {
turn_id: "turn-a".into(),
+ trace_id: None,
started_at: None,
model_context_window: None,
collaboration_mode_kind: Default::default(),
@@ -2680,6 +2696,7 @@ mod tests {
}),
EventMsg::TurnStarted(TurnStartedEvent {
turn_id: "turn-b".into(),
+ trace_id: None,
started_at: None,
model_context_window: None,
collaboration_mode_kind: Default::default(),
@@ -2728,6 +2745,7 @@ mod tests {
let events = vec![
EventMsg::TurnStarted(TurnStartedEvent {
turn_id: "turn-a".into(),
+ trace_id: None,
started_at: None,
model_context_window: None,
collaboration_mode_kind: Default::default(),
@@ -2748,6 +2766,7 @@ mod tests {
}),
EventMsg::TurnStarted(TurnStartedEvent {
turn_id: "turn-b".into(),
+ trace_id: None,
started_at: None,
model_context_window: None,
collaboration_mode_kind: Default::default(),
@@ -2789,6 +2808,7 @@ mod tests {
let items = vec![
RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent {
turn_id: "turn-compact".into(),
+ trace_id: None,
started_at: None,
model_context_window: None,
collaboration_mode_kind: Default::default(),
@@ -3042,6 +3062,7 @@ mod tests {
let events = vec![
EventMsg::TurnStarted(TurnStartedEvent {
turn_id: "turn-a".into(),
+ trace_id: None,
started_at: None,
model_context_window: None,
collaboration_mode_kind: Default::default(),
@@ -3098,6 +3119,7 @@ mod tests {
let events = vec![
EventMsg::TurnStarted(TurnStartedEvent {
turn_id: "turn-a".into(),
+ trace_id: None,
started_at: None,
model_context_window: None,
collaboration_mode_kind: Default::default(),
@@ -3156,6 +3178,7 @@ mod tests {
let items = vec![
RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent {
turn_id: "turn-a".into(),
+ trace_id: None,
started_at: None,
model_context_window: None,
collaboration_mode_kind: Default::default(),
@@ -3204,6 +3227,7 @@ mod tests {
let items = vec![
RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent {
turn_id: "turn-a".into(),
+ trace_id: None,
started_at: None,
model_context_window: None,
collaboration_mode_kind: Default::default(),
diff --git a/codex-rs/app-server-protocol/src/protocol/v2/config.rs b/codex-rs/app-server-protocol/src/protocol/v2/config.rs
index 25f62f2c24..fffe213811 100644
--- a/codex-rs/app-server-protocol/src/protocol/v2/config.rs
+++ b/codex-rs/app-server-protocol/src/protocol/v2/config.rs
@@ -361,6 +361,7 @@ pub struct ConfigRequirements {
pub allowed_permissions: Option>,
pub allowed_web_search_modes: Option>,
pub allow_managed_hooks_only: Option,
+ pub allow_appshots: Option,
pub computer_use: Option,
pub feature_requirements: Option>,
#[experimental("configRequirements/read.hooks")]
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 7112e9eb13..8f6824f397 100644
--- a/codex-rs/app-server-protocol/src/protocol/v2/tests.rs
+++ b/codex-rs/app-server-protocol/src/protocol/v2/tests.rs
@@ -1593,6 +1593,7 @@ fn config_requirements_granular_allowed_approval_policy_is_marked_experimental()
allowed_permissions: None,
allowed_web_search_modes: None,
allow_managed_hooks_only: None,
+ allow_appshots: None,
computer_use: None,
feature_requirements: None,
hooks: None,
diff --git a/codex-rs/app-server/src/bespoke_event_handling.rs b/codex-rs/app-server/src/bespoke_event_handling.rs
index efe6c69a32..5f784ea23a 100644
--- a/codex-rs/app-server/src/bespoke_event_handling.rs
+++ b/codex-rs/app-server/src/bespoke_event_handling.rs
@@ -3219,6 +3219,7 @@ mod tests {
"turn-1",
&EventMsg::TurnStarted(codex_protocol::protocol::TurnStartedEvent {
turn_id: "turn-1".to_string(),
+ trace_id: None,
started_at: Some(42),
model_context_window: None,
collaboration_mode_kind: Default::default(),
@@ -3252,6 +3253,7 @@ mod tests {
id: "turn-1".to_string(),
msg: EventMsg::TurnStarted(codex_protocol::protocol::TurnStartedEvent {
turn_id: "turn-1".to_string(),
+ trace_id: None,
started_at: Some(42),
model_context_window: None,
collaboration_mode_kind: Default::default(),
@@ -3301,6 +3303,7 @@ mod tests {
&event_turn_id,
&EventMsg::TurnStarted(codex_protocol::protocol::TurnStartedEvent {
turn_id: event_turn_id.clone(),
+ trace_id: None,
started_at: Some(42),
model_context_window: None,
collaboration_mode_kind: Default::default(),
diff --git a/codex-rs/app-server/src/request_processors/config_processor.rs b/codex-rs/app-server/src/request_processors/config_processor.rs
index 8f50a3c216..72948270a6 100644
--- a/codex-rs/app-server/src/request_processors/config_processor.rs
+++ b/codex-rs/app-server/src/request_processors/config_processor.rs
@@ -431,6 +431,7 @@ fn map_requirements_toml_to_api(requirements: ConfigRequirementsToml) -> ConfigR
normalized
}),
allow_managed_hooks_only: requirements.allow_managed_hooks_only,
+ allow_appshots: requirements.allow_appshots,
computer_use: requirements
.computer_use
.map(map_computer_use_requirements_to_api),
@@ -658,6 +659,17 @@ mod tests {
assert_eq!(mapped.hooks, None);
}
+ #[test]
+ fn requirements_api_includes_allow_appshots() {
+ let mapped = map_requirements_toml_to_api(ConfigRequirementsToml {
+ allow_appshots: Some(false),
+ ..ConfigRequirementsToml::default()
+ });
+
+ assert_eq!(mapped.allow_appshots, Some(false));
+ assert_eq!(mapped.hooks, None);
+ }
+
#[test]
fn requirements_api_includes_computer_use_requirements() {
let mapped = map_requirements_toml_to_api(ConfigRequirementsToml {
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 f4593ab7c4..c6b5541aa2 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
@@ -1155,6 +1155,7 @@ mod thread_processor_behavior_tests {
"turn-1",
&EventMsg::TurnStarted(codex_protocol::protocol::TurnStartedEvent {
turn_id: "turn-1".to_string(),
+ trace_id: None,
started_at: None,
model_context_window: None,
collaboration_mode_kind: Default::default(),
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 f21a973f63..6416499663 100644
--- a/codex-rs/app-server/tests/suite/v2/thread_resume.rs
+++ b/codex-rs/app-server/tests/suite/v2/thread_resume.rs
@@ -1431,6 +1431,7 @@ async fn thread_resume_token_usage_replay_ignores_stale_interrupted_tail_turn()
"type": "event_msg",
"payload": serde_json::to_value(EventMsg::TurnStarted(TurnStartedEvent {
turn_id: stale_turn_id.to_string(),
+ trace_id: None,
started_at: None,
model_context_window: None,
collaboration_mode_kind: Default::default(),
@@ -1518,6 +1519,7 @@ async fn thread_resume_token_usage_replay_can_belong_to_interrupted_turn() -> Re
"type": "event_msg",
"payload": serde_json::to_value(EventMsg::TurnStarted(TurnStartedEvent {
turn_id: interrupted_turn_id.to_string(),
+ trace_id: None,
started_at: None,
model_context_window: None,
collaboration_mode_kind: Default::default(),
@@ -1835,6 +1837,7 @@ async fn thread_resume_and_read_interrupt_incomplete_rollout_turn_when_thread_is
"type": "event_msg",
"payload": serde_json::to_value(EventMsg::TurnStarted(TurnStartedEvent {
turn_id: turn_id.to_string(),
+ trace_id: None,
started_at: None,
model_context_window: None,
collaboration_mode_kind: Default::default(),
diff --git a/codex-rs/cli/src/debug_sandbox.rs b/codex-rs/cli/src/debug_sandbox.rs
index 4833ff2c6d..918d81aad2 100644
--- a/codex-rs/cli/src/debug_sandbox.rs
+++ b/codex-rs/cli/src/debug_sandbox.rs
@@ -41,9 +41,11 @@ use seatbelt::DenialLogger;
pub async fn run_command_under_seatbelt(
command: SeatbeltCommand,
codex_linux_sandbox_exe: Option,
+ loader_overrides: LoaderOverrides,
) -> anyhow::Result<()> {
let SeatbeltCommand {
permissions_profile,
+ config_profile: _,
cwd,
include_managed_config,
allow_unix_sockets,
@@ -60,6 +62,7 @@ pub async fn run_command_under_seatbelt(
permissions_profile,
cwd,
managed_requirements_mode,
+ loader_overrides,
},
command,
config_overrides,
@@ -75,6 +78,7 @@ pub async fn run_command_under_seatbelt(
pub async fn run_command_under_seatbelt(
_command: SeatbeltCommand,
_codex_linux_sandbox_exe: Option,
+ _loader_overrides: LoaderOverrides,
) -> anyhow::Result<()> {
anyhow::bail!("Seatbelt sandbox is only available on macOS");
}
@@ -82,9 +86,11 @@ pub async fn run_command_under_seatbelt(
pub async fn run_command_under_landlock(
command: LandlockCommand,
codex_linux_sandbox_exe: Option,
+ loader_overrides: LoaderOverrides,
) -> anyhow::Result<()> {
let LandlockCommand {
permissions_profile,
+ config_profile: _,
cwd,
include_managed_config,
config_overrides,
@@ -99,6 +105,7 @@ pub async fn run_command_under_landlock(
permissions_profile,
cwd,
managed_requirements_mode,
+ loader_overrides,
},
command,
config_overrides,
@@ -113,9 +120,11 @@ pub async fn run_command_under_landlock(
pub async fn run_command_under_windows_sandbox(
command: WindowsCommand,
codex_linux_sandbox_exe: Option,
+ loader_overrides: LoaderOverrides,
) -> anyhow::Result<()> {
let WindowsCommand {
permissions_profile,
+ config_profile: _,
cwd,
include_managed_config,
config_overrides,
@@ -130,6 +139,7 @@ pub async fn run_command_under_windows_sandbox(
permissions_profile,
cwd,
managed_requirements_mode,
+ loader_overrides,
},
command,
config_overrides,
@@ -153,6 +163,7 @@ struct DebugSandboxConfigOptions {
permissions_profile: Option,
cwd: Option,
managed_requirements_mode: ManagedRequirementsMode,
+ loader_overrides: LoaderOverrides,
}
#[derive(Debug, Clone, Copy)]
@@ -650,7 +661,7 @@ async fn load_debug_sandbox_config(
}
async fn load_debug_sandbox_config_with_codex_home(
- mut cli_overrides: Vec<(String, TomlValue)>,
+ cli_overrides: Vec<(String, TomlValue)>,
codex_linux_sandbox_exe: Option,
options: DebugSandboxConfigOptions,
codex_home: Option,
@@ -660,7 +671,9 @@ async fn load_debug_sandbox_config_with_codex_home(
permissions_profile,
cwd,
managed_requirements_mode,
+ loader_overrides,
} = options;
+ let mut cli_overrides = cli_overrides;
if let Some(permissions_profile) = permissions_profile {
cli_overrides.push((
@@ -674,7 +687,7 @@ async fn load_debug_sandbox_config_with_codex_home(
// config. Keep that behavior unless this invocation explicitly passes a
// legacy `sandbox_mode` CLI override for compatibility with older callers.
let uses_legacy_sandbox_mode_override = cli_overrides_use_legacy_sandbox_mode(&cli_overrides);
- let config = build_debug_sandbox_config(
+ let config = build_debug_sandbox_config_with_loader_overrides(
cli_overrides.clone(),
ConfigOverrides {
cwd: cwd.clone(),
@@ -683,6 +696,7 @@ async fn load_debug_sandbox_config_with_codex_home(
},
codex_home.clone(),
managed_requirements_mode,
+ loader_overrides.clone(),
strict_config,
)
.await?;
@@ -691,7 +705,7 @@ async fn load_debug_sandbox_config_with_codex_home(
return Ok(config);
}
- build_debug_sandbox_config(
+ build_debug_sandbox_config_with_loader_overrides(
cli_overrides,
ConfigOverrides {
sandbox_mode: Some(SandboxMode::ReadOnly),
@@ -701,17 +715,19 @@ async fn load_debug_sandbox_config_with_codex_home(
},
codex_home,
managed_requirements_mode,
+ loader_overrides,
strict_config,
)
.await
.map_err(Into::into)
}
-async fn build_debug_sandbox_config(
+async fn build_debug_sandbox_config_with_loader_overrides(
cli_overrides: Vec<(String, TomlValue)>,
harness_overrides: ConfigOverrides,
codex_home: Option,
managed_requirements_mode: ManagedRequirementsMode,
+ mut loader_overrides: LoaderOverrides,
strict_config: bool,
) -> std::io::Result {
let mut builder = ConfigBuilder::default()
@@ -719,11 +735,9 @@ async fn build_debug_sandbox_config(
.harness_overrides(harness_overrides)
.strict_config(strict_config);
if matches!(managed_requirements_mode, ManagedRequirementsMode::Ignore) {
- builder = builder.loader_overrides(LoaderOverrides {
- ignore_managed_requirements: true,
- ..LoaderOverrides::default()
- });
+ loader_overrides.ignore_managed_requirements = true;
}
+ builder = builder.loader_overrides(loader_overrides);
if let Some(codex_home) = codex_home {
builder = builder
.codex_home(codex_home.clone())
@@ -750,6 +764,24 @@ mod tests {
use pretty_assertions::assert_eq;
use tempfile::TempDir;
+ async fn build_debug_sandbox_config(
+ cli_overrides: Vec<(String, TomlValue)>,
+ harness_overrides: ConfigOverrides,
+ codex_home: Option,
+ managed_requirements_mode: ManagedRequirementsMode,
+ strict_config: bool,
+ ) -> std::io::Result {
+ build_debug_sandbox_config_with_loader_overrides(
+ cli_overrides,
+ harness_overrides,
+ codex_home,
+ managed_requirements_mode,
+ LoaderOverrides::default(),
+ strict_config,
+ )
+ .await
+ }
+
fn escape_toml_path(path: &std::path::Path) -> String {
path.display().to_string().replace('\\', "\\\\")
}
@@ -758,6 +790,18 @@ mod tests {
codex_home: &TempDir,
docs: &std::path::Path,
private: &std::path::Path,
+ ) -> std::io::Result<()> {
+ write_permissions_profile_config_to_path(
+ &codex_home.path().join("config.toml"),
+ docs,
+ private,
+ )
+ }
+
+ fn write_permissions_profile_config_to_path(
+ config_path: &std::path::Path,
+ docs: &std::path::Path,
+ private: &std::path::Path,
) -> std::io::Result<()> {
std::fs::create_dir_all(private)?;
let config = format!(
@@ -772,7 +816,7 @@ mod tests {
escape_toml_path(docs),
escape_toml_path(private),
);
- std::fs::write(codex_home.path().join("config.toml"), config)?;
+ std::fs::write(config_path, config)?;
Ok(())
}
@@ -812,6 +856,7 @@ mod tests {
permissions_profile: None,
cwd: None,
managed_requirements_mode: ManagedRequirementsMode::Include,
+ loader_overrides: LoaderOverrides::default(),
},
Some(codex_home_path),
/*strict_config*/ false,
@@ -836,6 +881,70 @@ mod tests {
Ok(())
}
+ #[tokio::test]
+ async fn debug_sandbox_honors_config_profile_loader_overrides() -> anyhow::Result<()> {
+ let codex_home = TempDir::new()?;
+ let sandbox_paths = TempDir::new()?;
+ let docs = sandbox_paths.path().join("docs");
+ let private = docs.join("private");
+ let profile_path = codex_home.path().join("work.config.toml");
+ write_permissions_profile_config_to_path(&profile_path, &docs, &private)?;
+ let codex_home_path = codex_home.path().to_path_buf();
+ let loader_overrides = LoaderOverrides {
+ user_config_path: Some(AbsolutePathBuf::from_absolute_path(&profile_path)?),
+ user_config_profile: Some("work".parse().expect("profile name should parse")),
+ ..LoaderOverrides::default()
+ };
+
+ let profile_config = build_debug_sandbox_config_with_loader_overrides(
+ Vec::new(),
+ ConfigOverrides::default(),
+ Some(codex_home_path.clone()),
+ ManagedRequirementsMode::Include,
+ loader_overrides.clone(),
+ /*strict_config*/ false,
+ )
+ .await?;
+ let read_only_config = build_debug_sandbox_config(
+ Vec::new(),
+ ConfigOverrides {
+ sandbox_mode: Some(SandboxMode::ReadOnly),
+ ..Default::default()
+ },
+ Some(codex_home_path.clone()),
+ ManagedRequirementsMode::Include,
+ /*strict_config*/ false,
+ )
+ .await?;
+
+ let config = load_debug_sandbox_config_with_codex_home(
+ Vec::new(),
+ /*codex_linux_sandbox_exe*/ None,
+ DebugSandboxConfigOptions {
+ permissions_profile: None,
+ cwd: None,
+ managed_requirements_mode: ManagedRequirementsMode::Include,
+ loader_overrides,
+ },
+ Some(codex_home_path),
+ /*strict_config*/ false,
+ )
+ .await?;
+
+ assert!(config_uses_permission_profiles(&config));
+ assert_ne!(
+ profile_config.permissions.file_system_sandbox_policy(),
+ read_only_config.permissions.file_system_sandbox_policy(),
+ "test fixture should distinguish the profile config from read-only"
+ );
+ assert_eq!(
+ config.permissions.file_system_sandbox_policy(),
+ profile_config.permissions.file_system_sandbox_policy(),
+ );
+
+ Ok(())
+ }
+
#[tokio::test]
async fn debug_sandbox_honors_explicit_legacy_sandbox_mode() -> anyhow::Result<()> {
let codex_home = TempDir::new()?;
@@ -872,6 +981,7 @@ mod tests {
permissions_profile: None,
cwd: None,
managed_requirements_mode: ManagedRequirementsMode::Include,
+ loader_overrides: LoaderOverrides::default(),
},
Some(codex_home_path),
/*strict_config*/ false,
@@ -929,6 +1039,7 @@ mod tests {
permissions_profile: None,
cwd: None,
managed_requirements_mode: ManagedRequirementsMode::Include,
+ loader_overrides: LoaderOverrides::default(),
},
Some(codex_home_path),
/*strict_config*/ false,
@@ -955,6 +1066,7 @@ mod tests {
permissions_profile: Some(":workspace".to_string()),
cwd: None,
managed_requirements_mode: ManagedRequirementsMode::Ignore,
+ loader_overrides: LoaderOverrides::default(),
},
Some(codex_home.path().to_path_buf()),
/*strict_config*/ false,
@@ -993,6 +1105,7 @@ mod tests {
permissions_profile: Some("limited-read-test".to_string()),
cwd: None,
managed_requirements_mode: ManagedRequirementsMode::Ignore,
+ loader_overrides: LoaderOverrides::default(),
},
Some(codex_home.path().to_path_buf()),
/*strict_config*/ false,
@@ -1031,6 +1144,7 @@ mod tests {
permissions_profile: Some(":workspace".to_string()),
cwd: Some(cwd.path().to_path_buf()),
managed_requirements_mode: ManagedRequirementsMode::Ignore,
+ loader_overrides: LoaderOverrides::default(),
},
Some(codex_home.path().to_path_buf()),
/*strict_config*/ false,
diff --git a/codex-rs/cli/src/lib.rs b/codex-rs/cli/src/lib.rs
index d9e1efe980..5e2ba0caa5 100644
--- a/codex-rs/cli/src/lib.rs
+++ b/codex-rs/cli/src/lib.rs
@@ -5,6 +5,7 @@ pub(crate) mod login;
use clap::Parser;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_cli::CliConfigOverrides;
+use codex_utils_cli::ProfileV2Name;
use std::path::PathBuf;
pub use debug_sandbox::run_command_under_landlock;
@@ -28,6 +29,10 @@ pub struct SeatbeltCommand {
#[arg(long = "permissions-profile", value_name = "NAME")]
pub permissions_profile: Option,
+ /// Layer $CODEX_HOME/.config.toml on top of the base user config.
+ #[arg(long = "profile", short = 'p')]
+ pub config_profile: Option,
+
/// Working directory used for profile resolution and command execution.
#[arg(
short = 'C',
@@ -72,6 +77,10 @@ pub struct LandlockCommand {
#[arg(long = "permissions-profile", value_name = "NAME")]
pub permissions_profile: Option,
+ /// Layer $CODEX_HOME/.config.toml on top of the base user config.
+ #[arg(long = "profile", short = 'p')]
+ pub config_profile: Option,
+
/// Working directory used for profile resolution and command execution.
#[arg(
short = 'C',
@@ -103,6 +112,10 @@ pub struct WindowsCommand {
#[arg(long = "permissions-profile", value_name = "NAME")]
pub permissions_profile: Option,
+ /// Layer $CODEX_HOME/.config.toml on top of the base user config.
+ #[arg(long = "profile", short = 'p')]
+ pub config_profile: Option,
+
/// Working directory used for profile resolution and command execution.
#[arg(
short = 'C',
diff --git a/codex-rs/cli/src/main.rs b/codex-rs/cli/src/main.rs
index aefc022221..4bb7ef74a0 100644
--- a/codex-rs/cli/src/main.rs
+++ b/codex-rs/cli/src/main.rs
@@ -353,6 +353,10 @@ type HostSandboxArgs = UnsupportedSandboxArgs;
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
#[derive(Debug, Parser)]
struct UnsupportedSandboxArgs {
+ /// Layer $CODEX_HOME/.config.toml on top of the base user config.
+ #[arg(long = "profile", short = 'p')]
+ pub config_profile: Option,
+
#[clap(skip)]
pub config_overrides: CliConfigOverrides,
@@ -1242,6 +1246,11 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> {
root_remote_auth_token_env.as_deref(),
"sandbox",
)?;
+ let config_profile = sandbox_cli
+ .config_profile
+ .as_ref()
+ .or(interactive.config_profile_v2.as_ref());
+ let loader_overrides = loader_overrides_for_profile(config_profile)?;
prepend_config_flags(
&mut sandbox_cli.config_overrides,
root_config_overrides.clone(),
@@ -1250,22 +1259,28 @@ async fn cli_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> {
codex_cli::run_command_under_seatbelt(
sandbox_cli,
arg0_paths.codex_linux_sandbox_exe.clone(),
+ loader_overrides,
)
.await?;
#[cfg(target_os = "linux")]
codex_cli::run_command_under_landlock(
sandbox_cli,
arg0_paths.codex_linux_sandbox_exe.clone(),
+ loader_overrides,
)
.await?;
#[cfg(target_os = "windows")]
codex_cli::run_command_under_windows_sandbox(
sandbox_cli,
arg0_paths.codex_linux_sandbox_exe.clone(),
+ loader_overrides,
)
.await?;
#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
- anyhow::bail!("`codex sandbox` is not supported on this operating system");
+ {
+ let _ = loader_overrides;
+ anyhow::bail!("`codex sandbox` is not supported on this operating system");
+ }
}
Some(Subcommand::Debug(DebugCommand { subcommand })) => match subcommand {
DebugSubcommand::Models(cmd) => {
@@ -1441,11 +1456,12 @@ fn profile_v2_for_subcommand<'a>(
| Subcommand::Resume(_)
| Subcommand::Fork(_)
| Subcommand::Mcp(_)
+ | Subcommand::Sandbox(_)
| Subcommand::Debug(DebugCommand {
subcommand: DebugSubcommand::PromptInput(_),
}) => Ok(Some(profile_v2)),
_ => anyhow::bail!(
- "--profile only applies to runtime commands and `codex mcp`: `codex`, `codex exec`, `codex review`, `codex resume`, `codex fork`, `codex mcp`, and `codex debug prompt-input`."
+ "--profile only applies to runtime commands and `codex mcp`: `codex`, `codex exec`, `codex review`, `codex resume`, `codex fork`, `codex mcp`, `codex sandbox`, and `codex debug prompt-input`."
),
}
}
@@ -2252,6 +2268,12 @@ mod tests {
.as_deref(),
Some("work")
);
+ assert_eq!(
+ profile_v2_for_args(&["codex", "--profile", "work", "sandbox"])
+ .expect("sandbox supports config profile")
+ .as_deref(),
+ Some("work")
+ );
}
#[test]
@@ -2500,6 +2522,21 @@ mod tests {
assert_eq!(command.command, vec!["echo"]);
}
+ #[cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))]
+ #[test]
+ fn sandbox_parses_config_profile() {
+ let cli =
+ MultitoolCli::try_parse_from(["codex", "sandbox", "--profile", "work", "--", "echo"])
+ .expect("parse");
+
+ let Some(Subcommand::Sandbox(command)) = cli.subcommand else {
+ panic!("expected sandbox command");
+ };
+
+ assert_eq!(command.config_profile.as_deref(), Some("work"));
+ assert_eq!(command.command, vec!["echo"]);
+ }
+
#[cfg(any(target_os = "macos", target_os = "linux", target_os = "windows"))]
#[test]
fn sandbox_rejects_explicit_profile_controls_without_profile() {
diff --git a/codex-rs/cli/src/mcp_cmd.rs b/codex-rs/cli/src/mcp_cmd.rs
index 3104262c23..a84d1c9019 100644
--- a/codex-rs/cli/src/mcp_cmd.rs
+++ b/codex-rs/cli/src/mcp_cmd.rs
@@ -8,6 +8,7 @@ use anyhow::bail;
use clap::ArgGroup;
use codex_config::types::AppToolApproval;
use codex_config::types::McpServerConfig;
+use codex_config::types::McpServerOAuthConfig;
use codex_config::types::McpServerTransportConfig;
use codex_core::McpManager;
use codex_core::config::Config;
@@ -134,6 +135,14 @@ pub struct AddMcpStreamableHttpArgs {
requires = "url"
)]
pub bearer_token_env_var: Option,
+
+ /// Optional OAuth client identifier to use for this MCP server.
+ #[arg(long = "oauth-client-id", value_name = "CLIENT_ID", requires = "url")]
+ pub oauth_client_id: Option,
+
+ /// Optional OAuth resource parameter to include during MCP login.
+ #[arg(long = "oauth-resource", value_name = "RESOURCE", requires = "url")]
+ pub oauth_resource: Option,
}
#[derive(Debug, clap::Parser)]
@@ -282,7 +291,7 @@ async fn run_add(config_overrides: &CliConfigOverrides, add_args: AddArgs) -> Re
.await
.with_context(|| format!("failed to load MCP servers from {}", codex_home.display()))?;
- let transport = match transport_args {
+ let (transport, oauth_client_id, oauth_resource) = match transport_args {
AddMcpTransportArgs {
stdio: Some(stdio), ..
} => {
@@ -297,27 +306,37 @@ async fn run_add(config_overrides: &CliConfigOverrides, add_args: AddArgs) -> Re
} else {
Some(stdio.env.into_iter().collect::>())
};
- McpServerTransportConfig::Stdio {
- command: command_bin,
- args: command_args,
- env: env_map,
- env_vars: Vec::new(),
- cwd: None,
- }
+ (
+ McpServerTransportConfig::Stdio {
+ command: command_bin,
+ args: command_args,
+ env: env_map,
+ env_vars: Vec::new(),
+ cwd: None,
+ },
+ None,
+ None,
+ )
}
AddMcpTransportArgs {
streamable_http:
Some(AddMcpStreamableHttpArgs {
url,
bearer_token_env_var,
+ oauth_client_id,
+ oauth_resource,
}),
..
- } => McpServerTransportConfig::StreamableHttp {
- url,
- bearer_token_env_var,
- http_headers: None,
- env_http_headers: None,
- },
+ } => (
+ McpServerTransportConfig::StreamableHttp {
+ url,
+ bearer_token_env_var,
+ http_headers: None,
+ env_http_headers: None,
+ },
+ oauth_client_id,
+ oauth_resource,
+ ),
AddMcpTransportArgs { .. } => bail!("exactly one of --command or --url must be provided"),
};
@@ -334,8 +353,12 @@ async fn run_add(config_overrides: &CliConfigOverrides, add_args: AddArgs) -> Re
enabled_tools: None,
disabled_tools: None,
scopes: None,
- oauth: None,
- oauth_resource: None,
+ oauth: oauth_client_id
+ .clone()
+ .map(|client_id| McpServerOAuthConfig {
+ client_id: Some(client_id),
+ }),
+ oauth_resource: oauth_resource.clone(),
tools: HashMap::new(),
};
@@ -364,8 +387,8 @@ async fn run_add(config_overrides: &CliConfigOverrides, add_args: AddArgs) -> Re
oauth_config.http_headers,
oauth_config.env_http_headers,
&resolved_scopes,
- /*oauth_client_id*/ None,
- /*oauth_resource*/ None,
+ oauth_client_id.as_deref(),
+ oauth_resource.as_deref(),
config.mcp_oauth_callback_port,
config.mcp_oauth_callback_url.as_deref(),
)
diff --git a/codex-rs/cli/tests/mcp_add_remove.rs b/codex-rs/cli/tests/mcp_add_remove.rs
index ecfbed1264..d0fc5f327d 100644
--- a/codex-rs/cli/tests/mcp_add_remove.rs
+++ b/codex-rs/cli/tests/mcp_add_remove.rs
@@ -198,6 +198,42 @@ async fn add_streamable_http_with_custom_env_var() -> Result<()> {
Ok(())
}
+#[tokio::test]
+async fn add_streamable_http_with_oauth_options() -> Result<()> {
+ let codex_home = TempDir::new()?;
+
+ let mut add_cmd = codex_command(codex_home.path())?;
+ add_cmd
+ .args([
+ "mcp",
+ "add",
+ "oauth-server",
+ "--url",
+ "https://example.com/mcp",
+ "--oauth-client-id",
+ "eci-prd-pub-codex-123",
+ "--oauth-resource",
+ "https://resource.example.com",
+ ])
+ .assert()
+ .success();
+
+ let servers = load_global_mcp_servers(codex_home.path()).await?;
+ let oauth_server = servers
+ .get("oauth-server")
+ .expect("oauth server should exist");
+ assert_eq!(
+ oauth_server.oauth_client_id(),
+ Some("eci-prd-pub-codex-123")
+ );
+ assert_eq!(
+ oauth_server.oauth_resource.as_deref(),
+ Some("https://resource.example.com")
+ );
+
+ Ok(())
+}
+
#[tokio::test]
async fn add_streamable_http_rejects_removed_flag() -> Result<()> {
let codex_home = TempDir::new()?;
diff --git a/codex-rs/cloud-requirements/src/lib.rs b/codex-rs/cloud-requirements/src/lib.rs
index 27649ad4c1..19fbd8cd1d 100644
--- a/codex-rs/cloud-requirements/src/lib.rs
+++ b/codex-rs/cloud-requirements/src/lib.rs
@@ -1220,6 +1220,7 @@ mod tests {
remote_sandbox_config: None,
allowed_web_search_modes: None,
allow_managed_hooks_only: None,
+ allow_appshots: None,
computer_use: None,
guardian_policy_config: None,
feature_requirements: None,
@@ -1305,6 +1306,7 @@ mod tests {
remote_sandbox_config: None,
allowed_web_search_modes: None,
allow_managed_hooks_only: None,
+ allow_appshots: None,
computer_use: None,
guardian_policy_config: None,
feature_requirements: None,
@@ -1341,6 +1343,7 @@ mod tests {
remote_sandbox_config: None,
allowed_web_search_modes: None,
allow_managed_hooks_only: None,
+ allow_appshots: None,
computer_use: None,
guardian_policy_config: None,
feature_requirements: None,
@@ -1394,6 +1397,7 @@ mod tests {
remote_sandbox_config: None,
allowed_web_search_modes: None,
allow_managed_hooks_only: None,
+ allow_appshots: None,
computer_use: None,
guardian_policy_config: None,
feature_requirements: None,
@@ -1576,6 +1580,7 @@ command = "sample-mcp"
remote_sandbox_config: None,
allowed_web_search_modes: None,
allow_managed_hooks_only: None,
+ allow_appshots: None,
computer_use: None,
guardian_policy_config: None,
feature_requirements: None,
@@ -1659,6 +1664,7 @@ command = "sample-mcp"
remote_sandbox_config: None,
allowed_web_search_modes: None,
allow_managed_hooks_only: None,
+ allow_appshots: None,
computer_use: None,
guardian_policy_config: None,
feature_requirements: None,
@@ -1740,6 +1746,7 @@ command = "sample-mcp"
remote_sandbox_config: None,
allowed_web_search_modes: None,
allow_managed_hooks_only: None,
+ allow_appshots: None,
computer_use: None,
guardian_policy_config: None,
feature_requirements: None,
@@ -1949,6 +1956,7 @@ command = "sample-mcp"
remote_sandbox_config: None,
allowed_web_search_modes: None,
allow_managed_hooks_only: None,
+ allow_appshots: None,
computer_use: None,
guardian_policy_config: None,
feature_requirements: None,
@@ -1992,6 +2000,7 @@ command = "sample-mcp"
remote_sandbox_config: None,
allowed_web_search_modes: None,
allow_managed_hooks_only: None,
+ allow_appshots: None,
computer_use: None,
guardian_policy_config: None,
feature_requirements: None,
@@ -2055,6 +2064,7 @@ command = "sample-mcp"
remote_sandbox_config: None,
allowed_web_search_modes: None,
allow_managed_hooks_only: None,
+ allow_appshots: None,
computer_use: None,
guardian_policy_config: None,
feature_requirements: None,
@@ -2114,6 +2124,7 @@ command = "sample-mcp"
remote_sandbox_config: None,
allowed_web_search_modes: None,
allow_managed_hooks_only: None,
+ allow_appshots: None,
computer_use: None,
guardian_policy_config: None,
feature_requirements: None,
@@ -2175,6 +2186,7 @@ command = "sample-mcp"
remote_sandbox_config: None,
allowed_web_search_modes: None,
allow_managed_hooks_only: None,
+ allow_appshots: None,
computer_use: None,
guardian_policy_config: None,
feature_requirements: None,
@@ -2237,6 +2249,7 @@ command = "sample-mcp"
remote_sandbox_config: None,
allowed_web_search_modes: None,
allow_managed_hooks_only: None,
+ allow_appshots: None,
computer_use: None,
guardian_policy_config: None,
feature_requirements: None,
@@ -2303,6 +2316,7 @@ command = "sample-mcp"
remote_sandbox_config: None,
allowed_web_search_modes: None,
allow_managed_hooks_only: None,
+ allow_appshots: None,
computer_use: None,
guardian_policy_config: None,
feature_requirements: None,
@@ -2395,6 +2409,7 @@ command = "sample-mcp"
remote_sandbox_config: None,
allowed_web_search_modes: None,
allow_managed_hooks_only: None,
+ allow_appshots: None,
computer_use: None,
guardian_policy_config: None,
feature_requirements: None,
@@ -2433,6 +2448,7 @@ command = "sample-mcp"
remote_sandbox_config: None,
allowed_web_search_modes: None,
allow_managed_hooks_only: None,
+ allow_appshots: None,
computer_use: None,
guardian_policy_config: None,
feature_requirements: None,
diff --git a/codex-rs/codex-api/src/endpoint/images.rs b/codex-rs/codex-api/src/endpoint/images.rs
new file mode 100644
index 0000000000..9d1bd41eea
--- /dev/null
+++ b/codex-rs/codex-api/src/endpoint/images.rs
@@ -0,0 +1,302 @@
+use crate::auth::SharedAuthProvider;
+use crate::endpoint::session::EndpointSession;
+use crate::error::ApiError;
+use crate::images::ImageEditRequest;
+use crate::images::ImageGenerationRequest;
+use crate::images::ImageResponse;
+use crate::provider::Provider;
+use codex_client::HttpTransport;
+use codex_client::RequestTelemetry;
+use http::HeaderMap;
+use http::Method;
+use serde::Serialize;
+use serde_json::to_value;
+use std::sync::Arc;
+
+pub struct ImagesClient {
+ session: EndpointSession,
+}
+
+impl ImagesClient {
+ pub fn new(transport: T, provider: Provider, auth: SharedAuthProvider) -> Self {
+ Self {
+ session: EndpointSession::new(transport, provider, auth),
+ }
+ }
+
+ pub fn with_telemetry(self, request: Option>) -> Self {
+ Self {
+ session: self.session.with_request_telemetry(request),
+ }
+ }
+
+ pub async fn generate(
+ &self,
+ request: &ImageGenerationRequest,
+ extra_headers: HeaderMap,
+ ) -> Result {
+ self.post_image_request(
+ "images/generations",
+ request,
+ extra_headers,
+ "image generation",
+ )
+ .await
+ }
+
+ pub async fn edit(
+ &self,
+ request: &ImageEditRequest,
+ extra_headers: HeaderMap,
+ ) -> Result {
+ self.post_image_request("images/edits", request, extra_headers, "image edit")
+ .await
+ }
+
+ async fn post_image_request(
+ &self,
+ path: &str,
+ request: &R,
+ extra_headers: HeaderMap,
+ operation: &str,
+ ) -> Result {
+ let body = to_value(request)
+ .map_err(|e| ApiError::Stream(format!("failed to encode {operation} request: {e}")))?;
+ let resp = self
+ .session
+ .execute(Method::POST, path, extra_headers, Some(body))
+ .await?;
+ serde_json::from_slice(&resp.body)
+ .map_err(|e| ApiError::Stream(format!("failed to decode {operation} response: {e}")))
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::auth::AuthProvider;
+ use crate::images::ImageBackground;
+ use crate::images::ImageData;
+ use crate::images::ImageQuality;
+ use crate::images::ImageUrl;
+ use crate::provider::RetryConfig;
+ use async_trait::async_trait;
+ use codex_client::Request;
+ use codex_client::RequestBody;
+ use codex_client::Response;
+ use codex_client::StreamResponse;
+ use codex_client::TransportError;
+ use http::StatusCode;
+ use pretty_assertions::assert_eq;
+ use serde_json::json;
+ use std::sync::Mutex;
+ use std::time::Duration;
+
+ #[derive(Clone, Default)]
+ struct DummyAuth;
+
+ impl AuthProvider for DummyAuth {
+ fn add_auth_headers(&self, _headers: &mut HeaderMap) {}
+ }
+
+ #[derive(Clone)]
+ struct CapturingTransport {
+ last_request: Arc>>,
+ response_body: Arc>,
+ }
+
+ impl CapturingTransport {
+ fn new(response_body: Vec) -> Self {
+ Self {
+ last_request: Arc::new(Mutex::new(None)),
+ response_body: Arc::new(response_body),
+ }
+ }
+ }
+
+ #[async_trait]
+ impl HttpTransport for CapturingTransport {
+ async fn execute(&self, req: Request) -> Result {
+ *self.last_request.lock().expect("lock request store") = Some(req);
+ Ok(Response {
+ status: StatusCode::OK,
+ headers: HeaderMap::new(),
+ body: self.response_body.as_ref().clone().into(),
+ })
+ }
+
+ async fn stream(&self, _req: Request) -> Result {
+ Err(TransportError::Build("stream should not run".to_string()))
+ }
+ }
+
+ fn provider() -> Provider {
+ Provider {
+ name: "test".to_string(),
+ base_url: "https://example.com/api/codex".to_string(),
+ query_params: None,
+ headers: HeaderMap::new(),
+ retry: RetryConfig {
+ max_attempts: 1,
+ base_delay: Duration::from_millis(1),
+ retry_429: false,
+ retry_5xx: true,
+ retry_transport: true,
+ },
+ stream_idle_timeout: Duration::from_secs(1),
+ }
+ }
+
+ fn response_body() -> Vec {
+ serde_json::to_vec(&json!({
+ "created": 1778832973u64,
+ "background": "opaque",
+ "data": [{"b64_json": "REDACT"}],
+ "output_format": "png",
+ "quality": "medium",
+ "size": "1024x1536",
+ "usage": {
+ "input_tokens": 1474,
+ "input_tokens_details": {
+ "image_tokens": 1457,
+ "text_tokens": 17,
+ },
+ "output_tokens": 1372,
+ "output_tokens_details": {
+ "image_tokens": 1372,
+ "text_tokens": 0,
+ },
+ "total_tokens": 2846,
+ }
+ }))
+ .expect("serialize response")
+ }
+
+ fn expected_response() -> ImageResponse {
+ ImageResponse {
+ created: 1778832973,
+ background: Some(ImageBackground::Opaque),
+ data: vec![ImageData {
+ b64_json: "REDACT".to_string(),
+ }],
+ quality: Some(ImageQuality::Medium),
+ size: Some("1024x1536".to_string()),
+ }
+ }
+
+ fn captured_request(transport: &CapturingTransport) -> Request {
+ transport
+ .last_request
+ .lock()
+ .expect("lock request store")
+ .clone()
+ .expect("request should be captured")
+ }
+
+ #[tokio::test]
+ async fn generate_posts_typed_request_and_parses_image_response() {
+ let transport = CapturingTransport::new(response_body());
+ let client = ImagesClient::new(transport.clone(), provider(), Arc::new(DummyAuth));
+
+ let response = client
+ .generate(
+ &ImageGenerationRequest {
+ prompt: "a red fox in a field".to_string(),
+ background: Some(ImageBackground::Opaque),
+ model: "gpt-image-1.5".to_string(),
+ n: None,
+ quality: Some(ImageQuality::Medium),
+ size: Some("1024x1536".to_string()),
+ },
+ HeaderMap::new(),
+ )
+ .await
+ .expect("image generation request should succeed");
+
+ assert_eq!(response, expected_response());
+
+ let request = captured_request(&transport);
+ assert_eq!(
+ request.url,
+ "https://example.com/api/codex/images/generations"
+ );
+ assert_eq!(
+ request.body.as_ref().and_then(RequestBody::json),
+ Some(&json!({
+ "prompt": "a red fox in a field",
+ "background": "opaque",
+ "model": "gpt-image-1.5",
+ "quality": "medium",
+ "size": "1024x1536",
+ }))
+ );
+ }
+
+ #[tokio::test]
+ async fn edit_posts_typed_request_and_parses_image_response() {
+ let transport = CapturingTransport::new(response_body());
+ let client = ImagesClient::new(transport.clone(), provider(), Arc::new(DummyAuth));
+
+ let response = client
+ .edit(
+ &ImageEditRequest {
+ images: vec![ImageUrl {
+ image_url: "data:image/png;base64,Zm9v".to_string(),
+ }],
+ prompt: "add a red hat".to_string(),
+ background: None,
+ model: "gpt-image-1.5".to_string(),
+ n: None,
+ quality: None,
+ size: None,
+ },
+ HeaderMap::new(),
+ )
+ .await
+ .expect("image edit request should succeed");
+
+ assert_eq!(response, expected_response());
+
+ let request = captured_request(&transport);
+ assert_eq!(request.url, "https://example.com/api/codex/images/edits");
+ assert_eq!(
+ request.body.as_ref().and_then(RequestBody::json),
+ Some(&json!({
+ "images": [{"image_url": "data:image/png;base64,Zm9v"}],
+ "prompt": "add a red hat",
+ "model": "gpt-image-1.5",
+ }))
+ );
+ }
+
+ #[tokio::test]
+ async fn image_response_requires_image_data() {
+ let transport = CapturingTransport::new(
+ serde_json::to_vec(&json!({"created": 1778832973u64})).expect("serialize response"),
+ );
+ let client = ImagesClient::new(transport, provider(), Arc::new(DummyAuth));
+
+ let error = client
+ .generate(
+ &ImageGenerationRequest {
+ prompt: "a red fox in a field".to_string(),
+ background: None,
+ model: "gpt-image-1.5".to_string(),
+ n: None,
+ quality: None,
+ size: None,
+ },
+ HeaderMap::new(),
+ )
+ .await
+ .expect_err("image response without data should fail");
+
+ let ApiError::Stream(message) = error else {
+ panic!("expected image response decode error");
+ };
+ assert!(
+ message.starts_with("failed to decode image generation response: missing field `data`"),
+ "{message}"
+ );
+ }
+}
diff --git a/codex-rs/codex-api/src/endpoint/mod.rs b/codex-rs/codex-api/src/endpoint/mod.rs
index 21ebf372a1..106c5d73ff 100644
--- a/codex-rs/codex-api/src/endpoint/mod.rs
+++ b/codex-rs/codex-api/src/endpoint/mod.rs
@@ -1,4 +1,5 @@
pub(crate) mod compact;
+pub(crate) mod images;
pub(crate) mod memories;
pub(crate) mod models;
pub(crate) mod realtime_call;
@@ -9,6 +10,7 @@ pub(crate) mod search;
mod session;
pub use compact::CompactClient;
+pub use images::ImagesClient;
pub use memories::MemoriesClient;
pub use models::ModelsClient;
pub use realtime_call::RealtimeCallClient;
diff --git a/codex-rs/codex-api/src/images.rs b/codex-rs/codex-api/src/images.rs
new file mode 100644
index 0000000000..f915a5f78d
--- /dev/null
+++ b/codex-rs/codex-api/src/images.rs
@@ -0,0 +1,70 @@
+use serde::Deserialize;
+use serde::Serialize;
+
+#[derive(Debug, Clone, Serialize, PartialEq)]
+pub struct ImageGenerationRequest {
+ pub prompt: String,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub background: Option,
+ pub model: String,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub n: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub quality: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub size: Option,
+}
+
+#[derive(Debug, Clone, Serialize, PartialEq)]
+pub struct ImageEditRequest {
+ pub images: Vec,
+ pub prompt: String,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub background: Option,
+ pub model: String,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub n: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub quality: Option,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub size: Option,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
+pub struct ImageUrl {
+ pub image_url: String,
+}
+
+#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
+#[serde(rename_all = "lowercase")]
+pub enum ImageBackground {
+ Transparent,
+ Opaque,
+ Auto,
+}
+
+#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
+#[serde(rename_all = "lowercase")]
+pub enum ImageQuality {
+ Low,
+ Medium,
+ High,
+ Auto,
+}
+
+#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
+pub struct ImageResponse {
+ pub created: u64,
+ pub data: Vec,
+ #[serde(default)]
+ pub background: Option,
+ #[serde(default)]
+ pub quality: Option,
+ #[serde(default)]
+ pub size: Option,
+}
+
+#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
+pub struct ImageData {
+ pub b64_json: String,
+}
diff --git a/codex-rs/codex-api/src/lib.rs b/codex-rs/codex-api/src/lib.rs
index f47791b799..08176d8dec 100644
--- a/codex-rs/codex-api/src/lib.rs
+++ b/codex-rs/codex-api/src/lib.rs
@@ -4,6 +4,7 @@ pub(crate) mod common;
pub(crate) mod endpoint;
pub(crate) mod error;
pub(crate) mod files;
+pub(crate) mod images;
pub(crate) mod provider;
pub(crate) mod rate_limits;
pub(crate) mod requests;
@@ -41,6 +42,7 @@ pub use crate::common::WS_REQUEST_HEADER_TRACESTATE_CLIENT_METADATA_KEY;
pub use crate::common::create_text_param_for_request;
pub use crate::common::response_create_client_metadata;
pub use crate::endpoint::CompactClient;
+pub use crate::endpoint::ImagesClient;
pub use crate::endpoint::MemoriesClient;
pub use crate::endpoint::ModelsClient;
pub use crate::endpoint::RealtimeCallClient;
@@ -63,6 +65,13 @@ pub use crate::endpoint::SearchClient;
pub use crate::endpoint::session_update_session_json;
pub use crate::error::ApiError;
pub use crate::files::upload_local_file;
+pub use crate::images::ImageBackground;
+pub use crate::images::ImageData;
+pub use crate::images::ImageEditRequest;
+pub use crate::images::ImageGenerationRequest;
+pub use crate::images::ImageQuality;
+pub use crate::images::ImageResponse;
+pub use crate::images::ImageUrl;
pub use crate::provider::Provider;
pub use crate::provider::RetryConfig;
pub use crate::provider::is_azure_responses_provider;
diff --git a/codex-rs/config/src/config_requirements.rs b/codex-rs/config/src/config_requirements.rs
index d209c16199..93d16d3ccf 100644
--- a/codex-rs/config/src/config_requirements.rs
+++ b/codex-rs/config/src/config_requirements.rs
@@ -89,6 +89,7 @@ pub struct ConfigRequirements {
pub permission_profile: ConstrainedWithSource,
pub web_search_mode: ConstrainedWithSource,
pub allow_managed_hooks_only: Option>,
+ pub allow_appshots: Option>,
pub computer_use: Option>,
pub feature_requirements: Option>,
pub managed_hooks: Option>,
@@ -124,6 +125,7 @@ impl Default for ConfigRequirements {
/*source*/ None,
),
allow_managed_hooks_only: None,
+ allow_appshots: None,
computer_use: None,
feature_requirements: None,
managed_hooks: None,
@@ -750,6 +752,7 @@ pub struct ConfigRequirementsToml {
pub remote_sandbox_config: Option>,
pub allowed_web_search_modes: Option>,
pub allow_managed_hooks_only: Option,
+ pub allow_appshots: Option,
pub computer_use: Option,
#[serde(rename = "features", alias = "feature_requirements")]
pub feature_requirements: Option,
@@ -801,6 +804,7 @@ pub struct ConfigRequirementsWithSources {
pub allowed_permissions: Option>>,
pub allowed_web_search_modes: Option>>,
pub allow_managed_hooks_only: Option>,
+ pub allow_appshots: Option>,
pub computer_use: Option>,
pub feature_requirements: Option>,
pub hooks: Option>,
@@ -840,6 +844,7 @@ impl ConfigRequirementsWithSources {
remote_sandbox_config: _,
allowed_web_search_modes: _,
allow_managed_hooks_only: _,
+ allow_appshots: _,
computer_use: _,
feature_requirements: _,
hooks: _,
@@ -872,6 +877,7 @@ impl ConfigRequirementsWithSources {
allowed_permissions,
allowed_web_search_modes,
allow_managed_hooks_only,
+ allow_appshots,
computer_use,
feature_requirements,
hooks,
@@ -902,6 +908,7 @@ impl ConfigRequirementsWithSources {
allowed_permissions,
allowed_web_search_modes,
allow_managed_hooks_only,
+ allow_appshots,
computer_use,
feature_requirements,
hooks,
@@ -922,6 +929,7 @@ impl ConfigRequirementsWithSources {
remote_sandbox_config: None,
allowed_web_search_modes: allowed_web_search_modes.map(|sourced| sourced.value),
allow_managed_hooks_only: allow_managed_hooks_only.map(|sourced| sourced.value),
+ allow_appshots: allow_appshots.map(|sourced| sourced.value),
computer_use: computer_use.map(|sourced| sourced.value),
feature_requirements: feature_requirements.map(|sourced| sourced.value),
hooks: hooks.map(|sourced| sourced.value),
@@ -1008,6 +1016,7 @@ impl ConfigRequirementsToml {
&& self.remote_sandbox_config.is_none()
&& self.allowed_web_search_modes.is_none()
&& self.allow_managed_hooks_only.is_none()
+ && self.allow_appshots.is_none()
&& self
.computer_use
.as_ref()
@@ -1054,6 +1063,7 @@ impl TryFrom for ConfigRequirements {
allowed_permissions: _,
allowed_web_search_modes,
allow_managed_hooks_only,
+ allow_appshots,
computer_use,
feature_requirements,
hooks,
@@ -1291,6 +1301,7 @@ impl TryFrom for ConfigRequirements {
permission_profile,
web_search_mode,
allow_managed_hooks_only,
+ allow_appshots,
computer_use,
feature_requirements,
managed_hooks,
@@ -1361,6 +1372,7 @@ mod tests {
remote_sandbox_config: _,
allowed_web_search_modes,
allow_managed_hooks_only,
+ allow_appshots,
computer_use,
feature_requirements,
hooks,
@@ -1386,6 +1398,8 @@ mod tests {
.map(|value| Sourced::new(value, RequirementSource::Unknown)),
allow_managed_hooks_only: allow_managed_hooks_only
.map(|value| Sourced::new(value, RequirementSource::Unknown)),
+ allow_appshots: allow_appshots
+ .map(|value| Sourced::new(value, RequirementSource::Unknown)),
computer_use: computer_use.map(|value| Sourced::new(value, RequirementSource::Unknown)),
feature_requirements: feature_requirements
.map(|value| Sourced::new(value, RequirementSource::Unknown)),
@@ -1466,6 +1480,19 @@ mod tests {
Ok(())
}
+ #[test]
+ fn deserialize_allow_appshots() -> Result<()> {
+ let requirements: ConfigRequirementsToml = from_str(
+ r#"
+ allow_appshots = true
+ "#,
+ )?;
+
+ assert_eq!(requirements.allow_appshots, Some(true));
+ assert!(!requirements.is_empty());
+ Ok(())
+ }
+
#[test]
fn filesystem_requirements_table_cannot_define_a_permission_profile() {
let err = from_str::(
@@ -1484,6 +1511,19 @@ mod tests {
);
}
+ #[test]
+ fn allow_appshots_false_is_still_configured() -> Result<()> {
+ let requirements: ConfigRequirementsToml = from_str(
+ r#"
+ allow_appshots = false
+ "#,
+ )?;
+
+ assert_eq!(requirements.allow_appshots, Some(false));
+ assert!(!requirements.is_empty());
+ Ok(())
+ }
+
#[test]
fn deserialize_computer_use_requirements() -> Result<()> {
let requirements: ConfigRequirementsToml = from_str(
@@ -1539,6 +1579,7 @@ mod tests {
remote_sandbox_config: None,
allowed_web_search_modes: Some(allowed_web_search_modes.clone()),
allow_managed_hooks_only: Some(true),
+ allow_appshots: Some(false),
computer_use: Some(computer_use.clone()),
feature_requirements: Some(feature_requirements.clone()),
hooks: None,
@@ -1578,6 +1619,7 @@ mod tests {
/*value*/ true,
enforce_source.clone(),
)),
+ allow_appshots: Some(Sourced::new(/*value*/ false, enforce_source.clone(),)),
computer_use: Some(Sourced::new(computer_use, enforce_source.clone())),
feature_requirements: Some(Sourced::new(
feature_requirements,
@@ -1623,6 +1665,7 @@ mod tests {
allowed_permissions: None,
allowed_web_search_modes: None,
allow_managed_hooks_only: None,
+ allow_appshots: None,
computer_use: None,
feature_requirements: None,
hooks: None,
@@ -1673,6 +1716,7 @@ mod tests {
allowed_permissions: None,
allowed_web_search_modes: None,
allow_managed_hooks_only: None,
+ allow_appshots: None,
computer_use: None,
feature_requirements: None,
hooks: None,
diff --git a/codex-rs/core/src/agent/control_tests.rs b/codex-rs/core/src/agent/control_tests.rs
index 54114f05d1..ed39df1548 100644
--- a/codex-rs/core/src/agent/control_tests.rs
+++ b/codex-rs/core/src/agent/control_tests.rs
@@ -271,6 +271,7 @@ async fn get_status_returns_not_found_without_manager() {
async fn on_event_updates_status_from_task_started() {
let status = agent_status_from_event(&EventMsg::TurnStarted(TurnStartedEvent {
turn_id: "turn-1".to_string(),
+ trace_id: None,
started_at: None,
model_context_window: None,
collaboration_mode_kind: ModeKind::Default,
diff --git a/codex-rs/core/src/compact.rs b/codex-rs/core/src/compact.rs
index b5802268d3..f2bded8d06 100644
--- a/codex-rs/core/src/compact.rs
+++ b/codex-rs/core/src/compact.rs
@@ -100,6 +100,7 @@ pub(crate) async fn run_compact_task(
) -> CodexResult<()> {
let start_event = EventMsg::TurnStarted(TurnStartedEvent {
turn_id: turn_context.sub_id.clone(),
+ trace_id: turn_context.trace_id.clone(),
started_at: turn_context.turn_timing_state.started_at_unix_secs().await,
model_context_window: turn_context.model_context_window(),
collaboration_mode_kind: turn_context.collaboration_mode.mode,
diff --git a/codex-rs/core/src/compact_remote.rs b/codex-rs/core/src/compact_remote.rs
index 30d1e5f0e8..969da47544 100644
--- a/codex-rs/core/src/compact_remote.rs
+++ b/codex-rs/core/src/compact_remote.rs
@@ -62,6 +62,7 @@ pub(crate) async fn run_remote_compact_task(
) -> CodexResult<()> {
let start_event = EventMsg::TurnStarted(TurnStartedEvent {
turn_id: turn_context.sub_id.clone(),
+ trace_id: turn_context.trace_id.clone(),
started_at: turn_context.turn_timing_state.started_at_unix_secs().await,
model_context_window: turn_context.model_context_window(),
collaboration_mode_kind: turn_context.collaboration_mode.mode,
diff --git a/codex-rs/core/src/compact_remote_v2.rs b/codex-rs/core/src/compact_remote_v2.rs
index 251d37b35c..0e235a941f 100644
--- a/codex-rs/core/src/compact_remote_v2.rs
+++ b/codex-rs/core/src/compact_remote_v2.rs
@@ -78,6 +78,7 @@ pub(crate) async fn run_remote_compact_task(
) -> CodexResult<()> {
let start_event = EventMsg::TurnStarted(TurnStartedEvent {
turn_id: turn_context.sub_id.clone(),
+ trace_id: turn_context.trace_id.clone(),
started_at: turn_context.turn_timing_state.started_at_unix_secs().await,
model_context_window: turn_context.model_context_window(),
collaboration_mode_kind: turn_context.collaboration_mode.mode,
@@ -110,7 +111,7 @@ async fn run_remote_compact_task_inner(
turn_context.as_ref(),
trigger,
reason,
- CompactionImplementation::Responses,
+ CompactionImplementation::ResponsesCompactionV2,
phase,
)
.await;
diff --git a/codex-rs/core/src/config/config_loader_tests.rs b/codex-rs/core/src/config/config_loader_tests.rs
index db671e1d3a..2682e6ef41 100644
--- a/codex-rs/core/src/config/config_loader_tests.rs
+++ b/codex-rs/core/src/config/config_loader_tests.rs
@@ -1101,6 +1101,7 @@ allowed_approval_policies = ["on-request"]
remote_sandbox_config: None,
allowed_web_search_modes: None,
allow_managed_hooks_only: None,
+ allow_appshots: None,
computer_use: None,
feature_requirements: None,
hooks: None,
@@ -1161,6 +1162,7 @@ allowed_approval_policies = ["on-request"]
remote_sandbox_config: None,
allowed_web_search_modes: None,
allow_managed_hooks_only: None,
+ allow_appshots: None,
computer_use: None,
feature_requirements: None,
hooks: None,
@@ -1370,6 +1372,7 @@ async fn load_config_layers_includes_cloud_requirements() -> anyhow::Result<()>
remote_sandbox_config: None,
allowed_web_search_modes: None,
allow_managed_hooks_only: None,
+ allow_appshots: None,
computer_use: None,
feature_requirements: None,
hooks: None,
diff --git a/codex-rs/core/src/config/config_tests.rs b/codex-rs/core/src/config/config_tests.rs
index 89310277ab..09f17e201d 100644
--- a/codex-rs/core/src/config/config_tests.rs
+++ b/codex-rs/core/src/config/config_tests.rs
@@ -8098,6 +8098,7 @@ async fn test_requirements_web_search_mode_allowlist_does_not_warn_when_unset()
remote_sandbox_config: None,
allowed_web_search_modes: Some(vec![codex_config::WebSearchModeRequirement::Cached]),
allow_managed_hooks_only: None,
+ allow_appshots: None,
computer_use: None,
feature_requirements: None,
hooks: None,
@@ -8820,6 +8821,7 @@ async fn explicit_sandbox_mode_falls_back_when_disallowed_by_requirements() -> s
remote_sandbox_config: None,
allowed_web_search_modes: None,
allow_managed_hooks_only: None,
+ allow_appshots: None,
computer_use: None,
feature_requirements: None,
hooks: None,
diff --git a/codex-rs/core/src/config/mod.rs b/codex-rs/core/src/config/mod.rs
index a70492ef05..7044b9c676 100644
--- a/codex-rs/core/src/config/mod.rs
+++ b/codex-rs/core/src/config/mod.rs
@@ -2440,6 +2440,7 @@ impl Config {
permission_profile: mut constrained_permission_profile,
web_search_mode: mut constrained_web_search_mode,
allow_managed_hooks_only: _,
+ allow_appshots: _,
computer_use: _,
feature_requirements,
managed_hooks: _,
diff --git a/codex-rs/core/src/session/handlers.rs b/codex-rs/core/src/session/handlers.rs
index 8798db200c..da09fb3e7f 100644
--- a/codex-rs/core/src/session/handlers.rs
+++ b/codex-rs/core/src/session/handlers.rs
@@ -468,16 +468,8 @@ pub async fn reload_user_config(sess: &Arc) {
pub async fn compact(sess: &Arc, sub_id: String) {
let turn_context = sess.new_default_turn_with_sub_id(sub_id).await;
- sess.spawn_task(
- Arc::clone(&turn_context),
- vec![UserInput::Text {
- text: turn_context.compact_prompt().to_string(),
- // Compaction prompt is synthesized; no UI element ranges to preserve.
- text_elements: Vec::new(),
- }],
- CompactTask,
- )
- .await;
+ sess.spawn_task(Arc::clone(&turn_context), Vec::new(), CompactTask)
+ .await;
}
pub async fn thread_rollback(sess: &Arc, sub_id: String, num_turns: u32) {
diff --git a/codex-rs/core/src/session/rollout_reconstruction_tests.rs b/codex-rs/core/src/session/rollout_reconstruction_tests.rs
index fc2811710a..11b8651ae6 100644
--- a/codex-rs/core/src/session/rollout_reconstruction_tests.rs
+++ b/codex-rs/core/src/session/rollout_reconstruction_tests.rs
@@ -122,6 +122,7 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif
RolloutItem::EventMsg(EventMsg::TurnStarted(
codex_protocol::protocol::TurnStartedEvent {
turn_id: turn_id.clone(),
+ trace_id: None,
started_at: None,
model_context_window: Some(128_000),
collaboration_mode_kind: ModeKind::Default,
@@ -189,6 +190,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_com
RolloutItem::EventMsg(EventMsg::TurnStarted(
codex_protocol::protocol::TurnStartedEvent {
turn_id: first_turn_id.clone(),
+ trace_id: None,
started_at: None,
model_context_window: Some(128_000),
collaboration_mode_kind: ModeKind::Default,
@@ -218,6 +220,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_com
RolloutItem::EventMsg(EventMsg::TurnStarted(
codex_protocol::protocol::TurnStartedEvent {
turn_id: rolled_back_turn_id.clone(),
+ trace_id: None,
started_at: None,
model_context_window: Some(128_000),
collaboration_mode_kind: ModeKind::Default,
@@ -289,6 +292,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_inc
RolloutItem::EventMsg(EventMsg::TurnStarted(
codex_protocol::protocol::TurnStartedEvent {
turn_id: first_turn_id.clone(),
+ trace_id: None,
started_at: None,
model_context_window: Some(128_000),
collaboration_mode_kind: ModeKind::Default,
@@ -318,6 +322,7 @@ async fn reconstruct_history_rollback_keeps_history_and_metadata_in_sync_for_inc
RolloutItem::EventMsg(EventMsg::TurnStarted(
codex_protocol::protocol::TurnStartedEvent {
turn_id: incomplete_turn_id,
+ trace_id: None,
started_at: None,
model_context_window: Some(128_000),
collaboration_mode_kind: ModeKind::Default,
@@ -381,6 +386,7 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad
RolloutItem::EventMsg(EventMsg::TurnStarted(
codex_protocol::protocol::TurnStartedEvent {
turn_id: first_turn_id.clone(),
+ trace_id: None,
started_at: None,
model_context_window: Some(128_000),
collaboration_mode_kind: ModeKind::Default,
@@ -410,6 +416,7 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad
RolloutItem::EventMsg(EventMsg::TurnStarted(
codex_protocol::protocol::TurnStartedEvent {
turn_id: second_turn_id.clone(),
+ trace_id: None,
started_at: None,
model_context_window: Some(128_000),
collaboration_mode_kind: ModeKind::Default,
@@ -438,6 +445,7 @@ async fn reconstruct_history_rollback_skips_non_user_turns_for_history_and_metad
RolloutItem::EventMsg(EventMsg::TurnStarted(
codex_protocol::protocol::TurnStartedEvent {
turn_id: standalone_turn_id.clone(),
+ trace_id: None,
started_at: None,
model_context_window: Some(128_000),
collaboration_mode_kind: ModeKind::Default,
@@ -501,6 +509,7 @@ async fn reconstruct_history_rollback_counts_inter_agent_assistant_turns() {
RolloutItem::EventMsg(EventMsg::TurnStarted(
codex_protocol::protocol::TurnStartedEvent {
turn_id: first_turn_id.clone(),
+ trace_id: None,
started_at: None,
model_context_window: Some(128_000),
collaboration_mode_kind: ModeKind::Default,
@@ -530,6 +539,7 @@ async fn reconstruct_history_rollback_counts_inter_agent_assistant_turns() {
RolloutItem::EventMsg(EventMsg::TurnStarted(
codex_protocol::protocol::TurnStartedEvent {
turn_id: assistant_turn_id.clone(),
+ trace_id: None,
started_at: None,
model_context_window: Some(128_000),
collaboration_mode_kind: ModeKind::Default,
@@ -590,6 +600,7 @@ async fn reconstruct_history_rollback_clears_history_and_metadata_when_exceeding
RolloutItem::EventMsg(EventMsg::TurnStarted(
codex_protocol::protocol::TurnStartedEvent {
turn_id: only_turn_id.clone(),
+ trace_id: None,
started_at: None,
model_context_window: Some(128_000),
collaboration_mode_kind: ModeKind::Default,
@@ -643,6 +654,7 @@ async fn record_initial_history_resumed_rollback_skips_only_user_turns() {
RolloutItem::EventMsg(EventMsg::TurnStarted(
codex_protocol::protocol::TurnStartedEvent {
turn_id: user_turn_id.clone(),
+ trace_id: None,
started_at: None,
model_context_window: Some(128_000),
collaboration_mode_kind: ModeKind::Default,
@@ -671,6 +683,7 @@ async fn record_initial_history_resumed_rollback_skips_only_user_turns() {
RolloutItem::EventMsg(EventMsg::TurnStarted(
codex_protocol::protocol::TurnStartedEvent {
turn_id: standalone_turn_id.clone(),
+ trace_id: None,
started_at: None,
model_context_window: Some(128_000),
collaboration_mode_kind: ModeKind::Default,
@@ -716,6 +729,7 @@ async fn record_initial_history_resumed_rollback_drops_incomplete_user_turn_comp
RolloutItem::EventMsg(EventMsg::TurnStarted(
codex_protocol::protocol::TurnStartedEvent {
turn_id: previous_turn_id.clone(),
+ trace_id: None,
started_at: None,
model_context_window: Some(128_000),
collaboration_mode_kind: ModeKind::Default,
@@ -743,6 +757,7 @@ async fn record_initial_history_resumed_rollback_drops_incomplete_user_turn_comp
RolloutItem::EventMsg(EventMsg::TurnStarted(
codex_protocol::protocol::TurnStartedEvent {
turn_id: incomplete_turn_id,
+ trace_id: None,
started_at: None,
model_context_window: Some(128_000),
collaboration_mode_kind: ModeKind::Default,
@@ -875,6 +890,7 @@ async fn reconstruct_history_legacy_compaction_without_replacement_history_clear
RolloutItem::EventMsg(EventMsg::TurnStarted(
codex_protocol::protocol::TurnStartedEvent {
turn_id: current_turn_id.clone(),
+ trace_id: None,
started_at: None,
model_context_window: Some(128_000),
collaboration_mode_kind: ModeKind::Default,
@@ -939,6 +955,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis
RolloutItem::EventMsg(EventMsg::TurnStarted(
codex_protocol::protocol::TurnStartedEvent {
turn_id: previous_turn_id.clone(),
+ trace_id: None,
started_at: None,
model_context_window: Some(128_000),
collaboration_mode_kind: ModeKind::Default,
@@ -1043,6 +1060,7 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu
RolloutItem::EventMsg(EventMsg::TurnStarted(
codex_protocol::protocol::TurnStartedEvent {
turn_id: previous_turn_id.clone(),
+ trace_id: None,
started_at: None,
model_context_window: Some(128_000),
collaboration_mode_kind: ModeKind::Default,
@@ -1070,6 +1088,7 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu
RolloutItem::EventMsg(EventMsg::TurnStarted(
codex_protocol::protocol::TurnStartedEvent {
turn_id: aborted_turn_id,
+ trace_id: None,
started_at: None,
model_context_window: Some(128_000),
collaboration_mode_kind: ModeKind::Default,
@@ -1151,6 +1170,7 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo
RolloutItem::EventMsg(EventMsg::TurnStarted(
codex_protocol::protocol::TurnStartedEvent {
turn_id: previous_turn_id.clone(),
+ trace_id: None,
started_at: None,
model_context_window: Some(128_000),
collaboration_mode_kind: ModeKind::Default,
@@ -1178,6 +1198,7 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo
RolloutItem::EventMsg(EventMsg::TurnStarted(
codex_protocol::protocol::TurnStartedEvent {
turn_id: current_turn_id.clone(),
+ trace_id: None,
started_at: None,
model_context_window: Some(128_000),
collaboration_mode_kind: ModeKind::Default,
@@ -1268,6 +1289,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea
RolloutItem::EventMsg(EventMsg::TurnStarted(
codex_protocol::protocol::TurnStartedEvent {
turn_id: previous_turn_id.clone(),
+ trace_id: None,
started_at: None,
model_context_window: Some(128_000),
collaboration_mode_kind: ModeKind::Default,
@@ -1295,6 +1317,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea
RolloutItem::EventMsg(EventMsg::TurnStarted(
codex_protocol::protocol::TurnStartedEvent {
turn_id: incomplete_turn_id,
+ trace_id: None,
started_at: None,
model_context_window: Some(128_000),
collaboration_mode_kind: ModeKind::Default,
@@ -1346,6 +1369,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_preserves_turn_
RolloutItem::EventMsg(EventMsg::TurnStarted(
codex_protocol::protocol::TurnStartedEvent {
turn_id: current_turn_id,
+ trace_id: None,
started_at: None,
model_context_window: Some(128_000),
collaboration_mode_kind: ModeKind::Default,
@@ -1420,6 +1444,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear
RolloutItem::EventMsg(EventMsg::TurnStarted(
codex_protocol::protocol::TurnStartedEvent {
turn_id: previous_turn_id.clone(),
+ trace_id: None,
started_at: None,
model_context_window: Some(128_000),
collaboration_mode_kind: ModeKind::Default,
@@ -1447,6 +1472,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear
RolloutItem::EventMsg(EventMsg::TurnStarted(
codex_protocol::protocol::TurnStartedEvent {
turn_id: compacted_incomplete_turn_id,
+ trace_id: None,
started_at: None,
model_context_window: Some(128_000),
collaboration_mode_kind: ModeKind::Default,
@@ -1470,6 +1496,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear
RolloutItem::EventMsg(EventMsg::TurnStarted(
codex_protocol::protocol::TurnStartedEvent {
turn_id: replacing_turn_id,
+ trace_id: None,
started_at: None,
model_context_window: Some(128_000),
collaboration_mode_kind: ModeKind::Default,
diff --git a/codex-rs/core/src/session/tests.rs b/codex-rs/core/src/session/tests.rs
index d865340fe0..a47dcb569a 100644
--- a/codex-rs/core/src/session/tests.rs
+++ b/codex-rs/core/src/session/tests.rs
@@ -267,8 +267,24 @@ fn skill_message(text: &str) -> ResponseItem {
}
#[tokio::test]
-async fn regular_turn_emits_turn_started_without_waiting_for_startup_prewarm() {
- let (sess, tc, rx) = make_session_and_context_with_rx().await;
+async fn regular_turn_emits_turn_started_with_trace_id_without_waiting_for_startup_prewarm() {
+ let _trace_test_context = install_test_tracing("codex-core-tests");
+ let request_parent = W3cTraceContext {
+ traceparent: Some("00-00000000000000000000000000000011-0000000000000022-01".into()),
+ tracestate: Some("vendor=value".into()),
+ };
+ let request_span = info_span!("app_server.request");
+ assert!(set_parent_from_w3c_trace_context(
+ &request_span,
+ &request_parent
+ ));
+ let (sess, tc, rx) = make_session_and_context_with_rx()
+ .instrument(request_span)
+ .await;
+ assert_eq!(
+ tc.trace_id.as_deref(),
+ Some("00000000000000000000000000000011")
+ );
let (_tx, startup_prewarm_rx) = tokio::sync::oneshot::channel::<()>();
let handle = tokio::spawn(async move {
let _ = startup_prewarm_rx.await;
@@ -294,10 +310,11 @@ async fn regular_turn_emits_turn_started_without_waiting_for_startup_prewarm() {
.await
.expect("expected turn started event without waiting for startup prewarm")
.expect("channel open");
- assert!(matches!(
- first.msg,
- EventMsg::TurnStarted(TurnStartedEvent { turn_id, .. }) if turn_id == tc.sub_id
- ));
+ let EventMsg::TurnStarted(turn_started) = first.msg else {
+ panic!("expected turn started event");
+ };
+ assert_eq!(turn_started.turn_id, tc.sub_id);
+ assert_eq!(turn_started.trace_id, tc.trace_id);
sess.abort_all_tasks(TurnAbortReason::Interrupted).await;
}
@@ -2378,6 +2395,7 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() {
RolloutItem::EventMsg(EventMsg::TurnStarted(
codex_protocol::protocol::TurnStartedEvent {
turn_id: turn_id.clone(),
+ trace_id: None,
started_at: None,
model_context_window: Some(128_000),
collaboration_mode_kind: ModeKind::Default,
@@ -2573,6 +2591,7 @@ async fn thread_rollback_recomputes_previous_turn_settings_and_reference_context
RolloutItem::EventMsg(EventMsg::TurnStarted(
codex_protocol::protocol::TurnStartedEvent {
turn_id: first_turn_id.clone(),
+ trace_id: None,
started_at: None,
model_context_window: Some(128_000),
collaboration_mode_kind: ModeKind::Default,
@@ -2600,6 +2619,7 @@ async fn thread_rollback_recomputes_previous_turn_settings_and_reference_context
RolloutItem::EventMsg(EventMsg::TurnStarted(
codex_protocol::protocol::TurnStartedEvent {
turn_id: rolled_back_turn_id.clone(),
+ trace_id: None,
started_at: None,
model_context_window: Some(128_000),
collaboration_mode_kind: ModeKind::Default,
@@ -2684,6 +2704,7 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio
RolloutItem::EventMsg(EventMsg::TurnStarted(
codex_protocol::protocol::TurnStartedEvent {
turn_id: first_turn_id.clone(),
+ trace_id: None,
started_at: None,
model_context_window: Some(128_000),
collaboration_mode_kind: ModeKind::Default,
@@ -2709,6 +2730,7 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio
RolloutItem::EventMsg(EventMsg::TurnStarted(
codex_protocol::protocol::TurnStartedEvent {
turn_id: compact_turn_id.clone(),
+ trace_id: None,
started_at: None,
model_context_window: Some(128_000),
collaboration_mode_kind: ModeKind::Default,
@@ -2728,6 +2750,7 @@ async fn thread_rollback_restores_cleared_reference_context_item_after_compactio
RolloutItem::EventMsg(EventMsg::TurnStarted(
codex_protocol::protocol::TurnStartedEvent {
turn_id: rolled_back_turn_id.clone(),
+ trace_id: None,
started_at: None,
model_context_window: Some(128_000),
collaboration_mode_kind: ModeKind::Default,
@@ -2783,6 +2806,7 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() {
RolloutItem::EventMsg(EventMsg::TurnStarted(
codex_protocol::protocol::TurnStartedEvent {
turn_id: "turn-1".to_string(),
+ trace_id: None,
started_at: None,
model_context_window: Some(128_000),
collaboration_mode_kind: ModeKind::Default,
@@ -2808,6 +2832,7 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() {
RolloutItem::EventMsg(EventMsg::TurnStarted(
codex_protocol::protocol::TurnStartedEvent {
turn_id: "turn-2".to_string(),
+ trace_id: None,
started_at: None,
model_context_window: Some(128_000),
collaboration_mode_kind: ModeKind::Default,
@@ -2833,6 +2858,7 @@ async fn thread_rollback_persists_marker_and_replays_cumulatively() {
RolloutItem::EventMsg(EventMsg::TurnStarted(
codex_protocol::protocol::TurnStartedEvent {
turn_id: "turn-3".to_string(),
+ trace_id: None,
started_at: None,
model_context_window: Some(128_000),
collaboration_mode_kind: ModeKind::Default,
@@ -5712,7 +5738,7 @@ async fn spawn_task_turn_span_inherits_dispatch_trace_context() {
self: Arc,
_session: Arc,
_ctx: Arc,
- _input: Vec,
+ _input: Vec,
_cancellation_token: CancellationToken,
) -> Option {
let mut trace = self
@@ -7788,7 +7814,7 @@ impl SessionTask for NeverEndingTask {
self: Arc,
_session: Arc,
_ctx: Arc,
- _input: Vec,
+ _input: Vec,
cancellation_token: CancellationToken,
) -> Option {
if self.listen_to_cancellation_token {
@@ -7817,7 +7843,7 @@ impl SessionTask for GuardianDeniedApprovalTask {
self: Arc,
session: Arc,
ctx: Arc,
- _input: Vec,
+ _input: Vec,
cancellation_token: CancellationToken,
) -> Option {
let session = session.clone_session();
diff --git a/codex-rs/core/src/session/turn.rs b/codex-rs/core/src/session/turn.rs
index 0993f7f9f2..b3475492b7 100644
--- a/codex-rs/core/src/session/turn.rs
+++ b/codex-rs/core/src/session/turn.rs
@@ -121,8 +121,8 @@ pub(crate) enum RunTurnResult {
Terminal,
}
-/// Takes a user message as input and runs a loop where, at each sampling request, the model
-/// replies with either:
+/// Takes initial turn input and runs a loop where, at each sampling request,
+/// the model replies with either:
///
/// - requested function calls
/// - an assistant message
@@ -140,7 +140,7 @@ pub(crate) async fn run_turn(
turn_context: Arc,
turn_extension_data: Arc,
auto_compact_limiter: &mut AutoCompactTurnLimiter,
- input: Vec,
+ input: Vec,
prewarmed_client_session: Option,
cancellation_token: CancellationToken,
) -> RunTurnResult {
@@ -190,26 +190,13 @@ pub(crate) async fn run_turn(
if run_pending_session_start_hooks(&sess, &turn_context).await {
return RunTurnResult::Continue(None);
}
- if !input.is_empty() {
- let initial_turn_input = TurnInput::UserInput(input.clone());
- let user_prompt_submit_outcome =
- inspect_pending_input(&sess, &turn_context, &initial_turn_input).await;
- if user_prompt_submit_outcome.should_stop {
- record_additional_contexts(
- &sess,
- &turn_context,
- user_prompt_submit_outcome.additional_contexts,
- )
- .await;
- return RunTurnResult::Continue(None);
- }
- record_pending_input(
- &sess,
- &turn_context,
- initial_turn_input,
- user_prompt_submit_outcome.additional_contexts,
- )
- .await;
+ let mut can_drain_pending_input = input.is_empty();
+ let input_record_outcome = run_hooks_and_record_inputs(&sess, &turn_context, &input).await;
+ if input_record_outcome.accepted_user_input {
+ auto_compact_limiter.reset_after_user_input();
+ }
+ if input_record_outcome.should_stop {
+ return RunTurnResult::Continue(None);
}
sess.merge_connector_selection(explicitly_enabled_connectors.clone())
@@ -250,9 +237,8 @@ pub(crate) async fn run_turn(
// one instance across retries within this turn.
// Pending input is drained into history before building the next model request.
// However, we defer that drain until after sampling in two cases:
- // 1. At the start of a turn, so the fresh user prompt in `input` gets sampled first.
+ // 1. At the start of a turn, so the fresh turn input in `input` gets sampled first.
// 2. After auto-compact, when model/tool continuation needs to resume before any steer.
- let mut can_drain_pending_input = input.is_empty();
loop {
// Note that pending_input would be something like a message the user
@@ -264,31 +250,12 @@ pub(crate) async fn run_turn(
Vec::new()
};
- let mut blocked_pending_input = false;
- let mut accepted_pending_input = false;
- for pending_input_item in pending_input {
- let is_user_input = matches!(&pending_input_item, TurnInput::UserInput(_));
- let hook_outcome =
- inspect_pending_input(&sess, &turn_context, &pending_input_item).await;
- if hook_outcome.should_stop {
- blocked_pending_input = true;
- record_additional_contexts(&sess, &turn_context, hook_outcome.additional_contexts)
- .await;
- } else {
- accepted_pending_input = true;
- record_pending_input(
- &sess,
- &turn_context,
- pending_input_item,
- hook_outcome.additional_contexts,
- )
- .await;
- if is_user_input {
- auto_compact_limiter.reset_after_user_input();
- }
- }
+ let pending_input_record_outcome =
+ run_hooks_and_record_inputs(&sess, &turn_context, &pending_input).await;
+ if pending_input_record_outcome.accepted_user_input {
+ auto_compact_limiter.reset_after_user_input();
}
- if blocked_pending_input && !accepted_pending_input {
+ if pending_input_record_outcome.should_stop {
break;
}
@@ -476,6 +443,45 @@ pub(crate) async fn run_turn(
RunTurnResult::Continue(last_agent_message)
}
+#[derive(Default)]
+struct InputRecordOutcome {
+ should_stop: bool,
+ accepted_user_input: bool,
+}
+
+async fn run_hooks_and_record_inputs(
+ sess: &Arc,
+ turn_context: &Arc,
+ input: &[TurnInput],
+) -> InputRecordOutcome {
+ let mut blocked_input = false;
+ let mut accepted_input = false;
+ let mut accepted_user_input = false;
+ for input_item in input {
+ let hook_outcome = inspect_pending_input(sess, turn_context, input_item).await;
+ if hook_outcome.should_stop {
+ blocked_input = true;
+ record_additional_contexts(sess, turn_context, hook_outcome.additional_contexts).await;
+ } else {
+ accepted_input = true;
+ if matches!(input_item, TurnInput::UserInput(_)) {
+ accepted_user_input = true;
+ }
+ record_pending_input(
+ sess,
+ turn_context,
+ input_item.clone(),
+ hook_outcome.additional_contexts,
+ )
+ .await;
+ }
+ }
+ InputRecordOutcome {
+ should_stop: blocked_input && !accepted_input,
+ accepted_user_input,
+ }
+}
+
#[expect(
clippy::await_holding_invalid_type,
reason = "MCP tool listing borrows the read guard across cancellation-aware await"
@@ -483,9 +489,18 @@ pub(crate) async fn run_turn(
async fn build_skills_and_plugins(
sess: &Arc,
turn_context: &TurnContext,
- input: &[UserInput],
+ input: &[TurnInput],
cancellation_token: &CancellationToken,
) -> Option<(Vec, HashSet)> {
+ let user_input = input
+ .iter()
+ .filter_map(|item| match item {
+ TurnInput::UserInput(content) => Some(content.as_slice()),
+ TurnInput::ResponseInputItem(_) => None,
+ })
+ .flatten()
+ .cloned()
+ .collect::>();
let tracking = build_track_events_context(
turn_context.model_info.slug.clone(),
sess.conversation_id.to_string(),
@@ -499,7 +514,7 @@ async fn build_skills_and_plugins(
// Structured plugin:// mentions are resolved from the current session's
// enabled plugins, then converted into turn-scoped guidance below.
let mentioned_plugins =
- collect_explicit_plugin_mentions(input, loaded_plugins.capability_summaries());
+ collect_explicit_plugin_mentions(&user_input, loaded_plugins.capability_summaries());
let mcp_tools = if turn_context.apps_enabled() || !mentioned_plugins.is_empty() {
// Plugin mentions need raw MCP/app inventory even when app tools
// are normally hidden so we can describe the plugin's currently
@@ -537,7 +552,7 @@ async fn build_skills_and_plugins(
let skill_name_counts_lower =
build_skill_name_counts(&skills_outcome.skills, &skills_outcome.disabled_paths).1;
let mentioned_skills = collect_explicit_skill_mentions(
- input,
+ &user_input,
&skills_outcome.skills,
&skills_outcome.disabled_paths,
&connector_slug_counts,
@@ -579,7 +594,7 @@ async fn build_skills_and_plugins(
);
let plugin_items =
build_plugin_injections(&mentioned_plugins, &mcp_tools, &available_connectors);
- let mut explicitly_enabled_connectors = collect_explicit_app_ids(input);
+ let mut explicitly_enabled_connectors = collect_explicit_app_ids(&user_input);
explicitly_enabled_connectors.extend(skill_connector_ids);
let connector_names_by_id = available_connectors
.iter()
@@ -615,7 +630,7 @@ async fn build_skills_and_plugins(
async fn track_turn_resolved_config_analytics(
sess: &Session,
turn_context: &TurnContext,
- input: &[UserInput],
+ input: &[TurnInput],
) {
let thread_config = {
let state = sess.state.lock().await;
@@ -632,6 +647,11 @@ async fn track_turn_resolved_config_analytics(
thread_id: sess.conversation_id.to_string(),
num_input_images: input
.iter()
+ .filter_map(|item| match item {
+ TurnInput::UserInput(content) => Some(content.as_slice()),
+ TurnInput::ResponseInputItem(_) => None,
+ })
+ .flatten()
.filter(|item| {
matches!(item, UserInput::Image { .. } | UserInput::LocalImage { .. })
})
diff --git a/codex-rs/core/src/tasks/compact.rs b/codex-rs/core/src/tasks/compact.rs
index dddf46391e..77914633a2 100644
--- a/codex-rs/core/src/tasks/compact.rs
+++ b/codex-rs/core/src/tasks/compact.rs
@@ -2,6 +2,7 @@ use std::sync::Arc;
use super::SessionTask;
use super::SessionTaskContext;
+use crate::session::TurnInput;
use crate::session::turn_context::TurnContext;
use crate::state::TaskKind;
use codex_protocol::user_input::UserInput;
@@ -23,7 +24,7 @@ impl SessionTask for CompactTask {
self: Arc,
session: Arc,
ctx: Arc,
- input: Vec,
+ _input: Vec,
_cancellation_token: CancellationToken,
) -> Option {
let session = session.clone_session();
@@ -47,6 +48,11 @@ impl SessionTask for CompactTask {
/*inc*/ 1,
&[("type", "local")],
);
+ let input = vec![UserInput::Text {
+ text: ctx.compact_prompt().to_string(),
+ // Compaction prompt is synthesized; no UI element ranges to preserve.
+ text_elements: Vec::new(),
+ }];
crate::compact::run_compact_task(session.clone(), ctx, input).await
};
None
diff --git a/codex-rs/core/src/tasks/mod.rs b/codex-rs/core/src/tasks/mod.rs
index 9cb6b5f0f2..4d6db8acf2 100644
--- a/codex-rs/core/src/tasks/mod.rs
+++ b/codex-rs/core/src/tasks/mod.rs
@@ -214,7 +214,7 @@ pub(crate) trait SessionTask: Send + Sync + 'static {
self: Arc,
session: Arc,
ctx: Arc,
- input: Vec,
+ input: Vec,
cancellation_token: CancellationToken,
) -> impl std::future::Future