Compare commits

...

6 Commits

Author SHA1 Message Date
starr-openai
a4a048e328 codex: keep default cwd on provider metadata
Co-authored-by: Codex <noreply@openai.com>
2026-05-11 12:58:15 -07:00
starr-openai
90877fabe7 codex: address PR review feedback (#22186)
Co-authored-by: Codex <noreply@openai.com>
2026-05-11 11:44:11 -07:00
starr-openai
05189883f0 codex: fix CI failure on PR #22186
Co-authored-by: Codex <noreply@openai.com>
2026-05-11 11:43:05 -07:00
starr-openai
1872a193d8 codex: address PR review feedback (#22186)
Co-authored-by: Codex <noreply@openai.com>
2026-05-11 11:36:36 -07:00
starr-openai
94295b387f codex: fix CI failure on PR #22186
Co-authored-by: Codex <noreply@openai.com>
2026-05-11 11:34:09 -07:00
starr-openai
5af175990d Support default cwd for remote environments
Co-authored-by: Codex <noreply@openai.com>
2026-05-11 11:28:05 -07:00
7 changed files with 187 additions and 17 deletions

View File

@@ -18,8 +18,10 @@ pub(crate) fn default_thread_environment_selections(
.default_environment_ids()
.into_iter()
.map(|environment_id| TurnEnvironmentSelection {
cwd: environment_manager
.default_cwd(&environment_id)
.unwrap_or_else(|| cwd.clone()),
environment_id,
cwd: cwd.clone(),
})
.collect()
}
@@ -122,13 +124,18 @@ mod tests {
#[tokio::test]
async fn toml_default_thread_environment_selections_include_local_and_remote() {
let temp_dir = tempfile::tempdir().expect("tempdir");
let default_cwd = AbsolutePathBuf::from_absolute_path("/workspace").expect("cwd");
let default_cwd_display = default_cwd.as_path().display();
std::fs::write(
temp_dir.path().join("environments.toml"),
r#"
format!(
r#"
[[environments]]
id = "remote"
url = "ws://127.0.0.1:8765"
"#,
default_cwd = "{default_cwd_display}"
"#
),
)
.expect("write environments.toml");
let cwd = AbsolutePathBuf::current_dir().expect("cwd");
@@ -145,7 +152,7 @@ url = "ws://127.0.0.1:8765"
},
TurnEnvironmentSelection {
environment_id: REMOTE_ENVIRONMENT_ID.to_string(),
cwd,
cwd: default_cwd,
},
]
);

View File

@@ -49,7 +49,6 @@ use codex_analytics::SubAgentThreadStartedInput;
use codex_app_server_protocol::McpServerElicitationRequest;
use codex_app_server_protocol::McpServerElicitationRequestParams;
use codex_config::types::OAuthCredentialsStoreMode;
use codex_exec_server::Environment;
use codex_exec_server::EnvironmentManager;
use codex_exec_server::FileSystemSandboxContext;
use codex_extension_api::PromptSlot;
@@ -603,6 +602,10 @@ impl Codex {
account_plan_type,
config.features.enabled(Feature::FastMode),
);
let cwd = environment_selections
.primary()
.map(|environment| environment.cwd.clone())
.unwrap_or_else(|| config.cwd.clone());
let session_configuration = SessionConfiguration {
provider: config.model_provider.clone(),
collaboration_mode,
@@ -618,7 +621,7 @@ impl Codex {
permission_profile: config.permissions.permission_profile.clone(),
active_permission_profile: config.permissions.active_permission_profile(),
windows_sandbox_level: WindowsSandboxLevel::from_config(&config),
cwd: config.cwd.clone(),
cwd,
codex_home: config.codex_home.clone(),
thread_name: None,
environments: environment_selections.to_selections(),

View File

@@ -351,17 +351,22 @@ async fn start_thread_uses_all_default_environments_from_codex_home() {
let mut config = test_config().await;
config.codex_home = temp_dir.path().join("codex-home").abs();
config.cwd = config.codex_home.abs();
let local_cwd = config.cwd.display().to_string();
let dev_cwd = "/home/dev-user/code/codex";
std::fs::create_dir_all(&config.codex_home).expect("create codex home");
std::fs::write(
config.codex_home.join("environments.toml"),
r#"
format!(
r#"
default = "dev"
[[environments]]
id = "dev"
program = "ssh"
args = ["dev", "cd /tmp && true"]
"#,
default_cwd = "{dev_cwd}"
"#
),
)
.expect("write environments.toml");
@@ -394,6 +399,10 @@ args = ["dev", "cd /tmp && true"]
.start_thread(config)
.await
.expect("thread should start");
let next_turn = thread.thread.codex.session.new_default_turn().await;
assert_eq!(next_turn.cwd.display().to_string(), dev_cwd);
assert_eq!(next_turn.config.cwd.display().to_string(), dev_cwd);
let prompt_items = crate::prompt_debug::build_prompt_input_from_session(
thread.thread.codex.session.as_ref(),
@@ -416,15 +425,14 @@ args = ["dev", "cd /tmp && true"]
})
.expect("environment context prompt item");
assert!(environment_context.contains("<environments>"));
let cwd = thread.session_configured.cwd.display().to_string();
let dev_entry = format!(
r#"<environment id="dev">
<cwd>{cwd}</cwd>
<cwd>{dev_cwd}</cwd>
<shell>"#
);
let local_entry = format!(
r#"<environment id="local">
<cwd>{cwd}</cwd>
<cwd>{local_cwd}</cwd>
<shell>"#
);
let dev_position = environment_context

View File

@@ -2,8 +2,10 @@ use anyhow::Context;
use anyhow::Result;
use codex_exec_server::CopyOptions;
use codex_exec_server::CreateDirectoryOptions;
use codex_exec_server::ExecParams;
use codex_exec_server::FileSystemSandboxContext;
use codex_exec_server::LOCAL_ENVIRONMENT_ID;
use codex_exec_server::ProcessId;
use codex_exec_server::REMOTE_ENVIRONMENT_ID;
use codex_exec_server::RemoveOptions;
use codex_features::Feature;
@@ -84,6 +86,81 @@ async fn remote_test_env_can_connect_and_use_filesystem() -> Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn environments_toml_default_cwd_runs_in_docker_remote_environment() -> Result<()> {
let Some(_remote_env) = get_remote_test_env() else {
return Ok(());
};
let codex_home = TempDir::new()?;
let default_cwd = remote_test_file_path().join("default-cwd").abs();
let default_cwd_display = default_cwd.as_path().display();
remote_exec(&format!("mkdir -p {}", default_cwd.as_path().display()))?;
std::fs::write(
codex_home.path().join("environments.toml"),
format!(
r#"
default = "docker"
[[environments]]
id = "docker"
url = "{}"
default_cwd = "{default_cwd_display}"
"#,
std::env::var("CODEX_TEST_REMOTE_EXEC_SERVER_URL")?
),
)?;
let environment_manager = codex_exec_server::EnvironmentManager::from_codex_home(
codex_home.path(),
codex_exec_server::ExecServerRuntimePaths::new(
std::env::current_exe()?,
/*codex_linux_sandbox_exe*/ None,
)?,
)
.await?;
let environment = environment_manager
.default_environment()
.context("default environment should resolve")?;
assert_eq!(
environment_manager.default_cwd(REMOTE_ENVIRONMENT_ID),
Some(default_cwd.clone())
);
let started = environment
.get_exec_backend()
.start(ExecParams {
process_id: ProcessId::from("default-cwd-pwd"),
argv: vec!["pwd".to_string()],
cwd: default_cwd.to_path_buf(),
env_policy: None,
env: Default::default(),
tty: false,
pipe_stdin: false,
arg0: None,
})
.await?;
let response = started
.process
.read(
/*after_seq*/ None,
/*max_bytes*/ None,
Some(1_000),
)
.await?;
let stdout = response
.chunks
.into_iter()
.flat_map(|chunk| chunk.chunk.0)
.collect::<Vec<_>>();
assert_eq!(
String::from_utf8(stdout)?,
format!("{default_cwd_display}\n")
);
Ok(())
}
fn absolute_path(path: PathBuf) -> AbsolutePathBuf {
match AbsolutePathBuf::try_from(path) {
Ok(path) => path,

View File

@@ -2,6 +2,8 @@ use std::collections::HashMap;
use std::sync::Arc;
use std::sync::RwLock;
use codex_utils_absolute_path::AbsolutePathBuf;
use crate::ExecServerError;
use crate::ExecServerRuntimePaths;
use crate::ExecutorFileSystem;
@@ -41,6 +43,7 @@ pub const CODEX_EXEC_SERVER_URL_ENV_VAR: &str = "CODEX_EXEC_SERVER_URL";
#[derive(Debug)]
pub struct EnvironmentManager {
default_environment: Option<String>,
default_cwds: HashMap<String, AbsolutePathBuf>,
environments: RwLock<HashMap<String, Arc<Environment>>>,
local_environment: Arc<Environment>,
}
@@ -66,6 +69,7 @@ impl EnvironmentManager {
pub fn default_for_tests() -> Self {
Self {
default_environment: Some(LOCAL_ENVIRONMENT_ID.to_string()),
default_cwds: HashMap::new(),
environments: RwLock::new(HashMap::from([(
LOCAL_ENVIRONMENT_ID.to_string(),
Arc::new(Environment::default_for_tests()),
@@ -78,6 +82,7 @@ impl EnvironmentManager {
pub fn disabled_for_tests(local_runtime_paths: ExecServerRuntimePaths) -> Self {
Self {
default_environment: None,
default_cwds: HashMap::new(),
environments: RwLock::new(HashMap::new()),
local_environment: Arc::new(Environment::local(local_runtime_paths)),
}
@@ -152,6 +157,7 @@ impl EnvironmentManager {
) -> Result<Self, ExecServerError> {
let EnvironmentProviderSnapshot {
environments,
default_cwds,
default,
include_local,
} = snapshot;
@@ -195,8 +201,17 @@ impl EnvironmentManager {
Some(environment_id)
}
};
if let Some(environment_id) = default_cwds
.keys()
.find(|environment_id| !environment_map.contains_key(*environment_id))
{
return Err(ExecServerError::Protocol(format!(
"default cwd environment `{environment_id}` is not configured"
)));
}
Ok(Self {
default_environment,
default_cwds,
environments: RwLock::new(environment_map),
local_environment,
})
@@ -214,6 +229,13 @@ impl EnvironmentManager {
self.default_environment.as_deref()
}
/// Returns the configured startup cwd for a named environment.
pub fn default_cwd(&self, environment_id: &str) -> Option<AbsolutePathBuf> {
self.default_cwds
.get(environment_id)
.cloned()
}
/// Returns the ordered environment ids used for new thread startup.
pub fn default_environment_ids(&self) -> Vec<String> {
let Some(default_environment_id) = self.default_environment.as_ref() else {
@@ -551,6 +573,7 @@ mod tests {
Environment::create_for_tests(Some("ws://127.0.0.1:8765".to_string()))
.expect("remote environment"),
)],
default_cwds: HashMap::new(),
default: EnvironmentDefault::EnvironmentId(REMOTE_ENVIRONMENT_ID.to_string()),
include_local: false,
},
@@ -578,6 +601,7 @@ mod tests {
let provider = TestEnvironmentProvider {
snapshot: EnvironmentProviderSnapshot {
environments: vec![("".to_string(), Environment::default_for_tests())],
default_cwds: HashMap::new(),
default: EnvironmentDefault::Disabled,
include_local: false,
},
@@ -600,6 +624,7 @@ mod tests {
LOCAL_ENVIRONMENT_ID.to_string(),
Environment::default_for_tests(),
)],
default_cwds: HashMap::new(),
default: EnvironmentDefault::Disabled,
include_local: false,
},
@@ -616,6 +641,7 @@ mod tests {
#[tokio::test]
async fn environment_manager_uses_explicit_provider_default() {
let default_cwd = AbsolutePathBuf::from_absolute_path("/workspace").expect("cwd");
let provider = TestEnvironmentProvider {
snapshot: EnvironmentProviderSnapshot {
environments: vec![(
@@ -623,6 +649,7 @@ mod tests {
Environment::create_for_tests(Some("ws://127.0.0.1:8765".to_string()))
.expect("remote environment"),
)],
default_cwds: HashMap::from([("devbox".to_string(), default_cwd.clone())]),
default: EnvironmentDefault::EnvironmentId("devbox".to_string()),
include_local: true,
},
@@ -632,6 +659,7 @@ mod tests {
.expect("manager");
assert_eq!(manager.default_environment_id(), Some("devbox"));
assert_eq!(manager.default_cwd("devbox"), Some(default_cwd));
assert_eq!(
manager.default_environment_ids(),
vec!["devbox".to_string(), LOCAL_ENVIRONMENT_ID.to_string()]
@@ -648,6 +676,7 @@ mod tests {
Environment::create_for_tests(Some("ws://127.0.0.1:8765".to_string()))
.expect("remote environment"),
)],
default_cwds: HashMap::new(),
default: EnvironmentDefault::Disabled,
include_local: true,
},
@@ -675,6 +704,7 @@ mod tests {
Environment::create_for_tests(Some("ws://127.0.0.1:8765".to_string()))
.expect("remote environment"),
)],
default_cwds: HashMap::new(),
default: EnvironmentDefault::EnvironmentId("missing".to_string()),
include_local: true,
},

View File

@@ -1,4 +1,7 @@
use std::collections::HashMap;
use async_trait::async_trait;
use codex_utils_absolute_path::AbsolutePathBuf;
use crate::Environment;
use crate::ExecServerError;
@@ -8,9 +11,9 @@ use crate::environment::REMOTE_ENVIRONMENT_ID;
/// Lists the concrete environments available to Codex.
///
/// Implementations own a startup snapshot containing both the available
/// environment list in configured order and the default environment
/// selection. Providers should only return provider-owned remote environments;
/// Implementations own a startup snapshot containing the available environment
/// list in configured order plus provider-owned default selection metadata.
/// Providers should only return provider-owned remote environments;
/// `include_local` controls whether `EnvironmentManager` should add the local
/// environment to the snapshot.
#[async_trait]
@@ -22,6 +25,7 @@ pub trait EnvironmentProvider: Send + Sync {
#[derive(Clone, Debug)]
pub struct EnvironmentProviderSnapshot {
pub environments: Vec<(String, Environment)>,
pub default_cwds: HashMap<String, AbsolutePathBuf>,
pub default: EnvironmentDefault,
pub include_local: bool,
}
@@ -74,6 +78,7 @@ impl DefaultEnvironmentProvider {
EnvironmentProviderSnapshot {
environments,
default_cwds: HashMap::new(),
default,
include_local,
}
@@ -109,12 +114,14 @@ mod tests {
let snapshot = provider.snapshot().await.expect("environments");
let EnvironmentProviderSnapshot {
environments,
default_cwds,
default,
include_local,
} = snapshot;
let environments: HashMap<_, _> = environments.into_iter().collect();
assert!(include_local);
assert!(default_cwds.is_empty());
assert!(!environments.contains_key(LOCAL_ENVIRONMENT_ID));
assert!(!environments.contains_key(REMOTE_ENVIRONMENT_ID));
assert_eq!(
@@ -129,12 +136,14 @@ mod tests {
let snapshot = provider.snapshot().await.expect("environments");
let EnvironmentProviderSnapshot {
environments,
default_cwds,
default,
include_local,
} = snapshot;
let environments: HashMap<_, _> = environments.into_iter().collect();
assert!(include_local);
assert!(default_cwds.is_empty());
assert!(!environments.contains_key(LOCAL_ENVIRONMENT_ID));
assert!(!environments.contains_key(REMOTE_ENVIRONMENT_ID));
assert_eq!(
@@ -149,12 +158,14 @@ mod tests {
let snapshot = provider.snapshot().await.expect("environments");
let EnvironmentProviderSnapshot {
environments,
default_cwds,
default,
include_local,
} = snapshot;
let environments: HashMap<_, _> = environments.into_iter().collect();
assert!(!include_local);
assert!(default_cwds.is_empty());
assert!(!environments.contains_key(LOCAL_ENVIRONMENT_ID));
assert!(!environments.contains_key(REMOTE_ENVIRONMENT_ID));
assert_eq!(default, EnvironmentDefault::Disabled);
@@ -166,12 +177,14 @@ mod tests {
let snapshot = provider.snapshot().await.expect("environments");
let EnvironmentProviderSnapshot {
environments,
default_cwds,
default,
include_local,
} = snapshot;
let environments: HashMap<_, _> = environments.into_iter().collect();
assert!(!include_local);
assert!(default_cwds.is_empty());
assert!(!environments.contains_key(LOCAL_ENVIRONMENT_ID));
let remote_environment = &environments[REMOTE_ENVIRONMENT_ID];
assert!(remote_environment.is_remote());

View File

@@ -5,6 +5,7 @@ use std::path::PathBuf;
use std::time::Duration;
use async_trait::async_trait;
use codex_utils_absolute_path::AbsolutePathBuf;
use serde::Deserialize;
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
@@ -41,6 +42,7 @@ struct EnvironmentToml {
args: Option<Vec<String>>,
env: Option<HashMap<String, String>>,
cwd: Option<PathBuf>,
default_cwd: Option<AbsolutePathBuf>,
#[serde(default, with = "option_duration_secs")]
connect_timeout_sec: Option<Duration>,
#[serde(default, with = "option_duration_secs")]
@@ -50,6 +52,7 @@ struct EnvironmentToml {
#[derive(Clone, Debug, PartialEq, Eq)]
struct TomlEnvironmentProvider {
default: EnvironmentDefault,
default_cwds: HashMap<String, AbsolutePathBuf>,
environments: Vec<(String, ExecServerTransportParams)>,
}
@@ -64,19 +67,24 @@ impl TomlEnvironmentProvider {
config_dir: Option<&Path>,
) -> Result<Self, ExecServerError> {
let mut ids = HashSet::from([LOCAL_ENVIRONMENT_ID.to_string()]);
let mut default_cwds = HashMap::new();
let mut environments = Vec::with_capacity(config.environments.len());
for item in config.environments {
let (id, transport) = parse_environment_toml(item, config_dir)?;
let (id, transport, default_cwd) = parse_environment_toml(item, config_dir)?;
if !ids.insert(id.clone()) {
return Err(ExecServerError::Protocol(format!(
"environment id `{id}` is duplicated"
)));
}
if let Some(default_cwd) = default_cwd {
default_cwds.insert(id.clone(), default_cwd);
}
environments.push((id, transport));
}
let default = normalize_default_environment_id(config.default.as_deref(), &ids)?;
Ok(Self {
default,
default_cwds,
environments,
})
}
@@ -98,6 +106,7 @@ impl EnvironmentProvider for TomlEnvironmentProvider {
Ok(EnvironmentProviderSnapshot {
environments,
default_cwds: self.default_cwds.clone(),
default: self.default.clone(),
include_local: true,
})
@@ -107,7 +116,7 @@ impl EnvironmentProvider for TomlEnvironmentProvider {
fn parse_environment_toml(
item: EnvironmentToml,
config_dir: Option<&Path>,
) -> Result<(String, ExecServerTransportParams), ExecServerError> {
) -> Result<(String, ExecServerTransportParams, Option<AbsolutePathBuf>), ExecServerError> {
let EnvironmentToml {
id,
url,
@@ -115,6 +124,7 @@ fn parse_environment_toml(
args,
env,
cwd,
default_cwd,
connect_timeout_sec,
initialize_timeout_sec,
} = item;
@@ -168,7 +178,7 @@ fn parse_environment_toml(
}
};
Ok((id, transport_params))
Ok((id, transport_params, default_cwd))
}
fn normalize_stdio_cwd(
@@ -356,6 +366,7 @@ mod tests {
let snapshot = provider.snapshot().await.expect("environments");
let EnvironmentProviderSnapshot {
environments,
default_cwds,
default,
include_local,
} = snapshot;
@@ -367,6 +378,7 @@ mod tests {
let environments: HashMap<_, _> = environments.into_iter().collect();
assert!(include_local);
assert!(default_cwds.is_empty());
assert!(!environments.contains_key(LOCAL_ENVIRONMENT_ID));
assert_eq!(
environments["devbox"].exec_server_url(),
@@ -565,6 +577,24 @@ mod tests {
);
}
#[tokio::test]
async fn toml_provider_exposes_configured_default_cwd() {
let default_cwd = AbsolutePathBuf::from_absolute_path("/workspace").expect("cwd");
let provider = TomlEnvironmentProvider::new(EnvironmentsToml {
default: None,
environments: vec![EnvironmentToml {
id: "ssh-dev".to_string(),
program: Some("ssh".to_string()),
default_cwd: Some(default_cwd.clone()),
..Default::default()
}],
})
.expect("provider");
let snapshot = provider.snapshot().await.expect("environments");
assert_eq!(snapshot.default_cwds.get("ssh-dev"), Some(&default_cwd));
}
#[test]
fn toml_provider_rejects_relative_stdio_cwd_without_config_dir() {
let err = TomlEnvironmentProvider::new(EnvironmentsToml {
@@ -664,6 +694,7 @@ id = "ssh-dev"
program = "ssh"
args = ["dev", "codex exec-server --listen stdio"]
cwd = "/tmp"
default_cwd = "/workspace"
[environments.env]
CODEX_LOG = "debug"
"#,
@@ -698,6 +729,7 @@ CODEX_LOG = "debug"
"debug".to_string(),
)])),
cwd: Some(PathBuf::from("/tmp")),
default_cwd: Some(AbsolutePathBuf::from_absolute_path("/workspace").expect("cwd")),
..Default::default()
}
);