Allow session configuration with codex agents (#39870)

## Why

`codex agents` rejected invocation-specific configuration, preventing the
dashboard from applying settings when starting a shared thread.

## What changed

- Accept interactive options such as model, approval, sandbox, search, working
  directory, and configuration overrides when opening the agents dashboard.
- Forward supported session-flag configuration into threads started through an
  embedded or remote app server, while excluding unrelated values.
- Continue to reject initial prompts and images, along with local provider and
  additional-directory settings that cannot be applied to a remote server.

## Testing

- Cover accepted dashboard options and rejected incompatible inputs.
- Verify that explicit feature and sandbox overrides reach shared threads in
  both embedded and remote modes.

GitOrigin-RevId: f10aa1e16ff62b49d55679e987d9e458438ba3f8
This commit is contained in:
Eric Traut authored and copyberry committed 2026-08-21 06:08:26 +00:00
1 parent 9ab176f488
commit 44e95c857f
4 files changed
+195 -29

No files matched your search

+22 -20
View File
@@ -1092,25 +1092,31 @@ async fn cli_main(
let open_agents_overview = matches!(&subcommand, Some(Subcommand::Agents(_))); let open_agents_overview = matches!(&subcommand, Some(Subcommand::Agents(_)));
match subcommand { match subcommand {
None | Some(Subcommand::Agents(_)) => { None | Some(Subcommand::Agents(_)) => {
prepend_config_flags(
&mut interactive.config_overrides,
root_config_overrides.clone(),
);
if open_agents_overview { if open_agents_overview {
if !root_config_overrides.raw_overrides.is_empty() if interactive.prompt.is_some() || !interactive.images.is_empty() {
|| root_strict_config anyhow::bail!("`codex agents` does not accept an initial prompt or images");
|| interactive.prompt.is_some() }
|| !interactive.images.is_empty() if root_remote.is_some()
|| interactive.model.is_some() && (interactive.oss
|| interactive.oss || interactive.oss_provider.is_some()
|| interactive.oss_provider.is_some() || !interactive.add_dir.is_empty()
|| interactive.config_profile_v2.is_some() || interactive
|| interactive.sandbox_mode.is_some() .config_overrides
|| interactive.dangerously_bypass_approvals_and_sandbox .parse_overrides()
|| interactive.bypass_hook_trust .map_err(anyhow::Error::msg)?
|| interactive.cwd.is_some() && root_remote.is_none() .iter()
|| !interactive.add_dir.is_empty() .any(|(key, value)| {
|| interactive.approval_policy.is_some() key == "sandbox_workspace_write.writable_roots"
|| interactive.web_search || (key == "sandbox_workspace_write"
&& value.get("writable_roots").is_some())
}))
{ {
anyhow::bail!( anyhow::bail!(
"`codex agents` cannot attach to shared sessions with invocation-specific configuration overrides" "`codex agents` cannot apply local provider or additional-directory overrides to a remote server"
); );
} }
if is_workload_identity_selected() { if is_workload_identity_selected() {
@@ -1128,10 +1134,6 @@ async fn cli_main(
} }
interactive.agents_overview = true; interactive.agents_overview = true;
} }
prepend_config_flags(
&mut interactive.config_overrides,
root_config_overrides.clone(),
);
let exit_info = run_interactive_tui( let exit_info = run_interactive_tui(
interactive, interactive,
root_remote.clone(), root_remote.clone(),
+55 -2
View File
@@ -33,19 +33,72 @@ foo = "bar"
} }
#[test] #[test]
fn agents_reject_session_overrides_before_starting_the_daemon() -> Result<()> { fn agents_accept_interactive_configuration_overrides() -> Result<()> {
let codex_home = TempDir::new()?; let codex_home = TempDir::new()?;
for args in [ for args in [
["-c", "features.multi_agent_mode=true", "agents"].as_slice(),
["--enable", "multi_agent_mode", "agents"].as_slice(),
["--yolo", "agents"].as_slice(), ["--yolo", "agents"].as_slice(),
["--search", "agents"].as_slice(), ["--search", "agents"].as_slice(),
["--model", "gpt-5", "agents"].as_slice(), ["--model", "gpt-5", "agents"].as_slice(),
["--approve-for-me", "agents"].as_slice(),
["--cd", ".", "agents"].as_slice(),
] {
let mut cmd = codex_command(codex_home.path())?;
cmd.env("TERM", "xterm-256color").args(args);
#[cfg(not(unix))]
cmd.args(["--remote", "ws://127.0.0.1:4512"]);
cmd.assert()
.failure()
.stderr(contains("stdin is not a terminal"));
}
Ok(())
}
#[test]
fn agents_reject_inputs_that_cannot_be_applied() -> Result<()> {
let codex_home = TempDir::new()?;
for (args, expected_error) in [
(
["--image=image.png", "agents"].as_slice(),
"does not accept an initial prompt or images",
),
(
["--oss", "agents", "--remote", "ws://127.0.0.1:4512"].as_slice(),
"cannot apply local provider or additional-directory overrides",
),
(
[
"--add-dir",
".",
"agents",
"--remote",
"ws://127.0.0.1:4512",
]
.as_slice(),
"cannot apply local provider or additional-directory overrides",
),
(
[
"-c",
"sandbox_workspace_write.writable_roots=[\"../shared\"]",
"agents",
"--remote",
"ws://127.0.0.1:4512",
]
.as_slice(),
"cannot apply local provider or additional-directory overrides",
),
] { ] {
let mut cmd = codex_command(codex_home.path())?; let mut cmd = codex_command(codex_home.path())?;
cmd.args(args) cmd.args(args)
.assert() .assert()
.failure() .failure()
.stderr(contains("invocation-specific configuration overrides")); .stderr(contains(expected_error));
} }
Ok(()) Ok(())
+111 -1
View File
@@ -115,6 +115,7 @@ use codex_app_server_protocol::TurnStartResponse;
use codex_app_server_protocol::TurnSteerParams; use codex_app_server_protocol::TurnSteerParams;
use codex_app_server_protocol::TurnSteerResponse; use codex_app_server_protocol::TurnSteerResponse;
use codex_app_server_protocol::UserInput; use codex_app_server_protocol::UserInput;
use codex_config::ConfigLayerSource;
use codex_otel::TelemetryAuthMode; use codex_otel::TelemetryAuthMode;
use codex_protocol::ThreadId; use codex_protocol::ThreadId;
use codex_protocol::approvals::GuardianAssessmentEvent; use codex_protocol::approvals::GuardianAssessmentEvent;
@@ -1535,7 +1536,34 @@ fn approvals_reviewer_override_from_config(
fn config_request_overrides_from_config( fn config_request_overrides_from_config(
config: &Config, config: &Config,
) -> Option<HashMap<String, serde_json::Value>> { ) -> Option<HashMap<String, serde_json::Value>> {
let mut overrides = HashMap::new(); let mut session_config = toml::Value::Table(toml::Table::new());
for layer in config.config_layer_stack.layers_low_to_high() {
if matches!(&layer.name, ConfigLayerSource::SessionFlags) {
codex_config::merge_toml_values(&mut session_config, &layer.config);
}
}
let mut overrides: HashMap<_, _> = session_config
.as_table()
.into_iter()
.flatten()
.filter(|(key, _)| {
matches!(
key.as_str(),
"allow_login_shell"
| "default_permissions"
| "features"
| "network"
| "permissions"
| "sandbox_workspace_write"
| "shell_environment_policy"
)
})
.filter_map(|(key, value)| {
serde_json::to_value(value)
.ok()
.map(|value| (key.clone(), value))
})
.collect();
let mut insert = |key: &str, value: Option<String>| { let mut insert = |key: &str, value: Option<String>| {
if let Some(value) = value { if let Some(value) = value {
overrides.insert(key.to_string(), serde_json::Value::String(value)); overrides.insert(key.to_string(), serde_json::Value::String(value));
@@ -2371,6 +2399,88 @@ mod tests {
assert_eq!(params.history_mode, None); assert_eq!(params.history_mode, None);
} }
#[tokio::test]
async fn shared_thread_start_preserves_explicit_session_overrides() -> Result<()> {
let codex_home = tempfile::tempdir()?;
let workspace = codex_home.path().join("workspace");
std::fs::create_dir(&workspace)?;
std::fs::write(
codex_home.path().join("config.toml"),
"sandbox_mode = \"workspace-write\"\n[sandbox_workspace_write]\nnetwork_access = true\n",
)?;
let server_config = build_config(&codex_home).await;
let config = ConfigBuilder::default()
.codex_home(codex_home.path().to_path_buf())
.harness_overrides(ConfigOverrides {
cwd: Some(workspace.clone()),
..ConfigOverrides::default()
})
.cli_overrides(vec![
(
"features.multi_agent_mode".to_string(),
toml::Value::Boolean(true),
),
(
"sandbox_workspace_write.network_access".to_string(),
toml::Value::Boolean(false),
),
(
"instructions".to_string(),
toml::Value::String("unsafe ".repeat(10_000)),
),
("model".to_string(), "gpt-5".into()),
("approval_policy".to_string(), "never".into()),
])
.build()
.await?;
let params = thread_start_params_from_config(
&config,
ThreadParamsMode::Remote,
/*remote_cwd_override*/ None,
/*session_start_source*/ None,
);
let overrides = params.config.expect("config overrides");
assert_eq!(
(
overrides.get("features").cloned(),
overrides.get("sandbox_workspace_write").cloned(),
overrides.get("instructions").cloned(),
),
(
Some(serde_json::json!({ "multi_agent_mode": true })),
Some(serde_json::json!({ "network_access": false })),
None,
)
);
for mode in [ThreadParamsMode::Embedded, ThreadParamsMode::Remote] {
let mut app_server =
crate::start_embedded_app_server_for_picker(&server_config).await?;
app_server.thread_params_mode = mode;
app_server.remote_cwd_override = Some(workspace.clone());
let started = app_server.start_thread(&config).await?;
assert_eq!(
(
started.session.permission_profile.network_sandbox_policy(),
started.session.model.as_str(),
started.session.approval_policy,
started.session.cwd.as_path(),
),
(
NetworkSandboxPolicy::Restricted,
"gpt-5",
AskForApproval::Never,
workspace.as_path(),
)
);
app_server.shutdown().await?;
}
Ok(())
}
#[tokio::test] #[tokio::test]
async fn thread_start_params_include_cwd_for_embedded_sessions() { async fn thread_start_params_include_cwd_for_embedded_sessions() {
let temp_dir = tempfile::tempdir().expect("tempdir"); let temp_dir = tempfile::tempdir().expect("tempdir");
+7 -6
View File
@@ -136,12 +136,13 @@ pub(super) async fn run_main_inner(
} }
let reuse_implicit_local_daemon = !workload_identity_selected let reuse_implicit_local_daemon = !workload_identity_selected
&& can_reuse_implicit_local_daemon( && (cli.agents_overview
&cli_kv_overrides, || can_reuse_implicit_local_daemon(
&launch_loader_overrides, &cli_kv_overrides,
strict_config, &launch_loader_overrides,
cli.bypass_hook_trust, strict_config,
); cli.bypass_hook_trust,
));
let search_only_config_override = !workload_identity_selected let search_only_config_override = !workload_identity_selected
&& cli.web_search && cli.web_search
&& startup_preflight::has_only_search_config_override(&cli_kv_overrides) && startup_preflight::has_only_search_config_override(&cli_kv_overrides)