Compare commits

..

1 Commits

Author SHA1 Message Date
Michael Bolin
9e4bfa31a4 tests: add no-wait helper for apply patch event tests 2026-04-28 19:46:18 -07:00
50 changed files with 1240 additions and 1741 deletions

View File

@@ -92,4 +92,4 @@ quoted_args=""
for arg in "$@"; do
quoted_args+=" $(printf '%q' "$arg")"
done
docker exec -it "$CONTAINER_NAME" bash -c "cd \"/app$WORK_DIR\" && codex --sandbox workspace-write --ask-for-approval on-request ${quoted_args}"
docker exec -it "$CONTAINER_NAME" bash -c "cd \"/app$WORK_DIR\" && codex --full-auto ${quoted_args}"

View File

@@ -106,7 +106,7 @@ members = [
resolver = "2"
[workspace.package]
version = "0.126.0-alpha.15"
version = "0.0.0"
# Track the edition for all workspace crates in one place. Individual
# crates can still override this value, but keeping it here means new
# crates created with `cargo new -w ...` automatically inherit the 2024

View File

@@ -59,22 +59,19 @@ To test to see what happens when a command is run under the sandbox provided by
```
# macOS
codex sandbox macos [--log-denials] [COMMAND]...
codex sandbox macos [--full-auto] [--log-denials] [COMMAND]...
# Linux
codex sandbox linux [COMMAND]...
codex sandbox linux [--full-auto] [COMMAND]...
# Windows
codex sandbox windows [COMMAND]...
codex sandbox windows [--full-auto] [COMMAND]...
# Legacy aliases
codex debug seatbelt [--log-denials] [COMMAND]...
codex debug landlock [COMMAND]...
codex debug seatbelt [--full-auto] [--log-denials] [COMMAND]...
codex debug landlock [--full-auto] [COMMAND]...
```
To try a writable legacy sandbox mode with these commands, pass an explicit config override such
as `-c 'sandbox_mode="workspace-write"'`.
### 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:

View File

@@ -2029,17 +2029,14 @@ mod tests {
#[tokio::test]
async fn runtime_start_args_forward_environment_manager() {
let config = Arc::new(build_test_config().await);
let environment_manager = Arc::new(
EnvironmentManager::create_for_tests(
Some("ws://127.0.0.1:8765".to_string()),
ExecServerRuntimePaths::new(
std::env::current_exe().expect("current exe"),
/*codex_linux_sandbox_exe*/ None,
)
.expect("runtime paths"),
let environment_manager = Arc::new(EnvironmentManager::new(EnvironmentManagerArgs {
exec_server_url: Some("ws://127.0.0.1:8765".to_string()),
local_runtime_paths: ExecServerRuntimePaths::new(
std::env::current_exe().expect("current exe"),
/*codex_linux_sandbox_exe*/ None,
)
.await,
);
.expect("runtime paths"),
}));
let runtime_args = InProcessClientStartArgs {
arg0_paths: Arg0DispatchPaths::default(),

View File

@@ -252,7 +252,7 @@ impl ExternalAgentConfigService {
|| self.external_agent_home.join("settings.json"),
|repo_root| repo_root.join(EXTERNAL_AGENT_DIR).join("settings.json"),
);
let settings = effective_external_settings(&source_settings)?;
let settings = read_external_settings(&source_settings)?;
let target_config = repo_root.map_or_else(
|| self.codex_home.join("config.toml"),
|repo_root| repo_root.join(".codex").join("config.toml"),
@@ -569,7 +569,7 @@ impl ExternalAgentConfigService {
) -> io::Result<Option<JsonValue>> {
if repo_root.is_some() && source_settings.is_none() {
let home_settings = self.external_agent_home.join("settings.json");
match effective_external_settings(&home_settings) {
match read_external_settings(&home_settings) {
Ok(settings) => Ok(settings),
Err(err) => {
tracing::warn!(
@@ -636,7 +636,7 @@ impl ExternalAgentConfigService {
|cwd| cwd.join(EXTERNAL_AGENT_DIR).join("settings.json"),
);
let source_root = cwd.unwrap_or(self.external_agent_home.as_path());
let import_sources = effective_external_settings(&source_settings)?
let import_sources = read_external_settings(&source_settings)?
.map(|settings| collect_marketplace_import_sources(&settings, source_root))
.unwrap_or_default();
@@ -697,11 +697,9 @@ impl ExternalAgentConfigService {
|cwd| cwd.join(EXTERNAL_AGENT_DIR).join("settings.json"),
);
let source_root = cwd.unwrap_or(self.external_agent_home.as_path());
let import_source =
effective_external_settings(&source_settings)?.and_then(|settings| {
collect_marketplace_import_sources(&settings, source_root)
.remove(&marketplace_name)
});
let import_source = read_external_settings(&source_settings)?.and_then(|settings| {
collect_marketplace_import_sources(&settings, source_root).remove(&marketplace_name)
});
let Some(import_source) = import_source else {
outcome.failed_marketplaces.push(marketplace_name);
outcome.failed_plugin_ids.extend(plugin_ids);
@@ -769,9 +767,13 @@ impl ExternalAgentConfigService {
self.codex_home.join("config.toml"),
)
};
let Some(settings) = effective_external_settings(&source_settings)? else {
if !source_settings.is_file() {
return Ok(());
};
}
let raw_settings = fs::read_to_string(&source_settings)?;
let settings: JsonValue = serde_json::from_str(&raw_settings)
.map_err(|err| invalid_data_error(err.to_string()))?;
let migrated = build_config_from_external(&settings)?;
if is_empty_toml_table(&migrated) {
return Ok(());
@@ -820,7 +822,7 @@ impl ExternalAgentConfigService {
};
let settings = self.mcp_settings(
repo_root.as_deref(),
effective_external_settings(&source_settings)?,
read_external_settings(&source_settings)?,
)?;
let migrated = build_mcp_config_from_external(
self.source_root(repo_root.as_deref()).as_path(),
@@ -997,43 +999,6 @@ fn read_external_settings(path: &Path) -> io::Result<Option<JsonValue>> {
Ok(Some(settings))
}
fn effective_external_settings(project_settings: &Path) -> io::Result<Option<JsonValue>> {
let mut effective = read_external_settings(project_settings)?;
let Some(settings_dir) = project_settings.parent() else {
return Ok(effective);
};
let local_settings = settings_dir.join("settings.local.json");
let local_settings = match read_external_settings(&local_settings) {
Ok(Some(local_settings)) => local_settings,
Ok(None) => return Ok(effective),
Err(err) if err.kind() == io::ErrorKind::InvalidData => return Ok(effective),
Err(err) => return Err(err),
};
if let Some(effective) = effective.as_mut() {
merge_json_settings(effective, &local_settings);
} else {
effective = Some(local_settings);
}
Ok(effective)
}
fn merge_json_settings(existing: &mut JsonValue, incoming: &JsonValue) {
match (existing, incoming) {
(JsonValue::Object(existing), JsonValue::Object(incoming)) => {
for (key, incoming_value) in incoming {
match existing.get_mut(key) {
Some(existing_value) => merge_json_settings(existing_value, incoming_value),
None => {
existing.insert(key.clone(), incoming_value.clone());
}
}
}
}
(existing, incoming) => {
*existing = incoming.clone();
}
}
}
fn extract_plugin_migration_details(
settings: &JsonValue,
source_root: &Path,

View File

@@ -707,68 +707,6 @@ async fn import_home_migrates_supported_config_fields_skills_and_agents_md() {
);
}
#[tokio::test]
async fn import_home_config_uses_local_settings_over_project_settings() {
let (_root, external_agent_home, codex_home) = fixture_paths();
fs::create_dir_all(&external_agent_home).expect("create external agent home");
fs::write(
external_agent_home.join("settings.json"),
r#"{"env":{"FOO":"project","PROJECT_ONLY":"yes"},"sandbox":{"enabled":false}}"#,
)
.expect("write project settings");
fs::write(
external_agent_home.join("settings.local.json"),
r#"{"env":{"FOO":"local","LOCAL_ONLY":true},"sandbox":{"enabled":true}}"#,
)
.expect("write local settings");
service_for_paths(external_agent_home, codex_home.clone())
.import(vec![ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::Config,
description: String::new(),
cwd: None,
details: None,
}])
.await
.expect("import");
assert_eq!(
fs::read_to_string(codex_home.join("config.toml")).expect("read config"),
"sandbox_mode = \"workspace-write\"\n\n[shell_environment_policy]\ninherit = \"core\"\n\n[shell_environment_policy.set]\nFOO = \"local\"\nLOCAL_ONLY = \"true\"\nPROJECT_ONLY = \"yes\"\n"
);
}
#[tokio::test]
async fn import_home_config_ignores_invalid_local_settings() {
let (_root, external_agent_home, codex_home) = fixture_paths();
fs::create_dir_all(&external_agent_home).expect("create external agent home");
fs::write(
external_agent_home.join("settings.json"),
r#"{"env":{"FOO":"project"},"sandbox":{"enabled":false}}"#,
)
.expect("write project settings");
fs::write(
external_agent_home.join("settings.local.json"),
"{invalid json",
)
.expect("write local settings");
service_for_paths(external_agent_home, codex_home.clone())
.import(vec![ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::Config,
description: String::new(),
cwd: None,
details: None,
}])
.await
.expect("import");
assert_eq!(
fs::read_to_string(codex_home.join("config.toml")).expect("read config"),
"[shell_environment_policy]\ninherit = \"core\"\n\n[shell_environment_policy.set]\nFOO = \"project\"\n"
);
}
#[tokio::test]
async fn import_home_skips_empty_config_migration() {
let (_root, external_agent_home, codex_home) = fixture_paths();
@@ -1206,67 +1144,6 @@ command = "allowed-server"
assert_eq!(config, expected);
}
#[tokio::test]
async fn import_repo_mcp_uses_local_settings_toggles_over_project_settings() {
let root = TempDir::new().expect("create tempdir");
let repo_root = root.path().join("repo");
let external_agent_home = root.path().join(EXTERNAL_AGENT_DIR);
fs::create_dir_all(repo_root.join(".git")).expect("create git dir");
fs::create_dir_all(repo_root.join(EXTERNAL_AGENT_DIR)).expect("create external agent dir");
fs::write(
repo_root.join(".mcp.json"),
r#"{
"mcpServers": {
"project-disabled": {"command": "project-disabled-server"},
"local-disabled": {"command": "local-disabled-server"},
"local-enabled": {"command": "local-enabled-server"}
}
}"#,
)
.expect("write mcp");
fs::write(
repo_root.join(EXTERNAL_AGENT_DIR).join("settings.json"),
r#"{
"enabledMcpjsonServers": ["project-disabled", "local-disabled"],
"disabledMcpjsonServers": ["project-disabled"]
}"#,
)
.expect("write project settings");
fs::write(
repo_root
.join(EXTERNAL_AGENT_DIR)
.join("settings.local.json"),
r#"{
"enabledMcpjsonServers": ["local-enabled", "local-disabled"],
"disabledMcpjsonServers": ["local-disabled"]
}"#,
)
.expect("write local settings");
service_for_paths(external_agent_home, root.path().join(".codex"))
.import(vec![ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::McpServerConfig,
description: String::new(),
cwd: Some(repo_root.clone()),
details: None,
}])
.await
.expect("import");
let config: TomlValue = toml::from_str(
&fs::read_to_string(repo_root.join(".codex").join("config.toml")).expect("read config"),
)
.expect("parse config");
let expected: TomlValue = toml::from_str(
r#"
[mcp_servers.local-enabled]
command = "local-enabled-server"
"#,
)
.expect("parse expected config");
assert_eq!(config, expected);
}
#[tokio::test]
async fn import_repo_mcp_ignores_invalid_home_settings_when_repo_settings_missing() {
let root = TempDir::new().expect("create tempdir");
@@ -1409,64 +1286,6 @@ async fn detect_home_lists_enabled_plugins_from_settings() {
);
}
#[tokio::test]
async fn detect_home_plugins_uses_local_settings_over_project_settings() {
let (_root, external_agent_home, codex_home) = fixture_paths();
fs::create_dir_all(&external_agent_home).expect("create external agent home");
fs::write(
external_agent_home.join("settings.json"),
r#"{
"enabledPlugins": {
"formatter@acme-tools": true,
"legacy@acme-tools": true
},
"extraKnownMarketplaces": {
"acme-tools": {
"source": "acme-corp/external-agent-plugins"
}
}
}"#,
)
.expect("write project settings");
fs::write(
external_agent_home.join("settings.local.json"),
r#"{
"enabledPlugins": {
"formatter@acme-tools": false,
"deployer@acme-tools": true
}
}"#,
)
.expect("write local settings");
let items = service_for_paths(external_agent_home.clone(), codex_home)
.detect(ExternalAgentConfigDetectOptions {
include_home: true,
cwds: None,
})
.await
.expect("detect");
assert_eq!(
items,
vec![ExternalAgentConfigMigrationItem {
item_type: ExternalAgentConfigMigrationItemType::Plugins,
description: format!(
"Migrate enabled plugins from {}",
external_agent_home.join("settings.json").display()
),
cwd: None,
details: Some(MigrationDetails {
plugins: vec![PluginsMigration {
marketplace_name: "acme-tools".to_string(),
plugin_names: vec!["deployer".to_string(), "legacy".to_string()],
}],
..Default::default()
}),
}]
);
}
#[tokio::test]
async fn detect_repo_skips_plugins_that_are_already_configured_in_codex() {
let root = TempDir::new().expect("create tempdir");

View File

@@ -418,15 +418,12 @@ pub async fn run_main_with_transport_options(
auth: AppServerWebsocketAuthSettings,
runtime_options: AppServerRuntimeOptions,
) -> IoResult<()> {
let environment_manager = Arc::new(
EnvironmentManager::new(EnvironmentManagerArgs::new(
ExecServerRuntimePaths::from_optional_paths(
arg0_paths.codex_self_exe.clone(),
arg0_paths.codex_linux_sandbox_exe.clone(),
)?,
))
.await,
);
let environment_manager = Arc::new(EnvironmentManager::new(EnvironmentManagerArgs::from_env(
ExecServerRuntimePaths::from_optional_paths(
arg0_paths.codex_self_exe.clone(),
arg0_paths.codex_linux_sandbox_exe.clone(),
)?,
)));
let (transport_event_tx, mut transport_event_rx) =
mpsc::channel::<TransportEvent>(CHANNEL_CAPACITY);
let (outgoing_tx, mut outgoing_rx) = mpsc::channel::<OutgoingEnvelope>(CHANNEL_CAPACITY);

View File

@@ -333,7 +333,7 @@ async fn turn_start_emits_thread_scoped_warning_notification_for_trimmed_skills(
assert_eq!(warning.thread_id.as_deref(), Some(thread.id.as_str()));
assert_eq!(
warning.message,
"Exceeded skills context budget of 2%. All skill descriptions were removed and 7 additional skills were not included in the model-visible skills list."
"Warning: Exceeded skills context budget of 2%. All skill descriptions were removed and 7 additional skills were not included in the model-visible skills list."
);
timeout(

View File

@@ -6,7 +6,6 @@ mod seatbelt;
use std::path::PathBuf;
use std::process::Stdio;
use codex_config::LoaderOverrides;
use codex_core::config::Config;
use codex_core::config::ConfigBuilder;
use codex_core::config::ConfigOverrides;
@@ -43,24 +42,14 @@ pub async fn run_command_under_seatbelt(
codex_linux_sandbox_exe: Option<PathBuf>,
) -> anyhow::Result<()> {
let SeatbeltCommand {
permissions_profile,
cwd,
include_managed_config,
full_auto,
allow_unix_sockets,
log_denials,
config_overrides,
command,
} = command;
let managed_requirements_mode = ManagedRequirementsMode::for_profile_invocation(
&permissions_profile,
include_managed_config,
);
run_command_under_sandbox(
DebugSandboxConfigOptions {
permissions_profile,
cwd,
managed_requirements_mode,
},
full_auto,
command,
config_overrides,
codex_linux_sandbox_exe,
@@ -84,22 +73,12 @@ pub async fn run_command_under_landlock(
codex_linux_sandbox_exe: Option<PathBuf>,
) -> anyhow::Result<()> {
let LandlockCommand {
permissions_profile,
cwd,
include_managed_config,
full_auto,
config_overrides,
command,
} = command;
let managed_requirements_mode = ManagedRequirementsMode::for_profile_invocation(
&permissions_profile,
include_managed_config,
);
run_command_under_sandbox(
DebugSandboxConfigOptions {
permissions_profile,
cwd,
managed_requirements_mode,
},
full_auto,
command,
config_overrides,
codex_linux_sandbox_exe,
@@ -115,22 +94,12 @@ pub async fn run_command_under_windows(
codex_linux_sandbox_exe: Option<PathBuf>,
) -> anyhow::Result<()> {
let WindowsCommand {
permissions_profile,
cwd,
include_managed_config,
full_auto,
config_overrides,
command,
} = command;
let managed_requirements_mode = ManagedRequirementsMode::for_profile_invocation(
&permissions_profile,
include_managed_config,
);
run_command_under_sandbox(
DebugSandboxConfigOptions {
permissions_profile,
cwd,
managed_requirements_mode,
},
full_auto,
command,
config_overrides,
codex_linux_sandbox_exe,
@@ -148,34 +117,8 @@ enum SandboxType {
Windows,
}
#[derive(Debug)]
struct DebugSandboxConfigOptions {
permissions_profile: Option<String>,
cwd: Option<PathBuf>,
managed_requirements_mode: ManagedRequirementsMode,
}
#[derive(Debug, Clone, Copy)]
enum ManagedRequirementsMode {
Include,
Ignore,
}
impl ManagedRequirementsMode {
fn for_profile_invocation(
permissions_profile: &Option<String>,
include_managed_config: bool,
) -> Self {
if permissions_profile.is_some() && !include_managed_config {
Self::Ignore
} else {
Self::Include
}
}
}
async fn run_command_under_sandbox(
config_options: DebugSandboxConfigOptions,
full_auto: bool,
command: Vec<String>,
config_overrides: CliConfigOverrides,
codex_linux_sandbox_exe: Option<PathBuf>,
@@ -189,7 +132,7 @@ async fn run_command_under_sandbox(
.parse_overrides()
.map_err(anyhow::Error::msg)?,
codex_linux_sandbox_exe,
config_options,
full_auto,
)
.await?;
@@ -459,6 +402,14 @@ async fn run_command_under_windows_session(
std::process::exit(exit_code);
}
pub fn create_sandbox_mode(full_auto: bool) -> SandboxMode {
if full_auto {
SandboxMode::WorkspaceWrite
} else {
SandboxMode::ReadOnly
}
}
async fn spawn_debug_sandbox_child(
program: PathBuf,
args: Vec<String>,
@@ -628,68 +579,50 @@ mod windows_stdio_bridge {
async fn load_debug_sandbox_config(
cli_overrides: Vec<(String, TomlValue)>,
codex_linux_sandbox_exe: Option<PathBuf>,
options: DebugSandboxConfigOptions,
full_auto: bool,
) -> anyhow::Result<Config> {
load_debug_sandbox_config_with_codex_home(
cli_overrides,
codex_linux_sandbox_exe,
options,
full_auto,
/*codex_home*/ None,
)
.await
}
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,
full_auto: bool,
codex_home: Option<PathBuf>,
) -> anyhow::Result<Config> {
let DebugSandboxConfigOptions {
permissions_profile,
cwd,
managed_requirements_mode,
} = options;
if let Some(permissions_profile) = permissions_profile {
cli_overrides.push((
"default_permissions".to_string(),
TomlValue::String(permissions_profile),
));
}
// For legacy configs, `codex sandbox` historically defaulted to read-only
// instead of inheriting ambient `sandbox_mode` settings from user/system
// config. Keep that behavior unless this invocation explicitly passes a
// legacy `sandbox_mode` CLI override, which is now the documented writable
// replacement for the removed `--full-auto` flag.
let uses_legacy_sandbox_mode_override = cli_overrides_use_legacy_sandbox_mode(&cli_overrides);
let config = build_debug_sandbox_config(
cli_overrides.clone(),
ConfigOverrides {
cwd: cwd.clone(),
codex_linux_sandbox_exe: codex_linux_sandbox_exe.clone(),
..Default::default()
},
codex_home.clone(),
managed_requirements_mode,
)
.await?;
if config_uses_permission_profiles(&config) || uses_legacy_sandbox_mode_override {
if config_uses_permission_profiles(&config) {
if full_auto {
anyhow::bail!(
"`codex sandbox --full-auto` is only supported for legacy `sandbox_mode` configs; choose a writable `[permissions]` profile instead"
);
}
return Ok(config);
}
build_debug_sandbox_config(
cli_overrides,
ConfigOverrides {
sandbox_mode: Some(SandboxMode::ReadOnly),
cwd,
sandbox_mode: Some(create_sandbox_mode(full_auto)),
codex_linux_sandbox_exe,
..Default::default()
},
codex_home,
managed_requirements_mode,
)
.await
.map_err(Into::into)
@@ -699,17 +632,10 @@ async fn build_debug_sandbox_config(
cli_overrides: Vec<(String, TomlValue)>,
harness_overrides: ConfigOverrides,
codex_home: Option<PathBuf>,
managed_requirements_mode: ManagedRequirementsMode,
) -> std::io::Result<Config> {
let mut builder = ConfigBuilder::default()
.cli_overrides(cli_overrides)
.harness_overrides(harness_overrides);
if let ManagedRequirementsMode::Ignore = managed_requirements_mode {
builder = builder.loader_overrides(LoaderOverrides {
ignore_managed_requirements: true,
..Default::default()
});
}
if let Some(codex_home) = codex_home {
builder = builder
.codex_home(codex_home.clone())
@@ -726,14 +652,9 @@ fn config_uses_permission_profiles(config: &Config) -> bool {
.is_some()
}
fn cli_overrides_use_legacy_sandbox_mode(cli_overrides: &[(String, TomlValue)]) -> bool {
cli_overrides.iter().any(|(key, _)| key == "sandbox_mode")
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
use tempfile::TempDir;
fn escape_toml_path(path: &std::path::Path) -> String {
@@ -775,28 +696,22 @@ mod tests {
Vec::new(),
ConfigOverrides::default(),
Some(codex_home_path.clone()),
ManagedRequirementsMode::Include,
)
.await?;
let legacy_config = build_debug_sandbox_config(
Vec::new(),
ConfigOverrides {
sandbox_mode: Some(SandboxMode::ReadOnly),
sandbox_mode: Some(create_sandbox_mode(/*full_auto*/ false)),
..Default::default()
},
Some(codex_home_path.clone()),
ManagedRequirementsMode::Include,
)
.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,
},
/*full_auto*/ false,
Some(codex_home_path),
)
.await?;
@@ -820,224 +735,27 @@ mod tests {
}
#[tokio::test]
async fn debug_sandbox_honors_explicit_legacy_sandbox_mode() -> anyhow::Result<()> {
let codex_home = TempDir::new()?;
let codex_home_path = codex_home.path().to_path_buf();
let cli_overrides = vec![(
"sandbox_mode".to_string(),
TomlValue::String("workspace-write".to_string()),
)];
let workspace_write_config = build_debug_sandbox_config(
cli_overrides.clone(),
ConfigOverrides::default(),
Some(codex_home_path.clone()),
ManagedRequirementsMode::Include,
)
.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,
)
.await?;
let config = load_debug_sandbox_config_with_codex_home(
cli_overrides,
/*codex_linux_sandbox_exe*/ None,
DebugSandboxConfigOptions {
permissions_profile: None,
cwd: None,
managed_requirements_mode: ManagedRequirementsMode::Include,
},
Some(codex_home_path),
)
.await?;
if cfg!(target_os = "windows") {
assert_eq!(
workspace_write_config
.permissions
.file_system_sandbox_policy(),
read_only_config.permissions.file_system_sandbox_policy(),
"workspace-write downgrades to read-only when the Windows sandbox is disabled"
);
} else {
assert_ne!(
workspace_write_config
.permissions
.file_system_sandbox_policy(),
read_only_config.permissions.file_system_sandbox_policy(),
"test fixture should distinguish explicit workspace-write from read-only"
);
}
assert_eq!(
config.permissions.file_system_sandbox_policy(),
workspace_write_config
.permissions
.file_system_sandbox_policy(),
);
Ok(())
}
#[tokio::test]
async fn debug_sandbox_defaults_legacy_configs_to_read_only() -> anyhow::Result<()> {
let codex_home = TempDir::new()?;
let codex_home_path = codex_home.path().to_path_buf();
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,
)
.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,
},
Some(codex_home_path),
)
.await?;
assert!(!config_uses_permission_profiles(&config));
assert_eq!(
config.permissions.file_system_sandbox_policy(),
read_only_config.permissions.file_system_sandbox_policy(),
);
Ok(())
}
#[tokio::test]
async fn debug_sandbox_honors_explicit_builtin_permission_profile() -> anyhow::Result<()> {
let codex_home = TempDir::new()?;
let config = load_debug_sandbox_config_with_codex_home(
Vec::new(),
/*codex_linux_sandbox_exe*/ None,
DebugSandboxConfigOptions {
permissions_profile: Some(":workspace".to_string()),
cwd: None,
managed_requirements_mode: ManagedRequirementsMode::Ignore,
},
Some(codex_home.path().to_path_buf()),
)
.await?;
assert_eq!(
config.permissions.file_system_sandbox_policy(),
codex_protocol::models::PermissionProfile::workspace_write()
.file_system_sandbox_policy()
);
Ok(())
}
#[tokio::test]
async fn explicit_permission_profile_overrides_active_profile_sandbox_mode()
-> anyhow::Result<()> {
let codex_home = TempDir::new()?;
std::fs::write(
codex_home.path().join("config.toml"),
"profile = \"legacy\"\n\
\n\
[profiles.legacy]\n\
sandbox_mode = \"danger-full-access\"\n",
)?;
let config = load_debug_sandbox_config_with_codex_home(
Vec::new(),
/*codex_linux_sandbox_exe*/ None,
DebugSandboxConfigOptions {
permissions_profile: Some(":workspace".to_string()),
cwd: None,
managed_requirements_mode: ManagedRequirementsMode::Ignore,
},
Some(codex_home.path().to_path_buf()),
)
.await?;
assert_eq!(
config.permissions.file_system_sandbox_policy(),
codex_protocol::models::PermissionProfile::workspace_write()
.file_system_sandbox_policy()
);
Ok(())
}
#[tokio::test]
async fn debug_sandbox_honors_explicit_named_permission_profile() -> anyhow::Result<()> {
async fn debug_sandbox_rejects_full_auto_for_permission_profiles() -> anyhow::Result<()> {
let codex_home = TempDir::new()?;
let sandbox_paths = TempDir::new()?;
let docs = sandbox_paths.path().join("docs");
let private = docs.join("private");
write_permissions_profile_config(&codex_home, &docs, &private)?;
let config = load_debug_sandbox_config_with_codex_home(
let err = load_debug_sandbox_config_with_codex_home(
Vec::new(),
/*codex_linux_sandbox_exe*/ None,
DebugSandboxConfigOptions {
permissions_profile: Some("limited-read-test".to_string()),
cwd: None,
managed_requirements_mode: ManagedRequirementsMode::Ignore,
},
/*full_auto*/ true,
Some(codex_home.path().to_path_buf()),
)
.await?;
.await
.expect_err("full-auto should be rejected for active permission profiles");
let expected = build_debug_sandbox_config(
vec![(
"default_permissions".to_string(),
TomlValue::String("limited-read-test".to_string()),
)],
ConfigOverrides::default(),
Some(codex_home.path().to_path_buf()),
ManagedRequirementsMode::Include,
)
.await?;
assert_eq!(
config.permissions.file_system_sandbox_policy(),
expected.permissions.file_system_sandbox_policy()
assert!(
err.to_string().contains("--full-auto"),
"unexpected error: {err}"
);
Ok(())
}
#[tokio::test]
async fn debug_sandbox_uses_explicit_profile_cwd() -> anyhow::Result<()> {
let codex_home = TempDir::new()?;
let cwd = TempDir::new()?;
let config = load_debug_sandbox_config_with_codex_home(
Vec::new(),
/*codex_linux_sandbox_exe*/ None,
DebugSandboxConfigOptions {
permissions_profile: Some(":workspace".to_string()),
cwd: Some(cwd.path().to_path_buf()),
managed_requirements_mode: ManagedRequirementsMode::Ignore,
},
Some(codex_home.path().to_path_buf()),
)
.await?;
assert_eq!(config.cwd.as_path(), cwd.path());
Ok(())
}
}

View File

@@ -5,7 +5,6 @@ pub(crate) mod login;
use clap::Parser;
use codex_utils_absolute_path::AbsolutePathBuf;
use codex_utils_cli::CliConfigOverrides;
use std::path::PathBuf;
pub use debug_sandbox::run_command_under_landlock;
pub use debug_sandbox::run_command_under_seatbelt;
@@ -20,30 +19,11 @@ pub use login::run_login_with_device_code;
pub use login::run_login_with_device_code_fallback_to_browser;
pub use login::run_logout;
// TODO: Deduplicate these shared sandbox options if we remove the explicit
// `codex sandbox <os>` platform subcommands.
#[derive(Debug, Parser)]
pub struct SeatbeltCommand {
/// Named permissions profile to apply from the active configuration stack.
#[arg(long = "permissions-profile", value_name = "NAME")]
pub permissions_profile: Option<String>,
/// Working directory used for profile resolution and command execution.
#[arg(
short = 'C',
long = "cd",
value_name = "DIR",
requires = "permissions_profile"
)]
pub cwd: Option<PathBuf>,
/// Include managed requirements while resolving an explicit permissions profile.
#[arg(
long = "include-managed-config",
default_value_t = false,
requires = "permissions_profile"
)]
pub include_managed_config: bool,
/// Convenience alias for low-friction sandboxed automatic execution (network-disabled sandbox that can write to cwd and TMPDIR)
#[arg(long = "full-auto", default_value_t = false)]
pub full_auto: bool,
/// Allow the sandboxed command to bind/connect AF_UNIX sockets rooted at this path. Relative paths are resolved against the current directory. Repeat to allow multiple paths.
#[arg(long = "allow-unix-socket", value_parser = parse_allow_unix_socket_path)]
@@ -68,26 +48,9 @@ fn parse_allow_unix_socket_path(raw: &str) -> Result<AbsolutePathBuf, String> {
#[derive(Debug, Parser)]
pub struct LandlockCommand {
/// Named permissions profile to apply from the active configuration stack.
#[arg(long = "permissions-profile", value_name = "NAME")]
pub permissions_profile: Option<String>,
/// Working directory used for profile resolution and command execution.
#[arg(
short = 'C',
long = "cd",
value_name = "DIR",
requires = "permissions_profile"
)]
pub cwd: Option<PathBuf>,
/// Include managed requirements while resolving an explicit permissions profile.
#[arg(
long = "include-managed-config",
default_value_t = false,
requires = "permissions_profile"
)]
pub include_managed_config: bool,
/// Convenience alias for low-friction sandboxed automatic execution (network-disabled sandbox that can write to cwd and TMPDIR)
#[arg(long = "full-auto", default_value_t = false)]
pub full_auto: bool,
#[clap(skip)]
pub config_overrides: CliConfigOverrides,
@@ -99,26 +62,9 @@ pub struct LandlockCommand {
#[derive(Debug, Parser)]
pub struct WindowsCommand {
/// Named permissions profile to apply from the active configuration stack.
#[arg(long = "permissions-profile", value_name = "NAME")]
pub permissions_profile: Option<String>,
/// Working directory used for profile resolution and command execution.
#[arg(
short = 'C',
long = "cd",
value_name = "DIR",
requires = "permissions_profile"
)]
pub cwd: Option<PathBuf>,
/// Include managed requirements while resolving an explicit permissions profile.
#[arg(
long = "include-managed-config",
default_value_t = false,
requires = "permissions_profile"
)]
pub include_managed_config: bool,
/// Convenience alias for low-friction sandboxed automatic execution (network-disabled sandbox that can write to cwd and TMPDIR)
#[arg(long = "full-auto", default_value_t = false)]
pub full_auto: bool,
#[clap(skip)]
pub config_overrides: CliConfigOverrides,

View File

@@ -1352,12 +1352,16 @@ async fn run_debug_prompt_input_command(
));
}
let approval_policy = if shared.dangerously_bypass_approvals_and_sandbox {
let approval_policy = if shared.full_auto {
Some(AskForApproval::OnRequest)
} else if shared.dangerously_bypass_approvals_and_sandbox {
Some(AskForApproval::Never)
} else {
interactive.approval_policy.map(Into::into)
};
let sandbox_mode = if shared.dangerously_bypass_approvals_and_sandbox {
let sandbox_mode = if shared.full_auto {
Some(codex_protocol::config_types::SandboxMode::WorkspaceWrite)
} else if shared.dangerously_bypass_approvals_and_sandbox {
Some(codex_protocol::config_types::SandboxMode::DangerFullAccess)
} else {
shared.sandbox_mode.map(Into::into)
@@ -1922,38 +1926,6 @@ mod tests {
assert!(matches!(cli.subcommand, Some(Subcommand::Update)));
}
#[test]
fn sandbox_macos_parses_permissions_profile() {
let cli = MultitoolCli::try_parse_from([
"codex",
"sandbox",
"macos",
"--permissions-profile",
":workspace",
"--",
"echo",
])
.expect("parse");
let Some(Subcommand::Sandbox(SandboxArgs {
cmd: SandboxCommand::Macos(command),
})) = cli.subcommand
else {
panic!("expected sandbox macos command");
};
assert_eq!(command.permissions_profile.as_deref(), Some(":workspace"));
assert_eq!(command.command, vec!["echo"]);
}
#[test]
fn sandbox_macos_rejects_explicit_profile_controls_without_profile() {
let err = MultitoolCli::try_parse_from(["codex", "sandbox", "macos", "-C", "/tmp"])
.expect_err("parse should fail");
assert_eq!(err.kind(), clap::error::ErrorKind::MissingRequiredArgument);
}
#[test]
fn plugin_marketplace_remove_parses_under_plugin() {
let cli =
@@ -1978,35 +1950,6 @@ mod tests {
assert!(remove_result.is_err());
}
#[test]
fn full_auto_no_longer_parses_at_top_level() {
let result = MultitoolCli::try_parse_from(["codex", "--full-auto"]);
assert!(result.is_err());
}
#[test]
fn exec_full_auto_reports_migration_path() {
let cli = MultitoolCli::try_parse_from(["codex", "exec", "--full-auto", "summarize"])
.expect("exec should accept removed flag long enough to report a migration path");
let Some(Subcommand::Exec(exec)) = cli.subcommand else {
panic!("expected exec subcommand");
};
assert_eq!(
exec.removed_full_auto_warning(),
Some("warning: `--full-auto` is deprecated; use `--sandbox workspace-write` instead.")
);
}
#[test]
fn sandbox_full_auto_no_longer_parses() {
let result =
MultitoolCli::try_parse_from(["codex", "sandbox", "linux", "--full-auto", "--"]);
assert!(result.is_err());
}
fn sample_exit_info(conversation_id: Option<&str>, thread_name: Option<&str>) -> AppExitInfo {
let token_usage = TokenUsage {
output_tokens: 2,
@@ -2137,13 +2080,14 @@ mod tests {
}
#[test]
fn resume_merges_option_flags() {
fn resume_merges_option_flags_and_full_auto() {
let interactive = finalize_resume_from_args(
[
"codex",
"resume",
"sid",
"--oss",
"--full-auto",
"--search",
"--sandbox",
"workspace-write",
@@ -2172,6 +2116,7 @@ mod tests {
interactive.approval_policy,
Some(codex_utils_cli::ApprovalModeCliArg::OnRequest)
);
assert!(interactive.full_auto);
assert_eq!(
interactive.cwd.as_deref(),
Some(std::path::Path::new("/tmp"))

View File

@@ -92,46 +92,41 @@ pub async fn load_config_layers_state(
cloud_requirements: CloudRequirementsLoader,
thread_config_loader: &dyn ThreadConfigLoader,
) -> io::Result<ConfigLayerStack> {
let ignore_managed_requirements = overrides.ignore_managed_requirements;
let ignore_user_config = overrides.ignore_user_config;
let ignore_user_and_project_exec_policy_rules =
overrides.ignore_user_and_project_exec_policy_rules;
let mut config_requirements_toml = ConfigRequirementsWithSources::default();
if !ignore_managed_requirements {
if let Some(requirements) = cloud_requirements.get().await.map_err(io::Error::other)? {
merge_requirements_with_remote_sandbox_config(
&mut config_requirements_toml,
RequirementSource::CloudRequirements,
requirements,
);
}
#[cfg(target_os = "macos")]
macos::load_managed_admin_requirements_toml(
if let Some(requirements) = cloud_requirements.get().await.map_err(io::Error::other)? {
merge_requirements_with_remote_sandbox_config(
&mut config_requirements_toml,
overrides
.macos_managed_config_requirements_base64
.as_deref(),
)
.await?;
// Honor the system requirements.toml location.
let requirements_toml_file = system_requirements_toml_file_with_overrides(&overrides)?;
load_requirements_toml(fs, &mut config_requirements_toml, &requirements_toml_file).await?;
RequirementSource::CloudRequirements,
requirements,
);
}
#[cfg(target_os = "macos")]
macos::load_managed_admin_requirements_toml(
&mut config_requirements_toml,
overrides
.macos_managed_config_requirements_base64
.as_deref(),
)
.await?;
// Honor the system requirements.toml location.
let requirements_toml_file = system_requirements_toml_file_with_overrides(&overrides)?;
load_requirements_toml(fs, &mut config_requirements_toml, &requirements_toml_file).await?;
// Make a best-effort to support the legacy `managed_config.toml` as a
// requirements specification.
let loaded_config_layers =
layer_io::load_config_layers_internal(fs, codex_home, overrides.clone()).await?;
if !ignore_managed_requirements {
load_requirements_from_legacy_scheme(
&mut config_requirements_toml,
loaded_config_layers.clone(),
)
.await?;
}
load_requirements_from_legacy_scheme(
&mut config_requirements_toml,
loaded_config_layers.clone(),
)
.await?;
let thread_config_context = ThreadConfigContext {
thread_id: None,

View File

@@ -20,7 +20,6 @@ pub struct LoaderOverrides {
pub managed_config_path: Option<PathBuf>,
pub system_config_path: Option<PathBuf>,
pub system_requirements_path: Option<PathBuf>,
pub ignore_managed_requirements: bool,
pub ignore_user_config: bool,
pub ignore_user_and_project_exec_policy_rules: bool,
//TODO(gt): Add a macos_ prefix to this field and remove the target_os check.
@@ -39,7 +38,6 @@ impl LoaderOverrides {
managed_config_path: Some(base.join("managed_config.toml")),
system_config_path: Some(base.join("config.toml")),
system_requirements_path: Some(base.join("requirements.toml")),
ignore_managed_requirements: false,
ignore_user_config: false,
ignore_user_and_project_exec_policy_rules: false,
#[cfg(target_os = "macos")]

View File

@@ -16,12 +16,11 @@ use codex_utils_output_truncation::approx_token_count;
const DEFAULT_SKILL_METADATA_CHAR_BUDGET: usize = 8_000;
const SKILL_METADATA_CONTEXT_WINDOW_PERCENT: usize = 2;
const SKILL_DESCRIPTION_TRUNCATION_WARNING_THRESHOLD_CHARS: usize = 100;
const SKILL_DESCRIPTION_TRUNCATION_WARNING_THRESHOLD_CHARS: usize = 10;
const APPROX_BYTES_PER_TOKEN: usize = 4;
pub const SKILL_DESCRIPTION_TRUNCATED_WARNING: &str = "Skill descriptions were shortened to fit the skills context budget. Codex can still see every skill, but some descriptions are shorter. Disable unused skills or plugins to leave more room for the rest.";
pub const SKILL_DESCRIPTION_TRUNCATED_WARNING_WITH_PERCENT: &str = "Skill descriptions were shortened to fit the 2% skills context budget. Codex can still see every skill, but some descriptions are shorter. Disable unused skills or plugins to leave more room for the rest.";
pub const SKILL_DESCRIPTION_TRUNCATED_WARNING_PREFIX: &str = "Warning: Exceeded skills context budget. Loaded skill descriptions were truncated by an average of";
pub const SKILL_DESCRIPTIONS_REMOVED_WARNING_PREFIX: &str =
"Exceeded skills context budget. All skill descriptions were removed and";
"Warning: Exceeded skills context budget. All skill descriptions were removed and";
pub const SKILLS_INTRO_WITH_ABSOLUTE_PATHS: &str = "A skill is a set of local instructions to follow that is stored in a `SKILL.md` file. Below is the list of skills that can be used. Each entry includes a name, description, and file path so you can open the source for full instructions when using a specific skill.";
pub const SKILLS_INTRO_WITH_ALIASES: &str = "A skill is a set of local instructions to follow that is stored in a `SKILL.md` file. Below is the list of skills that can be used. Each entry includes a name, description, and a short path that can be expanded into an absolute path using the skill roots table.";
pub const SKILLS_HOW_TO_USE_WITH_ABSOLUTE_PATHS: &str = r###"- Discovery: The list above is the skills available in this session (name + description + file path). Skill bodies live on disk at the listed paths.
@@ -231,13 +230,11 @@ fn build_available_skills_from_lines(
} else if report.average_truncated_description_chars()
> SKILL_DESCRIPTION_TRUNCATION_WARNING_THRESHOLD_CHARS
{
Some(
match budget {
SkillMetadataBudget::Tokens(_) => SKILL_DESCRIPTION_TRUNCATED_WARNING_WITH_PERCENT,
SkillMetadataBudget::Characters(_) => SKILL_DESCRIPTION_TRUNCATED_WARNING,
}
.to_string(),
)
Some(format!(
"{} {} characters per skill.",
budget_warning_prefix(budget, SKILL_DESCRIPTION_TRUNCATED_WARNING_PREFIX),
report.average_truncated_description_chars()
))
} else {
None
};
@@ -434,13 +431,13 @@ fn skill_render_report(
impl SkillRenderReport {
fn average_truncated_description_chars(&self) -> usize {
if self.total_count == 0 || self.truncated_description_chars == 0 {
if self.truncated_description_count == 0 {
return 0;
}
self.truncated_description_chars
.saturating_add(self.total_count.saturating_sub(1))
/ self.total_count
.saturating_add(self.truncated_description_count.saturating_sub(1))
/ self.truncated_description_count
}
}
@@ -1051,51 +1048,30 @@ mod tests {
#[test]
fn budgeted_rendering_warns_when_average_description_truncation_exceeds_threshold() {
let long_description = "a".repeat(250);
let long_skill =
make_skill_with_description("long-skill", SkillScope::Repo, &long_description);
let empty_skill = make_skill_with_description("empty-skill", SkillScope::Repo, "");
let minimum_cost = SkillLine::new(&long_skill)
let alpha =
make_skill_with_description("alpha-skill", SkillScope::Repo, "abcdefghijklmnop");
let beta = make_skill_with_description("beta-skill", SkillScope::Repo, "uvwxyzabcdefghij");
let minimum_cost = SkillLine::new(&alpha)
.minimum_cost(SkillMetadataBudget::Characters(usize::MAX))
+ SkillLine::new(&empty_skill)
.minimum_cost(SkillMetadataBudget::Characters(usize::MAX));
let budget = SkillMetadataBudget::Characters(minimum_cost + 49);
+ SkillLine::new(&beta).minimum_cost(SkillMetadataBudget::Characters(usize::MAX));
let budget = SkillMetadataBudget::Characters(minimum_cost + 6);
let rendered = build_available_skills_from_metadata(&[long_skill, empty_skill], budget)
let rendered = build_available_skills_from_metadata(&[alpha, beta], budget)
.expect("skills should render");
assert_eq!(rendered.report.total_count, 2);
assert_eq!(rendered.report.included_count, 2);
assert_eq!(rendered.report.omitted_count, 0);
assert_eq!(rendered.report.truncated_description_chars, 202);
assert_eq!(rendered.report.truncated_description_count, 1);
assert_eq!(rendered.report.truncated_description_chars, 28);
assert_eq!(rendered.report.truncated_description_count, 2);
assert_eq!(
rendered.warning_message,
Some(
"Skill descriptions were shortened to fit the skills context budget. Codex can still see every skill, but some descriptions are shorter. Disable unused skills or plugins to leave more room for the rest."
"Warning: Exceeded skills context budget. Loaded skill descriptions were truncated by an average of 14 characters per skill."
.to_string()
)
);
}
#[test]
fn budgeted_rendering_token_budget_truncation_warning_mentions_two_percent() {
let long_description = "a".repeat(1000);
let long_skill =
make_skill_with_description("long-skill", SkillScope::Repo, &long_description);
let minimum_cost =
SkillLine::new(&long_skill).minimum_cost(SkillMetadataBudget::Tokens(usize::MAX));
let budget = SkillMetadataBudget::Tokens(minimum_cost + 1);
let rendered = build_available_skills_from_metadata(&[long_skill], budget)
.expect("skills should render");
assert_eq!(
rendered.warning_message,
Some(SKILL_DESCRIPTION_TRUNCATED_WARNING_WITH_PERCENT.to_string())
);
}
#[test]
fn budgeted_rendering_redistributes_unused_description_budget() {
let short = make_skill_with_description("short-skill", SkillScope::Repo, "x");
@@ -1140,7 +1116,7 @@ mod tests {
assert_eq!(
rendered.warning_message,
Some(
"Exceeded skills context budget. All skill descriptions were removed and 2 additional skills were not included in the model-visible skills list."
"Warning: Exceeded skills context budget. All skill descriptions were removed and 2 additional skills were not included in the model-visible skills list."
.to_string()
)
);
@@ -1169,7 +1145,7 @@ mod tests {
assert_eq!(
rendered.warning_message,
Some(
"Exceeded skills context budget. All skill descriptions were removed and 1 additional skill was not included in the model-visible skills list."
"Warning: Exceeded skills context budget. All skill descriptions were removed and 1 additional skill was not included in the model-visible skills list."
.to_string()
)
);

View File

@@ -521,7 +521,6 @@ impl AgentControl {
) -> CodexResult<ThreadId> {
if let SessionSource::SubAgent(SubAgentSource::ThreadSpawn { depth, .. }) = &session_source
&& *depth >= config.agent_max_depth
&& !config.features.enabled(Feature::MultiAgentV2)
{
let _ = config.features.disable(Feature::SpawnCsv);
let _ = config.features.disable(Feature::Collab);

View File

@@ -1084,58 +1084,6 @@ async fn load_config_layers_includes_cloud_requirements() -> anyhow::Result<()>
Ok(())
}
#[tokio::test]
async fn load_config_layers_can_ignore_managed_requirements() -> anyhow::Result<()> {
let tmp = tempdir()?;
let codex_home = tmp.path().join("home");
tokio::fs::create_dir_all(&codex_home).await?;
let cwd = AbsolutePathBuf::from_absolute_path(tmp.path())?;
let managed_config_path = tmp.path().join("managed_config.toml");
tokio::fs::write(&managed_config_path, "approval_policy = \"never\"\n").await?;
let system_requirements_path = tmp.path().join("requirements.toml");
tokio::fs::write(
&system_requirements_path,
"allowed_sandbox_modes = [\"read-only\"]\n",
)
.await?;
let mut overrides = LoaderOverrides::with_managed_config_path_for_tests(managed_config_path);
overrides.system_requirements_path = Some(system_requirements_path);
overrides.ignore_managed_requirements = true;
let cloud_requirements = CloudRequirementsLoader::new(async {
Ok(Some(ConfigRequirementsToml {
allowed_approval_policies: Some(vec![AskForApproval::Never]),
..Default::default()
}))
});
let mut config = ConfigBuilder::default()
.codex_home(codex_home)
.fallback_cwd(Some(cwd.to_path_buf()))
.loader_overrides(overrides)
.cloud_requirements(cloud_requirements)
.build()
.await?;
assert!(
config
.permissions
.approval_policy
.can_set(&AskForApproval::OnRequest)
.is_ok(),
"ignoring managed requirements should leave on-request approval allowed"
);
config
.permissions
.approval_policy
.set(AskForApproval::OnRequest)
.expect("ignoring managed requirements should allow setting on-request approval");
Ok(())
}
#[tokio::test]
async fn load_config_layers_includes_cloud_hook_requirements() -> anyhow::Result<()> {
let tmp = tempdir()?;

View File

@@ -8,7 +8,6 @@ use crate::windows_sandbox::WindowsSandboxLevelExt;
use crate::windows_sandbox::resolve_windows_sandbox_mode;
use crate::windows_sandbox::resolve_windows_sandbox_private_desktop;
use codex_config::CloudRequirementsLoader;
use codex_config::ConfigLayerSource;
use codex_config::ConfigLayerStack;
use codex_config::ConfigLayerStackOrdering;
use codex_config::ConfigRequirements;
@@ -1532,30 +1531,7 @@ fn resolve_permission_config_syntax(
sandbox_mode_override: Option<SandboxMode>,
profile_sandbox_mode: Option<SandboxMode>,
) -> Option<PermissionConfigSyntax> {
if sandbox_mode_override.is_some() {
return Some(PermissionConfigSyntax::Legacy);
}
let session_flags_select_profiles = config_layer_stack
.get_layers(
ConfigLayerStackOrdering::HighestPrecedenceFirst,
/*include_disabled*/ false,
)
.into_iter()
.find(|layer| matches!(layer.name, ConfigLayerSource::SessionFlags))
.and_then(|layer| {
layer
.config
.clone()
.try_into::<PermissionSelectionToml>()
.ok()
})
.is_some_and(|selection| selection.default_permissions.is_some());
if session_flags_select_profiles {
return Some(PermissionConfigSyntax::Profiles);
}
if profile_sandbox_mode.is_some() {
if sandbox_mode_override.is_some() || profile_sandbox_mode.is_some() {
return Some(PermissionConfigSyntax::Legacy);
}

View File

@@ -201,7 +201,7 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_options_and_status(
config.codex_linux_sandbox_exe.clone(),
)?;
let environment_manager =
EnvironmentManager::new(EnvironmentManagerArgs::new(local_runtime_paths)).await;
EnvironmentManager::new(EnvironmentManagerArgs::from_env(local_runtime_paths));
list_accessible_connectors_from_mcp_tools_with_environment_manager(
config,
force_refetch,

View File

@@ -61,6 +61,7 @@ pub(crate) fn selected_primary_environment(
#[cfg(test)]
mod tests {
use codex_exec_server::EnvironmentManagerArgs;
use codex_exec_server::ExecServerRuntimePaths;
use codex_exec_server::REMOTE_ENVIRONMENT_ID;
use codex_protocol::protocol::TurnEnvironmentSelection;
@@ -80,11 +81,10 @@ mod tests {
#[tokio::test]
async fn default_thread_environment_selections_use_manager_default_id() {
let cwd = AbsolutePathBuf::current_dir().expect("cwd");
let manager = EnvironmentManager::create_for_tests(
Some("ws://127.0.0.1:8765".to_string()),
test_runtime_paths(),
)
.await;
let manager = EnvironmentManager::new(EnvironmentManagerArgs {
exec_server_url: Some("ws://127.0.0.1:8765".to_string()),
local_runtime_paths: test_runtime_paths(),
});
assert_eq!(
default_thread_environment_selections(&manager, &cwd),
@@ -98,7 +98,10 @@ mod tests {
#[tokio::test]
async fn default_thread_environment_selections_empty_when_default_disabled() {
let cwd = AbsolutePathBuf::current_dir().expect("cwd");
let manager = EnvironmentManager::disabled_for_tests(test_runtime_paths());
let manager = EnvironmentManager::new(EnvironmentManagerArgs {
exec_server_url: Some("none".to_string()),
local_runtime_paths: test_runtime_paths(),
});
assert_eq!(
default_thread_environment_selections(&manager, &cwd),

View File

@@ -196,5 +196,8 @@ pub use file_watcher::FileWatcherEvent;
pub use installation_id::resolve_installation_id;
pub use turn_metadata::build_turn_metadata_header;
pub mod compact;
pub(crate) mod memory_trace;
pub use memory_trace::BuiltMemory;
pub use memory_trace::build_memories_from_trace_files;
mod memory_usage;
pub mod otel_init;

View File

@@ -0,0 +1,230 @@
use std::path::Path;
use std::path::PathBuf;
use crate::ModelClient;
use codex_api::RawMemory as ApiRawMemory;
use codex_api::RawMemoryMetadata as ApiRawMemoryMetadata;
use codex_otel::SessionTelemetry;
use codex_protocol::error::CodexErr;
use codex_protocol::error::Result;
use codex_protocol::openai_models::ModelInfo;
use codex_protocol::openai_models::ReasoningEffort as ReasoningEffortConfig;
use serde_json::Map;
use serde_json::Value;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BuiltMemory {
pub memory_id: String,
pub source_path: PathBuf,
pub raw_memory: String,
pub memory_summary: String,
}
struct PreparedTrace {
memory_id: String,
source_path: PathBuf,
payload: ApiRawMemory,
}
/// Loads raw trace files, normalizes items, and builds memory summaries.
///
/// The request/response wiring mirrors the memory summarize E2E flow:
/// `/v1/memories/trace_summarize` with one output object per input raw memory.
///
/// The caller provides the model selection, reasoning effort, and telemetry context explicitly so
/// the session-scoped [`ModelClient`] can be reused across turns.
pub async fn build_memories_from_trace_files(
client: &ModelClient,
trace_paths: &[PathBuf],
model_info: &ModelInfo,
effort: Option<ReasoningEffortConfig>,
session_telemetry: &SessionTelemetry,
) -> Result<Vec<BuiltMemory>> {
if trace_paths.is_empty() {
return Ok(Vec::new());
}
let mut prepared = Vec::with_capacity(trace_paths.len());
for (index, path) in trace_paths.iter().enumerate() {
prepared.push(prepare_trace(index + 1, path).await?);
}
let raw_memories = prepared.iter().map(|trace| trace.payload.clone()).collect();
let output = client
.summarize_memories(raw_memories, model_info, effort, session_telemetry)
.await?;
if output.len() != prepared.len() {
return Err(CodexErr::InvalidRequest(format!(
"unexpected memory summarize output length: expected {}, got {}",
prepared.len(),
output.len()
)));
}
Ok(prepared
.into_iter()
.zip(output)
.map(|(trace, summary)| BuiltMemory {
memory_id: trace.memory_id,
source_path: trace.source_path,
raw_memory: summary.raw_memory,
memory_summary: summary.memory_summary,
})
.collect())
}
async fn prepare_trace(index: usize, path: &Path) -> Result<PreparedTrace> {
let text = load_trace_text(path).await?;
let items = load_trace_items(path, &text)?;
let memory_id = build_memory_id(index, path);
let source_path = path.to_path_buf();
Ok(PreparedTrace {
memory_id: memory_id.clone(),
source_path: source_path.clone(),
payload: ApiRawMemory {
id: memory_id,
metadata: ApiRawMemoryMetadata {
source_path: source_path.display().to_string(),
},
items,
},
})
}
async fn load_trace_text(path: &Path) -> Result<String> {
let raw = tokio::fs::read(path).await?;
Ok(decode_trace_bytes(&raw))
}
fn decode_trace_bytes(raw: &[u8]) -> String {
if let Some(without_bom) = raw.strip_prefix(&[0xEF, 0xBB, 0xBF])
&& let Ok(text) = String::from_utf8(without_bom.to_vec())
{
return text;
}
if let Ok(text) = String::from_utf8(raw.to_vec()) {
return text;
}
raw.iter().map(|b| char::from(*b)).collect()
}
fn load_trace_items(path: &Path, text: &str) -> Result<Vec<Value>> {
if let Ok(Value::Array(items)) = serde_json::from_str::<Value>(text) {
let dict_items = items
.into_iter()
.filter(serde_json::Value::is_object)
.collect::<Vec<_>>();
if dict_items.is_empty() {
return Err(CodexErr::InvalidRequest(format!(
"no object items found in trace file: {}",
path.display()
)));
}
return normalize_trace_items(dict_items, path);
}
let mut parsed_items = Vec::new();
for line in text.lines() {
let line = line.trim();
if line.is_empty() || (!line.starts_with('{') && !line.starts_with('[')) {
continue;
}
let Ok(obj) = serde_json::from_str::<Value>(line) else {
continue;
};
match obj {
Value::Object(_) => parsed_items.push(obj),
Value::Array(inner) => {
parsed_items.extend(inner.into_iter().filter(serde_json::Value::is_object))
}
_ => {}
}
}
if parsed_items.is_empty() {
return Err(CodexErr::InvalidRequest(format!(
"no JSON items parsed from trace file: {}",
path.display()
)));
}
normalize_trace_items(parsed_items, path)
}
fn normalize_trace_items(items: Vec<Value>, path: &Path) -> Result<Vec<Value>> {
let mut normalized = Vec::new();
for item in items {
let Value::Object(obj) = item else {
continue;
};
if let Some(payload) = obj.get("payload") {
if obj.get("type").and_then(Value::as_str) != Some("response_item") {
continue;
}
match payload {
Value::Object(payload_item) => {
if is_allowed_trace_item(payload_item) {
normalized.push(Value::Object(payload_item.clone()));
}
}
Value::Array(payload_items) => {
for payload_item in payload_items {
if let Value::Object(payload_item) = payload_item
&& is_allowed_trace_item(payload_item)
{
normalized.push(Value::Object(payload_item.clone()));
}
}
}
_ => {}
}
continue;
}
if is_allowed_trace_item(&obj) {
normalized.push(Value::Object(obj));
}
}
if normalized.is_empty() {
return Err(CodexErr::InvalidRequest(format!(
"no valid trace items after normalization: {}",
path.display()
)));
}
Ok(normalized)
}
fn is_allowed_trace_item(item: &Map<String, Value>) -> bool {
let Some(item_type) = item.get("type").and_then(Value::as_str) else {
return false;
};
if item_type == "message" {
return matches!(
item.get("role").and_then(Value::as_str),
Some("assistant" | "system" | "developer" | "user")
);
}
true
}
fn build_memory_id(index: usize, path: &Path) -> String {
let stem = path
.file_stem()
.map(|stem| stem.to_string_lossy().into_owned())
.filter(|stem| !stem.is_empty())
.unwrap_or_else(|| "memory".to_string());
format!("memory_{index}_{stem}")
}
#[cfg(test)]
#[path = "memory_trace_tests.rs"]
mod tests;

View File

@@ -0,0 +1,73 @@
use super::*;
use pretty_assertions::assert_eq;
use tempfile::tempdir;
#[test]
fn normalize_trace_items_handles_payload_wrapper_and_message_role_filtering() {
let items = vec![
serde_json::json!({
"type": "response_item",
"payload": {"type": "message", "role": "assistant", "content": []}
}),
serde_json::json!({
"type": "response_item",
"payload": [
{"type": "message", "role": "user", "content": []},
{"type": "message", "role": "tool", "content": []},
{"type": "function_call", "name": "shell", "arguments": "{}", "call_id": "c1"}
]
}),
serde_json::json!({
"type": "not_response_item",
"payload": {"type": "message", "role": "assistant", "content": []}
}),
serde_json::json!({
"type": "message",
"role": "developer",
"content": []
}),
];
let normalized = normalize_trace_items(items, Path::new("trace.json")).expect("normalize");
let expected = vec![
serde_json::json!({"type": "message", "role": "assistant", "content": []}),
serde_json::json!({"type": "message", "role": "user", "content": []}),
serde_json::json!({"type": "function_call", "name": "shell", "arguments": "{}", "call_id": "c1"}),
serde_json::json!({"type": "message", "role": "developer", "content": []}),
];
assert_eq!(normalized, expected);
}
#[test]
fn load_trace_items_supports_jsonl_arrays_and_objects() {
let text = r#"
{"type":"response_item","payload":{"type":"message","role":"assistant","content":[]}}
[{"type":"message","role":"user","content":[]},{"type":"message","role":"tool","content":[]}]
"#;
let loaded = load_trace_items(Path::new("trace.jsonl"), text).expect("load");
let expected = vec![
serde_json::json!({"type":"message","role":"assistant","content":[]}),
serde_json::json!({"type":"message","role":"user","content":[]}),
];
assert_eq!(loaded, expected);
}
#[tokio::test]
async fn load_trace_text_decodes_utf8_sig() {
let dir = tempdir().expect("tempdir");
let path = dir.path().join("trace.json");
tokio::fs::write(
&path,
[
0xEF, 0xBB, 0xBF, b'[', b'{', b'"', b't', b'y', b'p', b'e', b'"', b':', b'"', b'm',
b'e', b's', b's', b'a', b'g', b'e', b'"', b',', b'"', b'r', b'o', b'l', b'e', b'"',
b':', b'"', b'u', b's', b'e', b'r', b'"', b',', b'"', b'c', b'o', b'n', b't', b'e',
b'n', b't', b'"', b':', b'[', b']', b'}', b']',
],
)
.await
.expect("write");
let text = load_trace_text(&path).await.expect("decode");
assert!(text.starts_with('['));
}

View File

@@ -45,7 +45,9 @@ pub async fn build_prompt_input(
.features
.enabled(Feature::DefaultModeRequestUserInput),
},
Arc::new(EnvironmentManager::new(EnvironmentManagerArgs::new(local_runtime_paths)).await),
Arc::new(EnvironmentManager::new(EnvironmentManagerArgs::from_env(
local_runtime_paths,
))),
/*analytics_events_client*/ None,
);
let thread = thread_manager.start_thread(config).await?;

View File

@@ -489,7 +489,6 @@ impl Codex {
if let SessionSource::SubAgent(SubAgentSource::ThreadSpawn { depth, .. }) = session_source
&& depth >= config.agent_max_depth
&& !config.features.enabled(Feature::MultiAgentV2)
{
let _ = config.features.disable(Feature::SpawnCsv);
let _ = config.features.disable(Feature::Collab);

View File

@@ -5607,7 +5607,7 @@ fn emit_thread_start_skill_metrics_records_enabled_kept_and_truncated_values() {
assert_eq!(
rendered.warning_message,
Some(
"Exceeded skills context budget. All skill descriptions were removed and 1 additional skill was not included in the model-visible skills list."
"Warning: Exceeded skills context budget. All skill descriptions were removed and 1 additional skill was not included in the model-visible skills list."
.to_string()
)
);
@@ -5718,7 +5718,7 @@ async fn build_initial_context_emits_thread_start_skill_warning_on_repeated_buil
assert!(matches!(
warning_event.msg,
EventMsg::Warning(WarningEvent { message })
if message == "Exceeded skills context budget of 2%. All skill descriptions were removed and 2 additional skills were not included in the model-visible skills list."
if message == "Warning: Exceeded skills context budget of 2%. All skill descriptions were removed and 2 additional skills were not included in the model-visible skills list."
));
let _ = session.build_initial_context(&turn_context).await;
@@ -5729,7 +5729,7 @@ async fn build_initial_context_emits_thread_start_skill_warning_on_repeated_buil
assert!(matches!(
warning_event.msg,
EventMsg::Warning(WarningEvent { message })
if message == "Exceeded skills context budget of 2%. All skill descriptions were removed and 2 additional skills were not included in the model-visible skills list."
if message == "Warning: Exceeded skills context budget of 2%. All skill descriptions were removed and 2 additional skills were not included in the model-visible skills list."
));
}

View File

@@ -47,6 +47,20 @@ fn assistant_msg(text: &str) -> ResponseItem {
}
}
fn disabled_environment_manager_for_tests() -> Arc<codex_exec_server::EnvironmentManager> {
let runtime_paths = codex_exec_server::ExecServerRuntimePaths::new(
std::env::current_exe().expect("current exe path"),
/*codex_linux_sandbox_exe*/ None,
)
.expect("runtime paths");
Arc::new(codex_exec_server::EnvironmentManager::new(
codex_exec_server::EnvironmentManagerArgs {
exec_server_url: Some("none".to_string()),
local_runtime_paths: runtime_paths,
},
))
}
fn contextual_user_interrupted_marker() -> ResponseItem {
interrupted_turn_history_marker(InterruptedTurnHistoryMarker::ContextualUser)
.expect("contextual-user interrupted marker should be enabled")
@@ -293,23 +307,11 @@ async fn start_thread_accepts_explicit_environment_when_default_environment_is_d
config.cwd = config.codex_home.abs();
std::fs::create_dir_all(&config.codex_home).expect("create codex home");
let runtime_paths = codex_exec_server::ExecServerRuntimePaths::new(
std::env::current_exe().expect("current exe path"),
/*codex_linux_sandbox_exe*/ None,
)
.expect("runtime paths");
let environment_manager = Arc::new(
codex_exec_server::EnvironmentManager::create_for_tests(
Some("none".to_string()),
runtime_paths,
)
.await,
);
let manager = ThreadManager::with_models_provider_and_home_for_tests(
CodexAuth::from_api_key("dummy"),
config.model_provider.clone(),
config.codex_home.to_path_buf(),
environment_manager,
disabled_environment_manager_for_tests(),
);
let thread = manager

View File

@@ -1,10 +1,15 @@
use super::*;
use crate::CodexThread;
use crate::ThreadManager;
use crate::config::AgentRoleConfig;
use crate::config::DEFAULT_AGENT_MAX_DEPTH;
use crate::context::TurnAborted;
use crate::function_tool::FunctionCallError;
use crate::session::tests::make_session_and_context;
use crate::session_prefix::format_subagent_notification_message;
use crate::state::TaskKind;
use crate::tasks::SessionTask;
use crate::tasks::SessionTaskContext;
use crate::tools::context::ToolOutput;
use crate::tools::handlers::multi_agents_v2::CloseAgentHandler as CloseAgentHandlerV2;
use crate::tools::handlers::multi_agents_v2::FollowupTaskHandler as FollowupTaskHandlerV2;
@@ -129,6 +134,121 @@ model_reasoning_effort = "minimal"
role_name
}
fn history_contains_inter_agent_communication(
history_items: &[ResponseItem],
expected: &InterAgentCommunication,
) -> bool {
history_items.iter().any(|item| {
let ResponseItem::Message { role, content, .. } = item else {
return false;
};
if role != "assistant" {
return false;
}
content.iter().any(|content_item| match content_item {
ContentItem::OutputText { text } => {
serde_json::from_str::<InterAgentCommunication>(text)
.ok()
.as_ref()
== Some(expected)
}
ContentItem::InputText { .. } | ContentItem::InputImage { .. } => false,
})
})
}
async fn wait_for_turn_aborted(
thread: &Arc<CodexThread>,
expected_turn_id: &str,
expected_reason: TurnAbortReason,
) {
timeout(Duration::from_secs(5), async {
loop {
let event = thread
.next_event()
.await
.expect("child thread should emit events");
if matches!(
event.msg,
EventMsg::TurnAborted(TurnAbortedEvent {
turn_id: Some(ref turn_id),
ref reason,
..
}) if turn_id == expected_turn_id && *reason == expected_reason
) {
break;
}
}
})
.await
.expect("expected child turn to be interrupted");
}
async fn wait_for_redirected_envelope_in_history(
thread: &Arc<CodexThread>,
expected: &InterAgentCommunication,
) {
timeout(Duration::from_secs(5), async {
loop {
let history_items = thread
.codex
.session
.clone_history()
.await
.raw_items()
.to_vec();
let saw_envelope =
history_contains_inter_agent_communication(&history_items, expected);
let saw_user_message = history_items.iter().any(|item| {
matches!(
item,
ResponseItem::Message { role, content, .. }
if role == "user"
&& content.iter().any(|content_item| matches!(
content_item,
ContentItem::InputText { text }
if text == &expected.content
))
)
});
if saw_envelope {
assert!(
!saw_user_message,
"redirected followup should be stored as an assistant envelope, not a plain user message"
);
break;
}
tokio::time::sleep(Duration::from_millis(10)).await;
}
})
.await
.expect("redirected followup envelope should appear in history");
}
#[derive(Clone, Copy)]
struct NeverEndingTask;
impl SessionTask for NeverEndingTask {
fn kind(&self) -> TaskKind {
TaskKind::Regular
}
fn span_name(&self) -> &'static str {
"session_task.multi_agent_never_ending"
}
async fn run(
self: Arc<Self>,
_session: Arc<SessionTaskContext>,
_ctx: Arc<TurnContext>,
_input: Vec<UserInput>,
cancellation_token: CancellationToken,
) -> Option<String> {
cancellation_token.cancelled().await;
None
}
}
fn expect_text_output<T>(output: T) -> (String, Option<bool>)
where
T: ToolOutput,
@@ -966,6 +1086,7 @@ async fn multi_agent_v2_followup_task_rejects_root_target_from_child() {
function_payload(json!({
"target": "/root",
"message": "run this",
"interrupt": true
})),
))
.await
@@ -1364,6 +1485,255 @@ async fn multi_agent_v2_send_message_rejects_interrupt_parameter() {
)));
}
#[tokio::test]
async fn multi_agent_v2_followup_task_interrupts_busy_child_without_losing_message() {
let (mut session, mut turn) = make_session_and_context().await;
let manager = thread_manager();
let root = manager
.start_thread((*turn.config).clone())
.await
.expect("root thread should start");
session.services.agent_control = manager.agent_control();
session.conversation_id = root.thread_id;
let mut config = turn.config.as_ref().clone();
let _ = config.features.enable(Feature::MultiAgentV2);
turn.config = Arc::new(config);
let session = Arc::new(session);
let turn = Arc::new(turn);
let worker_path = AgentPath::try_from("/root/worker").expect("worker path");
let agent_id = session
.services
.agent_control
.spawn_agent_with_metadata(
(*turn.config).clone(),
Op::CleanBackgroundTerminals,
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id: root.thread_id,
depth: 1,
agent_path: Some(worker_path.clone()),
agent_nickname: None,
agent_role: None,
})),
crate::agent::control::SpawnAgentOptions::default(),
)
.await
.expect("worker spawn should succeed")
.thread_id;
let thread = manager
.get_thread(agent_id)
.await
.expect("worker thread should exist");
let active_turn = thread.codex.session.new_default_turn().await;
let interrupted_turn_id = active_turn.sub_id.clone();
thread
.codex
.session
.spawn_task(
Arc::clone(&active_turn),
vec![UserInput::Text {
text: "working".to_string(),
text_elements: Vec::new(),
}],
NeverEndingTask,
)
.await;
FollowupTaskHandlerV2
.handle(invocation(
session,
turn,
"followup_task",
function_payload(json!({
"target": agent_id.to_string(),
"message": "continue",
"interrupt": true
})),
))
.await
.expect("interrupting v2 followup_task should succeed");
let ops = manager.captured_ops();
let ops_for_agent: Vec<&Op> = ops
.iter()
.filter_map(|(id, op)| (*id == agent_id).then_some(op))
.collect();
assert!(ops_for_agent.iter().any(|op| matches!(op, Op::Interrupt)));
assert!(ops_for_agent.iter().any(|op| {
matches!(
op,
Op::InterAgentCommunication { communication }
if communication.author == AgentPath::root()
&& communication.recipient.as_str() == "/root/worker"
&& communication.other_recipients.is_empty()
&& communication.content == "continue"
)
}));
wait_for_turn_aborted(&thread, &interrupted_turn_id, TurnAbortReason::Interrupted).await;
let history_items = thread
.codex
.session
.clone_history()
.await
.raw_items()
.to_vec();
assert!(
history_items.iter().any(|item| matches!(
item,
ResponseItem::Message { role, content, .. }
if role == "developer"
&& content.iter().any(|content_item| matches!(
content_item,
ContentItem::InputText { text }
if text.contains(TurnAborted::INTERRUPTED_DEVELOPER_GUIDANCE)
))
)),
"v2 interrupted-turn marker should be recorded as a developer input message"
);
assert!(
!history_items.iter().any(|item| matches!(
item,
ResponseItem::Message { role, content, .. }
if role == "user"
&& content.iter().any(|content_item| matches!(
content_item,
ContentItem::InputText { text } | ContentItem::OutputText { text }
if text.contains(TurnAborted::INTERRUPTED_GUIDANCE)
))
)),
"v2 interrupted-turn marker should not be recorded as a user message"
);
assert!(
!history_items.iter().any(|item| matches!(
item,
ResponseItem::Message { role, content, .. }
if role == "assistant"
&& content.iter().any(|content_item| matches!(
content_item,
ContentItem::InputText { text } | ContentItem::OutputText { text }
if text.contains(TurnAborted::INTERRUPTED_DEVELOPER_GUIDANCE)
))
)),
"v2 interrupted-turn marker should not be recorded as an assistant message"
);
wait_for_redirected_envelope_in_history(
&thread,
&InterAgentCommunication::new(
AgentPath::root(),
worker_path,
Vec::new(),
"continue".to_string(),
/*trigger_turn*/ true,
),
)
.await;
let _ = thread
.submit(Op::Shutdown {})
.await
.expect("shutdown should submit");
}
#[tokio::test]
async fn multi_agent_v2_followup_task_can_disable_interrupted_marker() {
let (mut session, mut turn) = make_session_and_context().await;
let manager = thread_manager();
let root = manager
.start_thread((*turn.config).clone())
.await
.expect("root thread should start");
session.services.agent_control = manager.agent_control();
session.conversation_id = root.thread_id;
let mut config = turn.config.as_ref().clone();
let _ = config.features.enable(Feature::MultiAgentV2);
config.agent_interrupt_message_enabled = false;
turn.config = Arc::new(config);
let session = Arc::new(session);
let turn = Arc::new(turn);
let worker_path = AgentPath::try_from("/root/worker").expect("worker path");
let agent_id = session
.services
.agent_control
.spawn_agent_with_metadata(
(*turn.config).clone(),
Op::CleanBackgroundTerminals,
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id: root.thread_id,
depth: 1,
agent_path: Some(worker_path),
agent_nickname: None,
agent_role: None,
})),
crate::agent::control::SpawnAgentOptions::default(),
)
.await
.expect("worker spawn should succeed")
.thread_id;
let thread = manager
.get_thread(agent_id)
.await
.expect("worker thread should exist");
let active_turn = thread.codex.session.new_default_turn().await;
let interrupted_turn_id = active_turn.sub_id.clone();
thread
.codex
.session
.spawn_task(
Arc::clone(&active_turn),
vec![UserInput::Text {
text: "working".to_string(),
text_elements: Vec::new(),
}],
NeverEndingTask,
)
.await;
FollowupTaskHandlerV2
.handle(invocation(
session,
turn,
"followup_task",
function_payload(json!({
"target": agent_id.to_string(),
"message": "continue",
"interrupt": true
})),
))
.await
.expect("interrupting v2 followup_task should succeed");
wait_for_turn_aborted(&thread, &interrupted_turn_id, TurnAbortReason::Interrupted).await;
let history_items = thread
.codex
.session
.clone_history()
.await
.raw_items()
.to_vec();
assert!(
!history_items.iter().any(|item| matches!(
item,
ResponseItem::Message { content, .. }
if content.iter().any(|content_item| matches!(
content_item,
ContentItem::InputText { text } | ContentItem::OutputText { text }
if text.contains(TurnAborted::INTERRUPTED_GUIDANCE)
|| text.contains(TurnAborted::INTERRUPTED_DEVELOPER_GUIDANCE)
))
)),
"disabled interrupted-turn marker should not be recorded in history"
);
let _ = thread
.submit(Op::Shutdown {})
.await
.expect("shutdown should submit");
}
#[tokio::test]
async fn multi_agent_v2_followup_task_completion_notifies_parent_on_every_turn() {
let (mut session, mut turn) = make_session_and_context().await;
@@ -1870,60 +2240,6 @@ async fn spawn_agent_allows_depth_up_to_configured_max_depth() {
assert_eq!(success, Some(true));
}
#[tokio::test]
async fn multi_agent_v2_spawn_agent_ignores_configured_max_depth() {
#[derive(Debug, Deserialize)]
struct SpawnAgentResult {
task_name: String,
nickname: Option<String>,
}
let (mut session, mut turn) = make_session_and_context().await;
let manager = thread_manager();
let mut config = (*turn.config).clone();
config.agent_max_depth = 1;
config
.features
.enable(Feature::MultiAgentV2)
.expect("test config should allow feature update");
let root = manager
.start_thread(config.clone())
.await
.expect("root thread should start");
session.services.agent_control = manager.agent_control();
session.conversation_id = root.thread_id;
turn.config = Arc::new(config);
let parent_path = AgentPath::try_from("/root/parent").expect("agent path");
turn.session_source = SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
parent_thread_id: root.thread_id,
depth: 1,
agent_path: Some(parent_path),
agent_nickname: None,
agent_role: None,
});
let invocation = invocation(
Arc::new(session),
Arc::new(turn),
"spawn_agent",
function_payload(json!({
"message": "hello",
"task_name": "child",
"fork_turns": "none"
})),
);
let output = SpawnAgentHandlerV2
.handle(invocation)
.await
.expect("multi-agent v2 spawn should ignore max depth");
let (content, success) = expect_text_output(output);
let result: SpawnAgentResult =
serde_json::from_str(&content).expect("spawn_agent result should be json");
assert_eq!(result.task_name, "/root/parent/child");
assert!(result.nickname.is_some());
assert_eq!(success, Some(true));
}
#[tokio::test]
async fn send_input_rejects_empty_message() {
let (session, turn) = make_session_and_context().await;

View File

@@ -2,6 +2,7 @@
use crate::agent::AgentStatus;
use crate::agent::agent_resolver::resolve_agent_target;
use crate::agent::exceeds_thread_spawn_depth_limit;
use crate::function_tool::FunctionCallError;
use crate::tools::context::ToolInvocation;
use crate::tools::context::ToolOutput;

View File

@@ -25,6 +25,7 @@ impl ToolHandler for Handler {
MessageDeliveryMode::TriggerTurn,
args.target,
args.message,
args.interrupt,
)
.await
}

View File

@@ -43,6 +43,8 @@ pub(crate) struct SendMessageArgs {
pub(crate) struct FollowupTaskArgs {
pub(crate) target: String,
pub(crate) message: String,
#[serde(default)]
pub(crate) interrupt: bool,
}
fn message_content(message: String) -> Result<String, FunctionCallError> {
@@ -60,8 +62,16 @@ pub(crate) async fn handle_message_string_tool(
mode: MessageDeliveryMode,
target: String,
message: String,
interrupt: bool,
) -> Result<FunctionToolOutput, FunctionCallError> {
handle_message_submission(invocation, mode, target, message_content(message)?).await
handle_message_submission(
invocation,
mode,
target,
message_content(message)?,
interrupt,
)
.await
}
async fn handle_message_submission(
@@ -69,6 +79,7 @@ async fn handle_message_submission(
mode: MessageDeliveryMode,
target: String,
prompt: String,
interrupt: bool,
) -> Result<FunctionToolOutput, FunctionCallError> {
let ToolInvocation {
session,
@@ -92,6 +103,14 @@ async fn handle_message_submission(
"Tasks can't be assigned to the root agent".to_string(),
));
}
if interrupt {
session
.services
.agent_control
.interrupt_agent(receiver_thread_id)
.await
.map_err(|err| collab_agent_error(receiver_thread_id, err))?;
}
session
.send_event(
&turn,

View File

@@ -25,6 +25,7 @@ impl ToolHandler for Handler {
MessageDeliveryMode::QueueOnly,
args.target,
args.message,
/*interrupt*/ false,
)
.await
}

View File

@@ -45,6 +45,12 @@ impl ToolHandler for Handler {
let session_source = turn.session_source.clone();
let child_depth = next_thread_spawn_depth(&session_source);
let max_depth = turn.config.agent_max_depth;
if exceeds_thread_spawn_depth_limit(child_depth, max_depth) {
return Err(FunctionCallError::RespondToModel(
"Agent depth limit reached. Solve the task yourself.".to_string(),
));
}
session
.send_event(
&turn,

View File

@@ -384,17 +384,15 @@ impl TestCodexBuilder {
.exec_server_url
.clone()
.or_else(|| test_env.exec_server_url().map(str::to_owned));
let local_runtime_paths = codex_exec_server::ExecServerRuntimePaths::new(
std::env::current_exe()?,
/*codex_linux_sandbox_exe*/ None,
)?;
let environment_manager = Arc::new(
codex_exec_server::EnvironmentManager::create_for_tests(
let environment_manager = Arc::new(codex_exec_server::EnvironmentManager::new(
codex_exec_server::EnvironmentManagerArgs {
exec_server_url,
local_runtime_paths,
)
.await,
);
local_runtime_paths: codex_exec_server::ExecServerRuntimePaths::new(
std::env::current_exe()?,
/*codex_linux_sandbox_exe*/ None,
)?,
},
));
let file_system = test_env.environment().get_filesystem();
let mut workspace_setups = vec![];
swap(&mut self.workspace_setups, &mut workspace_setups);
@@ -610,6 +608,11 @@ impl TestCodex {
.await
}
pub async fn submit_turn_without_wait(&self, prompt: &str) -> Result<()> {
self.submit_turn_without_wait_with_permission_profile(prompt, PermissionProfile::Disabled)
.await
}
pub async fn submit_turn_with_permission_profile(
&self,
prompt: &str,
@@ -623,6 +626,19 @@ impl TestCodex {
.await
}
pub async fn submit_turn_without_wait_with_permission_profile(
&self,
prompt: &str,
permission_profile: PermissionProfile,
) -> Result<()> {
self.submit_turn_without_wait_with_approval_and_permission_profile(
prompt,
AskForApproval::Never,
permission_profile,
)
.await
}
pub async fn submit_turn_with_policy(
&self,
prompt: &str,
@@ -683,6 +699,22 @@ impl TestCodex {
.await
}
pub async fn submit_turn_without_wait_with_approval_and_permission_profile(
&self,
prompt: &str,
approval_policy: AskForApproval,
permission_profile: PermissionProfile,
) -> Result<()> {
self.submit_turn_without_wait_with_permission_profile_context(
prompt,
approval_policy,
permission_profile,
/*service_tier*/ None,
/*environments*/ None,
)
.await
}
pub async fn submit_turn_with_environments(
&self,
prompt: &str,
@@ -698,6 +730,24 @@ impl TestCodex {
.await
}
async fn submit_turn_without_wait_with_permission_profile_context(
&self,
prompt: &str,
approval_policy: AskForApproval,
permission_profile: PermissionProfile,
service_tier: Option<Option<ServiceTier>>,
environments: Option<Vec<TurnEnvironmentSelection>>,
) -> Result<()> {
self.submit_turn_op_with_context(
prompt,
approval_policy,
permission_profile,
service_tier,
environments,
)
.await
}
async fn submit_turn_with_permission_profile_context(
&self,
prompt: &str,
@@ -723,6 +773,40 @@ impl TestCodex {
permission_profile: PermissionProfile,
service_tier: Option<Option<ServiceTier>>,
environments: Option<Vec<TurnEnvironmentSelection>>,
) -> Result<()> {
self.submit_turn_op_with_context(
prompt,
approval_policy,
permission_profile,
service_tier,
environments,
)
.await?;
let turn_id = wait_for_event_match(&self.codex, |event| match event {
EventMsg::TurnStarted(event) => Some(event.turn_id.clone()),
_ => None,
})
.await;
wait_for_event_with_timeout(
&self.codex,
|event| match event {
EventMsg::TurnComplete(event) => event.turn_id == turn_id,
_ => false,
},
SUBMIT_TURN_COMPLETE_TIMEOUT,
)
.await;
Ok(())
}
async fn submit_turn_op_with_context(
&self,
prompt: &str,
approval_policy: AskForApproval,
permission_profile: PermissionProfile,
service_tier: Option<Option<ServiceTier>>,
environments: Option<Vec<TurnEnvironmentSelection>>,
) -> Result<()> {
let (sandbox_policy, permission_profile) =
turn_permission_fields(permission_profile, self.config.cwd.as_path());
@@ -748,21 +832,6 @@ impl TestCodex {
personality: None,
})
.await?;
let turn_id = wait_for_event_match(&self.codex, |event| match event {
EventMsg::TurnStarted(event) => Some(event.turn_id.clone()),
_ => None,
})
.await;
wait_for_event_with_timeout(
&self.codex,
|event| match event {
EventMsg::TurnComplete(event) => event.turn_id == turn_id,
_ => false,
},
SUBMIT_TURN_COMPLETE_TIMEOUT,
)
.await;
Ok(())
}
}
@@ -889,6 +958,10 @@ impl TestCodexHarness {
Box::pin(self.test.submit_turn(prompt)).await
}
pub async fn submit_without_wait(&self, prompt: &str) -> Result<()> {
Box::pin(self.test.submit_turn_without_wait(prompt)).await
}
pub async fn submit_with_policy(
&self,
prompt: &str,

View File

@@ -15,11 +15,7 @@ use std::time::Duration;
use codex_features::Feature;
use codex_protocol::models::PermissionProfile;
use codex_protocol::permissions::NetworkSandboxPolicy;
use codex_protocol::protocol::AskForApproval;
use codex_protocol::protocol::EventMsg;
use codex_protocol::protocol::Op;
use codex_protocol::protocol::SandboxPolicy;
use codex_protocol::user_input::UserInput;
#[cfg(target_os = "linux")]
use codex_sandboxing::landlock::CODEX_LINUX_SANDBOX_ARG0;
use core_test_support::assert_regex_match;
@@ -61,33 +57,6 @@ async fn apply_patch_harness_with(
Box::pin(TestCodexHarness::with_remote_aware_builder(builder)).await
}
async fn submit_without_wait(harness: &TestCodexHarness, prompt: &str) -> Result<()> {
let test = harness.test();
let session_model = test.session_configured.model.clone();
test.codex
.submit(Op::UserTurn {
environments: None,
items: vec![UserInput::Text {
text: prompt.into(),
text_elements: Vec::new(),
}],
final_output_json_schema: None,
cwd: harness.cwd().to_path_buf(),
approval_policy: AskForApproval::Never,
approvals_reviewer: None,
sandbox_policy: SandboxPolicy::DangerFullAccess,
permission_profile: None,
model: session_model,
effort: None,
summary: None,
service_tier: None,
collaboration_mode: None,
personality: None,
})
.await?;
Ok(())
}
fn restrictive_workspace_write_profile() -> PermissionProfile {
PermissionProfile::workspace_write_with(
&[],
@@ -392,7 +361,9 @@ async fn apply_patch_cli_move_without_content_change_has_no_turn_diff(
let call_id = "apply-move-no-change";
mount_apply_patch(&harness, call_id, patch, "ok", model_output).await;
submit_without_wait(&harness, "rename without content change").await?;
harness
.submit_without_wait("rename without content change")
.await?;
let mut saw_turn_diff = false;
wait_for_event(&codex, |event| match event {
@@ -1000,7 +971,7 @@ async fn apply_patch_custom_tool_streaming_emits_updated_changes() -> Result<()>
)
.await;
submit_without_wait(&harness, "create streamed file").await?;
harness.submit_without_wait("create streamed file").await?;
let mut updates = Vec::new();
wait_for_event(&codex, |event| match event {
@@ -1078,7 +1049,9 @@ async fn apply_patch_shell_command_heredoc_with_cd_emits_turn_diff() -> Result<(
];
mount_sse_sequence(harness.server(), bodies).await;
submit_without_wait(&harness, "apply via shell heredoc with cd").await?;
harness
.submit_without_wait("apply via shell heredoc with cd")
.await?;
let mut saw_turn_diff = None;
let mut saw_patch_begin = false;
@@ -1143,7 +1116,7 @@ async fn apply_patch_shell_command_failure_propagates_error_and_skips_diff() ->
];
mount_sse_sequence(harness.server(), bodies).await;
submit_without_wait(&harness, "apply patch via shell").await?;
harness.submit_without_wait("apply patch via shell").await?;
let mut saw_turn_diff = false;
wait_for_event(&codex, |event| match event {
@@ -1279,7 +1252,7 @@ async fn apply_patch_emits_turn_diff_event_with_unified_diff(
let patch = format!("*** Begin Patch\n*** Add File: {file}\n+hello\n*** End Patch\n");
mount_apply_patch(&harness, call_id, patch.as_str(), "ok", model_output).await;
submit_without_wait(&harness, "emit diff").await?;
harness.submit_without_wait("emit diff").await?;
let mut saw_turn_diff = None;
wait_for_event(&codex, |event| match event {
@@ -1327,7 +1300,7 @@ async fn apply_patch_turn_diff_for_rename_with_content_change(
let patch = "*** Begin Patch\n*** Update File: old.txt\n*** Move to: new.txt\n@@\n-old\n+new\n*** End Patch";
mount_apply_patch(&harness, call_id, patch, "ok", model_output).await;
submit_without_wait(&harness, "rename with change").await?;
harness.submit_without_wait("rename with change").await?;
let mut last_diff: Option<String> = None;
wait_for_event(&codex, |event| match event {
@@ -1384,7 +1357,7 @@ async fn apply_patch_aggregates_diff_across_multiple_tool_calls() -> Result<()>
]);
mount_sse_sequence(harness.server(), vec![s1, s2, s3]).await;
submit_without_wait(&harness, "aggregate diffs").await?;
harness.submit_without_wait("aggregate diffs").await?;
let mut last_diff: Option<String> = None;
wait_for_event(&codex, |event| match event {
@@ -1441,7 +1414,9 @@ async fn apply_patch_aggregates_diff_preserves_success_after_failure() -> Result
];
mount_sse_sequence(harness.server(), responses).await;
submit_without_wait(&harness, "apply patch twice with failure").await?;
harness
.submit_without_wait("apply patch twice with failure")
.await?;
let mut last_diff: Option<String> = None;
wait_for_event_with_timeout(

View File

@@ -240,11 +240,14 @@ async fn list_skills_skips_cwd_roots_when_environment_disabled() -> Result<()> {
codex_core::test_support::auth_manager_from_auth(CodexAuth::from_api_key("dummy")),
SessionSource::Exec,
CollaborationModesConfig::default(),
Arc::new(EnvironmentManager::disabled_for_tests(
ExecServerRuntimePaths::new(
std::env::current_exe()?,
/*codex_linux_sandbox_exe*/ None,
)?,
Arc::new(EnvironmentManager::new(
codex_exec_server::EnvironmentManagerArgs {
exec_server_url: Some("none".to_string()),
local_runtime_paths: ExecServerRuntimePaths::new(
std::env::current_exe()?,
/*codex_linux_sandbox_exe*/ None,
)?,
},
)),
/*analytics_events_client*/ None,
);

View File

@@ -7,9 +7,6 @@ use crate::ExecutorFileSystem;
use crate::HttpClient;
use crate::client::LazyRemoteExecServerClient;
use crate::client::http_client::ReqwestHttpClient;
use crate::environment_provider::DefaultEnvironmentProvider;
use crate::environment_provider::EnvironmentProvider;
use crate::environment_provider::normalize_exec_server_url;
use crate::local_file_system::LocalFileSystem;
use crate::local_process::LocalProcess;
use crate::process::ExecBackend;
@@ -20,13 +17,15 @@ pub const CODEX_EXEC_SERVER_URL_ENV_VAR: &str = "CODEX_EXEC_SERVER_URL";
/// Owns the execution/filesystem environments available to the Codex runtime.
///
/// `EnvironmentManager` is a shared registry for concrete environments. Its
/// default constructor preserves the legacy `CODEX_EXEC_SERVER_URL` behavior
/// while provider-based construction accepts a provider-supplied snapshot.
/// `EnvironmentManager` is a shared registry for concrete environments. It
/// always creates a local environment under [`LOCAL_ENVIRONMENT_ID`]. When
/// `CODEX_EXEC_SERVER_URL` is set to a websocket URL, it also creates a remote
/// environment under [`REMOTE_ENVIRONMENT_ID`] and makes that the default
/// environment. Otherwise the local environment is the default.
///
/// Setting `CODEX_EXEC_SERVER_URL=none` disables environment access by leaving
/// the default environment unset while still keeping an explicit local
/// environment available through `local_environment()`. Callers use
/// the default environment unset while still keeping the local environment
/// available for internal callers by id. Callers use
/// `default_environment().is_some()` as the signal for model-facing
/// shell/filesystem tool availability.
///
@@ -37,7 +36,6 @@ pub const CODEX_EXEC_SERVER_URL_ENV_VAR: &str = "CODEX_EXEC_SERVER_URL";
pub struct EnvironmentManager {
default_environment: Option<String>,
environments: HashMap<String, Arc<Environment>>,
local_environment: Arc<Environment>,
}
pub const LOCAL_ENVIRONMENT_ID: &str = "local";
@@ -45,12 +43,21 @@ pub const REMOTE_ENVIRONMENT_ID: &str = "remote";
#[derive(Clone, Debug)]
pub struct EnvironmentManagerArgs {
pub exec_server_url: Option<String>,
pub local_runtime_paths: ExecServerRuntimePaths,
}
impl EnvironmentManagerArgs {
pub fn new(local_runtime_paths: ExecServerRuntimePaths) -> Self {
Self {
exec_server_url: None,
local_runtime_paths,
}
}
pub fn from_env(local_runtime_paths: ExecServerRuntimePaths) -> Self {
Self {
exec_server_url: std::env::var(CODEX_EXEC_SERVER_URL_ENV_VAR).ok(),
local_runtime_paths,
}
}
@@ -65,103 +72,39 @@ impl EnvironmentManager {
LOCAL_ENVIRONMENT_ID.to_string(),
Arc::new(Environment::default_for_tests()),
)]),
local_environment: Arc::new(Environment::default_for_tests()),
}
}
/// Builds a test-only manager with environment access disabled.
pub fn disabled_for_tests(local_runtime_paths: ExecServerRuntimePaths) -> Self {
let mut manager = Self::from_environments(HashMap::new(), local_runtime_paths);
manager.default_environment = None;
manager
}
/// Builds a test-only manager from a raw exec-server URL value.
pub async fn create_for_tests(
exec_server_url: Option<String>,
local_runtime_paths: ExecServerRuntimePaths,
) -> Self {
Self::from_default_provider_url(exec_server_url, local_runtime_paths).await
}
/// Builds a manager from `CODEX_EXEC_SERVER_URL` and local runtime paths
/// used when creating local filesystem helpers.
pub async fn new(args: EnvironmentManagerArgs) -> Self {
/// Builds a manager from the raw `CODEX_EXEC_SERVER_URL` value and local
/// runtime paths used when creating local filesystem helpers.
pub fn new(args: EnvironmentManagerArgs) -> Self {
let EnvironmentManagerArgs {
exec_server_url,
local_runtime_paths,
} = args;
let exec_server_url = std::env::var(CODEX_EXEC_SERVER_URL_ENV_VAR).ok();
Self::from_default_provider_url(exec_server_url, local_runtime_paths).await
}
async fn from_default_provider_url(
exec_server_url: Option<String>,
local_runtime_paths: ExecServerRuntimePaths,
) -> Self {
let environment_disabled = normalize_exec_server_url(exec_server_url.clone()).1;
let provider = DefaultEnvironmentProvider::new(exec_server_url);
let provider_environments = provider.environments(&local_runtime_paths);
let mut manager = Self::from_environments(provider_environments, local_runtime_paths);
if environment_disabled {
// TODO: Remove this legacy `CODEX_EXEC_SERVER_URL=none` crutch once
// environment attachment defaulting moves out of EnvironmentManager.
manager.default_environment = None;
}
manager
}
/// Builds a manager from a provider-supplied startup snapshot.
pub async fn from_provider<P>(
provider: &P,
local_runtime_paths: ExecServerRuntimePaths,
) -> Result<Self, ExecServerError>
where
P: EnvironmentProvider + ?Sized,
{
Self::from_provider_environments(
provider.get_environments(&local_runtime_paths).await?,
local_runtime_paths,
)
}
fn from_provider_environments(
environments: HashMap<String, Environment>,
local_runtime_paths: ExecServerRuntimePaths,
) -> Result<Self, ExecServerError> {
for id in environments.keys() {
if id.is_empty() {
return Err(ExecServerError::Protocol(
"environment id cannot be empty".to_string(),
));
}
}
Ok(Self::from_environments(environments, local_runtime_paths))
}
fn from_environments(
environments: HashMap<String, Environment>,
local_runtime_paths: ExecServerRuntimePaths,
) -> Self {
// TODO: Stop deriving a default environment here once omitted
// environment attachment is owned by thread/session setup.
let default_environment = if environments.contains_key(REMOTE_ENVIRONMENT_ID) {
Some(REMOTE_ENVIRONMENT_ID.to_string())
} else if environments.contains_key(LOCAL_ENVIRONMENT_ID) {
Some(LOCAL_ENVIRONMENT_ID.to_string())
} else {
let (exec_server_url, environment_disabled) = normalize_exec_server_url(exec_server_url);
let mut environments = HashMap::from([(
LOCAL_ENVIRONMENT_ID.to_string(),
Arc::new(Environment::local(local_runtime_paths.clone())),
)]);
let default_environment = if environment_disabled {
None
} else {
match exec_server_url {
Some(exec_server_url) => {
environments.insert(
REMOTE_ENVIRONMENT_ID.to_string(),
Arc::new(Environment::remote(exec_server_url, local_runtime_paths)),
);
Some(REMOTE_ENVIRONMENT_ID.to_string())
}
None => Some(LOCAL_ENVIRONMENT_ID.to_string()),
}
};
let local_environment = Arc::new(Environment::local(local_runtime_paths));
let environments = environments
.into_iter()
.map(|(id, environment)| (id, Arc::new(environment)))
.collect();
Self {
default_environment,
environments,
local_environment,
}
}
@@ -179,7 +122,10 @@ impl EnvironmentManager {
/// Returns the local environment instance used for internal runtime work.
pub fn local_environment(&self) -> Arc<Environment> {
Arc::clone(&self.local_environment)
match self.get_environment(LOCAL_ENVIRONMENT_ID) {
Some(environment) => environment,
None => unreachable!("EnvironmentManager always has a local environment"),
}
}
/// Returns a named environment instance.
@@ -258,7 +204,7 @@ impl Environment {
})
}
pub(crate) fn local(local_runtime_paths: ExecServerRuntimePaths) -> Self {
fn local(local_runtime_paths: ExecServerRuntimePaths) -> Self {
Self {
exec_server_url: None,
exec_backend: Arc::new(LocalProcess::default()),
@@ -270,7 +216,11 @@ impl Environment {
}
}
pub(crate) fn remote_inner(
fn remote(exec_server_url: String, local_runtime_paths: ExecServerRuntimePaths) -> Self {
Self::remote_inner(exec_server_url, Some(local_runtime_paths))
}
fn remote_inner(
exec_server_url: String,
local_runtime_paths: Option<ExecServerRuntimePaths>,
) -> Self {
@@ -314,13 +264,20 @@ impl Environment {
}
}
fn normalize_exec_server_url(exec_server_url: Option<String>) -> (Option<String>, bool) {
match exec_server_url.as_deref().map(str::trim) {
None | Some("") => (None, false),
Some(url) if url.eq_ignore_ascii_case("none") => (None, true),
Some(url) => (Some(url.to_string()), false),
}
}
#[cfg(test)]
mod tests {
use std::collections::HashMap;
use std::sync::Arc;
use super::Environment;
use super::EnvironmentManager;
use super::EnvironmentManagerArgs;
use super::LOCAL_ENVIRONMENT_ID;
use super::REMOTE_ENVIRONMENT_ID;
use crate::ExecServerRuntimePaths;
@@ -346,8 +303,10 @@ mod tests {
#[tokio::test]
async fn environment_manager_normalizes_empty_url() {
let manager =
EnvironmentManager::create_for_tests(Some(String::new()), test_runtime_paths()).await;
let manager = EnvironmentManager::new(EnvironmentManagerArgs {
exec_server_url: Some(String::new()),
local_runtime_paths: test_runtime_paths(),
});
let environment = manager.default_environment().expect("default environment");
assert_eq!(manager.default_environment_id(), Some(LOCAL_ENVIRONMENT_ID));
@@ -362,23 +321,29 @@ mod tests {
}
#[tokio::test]
async fn disabled_environment_manager_has_no_default_but_keeps_explicit_local_environment() {
let manager = EnvironmentManager::disabled_for_tests(test_runtime_paths());
async fn environment_manager_treats_none_value_as_disabled() {
let manager = EnvironmentManager::new(EnvironmentManagerArgs {
exec_server_url: Some("none".to_string()),
local_runtime_paths: test_runtime_paths(),
});
assert!(manager.default_environment().is_none());
assert_eq!(manager.default_environment_id(), None);
assert!(!manager.local_environment().is_remote());
assert!(manager.get_environment(LOCAL_ENVIRONMENT_ID).is_none());
assert!(
!manager
.get_environment(LOCAL_ENVIRONMENT_ID)
.expect("local environment")
.is_remote()
);
assert!(manager.get_environment(REMOTE_ENVIRONMENT_ID).is_none());
}
#[tokio::test]
async fn environment_manager_reports_remote_url() {
let manager = EnvironmentManager::create_for_tests(
Some("ws://127.0.0.1:8765".to_string()),
test_runtime_paths(),
)
.await;
let manager = EnvironmentManager::new(EnvironmentManagerArgs {
exec_server_url: Some("ws://127.0.0.1:8765".to_string()),
local_runtime_paths: test_runtime_paths(),
});
let environment = manager.default_environment().expect("default environment");
assert_eq!(
@@ -399,7 +364,6 @@ mod tests {
.expect("local environment")
.is_remote()
);
assert!(!manager.local_environment().is_remote());
}
#[tokio::test]
@@ -416,99 +380,45 @@ mod tests {
));
}
#[tokio::test]
async fn environment_manager_builds_from_provider_environments() {
let manager = EnvironmentManager::from_environments(
HashMap::from([(
REMOTE_ENVIRONMENT_ID.to_string(),
Environment::create_for_tests(Some("ws://127.0.0.1:8765".to_string()))
.expect("remote environment"),
)]),
test_runtime_paths(),
);
assert_eq!(
manager.default_environment_id(),
Some(REMOTE_ENVIRONMENT_ID)
);
assert!(
manager
.get_environment(REMOTE_ENVIRONMENT_ID)
.expect("remote environment")
.is_remote()
);
assert!(manager.get_environment(LOCAL_ENVIRONMENT_ID).is_none());
assert!(!manager.local_environment().is_remote());
}
#[tokio::test]
async fn environment_manager_rejects_empty_environment_id() {
let err = EnvironmentManager::from_provider_environments(
HashMap::from([("".to_string(), Environment::default_for_tests())]),
test_runtime_paths(),
)
.expect_err("empty id should fail");
assert_eq!(
err.to_string(),
"exec-server protocol error: environment id cannot be empty"
);
}
#[tokio::test]
async fn environment_manager_uses_provider_supplied_local_environment() {
let manager = EnvironmentManager::create_for_tests(
/*exec_server_url*/ None,
test_runtime_paths(),
)
.await;
assert_eq!(manager.default_environment_id(), Some(LOCAL_ENVIRONMENT_ID));
let provider_local = manager
.get_environment(LOCAL_ENVIRONMENT_ID)
.expect("provider local environment");
assert!(!provider_local.is_remote());
assert!(!manager.local_environment().is_remote());
assert!(!Arc::ptr_eq(&provider_local, &manager.local_environment()));
}
#[tokio::test]
async fn environment_manager_carries_local_runtime_paths() {
let runtime_paths = test_runtime_paths();
let manager = EnvironmentManager::create_for_tests(
/*exec_server_url*/ None,
runtime_paths.clone(),
)
.await;
let manager = EnvironmentManager::new(EnvironmentManagerArgs {
exec_server_url: None,
local_runtime_paths: runtime_paths.clone(),
});
let environment = manager.default_environment().expect("default environment");
assert_eq!(environment.local_runtime_paths(), Some(&runtime_paths));
let manager = EnvironmentManager::create_for_tests(
environment.exec_server_url().map(str::to_owned),
environment
let manager = EnvironmentManager::new(EnvironmentManagerArgs {
exec_server_url: environment.exec_server_url().map(str::to_owned),
local_runtime_paths: environment
.local_runtime_paths()
.expect("local runtime paths")
.clone(),
)
.await;
});
let environment = manager.default_environment().expect("default environment");
assert_eq!(environment.local_runtime_paths(), Some(&runtime_paths));
}
#[tokio::test]
async fn disabled_environment_manager_has_no_default_environment() {
let manager = EnvironmentManager::disabled_for_tests(test_runtime_paths());
let manager = EnvironmentManager::new(EnvironmentManagerArgs {
exec_server_url: Some("none".to_string()),
local_runtime_paths: test_runtime_paths(),
});
assert!(manager.default_environment().is_none());
assert_eq!(manager.default_environment_id(), None);
}
#[tokio::test]
async fn environment_manager_keeps_default_provider_local_lookup_when_default_disabled() {
let manager =
EnvironmentManager::create_for_tests(Some("none".to_string()), test_runtime_paths())
.await;
async fn environment_manager_keeps_local_lookup_when_default_disabled() {
let manager = EnvironmentManager::new(EnvironmentManagerArgs {
exec_server_url: Some("none".to_string()),
local_runtime_paths: test_runtime_paths(),
});
assert!(manager.default_environment().is_none());
assert_eq!(manager.default_environment_id(), None);

View File

@@ -1,172 +0,0 @@
use std::collections::HashMap;
use async_trait::async_trait;
use crate::Environment;
use crate::ExecServerError;
use crate::ExecServerRuntimePaths;
use crate::environment::CODEX_EXEC_SERVER_URL_ENV_VAR;
use crate::environment::LOCAL_ENVIRONMENT_ID;
use crate::environment::REMOTE_ENVIRONMENT_ID;
/// Lists the concrete environments available to Codex.
///
/// Implementations should return the provider-owned startup snapshot that
/// `EnvironmentManager` will cache. Providers that want the local environment to
/// be addressable by id should include it explicitly in the returned map.
#[async_trait]
pub trait EnvironmentProvider: Send + Sync {
/// Returns the environments available for a new manager.
async fn get_environments(
&self,
local_runtime_paths: &ExecServerRuntimePaths,
) -> Result<HashMap<String, Environment>, ExecServerError>;
}
/// Default provider backed by `CODEX_EXEC_SERVER_URL`.
#[derive(Clone, Debug)]
pub struct DefaultEnvironmentProvider {
exec_server_url: Option<String>,
}
impl DefaultEnvironmentProvider {
/// Builds a provider from an already-read raw `CODEX_EXEC_SERVER_URL` value.
pub fn new(exec_server_url: Option<String>) -> Self {
Self { exec_server_url }
}
/// Builds a provider by reading `CODEX_EXEC_SERVER_URL`.
pub fn from_env() -> Self {
Self::new(std::env::var(CODEX_EXEC_SERVER_URL_ENV_VAR).ok())
}
pub(crate) fn environments(
&self,
local_runtime_paths: &ExecServerRuntimePaths,
) -> HashMap<String, Environment> {
let mut environments = HashMap::from([(
LOCAL_ENVIRONMENT_ID.to_string(),
Environment::local(local_runtime_paths.clone()),
)]);
let exec_server_url = normalize_exec_server_url(self.exec_server_url.clone()).0;
if let Some(exec_server_url) = exec_server_url {
environments.insert(
REMOTE_ENVIRONMENT_ID.to_string(),
Environment::remote_inner(exec_server_url, Some(local_runtime_paths.clone())),
);
}
environments
}
}
#[async_trait]
impl EnvironmentProvider for DefaultEnvironmentProvider {
async fn get_environments(
&self,
local_runtime_paths: &ExecServerRuntimePaths,
) -> Result<HashMap<String, Environment>, ExecServerError> {
Ok(self.environments(local_runtime_paths))
}
}
pub(crate) fn normalize_exec_server_url(exec_server_url: Option<String>) -> (Option<String>, bool) {
match exec_server_url.as_deref().map(str::trim) {
None | Some("") => (None, false),
Some(url) if url.eq_ignore_ascii_case("none") => (None, true),
Some(url) => (Some(url.to_string()), false),
}
}
#[cfg(test)]
mod tests {
use pretty_assertions::assert_eq;
use super::*;
use crate::ExecServerRuntimePaths;
fn test_runtime_paths() -> ExecServerRuntimePaths {
ExecServerRuntimePaths::new(
std::env::current_exe().expect("current exe"),
/*codex_linux_sandbox_exe*/ None,
)
.expect("runtime paths")
}
#[tokio::test]
async fn default_provider_returns_local_environment_when_url_is_missing() {
let provider = DefaultEnvironmentProvider::new(/*exec_server_url*/ None);
let runtime_paths = test_runtime_paths();
let environments = provider
.get_environments(&runtime_paths)
.await
.expect("environments");
assert!(!environments[LOCAL_ENVIRONMENT_ID].is_remote());
assert_eq!(
environments[LOCAL_ENVIRONMENT_ID].local_runtime_paths(),
Some(&runtime_paths)
);
assert!(!environments.contains_key(REMOTE_ENVIRONMENT_ID));
}
#[tokio::test]
async fn default_provider_returns_local_environment_when_url_is_empty() {
let provider = DefaultEnvironmentProvider::new(Some(String::new()));
let runtime_paths = test_runtime_paths();
let environments = provider
.get_environments(&runtime_paths)
.await
.expect("environments");
assert!(!environments[LOCAL_ENVIRONMENT_ID].is_remote());
assert!(!environments.contains_key(REMOTE_ENVIRONMENT_ID));
}
#[tokio::test]
async fn default_provider_returns_local_environment_for_none_value() {
let provider = DefaultEnvironmentProvider::new(Some("none".to_string()));
let runtime_paths = test_runtime_paths();
let environments = provider
.get_environments(&runtime_paths)
.await
.expect("environments");
assert!(!environments[LOCAL_ENVIRONMENT_ID].is_remote());
assert!(!environments.contains_key(REMOTE_ENVIRONMENT_ID));
}
#[tokio::test]
async fn default_provider_adds_remote_environment_for_websocket_url() {
let provider = DefaultEnvironmentProvider::new(Some("ws://127.0.0.1:8765".to_string()));
let runtime_paths = test_runtime_paths();
let environments = provider
.get_environments(&runtime_paths)
.await
.expect("environments");
assert!(!environments[LOCAL_ENVIRONMENT_ID].is_remote());
let remote_environment = &environments[REMOTE_ENVIRONMENT_ID];
assert!(remote_environment.is_remote());
assert_eq!(
remote_environment.exec_server_url(),
Some("ws://127.0.0.1:8765")
);
}
#[tokio::test]
async fn default_provider_normalizes_exec_server_url() {
let provider = DefaultEnvironmentProvider::new(Some(" ws://127.0.0.1:8765 ".to_string()));
let runtime_paths = test_runtime_paths();
let environments = provider
.get_environments(&runtime_paths)
.await
.expect("environments");
assert_eq!(
environments[REMOTE_ENVIRONMENT_ID].exec_server_url(),
Some("ws://127.0.0.1:8765")
);
}
}

View File

@@ -2,7 +2,6 @@ mod client;
mod client_api;
mod connection;
mod environment;
mod environment_provider;
mod fs_helper;
mod fs_helper_main;
mod fs_sandbox;
@@ -39,8 +38,6 @@ pub use environment::EnvironmentManager;
pub use environment::EnvironmentManagerArgs;
pub use environment::LOCAL_ENVIRONMENT_ID;
pub use environment::REMOTE_ENVIRONMENT_ID;
pub use environment_provider::DefaultEnvironmentProvider;
pub use environment_provider::EnvironmentProvider;
pub use fs_helper::CODEX_FS_HELPER_ARG1;
pub use fs_helper_main::main as run_fs_helper_main;
pub use local_file_system::LOCAL_FS;

View File

@@ -35,16 +35,6 @@ pub struct Cli {
#[arg(long = "ignore-rules", global = true, default_value_t = false)]
pub ignore_rules: bool,
/// Legacy compatibility trap for the removed `--full-auto` flag.
#[arg(
long = "full-auto",
hide = true,
global = true,
default_value_t = false,
conflicts_with = "dangerously_bypass_approvals_and_sandbox"
)]
pub removed_full_auto: bool,
/// Path to a JSON Schema file describing the model's final response shape.
#[arg(long = "output-schema", value_name = "FILE")]
pub output_schema: Option<PathBuf>,
@@ -95,18 +85,6 @@ impl std::ops::DerefMut for Cli {
}
}
impl Cli {
pub fn removed_full_auto_warning(&self) -> Option<&'static str> {
if self.removed_full_auto {
return Some(
"warning: `--full-auto` is deprecated; use `--sandbox workspace-write` instead.",
);
}
None
}
}
#[derive(Debug, Default)]
pub struct ExecSharedCliOptions(SharedCliOptions);
@@ -152,6 +130,7 @@ impl FromArgMatches for ExecSharedCliOptions {
fn mark_exec_global_args(cmd: clap::Command) -> clap::Command {
cmd.mut_arg("model", |arg| arg.global(true))
.mut_arg("full_auto", |arg| arg.global(true))
.mut_arg("dangerously_bypass_approvals_and_sandbox", |arg| {
arg.global(true)
})

View File

@@ -70,13 +70,3 @@ fn parses_config_isolation_flags() {
assert!(cli.ignore_user_config);
assert!(cli.ignore_rules);
}
#[test]
fn removed_full_auto_flag_reports_migration_path() {
let cli = Cli::parse_from(["codex-exec", "--full-auto", "summarize"]);
assert_eq!(
cli.removed_full_auto_warning(),
Some("warning: `--full-auto` is deprecated; use `--sandbox workspace-write` instead.")
);
}

View File

@@ -216,11 +216,6 @@ fn exec_root_span() -> tracing::Span {
}
pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> {
#[allow(clippy::print_stderr)]
if let Some(message) = cli.removed_full_auto_warning() {
eprintln!("{message}");
}
if let Err(err) = set_default_originator("codex_exec".to_string()) {
tracing::warn!(?err, "Failed to set codex exec originator override {err:?}");
}
@@ -232,7 +227,6 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result
ephemeral,
ignore_user_config,
ignore_rules,
removed_full_auto,
color,
last_message_file,
json: json_mode,
@@ -248,6 +242,7 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result
oss_provider,
config_profile,
sandbox_mode: sandbox_mode_cli_arg,
full_auto,
dangerously_bypass_approvals_and_sandbox,
cwd,
add_dir,
@@ -274,7 +269,7 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result
.with_writer(std::io::stderr)
.with_filter(env_filter);
let sandbox_mode = if removed_full_auto {
let sandbox_mode = if full_auto {
Some(SandboxMode::WorkspaceWrite)
} else if dangerously_bypass_approvals_and_sandbox {
Some(SandboxMode::DangerFullAccess)
@@ -507,9 +502,9 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result
cloud_requirements: run_cloud_requirements,
feedback: CodexFeedback::new(),
log_db: None,
environment_manager: std::sync::Arc::new(
EnvironmentManager::new(EnvironmentManagerArgs::new(local_runtime_paths)).await,
),
environment_manager: std::sync::Arc::new(EnvironmentManager::new(
EnvironmentManagerArgs::from_env(local_runtime_paths),
)),
config_warnings,
session_source: SessionSource::Exec,
enable_codex_api_key_env: true,

View File

@@ -210,9 +210,7 @@ pub fn import_commands(source_commands: &Path, target_skills: &Path) -> io::Resu
}
fs::create_dir_all(&target_dir)?;
let source_name = command_source_name(source_commands, &source_file);
let Some(description) = command_skill_description(&document, &source_name) else {
continue;
};
let description = command_skill_description(&document, &source_name);
fs::write(
target_dir.join("SKILL.md"),
render_command_skill(&document.body, &name, &description, &source_name),
@@ -663,197 +661,86 @@ fn rewrite_hook_command(command: &str, target_config_dir: Option<&Path>) -> Stri
let Some(target_config_dir) = target_config_dir else {
return command.to_string();
};
if looks_like_windows_hook_command(command) {
return command.to_string();
}
let target_hooks_dir = target_config_dir.join(EXTERNAL_AGENT_MIGRATED_HOOKS_SUBDIR);
let hook_dir = shell_quote_path_prefix(&target_hooks_dir);
let project_dir_env_var = external_agent_project_dir_env_var();
let source_hooks_path = format!(
"{}/{EXTERNAL_AGENT_HOOKS_SUBDIR}/",
external_agent_config_dir()
);
let command = replace_quoted_hook_paths(command, '\'', &source_hooks_path, &target_hooks_dir);
let command = replace_quoted_hook_paths(&command, '"', &source_hooks_path, &target_hooks_dir);
replace_unquoted_hook_paths(&command, &source_hooks_path, &target_hooks_dir)
let source_hook_prefixes = [
format!("${{{project_dir_env_var}}}/{source_hooks_path}"),
format!("${project_dir_env_var}/{source_hooks_path}"),
format!("./{source_hooks_path}"),
source_hooks_path.clone(),
];
let command = replace_quoted_hook_path_prefixes(
command,
'\'',
&source_hook_prefixes,
&hook_dir,
&target_hooks_dir,
);
let command = replace_quoted_hook_path_prefixes(
&command,
'"',
&source_hook_prefixes,
&hook_dir,
&target_hooks_dir,
);
command
.replace(
&format!("\"${project_dir_env_var}\"/{source_hooks_path}"),
&hook_dir,
)
.replace(
&format!("\"${{{project_dir_env_var}}}\"/{source_hooks_path}"),
&hook_dir,
)
.replace(
&format!("${{{project_dir_env_var}}}/{source_hooks_path}"),
&hook_dir,
)
.replace(
&format!("${project_dir_env_var}/{source_hooks_path}"),
&hook_dir,
)
.replace(&format!("./{source_hooks_path}"), &hook_dir)
.replace(&source_hooks_path, &hook_dir)
}
fn replace_quoted_hook_paths(
fn replace_quoted_hook_path_prefixes(
command: &str,
quote: char,
source_hooks_path: &str,
source_hook_prefixes: &[String],
hook_dir: &str,
target_hooks_dir: &Path,
) -> String {
let mut rewritten = command.to_string();
let mut search_start = 0usize;
while let Some(relative_start) = rewritten[search_start..].find(quote) {
let start = search_start + relative_start;
let content_start = start + quote.len_utf8();
let Some(relative_end) = rewritten[content_start..].find(quote) else {
break;
};
let end = content_start + relative_end;
let content = &rewritten[content_start..end];
if let Some(source_hooks_start) = content.find(source_hooks_path) {
let suffix_start = source_hooks_start + source_hooks_path.len();
let suffix = &content[suffix_start..];
let Some(replacement) =
target_hook_path_replacement(target_hooks_dir, content, source_hooks_start, suffix)
else {
search_start = end + quote.len_utf8();
continue;
};
rewritten.replace_range(start..end + quote.len_utf8(), &replacement);
search_start = start + replacement.len();
} else {
search_start = end + quote.len_utf8();
for source_hook_prefix in source_hook_prefixes {
let quoted_prefix = format!("{quote}{source_hook_prefix}");
let mut search_start = 0usize;
while let Some(relative_start) = rewritten[search_start..].find(&quoted_prefix) {
let start = search_start + relative_start;
let path_start = start + quoted_prefix.len();
if let Some(relative_end) = rewritten[path_start..].find(quote) {
let end = path_start + relative_end;
let suffix = rewritten[path_start..end].to_string();
let replacement =
shell_single_quote(target_hooks_dir.join(suffix).to_string_lossy().as_ref());
rewritten.replace_range(start..end + quote.len_utf8(), &replacement);
search_start = start + replacement.len();
} else {
rewritten.replace_range(start..path_start, hook_dir);
search_start = start + hook_dir.len();
}
}
}
rewritten
}
fn replace_unquoted_hook_paths(
command: &str,
source_hooks_path: &str,
target_hooks_dir: &Path,
) -> String {
let mut rewritten = command.to_string();
let mut search_start = 0usize;
while let Some(source_hooks_start) =
find_unquoted_source_hook_path(&rewritten, source_hooks_path, search_start)
{
let path_start = shell_path_start(&rewritten, source_hooks_start);
let path_end = shell_path_end(&rewritten, source_hooks_start + source_hooks_path.len());
if is_assignment_value_start(&rewritten, path_start) {
search_start = source_hooks_start + source_hooks_path.len();
continue;
}
let path = rewritten[path_start..path_end].to_string();
let suffix = rewritten[source_hooks_start + source_hooks_path.len()..path_end].to_string();
if let Some(replacement) = target_hook_path_replacement(
target_hooks_dir,
&path,
source_hooks_start - path_start,
&suffix,
) {
rewritten.replace_range(path_start..path_end, &replacement);
search_start = path_start + replacement.len();
} else {
search_start = source_hooks_start + source_hooks_path.len();
}
}
rewritten
}
fn find_unquoted_source_hook_path(
command: &str,
source_hooks_path: &str,
start: usize,
) -> Option<usize> {
let mut in_single_quote = false;
let mut in_double_quote = false;
let mut escaped = false;
for (offset, ch) in command[start..].char_indices() {
let index = start + offset;
if escaped {
escaped = false;
continue;
}
if !in_single_quote && ch == '\\' {
escaped = true;
continue;
}
match ch {
'\'' if !in_double_quote => {
in_single_quote = !in_single_quote;
}
'"' if !in_single_quote => {
in_double_quote = !in_double_quote;
}
_ if !in_single_quote
&& !in_double_quote
&& command[index..].starts_with(source_hooks_path) =>
{
return Some(index);
}
_ => {}
}
}
None
}
fn is_pure_shell_path_content(content: &str, source_hooks_start: usize) -> bool {
let prefix = &content[..source_hooks_start];
(prefix.is_empty() || prefix == "./" || prefix.ends_with('/'))
&& !prefix.chars().any(is_shell_path_boundary)
}
fn shell_path_start(command: &str, end: usize) -> usize {
command[..end]
.char_indices()
.filter_map(|(index, ch)| is_shell_path_boundary(ch).then_some(index + ch.len_utf8()))
.next_back()
.unwrap_or(0)
}
fn shell_path_end(command: &str, start: usize) -> usize {
let mut escaped = false;
for (offset, ch) in command[start..].char_indices() {
if escaped {
escaped = false;
continue;
}
if ch == '\\' {
escaped = true;
continue;
}
if is_shell_path_boundary(ch) {
return start + offset;
}
}
command.len()
}
fn is_shell_path_boundary(ch: char) -> bool {
ch.is_whitespace() || matches!(ch, '=' | ';' | '|' | '&' | '<' | '>' | '(' | ')')
}
fn is_assignment_value_start(command: &str, path_start: usize) -> bool {
command[..path_start]
.chars()
.next_back()
.is_some_and(|ch| ch == '=')
}
fn target_hook_path_replacement(
target_hooks_dir: &Path,
path: &str,
source_hooks_start: usize,
suffix: &str,
) -> Option<String> {
if !is_pure_shell_path_content(path, source_hooks_start) || !is_static_hook_path_suffix(suffix)
{
return None;
}
Some(shell_single_quote(
target_hooks_dir.join(suffix).to_string_lossy().as_ref(),
))
}
fn is_static_hook_path_suffix(suffix: &str) -> bool {
!suffix.is_empty()
&& !suffix
.chars()
.any(|ch| matches!(ch, '\\' | '$' | '`' | '*' | '?' | '[' | '{' | '}'))
}
fn looks_like_windows_hook_command(command: &str) -> bool {
let source_hooks_backslash_path = format!(
r"{}\{EXTERNAL_AGENT_HOOKS_SUBDIR}\",
external_agent_config_dir()
);
let project_dir_env_var = external_agent_project_dir_env_var();
command.contains(&source_hooks_backslash_path)
|| command.contains(&format!("%{project_dir_env_var}%"))
|| command.contains(&format!("$env:{project_dir_env_var}"))
fn shell_quote_path_prefix(path: &Path) -> String {
format!("{}/", shell_single_quote(path.to_string_lossy().as_ref()))
}
fn shell_single_quote(value: &str) -> String {
@@ -1126,15 +1013,12 @@ fn command_skill_name_if_supported(
source_file: &Path,
document: &ParsedDocument,
) -> Option<String> {
if source_file.file_stem().and_then(|stem| stem.to_str()) == Some("README") {
return None;
}
let source_name = command_source_name(source_commands, source_file);
let description = command_skill_description(document, &source_name)?;
let name = command_skill_name(source_commands, source_file);
if name.chars().count() > MAX_SKILL_NAME_LEN {
return None;
}
let source_name = command_source_name(source_commands, source_file);
let description = command_skill_description(document, &source_name);
if description.chars().count() > MAX_SKILL_DESCRIPTION_LEN {
return None;
}
@@ -1144,13 +1028,14 @@ fn command_skill_name_if_supported(
Some(name)
}
fn command_skill_description(document: &ParsedDocument, _source_name: &str) -> Option<String> {
fn command_skill_description(document: &ParsedDocument, source_name: &str) -> String {
document
.frontmatter
.get("description")
.and_then(FrontmatterValue::as_scalar)
.filter(|value| !value.trim().is_empty())
.map(ToOwned::to_owned)
.unwrap_or_else(|| format!("Run the migrated source command `{source_name}`."))
}
fn command_source_name(source_commands: &Path, source_file: &Path) -> String {
@@ -1410,7 +1295,10 @@ mod tests {
}
fn migrated_hook_command(script_name: &str) -> String {
migrated_quoted_hook_command(script_name)
let hook_dir = shell_quote_path_prefix(
&Path::new("/repo/.codex").join(EXTERNAL_AGENT_MIGRATED_HOOKS_SUBDIR),
);
format!("python3 {hook_dir}{script_name}")
}
fn migrated_quoted_hook_command(script_name: &str) -> String {
@@ -1690,15 +1578,6 @@ command = "enabled-server"
assert!(command_skill_name_if_supported(&root, &file, &document).is_none());
}
#[test]
fn commands_without_description_are_skipped() {
let root = source_path("commands");
let file = source_path("commands/README.md");
let document = parse_document_content("# Notes\n\nThis documents commands.\n");
assert!(command_skill_name_if_supported(&root, &file, &document).is_none());
}
#[test]
fn command_slug_collisions_are_skipped() {
let root = tempfile::TempDir::new().expect("tempdir");
@@ -1938,10 +1817,6 @@ Review carefully."""
#[test]
fn hook_command_paths_rewrite_to_target_hook_dir() {
let project_dir_env_var = external_agent_project_dir_env_var();
let plugin_root_env_var = format!(
"{}_PLUGIN_ROOT",
SOURCE_EXTERNAL_AGENT_NAME.to_ascii_uppercase()
);
let source_hooks_path = format!(
"{}/{EXTERNAL_AGENT_HOOKS_SUBDIR}",
external_agent_config_dir()
@@ -1953,19 +1828,6 @@ Review carefully."""
),
migrated_hook_command("check.py")
);
assert_eq!(
rewrite_hook_command(
&format!("\"${project_dir_env_var}\"/{source_hooks_path}/check-style.sh"),
Some(Path::new("/repo/.codex")),
),
shell_single_quote(
Path::new("/repo/.codex")
.join(EXTERNAL_AGENT_MIGRATED_HOOKS_SUBDIR)
.join("check-style.sh")
.to_string_lossy()
.as_ref()
)
);
assert_eq!(
rewrite_hook_command(
&source_hook_command("check.py"),
@@ -1994,67 +1856,6 @@ Review carefully."""
),
migrated_quoted_hook_command("check.py")
);
assert_eq!(
rewrite_hook_command(
&format!("bash -lc \"python3 {source_hooks_path}/check.py\""),
Some(Path::new("/repo/.codex")),
),
format!("bash -lc \"python3 {source_hooks_path}/check.py\"")
);
assert_eq!(
rewrite_hook_command(
&format!(
"HOOK=${{{project_dir_env_var}}}/{source_hooks_path}/check.py python3 \"$HOOK\""
),
Some(Path::new("/repo/.codex")),
),
format!(
"HOOK=${{{project_dir_env_var}}}/{source_hooks_path}/check.py python3 \"$HOOK\""
)
);
assert_eq!(
rewrite_hook_command(
&format!("python3 {source_hooks_path}/${{SCRIPT}}.py"),
Some(Path::new("/repo/.codex")),
),
format!("python3 {source_hooks_path}/${{SCRIPT}}.py")
);
assert_eq!(
rewrite_hook_command(
&format!("python3 {source_hooks_path}/{{lint,fmt}}.sh"),
Some(Path::new("/repo/.codex")),
),
format!("python3 {source_hooks_path}/{{lint,fmt}}.sh")
);
assert_eq!(
rewrite_hook_command(
&format!("python3 {source_hooks_path}/my\\ script.py"),
Some(Path::new("/repo/.codex")),
),
format!("python3 {source_hooks_path}/my\\ script.py")
);
assert_eq!(
rewrite_hook_command(
&format!("python3 .{SOURCE_EXTERNAL_AGENT_NAME}\\hooks\\check.py"),
Some(Path::new("/repo/.codex")),
),
format!("python3 .{}\\hooks\\check.py", SOURCE_EXTERNAL_AGENT_NAME)
);
assert_eq!(
rewrite_hook_command(
&format!(
"python3 \"%{}%\\{}\\hooks\\check.py\"",
project_dir_env_var,
external_agent_config_dir()
),
Some(Path::new("/repo/.codex")),
),
format!(
"python3 \"%{}%\\{}\\hooks\\check.py\"",
project_dir_env_var,
external_agent_config_dir()
)
);
assert_eq!(
rewrite_hook_command(
&format!("python3 '${{{project_dir_env_var}}}/{source_hooks_path}/my script.py'"),
@@ -2062,27 +1863,6 @@ Review carefully."""
),
migrated_quoted_hook_command("my script.py")
);
assert_eq!(
rewrite_hook_command(
&format!("/repo/{source_hooks_path}/check.py 2>/dev/null || true"),
Some(Path::new("/repo/.codex")),
),
format!(
"{} 2>/dev/null || true",
shell_single_quote(
Path::new("/repo/.codex")
.join(EXTERNAL_AGENT_MIGRATED_HOOKS_SUBDIR)
.join("check.py")
.to_string_lossy()
.as_ref()
)
)
);
let plugin_script_command = format!("${{{plugin_root_env_var}}}/scripts/format.sh");
assert_eq!(
rewrite_hook_command(&plugin_script_command, Some(Path::new("/repo/.codex")),),
plugin_script_command
);
}
#[test]

View File

@@ -60,15 +60,12 @@ pub async fn run_main(
arg0_paths: Arg0DispatchPaths,
cli_config_overrides: CliConfigOverrides,
) -> IoResult<()> {
let environment_manager = Arc::new(
EnvironmentManager::new(EnvironmentManagerArgs::new(
ExecServerRuntimePaths::from_optional_paths(
arg0_paths.codex_self_exe.clone(),
arg0_paths.codex_linux_sandbox_exe.clone(),
)?,
))
.await,
);
let environment_manager = Arc::new(EnvironmentManager::new(EnvironmentManagerArgs::from_env(
ExecServerRuntimePaths::from_optional_paths(
arg0_paths.codex_self_exe.clone(),
arg0_paths.codex_linux_sandbox_exe.clone(),
)?,
)));
// Parse CLI overrides once and derive the base Config eagerly so later
// components do not need to work with raw TOML values.
let cli_kv_overrides = cli_config_overrides.parse_overrides().map_err(|e| {

View File

@@ -139,7 +139,7 @@ pub fn create_send_message_tool() -> ToolSpec {
ToolSpec::Function(ResponsesApiTool {
name: "send_message".to_string(),
description: "Send a message to an existing agent. The message will be delivered promptly. Does not trigger a new turn."
description: "Send a string message to an existing agent without triggering a new turn."
.to_string(),
strict: false,
defer_loading: None,
@@ -166,11 +166,18 @@ pub fn create_followup_task_tool() -> ToolSpec {
"Message text to send to the target agent.".to_string(),
)),
),
(
"interrupt".to_string(),
JsonSchema::boolean(Some(
"When true, stop the agent's current task and handle this immediately. When false (default), queue this message; if the target is already running, it starts the target's next turn after the current turn completes."
.to_string(),
)),
),
]);
ToolSpec::Function(ResponsesApiTool {
name: "followup_task".to_string(),
description: "Send a message to an existing non-root target agent and trigger a turn in that target. If the target is currently mid-turn, the message is queued and will be used to start the target's next turn, after the current turn completes."
description: "Send a string message to an existing non-root agent and trigger a turn in the target. Use interrupt=true to redirect work immediately. If interrupt=false and the target's turn has not completed, the message is queued and starts the target's next turn after the current turn completes."
.to_string(),
strict: false,
defer_loading: None,

View File

@@ -131,6 +131,7 @@ fn spawn_agent_tool_v1_keeps_legacy_fork_context_field() {
#[test]
fn send_message_tool_requires_message_and_has_no_output_schema() {
let ToolSpec::Function(ResponsesApiTool {
description,
parameters,
output_schema,
..
@@ -150,6 +151,10 @@ fn send_message_tool_requires_message_and_has_no_output_schema() {
assert!(properties.contains_key("message"));
assert!(!properties.contains_key("interrupt"));
assert!(!properties.contains_key("items"));
assert_eq!(
description,
"Send a string message to an existing agent without triggering a new turn."
);
assert_eq!(
properties
.get("target")
@@ -166,6 +171,7 @@ fn send_message_tool_requires_message_and_has_no_output_schema() {
#[test]
fn followup_task_tool_requires_message_and_has_no_output_schema() {
let ToolSpec::Function(ResponsesApiTool {
description,
parameters,
output_schema,
..
@@ -183,7 +189,22 @@ fn followup_task_tool_requires_message_and_has_no_output_schema() {
.expect("followup_task should use object params");
assert!(properties.contains_key("target"));
assert!(properties.contains_key("message"));
assert!(properties.contains_key("interrupt"));
assert!(!properties.contains_key("items"));
assert!(description.contains(
"Send a string message to an existing non-root agent and trigger a turn in the target."
));
assert!(description.contains(
"If interrupt=false and the target's turn has not completed, the message is queued"
));
assert_eq!(
properties
.get("interrupt")
.and_then(|schema| schema.description.as_deref()),
Some(
"When true, stop the agent's current task and handle this immediately. When false (default), queue this message; if the target is already running, it starts the target's next turn after the current turn completes."
)
);
assert_eq!(
parameters.required.as_ref(),
Some(&vec!["target".to_string(), "message".to_string()])

View File

@@ -191,7 +191,7 @@ async fn live_app_server_warning_notification_renders_message() {
chat.handle_server_notification(
ServerNotification::Warning(WarningNotification {
thread_id: None,
message: "Exceeded skills context budget of 2%. All skill descriptions were removed and 2 additional skills were not included in the model-visible skills list.".to_string(),
message: "Warning: Exceeded skills context budget of 2%. All skill descriptions were removed and 2 additional skills were not included in the model-visible skills list.".to_string(),
}),
/*replay_kind*/ None,
);
@@ -201,7 +201,7 @@ async fn live_app_server_warning_notification_renders_message() {
let rendered = lines_to_single_string(&cells[0]);
let normalized = rendered.split_whitespace().collect::<Vec<_>>().join(" ");
assert!(
normalized.contains("Exceeded skills context budget of 2%."),
normalized.contains("Warning: Exceeded skills context budget of 2%."),
"expected warning notification message, got {rendered}"
);
assert!(

View File

@@ -697,7 +697,12 @@ pub async fn run_main(
.cwd
.clone()
.filter(|_| matches!(app_server_target, AppServerTarget::Remote { .. }));
let (sandbox_mode, approval_policy) = if cli.dangerously_bypass_approvals_and_sandbox {
let (sandbox_mode, approval_policy) = if cli.full_auto {
(
Some(SandboxMode::WorkspaceWrite),
Some(AskForApproval::OnRequest),
)
} else if cli.dangerously_bypass_approvals_and_sandbox {
(
Some(SandboxMode::DangerFullAccess),
Some(AskForApproval::Never),
@@ -741,15 +746,12 @@ pub async fn run_main(
}
};
let environment_manager = Arc::new(
EnvironmentManager::new(EnvironmentManagerArgs::new(
ExecServerRuntimePaths::from_optional_paths(
arg0_paths.codex_self_exe.clone(),
arg0_paths.codex_linux_sandbox_exe.clone(),
)?,
))
.await,
);
let environment_manager = Arc::new(EnvironmentManager::new(EnvironmentManagerArgs::from_env(
ExecServerRuntimePaths::from_optional_paths(
arg0_paths.codex_self_exe.clone(),
arg0_paths.codex_linux_sandbox_exe.clone(),
)?,
)));
let cwd = cli.cwd.clone();
let config_cwd =
config_cwd_for_app_server_target(cwd.as_deref(), &app_server_target, &environment_manager)?;
@@ -2015,14 +2017,14 @@ mod tests {
Path::new("/definitely/not/local/to/this/test")
};
let target = AppServerTarget::Embedded;
let environment_manager = EnvironmentManager::create_for_tests(
Some("ws://127.0.0.1:8765".to_string()),
ExecServerRuntimePaths::new(
std::env::current_exe().expect("current exe"),
/*codex_linux_sandbox_exe*/ None,
)?,
)
.await;
let environment_manager =
EnvironmentManager::new(codex_exec_server::EnvironmentManagerArgs {
exec_server_url: Some("ws://127.0.0.1:8765".to_string()),
local_runtime_paths: ExecServerRuntimePaths::new(
std::env::current_exe().expect("current exe"),
/*codex_linux_sandbox_exe*/ None,
)?,
});
let config_cwd =
config_cwd_for_app_server_target(Some(remote_only_cwd), &target, &environment_manager)?;

View File

@@ -38,12 +38,17 @@ pub struct SharedCliOptions {
#[arg(long = "sandbox", short = 's')]
pub sandbox_mode: Option<SandboxModeCliArg>,
/// Convenience alias for low-friction sandboxed automatic execution.
#[arg(long = "full-auto", default_value_t = false)]
pub full_auto: bool,
/// Skip all confirmation prompts and execute commands without sandboxing.
/// EXTREMELY DANGEROUS. Intended solely for running in environments that are externally sandboxed.
#[arg(
long = "dangerously-bypass-approvals-and-sandbox",
alias = "yolo",
default_value_t = false
default_value_t = false,
conflicts_with = "full_auto"
)]
pub dangerously_bypass_approvals_and_sandbox: bool,
@@ -58,8 +63,9 @@ pub struct SharedCliOptions {
impl SharedCliOptions {
pub fn inherit_exec_root_options(&mut self, root: &Self) {
let self_selected_sandbox_mode =
self.sandbox_mode.is_some() || self.dangerously_bypass_approvals_and_sandbox;
let self_selected_sandbox_mode = self.sandbox_mode.is_some()
|| self.full_auto
|| self.dangerously_bypass_approvals_and_sandbox;
let Self {
images,
model,
@@ -67,6 +73,7 @@ impl SharedCliOptions {
oss_provider,
config_profile,
sandbox_mode,
full_auto,
dangerously_bypass_approvals_and_sandbox,
cwd,
add_dir,
@@ -78,6 +85,7 @@ impl SharedCliOptions {
oss_provider: root_oss_provider,
config_profile: root_config_profile,
sandbox_mode: root_sandbox_mode,
full_auto: root_full_auto,
dangerously_bypass_approvals_and_sandbox: root_dangerously_bypass_approvals_and_sandbox,
cwd: root_cwd,
add_dir: root_add_dir,
@@ -99,6 +107,7 @@ impl SharedCliOptions {
*sandbox_mode = *root_sandbox_mode;
}
if !self_selected_sandbox_mode {
*full_auto = *root_full_auto;
*dangerously_bypass_approvals_and_sandbox =
*root_dangerously_bypass_approvals_and_sandbox;
}
@@ -119,6 +128,7 @@ impl SharedCliOptions {
pub fn apply_subcommand_overrides(&mut self, subcommand: Self) {
let subcommand_selected_sandbox_mode = subcommand.sandbox_mode.is_some()
|| subcommand.full_auto
|| subcommand.dangerously_bypass_approvals_and_sandbox;
let Self {
images,
@@ -127,6 +137,7 @@ impl SharedCliOptions {
oss_provider,
config_profile,
sandbox_mode,
full_auto,
dangerously_bypass_approvals_and_sandbox,
cwd,
add_dir,
@@ -146,6 +157,7 @@ impl SharedCliOptions {
}
if subcommand_selected_sandbox_mode {
self.sandbox_mode = sandbox_mode;
self.full_auto = full_auto;
self.dangerously_bypass_approvals_and_sandbox =
dangerously_bypass_approvals_and_sandbox;
}

View File

@@ -74,13 +74,11 @@ def run_sbx(
env.update(ENV_BASE)
if env_extra:
env.update(env_extra)
# Map policy to codex CLI overrides.
# read-only => default; workspace-write => legacy sandbox_mode override
# Map policy to codex CLI flags
# read-only => default; workspace-write => --full-auto
if policy not in ("read-only", "workspace-write"):
raise ValueError(f"unknown policy: {policy}")
policy_flags: List[str] = (
["-c", 'sandbox_mode="workspace-write"'] if policy == "workspace-write" else []
)
policy_flags: List[str] = ["--full-auto"] if policy == "workspace-write" else []
overrides: List[str] = []
if policy == "workspace-write" and additional_root is not None:

View File

@@ -1,4 +1,3 @@
use std::ffi::c_void;
use std::io;
use windows_sys::Win32::Foundation::GetLastError;
use windows_sys::Win32::Foundation::HANDLE;
@@ -46,13 +45,14 @@ impl ProcThreadAttributeList {
pub fn set_pseudoconsole(&mut self, hpc: isize) -> io::Result<()> {
let list = self.as_mut_ptr();
let mut hpc_value = hpc;
let ok = unsafe {
UpdateProcThreadAttribute(
list,
0,
PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE,
hpc as *mut c_void,
std::mem::size_of::<HANDLE>(),
(&mut hpc_value as *mut isize).cast(),
std::mem::size_of::<isize>(),
std::ptr::null_mut(),
std::ptr::null_mut(),
)