merge main into compaction death loop

This commit is contained in:
Roy Han
2026-05-22 15:40:10 -07:00
61 changed files with 1437 additions and 350 deletions
+13 -2
View File
@@ -1,4 +1,3 @@
<p align="center"><code>npm i -g @openai/codex</code><br />or <code>brew install --cask codex</code></p>
<p align="center"><strong>Codex CLI</strong> is a coding agent from OpenAI that runs locally on your computer.
<p align="center">
<img src="https://github.com/openai/codex/blob/main/.github/codex-cli-splash.png" alt="Codex CLI splash" width="80%" />
@@ -14,7 +13,19 @@ If you want Codex in your code editor (VS Code, Cursor, Windsurf), <a href="http
### Installing and running Codex CLI
Install globally with your preferred package manager:
Run the following on Mac or Linux to install Codex CLI:
```shell
curl -fsSL https://chatgpt.com/codex/install.sh | sh
```
Run the following on Windows to install Codex CLI:
```
powershell -ExecutionPolicy ByPass -c "irm https://chatgpt.com/codex/install.ps1 | iex"
```
Codex CLI can also be installed via the following package managers:
```shell
# Install using npm
+1
View File
@@ -4146,6 +4146,7 @@ dependencies = [
"serde_json",
"tempfile",
"tokio",
"tracing-appender",
"windows 0.58.0",
"windows-sys 0.52.0",
]
+4
View File
@@ -66,6 +66,10 @@ codex sandbox [COMMAND]...
codex sandbox --log-denials [COMMAND]...
```
`codex sandbox` also accepts `--profile NAME` (`-p NAME`) to layer
`$CODEX_HOME/NAME.config.toml` onto the base user config for the sandboxed
command.
### Selecting a sandbox policy via `--sandbox`
The Rust CLI exposes a dedicated `--sandbox` (`-s`) flag that lets you pick the sandbox policy **without** having to reach for the generic `-c/--config` option:
@@ -1262,6 +1262,14 @@ fn compaction_event_serializes_expected_shape() {
);
}
#[test]
fn compaction_implementation_serializes_remote_v2() {
let payload = serde_json::to_value(CompactionImplementation::ResponsesCompactionV2)
.expect("serialize compaction implementation");
assert_eq!(payload, json!("responses_compaction_v2"));
}
#[test]
fn app_used_dedupe_is_keyed_by_turn_and_connector() {
let (sender, _receiver) = mpsc::channel(1);
+1
View File
@@ -229,6 +229,7 @@ pub enum CompactionReason {
#[serde(rename_all = "snake_case")]
pub enum CompactionImplementation {
Responses,
ResponsesCompactionV2,
ResponsesCompact,
}
@@ -7715,6 +7715,12 @@
},
"ConfigRequirements": {
"properties": {
"allowAppshots": {
"type": [
"boolean",
"null"
]
},
"allowManagedHooksOnly": {
"type": [
"boolean",
@@ -4084,6 +4084,12 @@
},
"ConfigRequirements": {
"properties": {
"allowAppshots": {
"type": [
"boolean",
"null"
]
},
"allowManagedHooksOnly": {
"type": [
"boolean",
@@ -73,6 +73,12 @@
},
"ConfigRequirements": {
"properties": {
"allowAppshots": {
"type": [
"boolean",
"null"
]
},
"allowManagedHooksOnly": {
"type": [
"boolean",
@@ -7,4 +7,4 @@ import type { ComputerUseRequirements } from "./ComputerUseRequirements";
import type { ResidencyRequirement } from "./ResidencyRequirement";
import type { SandboxMode } from "./SandboxMode";
export type ConfigRequirements = {allowedApprovalPolicies: Array<AskForApproval> | null, allowedSandboxModes: Array<SandboxMode> | null, allowedPermissions: Array<string> | null, allowedWebSearchModes: Array<WebSearchMode> | null, allowManagedHooksOnly: boolean | null, computerUse: ComputerUseRequirements | null, featureRequirements: { [key in string]?: boolean } | null, enforceResidency: ResidencyRequirement | null};
export type ConfigRequirements = {allowedApprovalPolicies: Array<AskForApproval> | null, allowedSandboxModes: Array<SandboxMode> | null, allowedPermissions: Array<string> | null, allowedWebSearchModes: Array<WebSearchMode> | null, allowManagedHooksOnly: boolean | null, allowAppshots: boolean | null, computerUse: ComputerUseRequirements | null, featureRequirements: { [key in string]?: boolean } | null, enforceResidency: ResidencyRequirement | null};
@@ -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(),
@@ -361,6 +361,7 @@ pub struct ConfigRequirements {
pub allowed_permissions: Option<Vec<String>>,
pub allowed_web_search_modes: Option<Vec<WebSearchMode>>,
pub allow_managed_hooks_only: Option<bool>,
pub allow_appshots: Option<bool>,
pub computer_use: Option<ComputerUseRequirements>,
pub feature_requirements: Option<BTreeMap<String, bool>>,
#[experimental("configRequirements/read.hooks")]
@@ -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,
@@ -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(),
@@ -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 {
@@ -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(),
@@ -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(),
+123 -9
View File
@@ -41,9 +41,11 @@ use seatbelt::DenialLogger;
pub async fn run_command_under_seatbelt(
command: SeatbeltCommand,
codex_linux_sandbox_exe: Option<PathBuf>,
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<PathBuf>,
_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<PathBuf>,
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<PathBuf>,
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<String>,
cwd: Option<PathBuf>,
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<PathBuf>,
options: DebugSandboxConfigOptions,
codex_home: Option<PathBuf>,
@@ -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<PathBuf>,
managed_requirements_mode: ManagedRequirementsMode,
mut loader_overrides: LoaderOverrides,
strict_config: bool,
) -> std::io::Result<Config> {
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<PathBuf>,
managed_requirements_mode: ManagedRequirementsMode,
strict_config: bool,
) -> std::io::Result<Config> {
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,
+13
View File
@@ -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<String>,
/// Layer $CODEX_HOME/<name>.config.toml on top of the base user config.
#[arg(long = "profile", short = 'p')]
pub config_profile: Option<ProfileV2Name>,
/// 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<String>,
/// Layer $CODEX_HOME/<name>.config.toml on top of the base user config.
#[arg(long = "profile", short = 'p')]
pub config_profile: Option<ProfileV2Name>,
/// 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<String>,
/// Layer $CODEX_HOME/<name>.config.toml on top of the base user config.
#[arg(long = "profile", short = 'p')]
pub config_profile: Option<ProfileV2Name>,
/// Working directory used for profile resolution and command execution.
#[arg(
short = 'C',
+39 -2
View File
@@ -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/<name>.config.toml on top of the base user config.
#[arg(long = "profile", short = 'p')]
pub config_profile: Option<ProfileV2Name>,
#[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() {
+41 -18
View File
@@ -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<String>,
/// 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<String>,
/// Optional OAuth resource parameter to include during MCP login.
#[arg(long = "oauth-resource", value_name = "RESOURCE", requires = "url")]
pub oauth_resource: Option<String>,
}
#[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::<HashMap<_, _>>())
};
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(),
)
+36
View File
@@ -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()?;
+16
View File
@@ -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,
+302
View File
@@ -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<T: HttpTransport> {
session: EndpointSession<T>,
}
impl<T: HttpTransport> ImagesClient<T> {
pub fn new(transport: T, provider: Provider, auth: SharedAuthProvider) -> Self {
Self {
session: EndpointSession::new(transport, provider, auth),
}
}
pub fn with_telemetry(self, request: Option<Arc<dyn RequestTelemetry>>) -> Self {
Self {
session: self.session.with_request_telemetry(request),
}
}
pub async fn generate(
&self,
request: &ImageGenerationRequest,
extra_headers: HeaderMap,
) -> Result<ImageResponse, ApiError> {
self.post_image_request(
"images/generations",
request,
extra_headers,
"image generation",
)
.await
}
pub async fn edit(
&self,
request: &ImageEditRequest,
extra_headers: HeaderMap,
) -> Result<ImageResponse, ApiError> {
self.post_image_request("images/edits", request, extra_headers, "image edit")
.await
}
async fn post_image_request<R: Serialize>(
&self,
path: &str,
request: &R,
extra_headers: HeaderMap,
operation: &str,
) -> Result<ImageResponse, ApiError> {
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<Mutex<Option<Request>>>,
response_body: Arc<Vec<u8>>,
}
impl CapturingTransport {
fn new(response_body: Vec<u8>) -> 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<Response, TransportError> {
*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<StreamResponse, TransportError> {
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<u8> {
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}"
);
}
}
+2
View File
@@ -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;
+70
View File
@@ -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<ImageBackground>,
pub model: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub n: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub quality: Option<ImageQuality>,
#[serde(skip_serializing_if = "Option::is_none")]
pub size: Option<String>,
}
#[derive(Debug, Clone, Serialize, PartialEq)]
pub struct ImageEditRequest {
pub images: Vec<ImageUrl>,
pub prompt: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub background: Option<ImageBackground>,
pub model: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub n: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
pub quality: Option<ImageQuality>,
#[serde(skip_serializing_if = "Option::is_none")]
pub size: Option<String>,
}
#[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<ImageData>,
#[serde(default)]
pub background: Option<ImageBackground>,
#[serde(default)]
pub quality: Option<ImageQuality>,
#[serde(default)]
pub size: Option<String>,
}
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
pub struct ImageData {
pub b64_json: String,
}
+9
View File
@@ -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;
@@ -89,6 +89,7 @@ pub struct ConfigRequirements {
pub permission_profile: ConstrainedWithSource<PermissionProfile>,
pub web_search_mode: ConstrainedWithSource<WebSearchMode>,
pub allow_managed_hooks_only: Option<Sourced<bool>>,
pub allow_appshots: Option<Sourced<bool>>,
pub computer_use: Option<Sourced<ComputerUseRequirementsToml>>,
pub feature_requirements: Option<Sourced<FeatureRequirementsToml>>,
pub managed_hooks: Option<ConstrainedWithSource<ManagedHooksRequirementsToml>>,
@@ -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<Vec<RemoteSandboxConfigToml>>,
pub allowed_web_search_modes: Option<Vec<WebSearchModeRequirement>>,
pub allow_managed_hooks_only: Option<bool>,
pub allow_appshots: Option<bool>,
pub computer_use: Option<ComputerUseRequirementsToml>,
#[serde(rename = "features", alias = "feature_requirements")]
pub feature_requirements: Option<FeatureRequirementsToml>,
@@ -801,6 +804,7 @@ pub struct ConfigRequirementsWithSources {
pub allowed_permissions: Option<Sourced<Vec<String>>>,
pub allowed_web_search_modes: Option<Sourced<Vec<WebSearchModeRequirement>>>,
pub allow_managed_hooks_only: Option<Sourced<bool>>,
pub allow_appshots: Option<Sourced<bool>>,
pub computer_use: Option<Sourced<ComputerUseRequirementsToml>>,
pub feature_requirements: Option<Sourced<FeatureRequirementsToml>>,
pub hooks: Option<Sourced<ManagedHooksRequirementsToml>>,
@@ -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<ConfigRequirementsWithSources> 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<ConfigRequirementsWithSources> 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::<ConfigRequirementsToml>(
@@ -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,
+1
View File
@@ -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,
+1
View File
@@ -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,
+1
View File
@@ -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,
+2 -1
View File
@@ -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;
@@ -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,
+2
View File
@@ -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,
+1
View File
@@ -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: _,
+2 -10
View File
@@ -468,16 +468,8 @@ pub async fn reload_user_config(sess: &Arc<Session>) {
pub async fn compact(sess: &Arc<Session>, 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<Session>, sub_id: String, num_turns: u32) {
@@ -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,
+35 -9
View File
@@ -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<Self>,
_session: Arc<SessionTaskContext>,
_ctx: Arc<TurnContext>,
_input: Vec<UserInput>,
_input: Vec<TurnInput>,
_cancellation_token: CancellationToken,
) -> Option<String> {
let mut trace = self
@@ -7788,7 +7814,7 @@ impl SessionTask for NeverEndingTask {
self: Arc<Self>,
_session: Arc<SessionTaskContext>,
_ctx: Arc<TurnContext>,
_input: Vec<UserInput>,
_input: Vec<TurnInput>,
cancellation_token: CancellationToken,
) -> Option<String> {
if self.listen_to_cancellation_token {
@@ -7817,7 +7843,7 @@ impl SessionTask for GuardianDeniedApprovalTask {
self: Arc<Self>,
session: Arc<SessionTaskContext>,
ctx: Arc<TurnContext>,
_input: Vec<UserInput>,
_input: Vec<TurnInput>,
cancellation_token: CancellationToken,
) -> Option<String> {
let session = session.clone_session();
+74 -54
View File
@@ -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<TurnContext>,
turn_extension_data: Arc<codex_extension_api::ExtensionData>,
auto_compact_limiter: &mut AutoCompactTurnLimiter,
input: Vec<UserInput>,
input: Vec<TurnInput>,
prewarmed_client_session: Option<ModelClientSession>,
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<Session>,
turn_context: &Arc<TurnContext>,
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<Session>,
turn_context: &TurnContext,
input: &[UserInput],
input: &[TurnInput],
cancellation_token: &CancellationToken,
) -> Option<(Vec<ResponseItem>, HashSet<String>)> {
let user_input = input
.iter()
.filter_map(|item| match item {
TurnInput::UserInput(content) => Some(content.as_slice()),
TurnInput::ResponseInputItem(_) => None,
})
.flatten()
.cloned()
.collect::<Vec<_>>();
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 { .. })
})
+7 -1
View File
@@ -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<Self>,
session: Arc<SessionTaskContext>,
ctx: Arc<TurnContext>,
input: Vec<UserInput>,
_input: Vec<TurnInput>,
_cancellation_token: CancellationToken,
) -> Option<String> {
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
+9 -4
View File
@@ -214,7 +214,7 @@ pub(crate) trait SessionTask: Send + Sync + 'static {
self: Arc<Self>,
session: Arc<SessionTaskContext>,
ctx: Arc<TurnContext>,
input: Vec<UserInput>,
input: Vec<TurnInput>,
cancellation_token: CancellationToken,
) -> impl std::future::Future<Output = Option<String>> + Send;
@@ -245,7 +245,7 @@ pub(crate) trait AnySessionTask: Send + Sync + 'static {
self: Arc<Self>,
session: Arc<SessionTaskContext>,
ctx: Arc<TurnContext>,
input: Vec<UserInput>,
input: Vec<TurnInput>,
cancellation_token: CancellationToken,
) -> BoxFuture<'static, Option<String>>;
@@ -276,7 +276,7 @@ where
self: Arc<Self>,
session: Arc<SessionTaskContext>,
ctx: Arc<TurnContext>,
input: Vec<UserInput>,
input: Vec<TurnInput>,
cancellation_token: CancellationToken,
) -> BoxFuture<'static, Option<String>> {
Box::pin(SessionTask::run(
@@ -380,6 +380,11 @@ impl Session {
));
let ctx = Arc::clone(&turn_context);
let task_for_run = Arc::clone(&task);
let task_input = if input.is_empty() {
Vec::new()
} else {
vec![TurnInput::UserInput(input)]
};
let task_cancellation_token = cancellation_token.child_token();
// Task-owned turn spans keep a core-owned span open for the
// full task lifecycle after the submission dispatch span ends.
@@ -405,7 +410,7 @@ impl Session {
.run(
Arc::clone(&session_ctx),
ctx,
input,
task_input,
task_cancellation_token.child_token(),
)
.await;
+2 -2
View File
@@ -13,7 +13,6 @@ use codex_protocol::protocol::CodexErrorInfo;
use codex_protocol::protocol::ErrorEvent;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::TurnStartedEvent;
use codex_protocol::user_input::UserInput;
use tracing::Instrument;
use tracing::trace_span;
@@ -46,7 +45,7 @@ impl SessionTask for RegularTask {
self: Arc<Self>,
session: Arc<SessionTaskContext>,
ctx: Arc<TurnContext>,
input: Vec<UserInput>,
input: Vec<TurnInput>,
cancellation_token: CancellationToken,
) -> Option<String> {
let sess = session.clone_session();
@@ -56,6 +55,7 @@ impl SessionTask for RegularTask {
// not wait on startup prewarm resolution.
let event = EventMsg::TurnStarted(TurnStartedEvent {
turn_id: ctx.sub_id.clone(),
trace_id: ctx.trace_id.clone(),
started_at: ctx.turn_timing_state.started_at_unix_secs().await,
model_context_window: ctx.model_context_window(),
collaboration_mode_kind: ctx.collaboration_mode.mode,
+11 -2
View File
@@ -20,6 +20,7 @@ use crate::codex_delegate::run_codex_thread_one_shot;
use crate::config::Constrained;
use crate::review_format::format_review_findings_block;
use crate::review_format::render_review_output_text;
use crate::session::TurnInput;
use crate::session::session::Session;
use crate::session::turn_context::TurnContext;
use crate::state::TaskKind;
@@ -59,7 +60,7 @@ impl SessionTask for ReviewTask {
self: Arc<Self>,
session: Arc<SessionTaskContext>,
ctx: Arc<TurnContext>,
input: Vec<UserInput>,
input: Vec<TurnInput>,
cancellation_token: CancellationToken,
) -> Option<String> {
session.session.services.session_telemetry.counter(
@@ -68,11 +69,19 @@ impl SessionTask for ReviewTask {
&[],
);
let mut user_input = Vec::new();
for item in input {
match item {
TurnInput::UserInput(mut content) => user_input.append(&mut content),
TurnInput::ResponseInputItem(_) => {}
}
}
// Start sub-codex conversation and get the receiver for events.
let output = match start_review_conversation(
session.clone(),
ctx.clone(),
input,
user_input,
cancellation_token.clone(),
)
.await
+3 -2
View File
@@ -7,7 +7,6 @@ use codex_network_proxy::PROXY_ACTIVE_ENV_KEY;
use codex_network_proxy::PROXY_ENV_KEYS;
#[cfg(target_os = "macos")]
use codex_network_proxy::PROXY_GIT_SSH_COMMAND_ENV_KEY;
use codex_protocol::user_input::UserInput;
use tokio_util::sync::CancellationToken;
use tracing::error;
use uuid::Uuid;
@@ -17,6 +16,7 @@ use crate::exec::StdoutStream;
use crate::exec::execute_exec_request;
use crate::exec_env::create_env;
use crate::sandboxing::ExecRequest;
use crate::session::TurnInput;
use crate::session::turn_context::TurnContext;
use crate::state::TaskKind;
use crate::tools::format_exec_output_str;
@@ -77,7 +77,7 @@ impl SessionTask for UserShellCommandTask {
self: Arc<Self>,
session: Arc<SessionTaskContext>,
turn_context: Arc<TurnContext>,
_input: Vec<UserInput>,
_input: Vec<TurnInput>,
cancellation_token: CancellationToken,
) -> Option<String> {
execute_user_shell_command(
@@ -114,6 +114,7 @@ pub(crate) async fn execute_user_shell_command(
// freshly reinjected context before the summary/replacement history is applied.
let 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,
@@ -187,6 +187,7 @@ fn out_of_range_truncation_drops_pre_user_active_turn_prefix() {
RolloutItem::ResponseItem(assistant_msg("a1")),
RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent {
turn_id: "turn-2".to_string(),
trace_id: None,
started_at: None,
model_context_window: None,
collaboration_mode_kind: Default::default(),
@@ -1307,6 +1308,7 @@ async fn interrupted_fork_snapshot_preserves_explicit_turn_id() {
InitialHistory::Forked(vec![
RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent {
turn_id: "turn-explicit".to_string(),
trace_id: None,
started_at: None,
model_context_window: None,
collaboration_mode_kind: Default::default(),
+21 -11
View File
@@ -758,24 +758,34 @@ async fn steered_user_input_resets_auto_compact_limit() {
];
let (server, _completions) = start_streaming_sse_server(vec![
gated_commentary_sampling_chunks(1, "first answer", gate_first_completed_rx, 500),
compact_summary_chunks(1, 500),
gated_commentary_sampling_chunks(
2,
/*idx*/ 1,
"first answer",
gate_first_completed_rx,
/*total_tokens*/ 500,
),
compact_summary_chunks(/*idx*/ 1, /*total_tokens*/ 500),
gated_commentary_sampling_chunks(
/*idx*/ 2,
"processed second prompt",
gate_second_completed_rx,
500,
/*total_tokens*/ 500,
),
compact_summary_chunks(2, 500),
gated_commentary_sampling_chunks(3, "processed third prompt", gate_third_completed_rx, 500),
compact_summary_chunks(3, 500),
compact_summary_chunks(/*idx*/ 2, /*total_tokens*/ 500),
gated_commentary_sampling_chunks(
4,
/*idx*/ 3,
"processed third prompt",
gate_third_completed_rx,
/*total_tokens*/ 500,
),
compact_summary_chunks(/*idx*/ 3, /*total_tokens*/ 500),
gated_commentary_sampling_chunks(
/*idx*/ 4,
"processed fourth prompt",
gate_fourth_completed_rx,
500,
/*total_tokens*/ 500,
),
compact_summary_chunks(4, 500),
compact_summary_chunks(/*idx*/ 4, /*total_tokens*/ 500),
final_chunks,
])
.await;
@@ -849,7 +859,7 @@ async fn steered_user_input_is_rejected_when_auto_compact_guard_fires() {
}
responses.extend([
fourth_sampling_chunks,
compact_summary_chunks(4, token_count_used_after_compaction),
compact_summary_chunks(/*idx*/ 4, token_count_used_after_compaction),
]);
let (server, _completions) = start_streaming_sse_server(responses).await;
@@ -50,6 +50,7 @@ fn resume_history(
history: vec![
RolloutItem::EventMsg(EventMsg::TurnStarted(TurnStartedEvent {
turn_id: turn_id.clone(),
trace_id: None,
started_at: None,
model_context_window: None,
collaboration_mode_kind: ModeKind::Default,
@@ -67,6 +67,7 @@ fn rollout_items_from_messages(messages: &[ConversationMessage]) -> Vec<RolloutI
items.push(RolloutItem::EventMsg(EventMsg::TurnStarted(
TurnStartedEvent {
turn_id: turn_id.clone(),
trace_id: None,
started_at: message.timestamp,
model_context_window: None,
collaboration_mode_kind: Default::default(),
+4
View File
@@ -1850,6 +1850,10 @@ pub struct TurnCompleteEvent {
#[derive(Debug, Clone, Deserialize, Serialize, JsonSchema, TS)]
pub struct TurnStartedEvent {
pub turn_id: String,
// Persist for rollout consumers that correlate turns with telemetry traces.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(optional)]
pub trace_id: Option<String>,
/// Unix timestamp (in seconds) when the turn started.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[ts(type = "number | null", optional)]
+18
View File
@@ -156,6 +156,17 @@ fn render_debug_config_lines(stack: &ConfigLayerStack) -> Vec<Line<'static>> {
));
}
if let Some(allow_appshots) = requirements_toml.allow_appshots {
requirement_lines.push(requirement_line(
"allow_appshots",
allow_appshots.to_string(),
requirements
.allow_appshots
.as_ref()
.map(|sourced| &sourced.source),
));
}
if requirements_toml.guardian_policy_config.is_some() {
requirement_lines.push(requirement_line(
"guardian_policy_config",
@@ -662,6 +673,10 @@ mod tests {
/*value*/ true,
RequirementSource::CloudRequirements,
)),
allow_appshots: Some(Sourced::new(
/*value*/ false,
RequirementSource::CloudRequirements,
)),
feature_requirements: Some(Sourced::new(
FeatureRequirementsToml {
entries: BTreeMap::from([("guardian_approval".to_string(), true)]),
@@ -701,6 +716,7 @@ mod tests {
remote_sandbox_config: None,
allowed_web_search_modes: Some(vec![WebSearchModeRequirement::Cached]),
allow_managed_hooks_only: Some(true),
allow_appshots: Some(false),
computer_use: None,
guardian_policy_config: Some("Use the managed guardian policy.".to_string()),
feature_requirements: Some(FeatureRequirementsToml {
@@ -763,6 +779,7 @@ mod tests {
)
);
assert!(rendered.contains("allow_managed_hooks_only: true (source: cloud requirements)"));
assert!(rendered.contains("allow_appshots: false (source: cloud requirements)"));
assert!(
rendered.contains("guardian_policy_config: configured (source: cloud requirements)")
);
@@ -917,6 +934,7 @@ approval_policy = "never"
remote_sandbox_config: None,
allowed_web_search_modes: Some(Vec::new()),
allow_managed_hooks_only: None,
allow_appshots: None,
computer_use: None,
guardian_policy_config: None,
feature_requirements: None,
+71 -1
View File
@@ -166,6 +166,8 @@ mod tests {
}
pub fn set_modes() -> Result<()> {
ensure_virtual_terminal_processing()?;
execute!(stdout(), EnableBracketedPaste)?;
enable_raw_mode()?;
@@ -239,12 +241,16 @@ fn restore_common(
raw_mode_restore: RawModeRestore,
keyboard_restore: KeyboardRestore,
) -> Result<()> {
let mut first_error = ensure_virtual_terminal_processing().err();
match keyboard_restore {
KeyboardRestore::PopStack => keyboard_modes::restore_keyboard_enhancement_stack(),
KeyboardRestore::ResetAfterExit => keyboard_modes::reset_keyboard_reporting_after_exit(),
}
let mut first_error = execute!(stdout(), DisableBracketedPaste).err();
if let Err(err) = execute!(stdout(), DisableBracketedPaste) {
first_error.get_or_insert(err);
}
let _ = execute!(stdout(), DisableFocusChange);
if matches!(raw_mode_restore, RawModeRestore::Disable)
&& let Err(err) = disable_raw_mode()
@@ -797,6 +803,8 @@ impl Tui {
// the synchronized update, to avoid racing with the event reader.
let mut pending_viewport_area = self.pending_viewport_area()?;
ensure_virtual_terminal_processing()?;
stdout().sync_update(|_| {
#[cfg(unix)]
if let Some(prepared) = prepared_resume.take() {
@@ -854,6 +862,10 @@ impl Tui {
&mut self,
request: Option<crate::pets::AmbientPetDraw>,
) -> std::result::Result<(), crate::pets::PetImageRenderError> {
if let Err(err) = ensure_virtual_terminal_processing() {
return Err(crate::pets::PetImageRenderError::Terminal(err));
}
let terminal = &mut self.terminal;
let state = &mut self.ambient_pet_image_state;
stdout().sync_update(|_| {
@@ -869,6 +881,10 @@ impl Tui {
&mut self,
request: Option<crate::pets::AmbientPetDraw>,
) -> std::result::Result<(), crate::pets::PetImageRenderError> {
if let Err(err) = ensure_virtual_terminal_processing() {
return Err(crate::pets::PetImageRenderError::Terminal(err));
}
let terminal = &mut self.terminal;
let state = &mut self.pet_picker_preview_image_state;
stdout().sync_update(|_| {
@@ -887,6 +903,10 @@ impl Tui {
pub fn clear_ambient_pet_image(
&mut self,
) -> std::result::Result<(), crate::pets::PetImageRenderError> {
if let Err(err) = ensure_virtual_terminal_processing() {
return Err(crate::pets::PetImageRenderError::Terminal(err));
}
crate::pets::render_ambient_pet_image(
self.terminal.backend_mut(),
&mut self.ambient_pet_image_state,
@@ -911,6 +931,8 @@ impl Tui {
.suspend_context
.prepare_resume_action(&mut self.terminal, &mut self.alt_saved_viewport);
ensure_virtual_terminal_processing()?;
stdout().sync_update(|_| {
#[cfg(unix)]
if let Some(prepared) = prepared_resume.take() {
@@ -968,3 +990,51 @@ impl Tui {
Ok(None)
}
}
#[cfg(windows)]
fn ensure_virtual_terminal_processing() -> Result<()> {
use windows_sys::Win32::Foundation::HANDLE;
use windows_sys::Win32::Foundation::INVALID_HANDLE_VALUE;
use windows_sys::Win32::System::Console::ENABLE_PROCESSED_OUTPUT;
use windows_sys::Win32::System::Console::ENABLE_VIRTUAL_TERMINAL_PROCESSING;
use windows_sys::Win32::System::Console::GetConsoleMode;
use windows_sys::Win32::System::Console::GetStdHandle;
use windows_sys::Win32::System::Console::STD_ERROR_HANDLE;
use windows_sys::Win32::System::Console::STD_OUTPUT_HANDLE;
use windows_sys::Win32::System::Console::SetConsoleMode;
fn enable_for_handle(handle: HANDLE) -> Result<()> {
if handle == INVALID_HANDLE_VALUE || handle == 0 {
return Ok(());
}
let mut mode = 0;
if unsafe { GetConsoleMode(handle, &mut mode) } == 0 {
return Ok(());
}
let requested = ENABLE_PROCESSED_OUTPUT | ENABLE_VIRTUAL_TERMINAL_PROCESSING;
if mode & requested == requested {
return Ok(());
}
if unsafe { SetConsoleMode(handle, mode | requested) } == 0 {
return Err(std::io::Error::last_os_error());
}
Ok(())
}
let stdout_handle = unsafe { GetStdHandle(STD_OUTPUT_HANDLE) };
enable_for_handle(stdout_handle)?;
let stderr_handle = unsafe { GetStdHandle(STD_ERROR_HANDLE) };
enable_for_handle(stderr_handle)?;
Ok(())
}
#[cfg(not(windows))]
fn ensure_virtual_terminal_processing() -> Result<()> {
Ok(())
}
+12 -2
View File
@@ -47,7 +47,12 @@ impl UpdateAction {
),
UpdateAction::StandaloneWindows => (
"powershell",
&["-c", "irm https://chatgpt.com/codex/install.ps1|iex"],
&[
"-ExecutionPolicy",
"Bypass",
"-c",
"irm https://chatgpt.com/codex/install.ps1 | iex",
],
),
}
}
@@ -142,7 +147,12 @@ mod tests {
UpdateAction::StandaloneWindows.command_args(),
(
"powershell",
&["-c", "irm https://chatgpt.com/codex/install.ps1|iex"][..],
&[
"-ExecutionPolicy",
"Bypass",
"-c",
"irm https://chatgpt.com/codex/install.ps1 | iex"
][..],
)
);
}
+1
View File
@@ -37,6 +37,7 @@ serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
tempfile = "3"
tokio = { workspace = true, features = ["sync", "rt"] }
tracing-appender = { workspace = true }
windows = { version = "0.58", features = [
"Win32_Foundation",
"Win32_NetworkManagement_WindowsFirewall",
@@ -6,7 +6,6 @@ use anyhow::Result;
use base64::Engine;
use base64::engine::general_purpose::STANDARD as BASE64;
use codex_otel::StatsigMetricsSettings;
use codex_windows_sandbox::LOG_FILE_NAME;
use codex_windows_sandbox::SETUP_VERSION;
use codex_windows_sandbox::SetupErrorCode;
use codex_windows_sandbox::SetupErrorReport;
@@ -21,6 +20,7 @@ use codex_windows_sandbox::hide_newly_created_users;
use codex_windows_sandbox::install_wfp_filters;
use codex_windows_sandbox::is_command_cwd_root;
use codex_windows_sandbox::log_note;
use codex_windows_sandbox::log_writer;
use codex_windows_sandbox::path_mask_allows;
use codex_windows_sandbox::sandbox_bin_dir;
use codex_windows_sandbox::sandbox_dir;
@@ -36,7 +36,6 @@ use serde::Serialize;
use std::collections::HashSet;
use std::ffi::OsStr;
use std::ffi::c_void;
use std::fs::File;
use std::io::Write;
use std::os::windows::process::CommandExt;
use std::path::Path;
@@ -109,7 +108,7 @@ enum SetupMode {
ReadAclsOnly,
}
fn log_line(log: &mut File, msg: &str) -> Result<()> {
fn log_line(log: &mut dyn Write, msg: &str) -> Result<()> {
let ts = chrono::Utc::now().to_rfc3339();
writeln!(log, "[{ts}] {msg}").map_err(|err| {
anyhow::Error::new(SetupFailure::new(
@@ -156,7 +155,7 @@ fn workspace_write_cap_sids_for_path(
Ok(sid_strs)
}
fn spawn_read_acl_helper(payload: &Payload, _log: &mut File) -> Result<()> {
fn spawn_read_acl_helper(payload: &Payload, _log: &mut dyn Write) -> Result<()> {
let mut read_payload = payload.clone();
read_payload.mode = SetupMode::ReadAclsOnly;
read_payload.refresh_only = true;
@@ -182,7 +181,7 @@ struct ReadAclSubjects<'a> {
fn apply_read_acls(
read_roots: &[PathBuf],
subjects: &ReadAclSubjects<'_>,
log: &mut File,
log: &mut dyn Write,
refresh_errors: &mut Vec<String>,
access_mask: u32,
access_label: &str,
@@ -259,7 +258,7 @@ fn read_mask_allows_or_log(
read_mask: u32,
access_label: &str,
refresh_errors: &mut Vec<String>,
log: &mut File,
log: &mut dyn Write,
) -> Result<bool> {
match path_mask_allows(root, psids, read_mask, /*require_all_bits*/ true) {
Ok(has) => Ok(has),
@@ -294,7 +293,7 @@ fn lock_sandbox_dir(
sandbox_group_access_mode: i32,
sandbox_group_mask: u32,
real_user_mask: u32,
_log: &mut File,
_log: &mut dyn Write,
) -> Result<()> {
std::fs::create_dir_all(dir)?;
let system_sid = resolve_sid("SYSTEM")?;
@@ -391,8 +390,7 @@ pub fn main() -> Result<()> {
if let Ok(codex_home) = std::env::var("CODEX_HOME") {
let sbx_dir = sandbox_dir(Path::new(&codex_home));
let _ = std::fs::create_dir_all(&sbx_dir);
let log_path = sbx_dir.join(LOG_FILE_NAME);
if let Ok(mut f) = File::options().create(true).append(true).open(&log_path) {
if let Some(mut f) = log_writer(&sbx_dir) {
let _ = writeln!(
f,
"[{}] top-level error: {}",
@@ -442,17 +440,12 @@ fn real_main() -> Result<()> {
format!("failed to create sandbox dir {}: {err}", sbx_dir.display()),
))
})?;
let log_path = sbx_dir.join(LOG_FILE_NAME);
let mut log = File::options()
.create(true)
.append(true)
.open(&log_path)
.map_err(|err| {
anyhow::Error::new(SetupFailure::new(
SetupErrorCode::HelperLogFailed,
format!("open log {} failed: {err}", log_path.display()),
))
})?;
let mut log = log_writer(&sbx_dir).ok_or_else(|| {
anyhow::Error::new(SetupFailure::new(
SetupErrorCode::HelperLogFailed,
format!("open log in {} failed", sbx_dir.display()),
))
})?;
let result = run_setup(&payload, &mut log, &sbx_dir);
if let Err(err) = &result {
let _ = log_line(&mut log, &format!("setup error: {err:?}"));
@@ -480,14 +473,14 @@ fn real_main() -> Result<()> {
result
}
fn run_setup(payload: &Payload, log: &mut File, sbx_dir: &Path) -> Result<()> {
fn run_setup(payload: &Payload, log: &mut dyn Write, sbx_dir: &Path) -> Result<()> {
match payload.mode {
SetupMode::ReadAclsOnly => run_read_acl_only(payload, log),
SetupMode::Full => run_setup_full(payload, log, sbx_dir),
}
}
fn run_read_acl_only(payload: &Payload, log: &mut File) -> Result<()> {
fn run_read_acl_only(payload: &Payload, log: &mut dyn Write) -> Result<()> {
let _read_acl_guard = match acquire_read_acl_mutex()? {
Some(guard) => guard,
None => {
@@ -550,7 +543,7 @@ fn run_read_acl_only(payload: &Payload, log: &mut File) -> Result<()> {
Ok(())
}
fn run_setup_full(payload: &Payload, log: &mut File, sbx_dir: &Path) -> Result<()> {
fn run_setup_full(payload: &Payload, log: &mut dyn Write, sbx_dir: &Path) -> Result<()> {
let refresh_only = payload.refresh_only;
if !refresh_only {
let provision_result = provision_sandbox_users(
@@ -1,5 +1,4 @@
use anyhow::Result;
use std::fs::File;
use std::io::Write;
use windows::Win32::Foundation::S_OK;
@@ -57,7 +56,7 @@ pub fn ensure_offline_proxy_allowlist(
offline_sid: &str,
proxy_ports: &[u16],
allow_local_binding: bool,
log: &mut File,
log: &mut dyn Write,
) -> Result<()> {
let local_user_spec = format!("O:LSD:(A;;CC;;;{offline_sid})");
@@ -154,7 +153,7 @@ pub fn ensure_offline_proxy_allowlist(
result
}
pub fn ensure_offline_outbound_block(offline_sid: &str, log: &mut File) -> Result<()> {
pub fn ensure_offline_outbound_block(offline_sid: &str, log: &mut dyn Write) -> Result<()> {
let local_user_spec = format!("O:LSD:(A;;CC;;;{offline_sid})");
let hr = unsafe { CoInitializeEx(None, COINIT_APARTMENTTHREADED) };
@@ -206,7 +205,11 @@ pub fn ensure_offline_outbound_block(offline_sid: &str, log: &mut File) -> Resul
result
}
fn remove_rule_if_present(rules: &INetFwRules, internal_name: &str, log: &mut File) -> Result<()> {
fn remove_rule_if_present(
rules: &INetFwRules,
internal_name: &str,
log: &mut dyn Write,
) -> Result<()> {
let name = BSTR::from(internal_name);
if unsafe { rules.Item(&name) }.is_ok() {
unsafe { rules.Remove(&name) }.map_err(|err| {
@@ -266,7 +269,11 @@ fn validate_local_policy_modify_result(
)))
}
fn ensure_block_rule(rules: &INetFwRules, spec: &BlockRuleSpec<'_>, log: &mut File) -> Result<()> {
fn ensure_block_rule(
rules: &INetFwRules,
spec: &BlockRuleSpec<'_>,
log: &mut dyn Write,
) -> Result<()> {
let name = BSTR::from(spec.internal_name);
let rule: INetFwRule3 = match unsafe { rules.Item(&name) } {
Ok(existing) => existing.cast().map_err(|err| {
@@ -453,7 +460,7 @@ fn port_range_string(start: u32, end: u32) -> String {
}
}
fn log_line(log: &mut File, msg: &str) -> Result<()> {
fn log_line(log: &mut dyn Write, msg: &str) -> Result<()> {
let ts = chrono::Utc::now().to_rfc3339();
writeln!(log, "[{ts}] {msg}")?;
Ok(())
@@ -7,7 +7,7 @@ use rand::rngs::SmallRng;
use serde::Serialize;
use std::ffi::OsStr;
use std::ffi::c_void;
use std::fs::File;
use std::io::Write;
use std::path::Path;
use std::path::PathBuf;
use windows_sys::Win32::Foundation::ERROR_INSUFFICIENT_BUFFER;
@@ -49,7 +49,7 @@ const SID_AUTHENTICATED_USERS: &str = "S-1-5-11";
const SID_EVERYONE: &str = "S-1-1-0";
const SID_SYSTEM: &str = "S-1-5-18";
pub fn ensure_sandbox_users_group(log: &mut File) -> Result<()> {
pub fn ensure_sandbox_users_group(log: &mut dyn Write) -> Result<()> {
ensure_local_group(SANDBOX_USERS_GROUP, SANDBOX_USERS_GROUP_COMMENT, log)
}
@@ -63,7 +63,7 @@ pub fn provision_sandbox_users(
online_username: &str,
proxy_ports: &[u16],
allow_local_binding: bool,
log: &mut File,
log: &mut dyn Write,
) -> Result<()> {
ensure_sandbox_users_group(log)?;
super::log_line(
@@ -86,13 +86,13 @@ pub fn provision_sandbox_users(
Ok(())
}
pub fn ensure_sandbox_user(username: &str, password: &str, log: &mut File) -> Result<()> {
pub fn ensure_sandbox_user(username: &str, password: &str, log: &mut dyn Write) -> Result<()> {
ensure_local_user(username, password, log)?;
ensure_local_group_member(SANDBOX_USERS_GROUP, username)?;
Ok(())
}
pub fn ensure_local_user(name: &str, password: &str, log: &mut File) -> Result<()> {
pub fn ensure_local_user(name: &str, password: &str, log: &mut dyn Write) -> Result<()> {
let name_w = to_wide(OsStr::new(name));
let pwd_w = to_wide(OsStr::new(password));
unsafe {
@@ -156,7 +156,7 @@ pub fn ensure_local_user(name: &str, password: &str, log: &mut File) -> Result<(
Ok(())
}
pub fn ensure_local_group(name: &str, comment: &str, log: &mut File) -> Result<()> {
pub fn ensure_local_group(name: &str, comment: &str, log: &mut dyn Write) -> Result<()> {
const ERROR_ALIAS_EXISTS: u32 = 1379;
const NERR_GROUP_EXISTS: u32 = 2223;
@@ -1,5 +1,5 @@
use std::ffi::c_void;
use std::fs::File;
use std::io::Write;
use std::path::PathBuf;
use anyhow::Result;
@@ -13,7 +13,7 @@ use windows_sys::Win32::Storage::FileSystem::FILE_GENERIC_READ;
pub(super) fn ensure_codex_app_runtime_bin_readable(
sandbox_group_psid: *mut c_void,
refresh_errors: &mut Vec<String>,
log: &mut File,
log: &mut dyn Write,
) -> Result<()> {
let local_app_data = std::env::var_os("LOCALAPPDATA")
.map(PathBuf::from)
+5 -1
View File
@@ -183,10 +183,14 @@ pub use ipc_framed::read_frame;
#[cfg(target_os = "windows")]
pub use ipc_framed::write_frame;
#[cfg(target_os = "windows")]
pub use logging::LOG_FILE_NAME;
pub use logging::current_log_file_path;
#[cfg(target_os = "windows")]
pub use logging::log_file_path_for_utc_date;
#[cfg(target_os = "windows")]
pub use logging::log_note;
#[cfg(target_os = "windows")]
pub use logging::log_writer;
#[cfg(target_os = "windows")]
pub use path_normalization::canonicalize_path;
#[cfg(target_os = "windows")]
pub use policy::SandboxPolicy;
+64 -10
View File
@@ -1,13 +1,16 @@
use std::fs::OpenOptions;
use std::io::Write;
use std::path::Path;
use std::path::PathBuf;
use std::sync::OnceLock;
use codex_utils_string::take_bytes_at_char_boundary;
use tracing_appender::rolling::RollingFileAppender;
use tracing_appender::rolling::Rotation;
const LOG_COMMAND_PREVIEW_LIMIT: usize = 200;
pub const LOG_FILE_NAME: &str = "sandbox.log";
pub const LOG_FILE_PREFIX: &str = "sandbox";
pub const LOG_FILE_SUFFIX: &str = "log";
pub const MAX_LOG_FILES: usize = 90;
fn exe_label() -> &'static str {
static LABEL: OnceLock<String> = OnceLock::new();
@@ -28,18 +31,35 @@ fn preview(command: &[String]) -> String {
}
}
fn log_file_path(base_dir: &Path) -> Option<PathBuf> {
if base_dir.is_dir() {
Some(base_dir.join(LOG_FILE_NAME))
} else {
None
pub fn log_file_path_for_utc_date(base_dir: &Path, date: chrono::NaiveDate) -> PathBuf {
base_dir.join(format!(
"{LOG_FILE_PREFIX}.{}.{}",
date.format("%Y-%m-%d"),
LOG_FILE_SUFFIX
))
}
pub fn current_log_file_path(base_dir: &Path) -> PathBuf {
log_file_path_for_utc_date(base_dir, chrono::Utc::now().date_naive())
}
pub fn log_writer(base_dir: &Path) -> Option<RollingFileAppender> {
if !base_dir.is_dir() {
return None;
}
RollingFileAppender::builder()
.rotation(Rotation::DAILY)
.filename_prefix(LOG_FILE_PREFIX)
.filename_suffix(LOG_FILE_SUFFIX)
.max_log_files(MAX_LOG_FILES)
.build(base_dir)
.ok()
}
fn append_line(line: &str, base_dir: Option<&Path>) {
if let Some(dir) = base_dir
&& let Some(path) = log_file_path(dir)
&& let Ok(mut f) = OpenOptions::new().create(true).append(true).open(path)
&& let Some(mut f) = log_writer(dir)
{
let _ = writeln!(f, "{line}");
}
@@ -68,7 +88,7 @@ pub fn debug_log(msg: &str, base_dir: Option<&Path>) {
}
}
// Unconditional note logging to sandbox.log
// Unconditional note logging to the daily sandbox log.
pub fn log_note(msg: &str, base_dir: Option<&Path>) {
let ts = chrono::Local::now().format("%Y-%m-%d %H:%M:%S%.3f");
append_line(&format!("[{ts} {}] {}", exe_label(), msg), base_dir);
@@ -88,4 +108,38 @@ mod tests {
let previewed = result.unwrap();
assert!(previewed.len() <= LOG_COMMAND_PREVIEW_LIMIT);
}
#[test]
fn log_note_writes_to_daily_rolling_log() {
let tempdir = tempfile::tempdir().expect("tempdir");
log_note("hello daily log", Some(tempdir.path()));
let entries = std::fs::read_dir(tempdir.path())
.expect("read log dir")
.collect::<Result<Vec<_>, _>>()
.expect("read entries");
assert_eq!(entries.len(), 1);
let log_path = entries[0].path();
let filename = log_path
.file_name()
.and_then(|name| name.to_str())
.expect("utf-8 filename");
assert!(filename.starts_with("sandbox."));
assert!(filename.ends_with(".log"));
let log = std::fs::read_to_string(log_path).expect("read log");
assert!(log.contains("hello daily log"));
}
#[test]
fn log_file_path_for_utc_date_matches_rolling_appender_name() {
let date = chrono::NaiveDate::from_ymd_opt(2026, 5, 21).expect("valid date");
assert_eq!(
log_file_path_for_utc_date(Path::new("logs"), date),
PathBuf::from("logs").join("sandbox.2026-05-21.log")
);
}
}
@@ -70,7 +70,7 @@ fn sandbox_home(name: &str) -> TempDir {
}
fn sandbox_log(codex_home: &Path) -> String {
let log_path = codex_home.join(".sandbox").join("sandbox.log");
let log_path = crate::current_log_file_path(&codex_home.join(".sandbox"));
fs::read_to_string(&log_path)
.unwrap_or_else(|err| format!("failed to read {}: {err}", log_path.display()))
}
+222
View File
@@ -0,0 +1,222 @@
"""Fetch executable artifacts from checked-in DotSlash manifests."""
import hashlib
import json
import shutil
import stat
import tarfile
import tempfile
import zipfile
from dataclasses import dataclass
from pathlib import Path
from urllib.parse import urlparse
from urllib.request import urlopen
from .targets import TargetSpec
DOWNLOAD_TIMEOUT_SECS = 60
@dataclass(frozen=True)
class DotSlashArtifact:
size: int
digest: str
archive_format: str
archive_member: str
url: str
def fetch_dotslash_executable(
spec: TargetSpec,
*,
manifest_path: Path,
artifact_label: str,
cache_key: str,
dest_name: str,
missing_ok: bool = False,
) -> Path | None:
artifact = artifact_for_target(
spec,
manifest_path,
artifact_label=artifact_label,
missing_ok=missing_ok,
)
if artifact is None:
return None
cache_dir = default_cache_root() / cache_key
archive_path = cache_dir / archive_filename(artifact.url)
if not archive_is_valid(archive_path, artifact, artifact_label):
download_archive(artifact.url, archive_path)
try:
verify_archive(archive_path, artifact, artifact_label)
except RuntimeError:
archive_path.unlink(missing_ok=True)
raise
dest = cache_dir / dest_name
extract_archive_member(archive_path, artifact, dest, artifact_label)
if not spec.is_windows:
mode = dest.stat().st_mode
dest.chmod(mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
return dest
def artifact_for_target(
spec: TargetSpec,
manifest_path: Path,
*,
artifact_label: str,
missing_ok: bool = False,
) -> DotSlashArtifact | None:
manifest = load_manifest(manifest_path)
platform_info = manifest.get("platforms", {}).get(spec.dotslash_platform)
if platform_info is None:
if missing_ok:
return None
raise RuntimeError(
f"{artifact_label} manifest {manifest_path} is missing platform "
f"{spec.dotslash_platform!r}"
)
providers = platform_info.get("providers")
if not providers:
raise RuntimeError(
f"{artifact_label} manifest {manifest_path} has no providers for "
f"{spec.dotslash_platform!r}"
)
hash_name = platform_info.get("hash")
if hash_name != "sha256":
raise RuntimeError(
f"Unsupported {artifact_label} hash {hash_name!r} for "
f"{spec.dotslash_platform!r}; expected sha256"
)
return DotSlashArtifact(
size=int(platform_info["size"]),
digest=str(platform_info["digest"]),
archive_format=str(platform_info["format"]),
archive_member=str(platform_info["path"]),
url=str(providers[0]["url"]),
)
def load_manifest(manifest_path: Path) -> dict:
text = manifest_path.read_text(encoding="utf-8")
if text.startswith("#!"):
text = "\n".join(text.splitlines()[1:])
return json.loads(text)
def default_cache_root() -> Path:
return Path(tempfile.gettempdir()) / "codex-package"
def archive_filename(url: str) -> str:
filename = Path(urlparse(url).path).name
if not filename:
raise RuntimeError(f"Unable to determine archive filename from {url}")
return filename
def archive_is_valid(
archive_path: Path,
artifact: DotSlashArtifact,
artifact_label: str,
) -> bool:
if not archive_path.is_file():
return False
try:
verify_archive(archive_path, artifact, artifact_label)
except RuntimeError:
archive_path.unlink(missing_ok=True)
return False
return True
def verify_archive(
archive_path: Path,
artifact: DotSlashArtifact,
artifact_label: str,
) -> None:
actual_size = archive_path.stat().st_size
if actual_size != artifact.size:
raise RuntimeError(
f"{artifact_label} archive {archive_path} has size {actual_size}, "
f"expected {artifact.size}"
)
digest = hashlib.sha256()
with open(archive_path, "rb") as fh:
for chunk in iter(lambda: fh.read(1024 * 1024), b""):
digest.update(chunk)
actual_digest = digest.hexdigest()
if actual_digest != artifact.digest:
raise RuntimeError(
f"{artifact_label} archive {archive_path} has sha256 {actual_digest}, "
f"expected {artifact.digest}"
)
def download_archive(url: str, archive_path: Path) -> None:
archive_path.parent.mkdir(parents=True, exist_ok=True)
temp_path = archive_path.with_suffix(f"{archive_path.suffix}.tmp")
temp_path.unlink(missing_ok=True)
try:
with urlopen(url, timeout=DOWNLOAD_TIMEOUT_SECS) as response:
with open(temp_path, "wb") as out:
shutil.copyfileobj(response, out)
temp_path.replace(archive_path)
finally:
temp_path.unlink(missing_ok=True)
def extract_archive_member(
archive_path: Path,
artifact: DotSlashArtifact,
dest: Path,
artifact_label: str,
) -> None:
dest.parent.mkdir(parents=True, exist_ok=True)
dest.unlink(missing_ok=True)
if artifact.archive_format == "tar.gz":
with tarfile.open(archive_path, "r:gz") as archive:
try:
member = archive.getmember(artifact.archive_member)
except KeyError as exc:
raise RuntimeError(
f"{artifact_label} archive {archive_path} is missing "
f"{artifact.archive_member!r}"
) from exc
extracted = archive.extractfile(member)
if extracted is None:
raise RuntimeError(
f"{artifact_label} archive member {artifact.archive_member!r} is not a file"
)
with extracted, open(dest, "wb") as out:
shutil.copyfileobj(extracted, out)
return
if artifact.archive_format == "zip":
with zipfile.ZipFile(archive_path) as archive:
try:
with archive.open(artifact.archive_member) as extracted:
with open(dest, "wb") as out:
shutil.copyfileobj(extracted, out)
except KeyError as exc:
raise RuntimeError(
f"{artifact_label} archive {archive_path} is missing "
f"{artifact.archive_member!r}"
) from exc
return
raise RuntimeError(
f"Unsupported {artifact_label} archive format {artifact.archive_format!r}; "
"expected tar.gz or zip"
)
+10 -170
View File
@@ -1,33 +1,14 @@
"""Fetch ripgrep from the DotSlash manifest used by the package builder."""
import hashlib
import json
import shutil
import stat
import tarfile
import tempfile
import zipfile
from dataclasses import dataclass
from pathlib import Path
from urllib.parse import urlparse
from urllib.request import urlopen
from .dotslash import fetch_dotslash_executable
from .targets import REPO_ROOT
from .targets import TargetSpec
from .targets import resolve_input_path
RG_MANIFEST = REPO_ROOT / "scripts" / "codex_package" / "rg"
DOWNLOAD_TIMEOUT_SECS = 60
@dataclass(frozen=True)
class RgArtifact:
size: int
digest: str
archive_format: str
archive_member: str
url: str
def resolve_rg_bin(spec: TargetSpec, rg_bin: Path | None) -> Path:
@@ -41,155 +22,14 @@ def fetch_rg(
spec: TargetSpec,
*,
manifest_path: Path = RG_MANIFEST,
cache_root: Path | None = None,
) -> Path:
artifact = artifact_for_target(spec, manifest_path)
cache_dir = (cache_root or default_cache_root()) / f"{spec.target}-rg"
archive_path = cache_dir / archive_filename(artifact.url)
if not archive_is_valid(archive_path, artifact):
download_archive(artifact.url, archive_path)
try:
verify_archive(archive_path, artifact)
except RuntimeError:
archive_path.unlink(missing_ok=True)
raise
dest = cache_dir / spec.rg_name
extract_rg(archive_path, artifact, dest)
if not spec.is_windows:
mode = dest.stat().st_mode
dest.chmod(mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
return dest
def artifact_for_target(spec: TargetSpec, manifest_path: Path) -> RgArtifact:
manifest = load_manifest(manifest_path)
try:
platform_info = manifest["platforms"][spec.dotslash_platform]
except KeyError as exc:
raise RuntimeError(
f"ripgrep manifest {manifest_path} is missing platform {spec.dotslash_platform!r}"
) from exc
providers = platform_info.get("providers")
if not providers:
raise RuntimeError(
f"ripgrep manifest {manifest_path} has no providers for {spec.dotslash_platform!r}"
)
hash_name = platform_info.get("hash")
if hash_name != "sha256":
raise RuntimeError(
f"Unsupported ripgrep hash {hash_name!r} for "
f"{spec.dotslash_platform!r}; expected sha256"
)
return RgArtifact(
size=int(platform_info["size"]),
digest=str(platform_info["digest"]),
archive_format=str(platform_info["format"]),
archive_member=str(platform_info["path"]),
url=str(providers[0]["url"]),
)
def load_manifest(manifest_path: Path) -> dict:
text = manifest_path.read_text(encoding="utf-8")
if text.startswith("#!"):
text = "\n".join(text.splitlines()[1:])
return json.loads(text)
def default_cache_root() -> Path:
return Path(tempfile.gettempdir()) / "codex-package"
def archive_filename(url: str) -> str:
filename = Path(urlparse(url).path).name
if not filename:
raise RuntimeError(f"Unable to determine archive filename from {url}")
return filename
def archive_is_valid(archive_path: Path, artifact: RgArtifact) -> bool:
if not archive_path.is_file():
return False
try:
verify_archive(archive_path, artifact)
except RuntimeError:
archive_path.unlink(missing_ok=True)
return False
return True
def verify_archive(archive_path: Path, artifact: RgArtifact) -> None:
actual_size = archive_path.stat().st_size
if actual_size != artifact.size:
raise RuntimeError(
f"ripgrep archive {archive_path} has size {actual_size}, expected {artifact.size}"
)
digest = hashlib.sha256()
with open(archive_path, "rb") as fh:
for chunk in iter(lambda: fh.read(1024 * 1024), b""):
digest.update(chunk)
actual_digest = digest.hexdigest()
if actual_digest != artifact.digest:
raise RuntimeError(
f"ripgrep archive {archive_path} has sha256 {actual_digest}, "
f"expected {artifact.digest}"
)
def download_archive(url: str, archive_path: Path) -> None:
archive_path.parent.mkdir(parents=True, exist_ok=True)
temp_path = archive_path.with_suffix(f"{archive_path.suffix}.tmp")
temp_path.unlink(missing_ok=True)
try:
with urlopen(url, timeout=DOWNLOAD_TIMEOUT_SECS) as response:
with open(temp_path, "wb") as out:
shutil.copyfileobj(response, out)
temp_path.replace(archive_path)
finally:
temp_path.unlink(missing_ok=True)
def extract_rg(archive_path: Path, artifact: RgArtifact, dest: Path) -> None:
dest.parent.mkdir(parents=True, exist_ok=True)
dest.unlink(missing_ok=True)
if artifact.archive_format == "tar.gz":
with tarfile.open(archive_path, "r:gz") as archive:
try:
member = archive.getmember(artifact.archive_member)
except KeyError as exc:
raise RuntimeError(
f"ripgrep archive {archive_path} is missing {artifact.archive_member!r}"
) from exc
extracted = archive.extractfile(member)
if extracted is None:
raise RuntimeError(
f"ripgrep archive member {artifact.archive_member!r} is not a file"
)
with extracted, open(dest, "wb") as out:
shutil.copyfileobj(extracted, out)
return
if artifact.archive_format == "zip":
with zipfile.ZipFile(archive_path) as archive:
try:
with archive.open(artifact.archive_member) as extracted:
with open(dest, "wb") as out:
shutil.copyfileobj(extracted, out)
except KeyError as exc:
raise RuntimeError(
f"ripgrep archive {archive_path} is missing {artifact.archive_member!r}"
) from exc
return
raise RuntimeError(
f"Unsupported ripgrep archive format {artifact.archive_format!r}; expected tar.gz or zip"
rg_bin = fetch_dotslash_executable(
spec,
manifest_path=manifest_path,
artifact_label="ripgrep",
cache_key=f"{spec.target}-rg",
dest_name=spec.rg_name,
)
if rg_bin is None:
raise AssertionError("ripgrep is required for all package targets")
return rg_bin