Conditional codex_home dotenv (#29959)

## Summary

Adds conditional dotenv overlays under `CODEX_HOME`. After loading the
current `.env`, Codex discovers `.env.*` files in lexicographic order
and applies each overlay when its TCP condition passes.

Evaluation and environment mutation occur during single-threaded
startup, before Codex creates its runtime, workers, sessions, or network
clients.

## Supported behavior

- TCP connectivity checks using either:
  - Explicit `host` and `port`.
- A URL or authority stored in an overlay assignment referenced by
`from`.
- Direct negation of a TCP check using `not`.
- Setting dotenv assignments when a condition passes.
- Unsetting variables with `# codex-env-unset`.
- A default 500 ms connection timeout with a maximum of 5 seconds.
- Ignores filenames ending in `~` or a case-insensitive final suffix of
`bak`, `back`, `backup`, `bkp`, `old`, `orig`, `original`, `save`,
`saved`, `disable`, `disabled`, `inactive`, `off`, `tmp`, `temp`, `swp`,
`swo`, `example`, `sample`, `template`, or `dist`.
- Fail-closed handling of malformed overlays without exposing
environment values.
- Case-insensitive protection against setting or unsetting `CODEX_*`
variables.

Files without a `# codex-env-if:` directive as their first non-empty
line are ignored.

## Usage

Set variables when an endpoint is reachable:

```dotenv
# ~/.codex/.env.10-proxy-on
# codex-env-if: {"type":"tcp_connect","from":"HTTPS_PROXY","timeout_ms":500}

HTTPS_PROXY=http://proxy.example.com:8080
HTTP_PROXY=http://proxy.example.com:8080
ALL_PROXY=http://proxy.example.com:8080
NO_PROXY=localhost,127.0.0.1,.example.com
```

Unset variables when the endpoint is unreachable:

```dotenv
# ~/.codex/.env.20-proxy-off
# codex-env-if: {"not":{"type":"tcp_connect","host":"proxy.example.com","port":8080,"timeout_ms":500}}
# codex-env-unset: ["HTTPS_PROXY","HTTP_PROXY","ALL_PROXY","NO_PROXY"]
```

Each overlay is evaluated independently. A full Codex restart is
required after changing overlays or moving between networks.

The timeout bounds TCP connection attempts but does not bound
synchronous DNS resolution.

## Testing

```console
just test -p codex-arg0
```

For manual validation:

1. Configure an overlay with a reachable TCP endpoint and a test
assignment.
2. Start Codex and verify the assignment is present in a spawned
command.
3. Restart Codex with the endpoint unreachable and verify a negated
overlay removes inherited variables.
4. Verify malformed overlays are skipped and `CODEX_*` variables remain
unchanged.

## Future ideas:

- File-existence conditions.
- Environment-variable equality conditions.
- Operating-system conditions.
- General condition composition with `all`, `any`, and arbitrarily
nested `not`.
This commit is contained in:
canvrno-oai
2026-07-06 10:27:39 -07:00
committed by GitHub
parent 8f5bb6171e
commit 8268cbfb0e
5 changed files with 771 additions and 7 deletions

3
codex-rs/Cargo.lock generated
View File

@@ -2227,8 +2227,11 @@ dependencies = [
"codex-windows-sandbox",
"dotenvy",
"pretty_assertions",
"serde",
"serde_json",
"tempfile",
"tokio",
"url",
]
[[package]]

View File

@@ -23,8 +23,11 @@ codex-shell-escalation = { workspace = true }
codex-utils-absolute-path = { workspace = true }
codex-utils-home-dir = { workspace = true }
dotenvy = { workspace = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
tempfile = { workspace = true }
tokio = { workspace = true, features = ["rt-multi-thread"] }
tokio = { workspace = true, features = ["net", "rt-multi-thread", "time"] }
url = { workspace = true }
[target.'cfg(windows)'.dependencies]
codex-windows-sandbox = { workspace = true }

View File

@@ -0,0 +1,281 @@
use super::*;
use pretty_assertions::assert_eq;
use std::cell::Cell;
use std::cell::RefCell;
use std::collections::BTreeMap;
use std::fs;
use std::time::Duration;
#[derive(Debug, Default, PartialEq, Eq)]
struct TestEnvironment(BTreeMap<String, String>);
impl StartupEnvironment for TestEnvironment {
fn set(&mut self, key: &str, value: &str) {
self.0.insert(key.to_string(), value.to_string());
}
fn remove(&mut self, key: &str) {
self.0.remove(key);
}
}
fn write_overlay(codex_home: &Path, name: &str, contents: &str) -> std::io::Result<()> {
fs::write(codex_home.join(name), contents)
}
fn test_environment<const N: usize>(values: [(&str, &str); N]) -> TestEnvironment {
TestEnvironment(BTreeMap::from(
values.map(|(key, value)| (key.to_string(), value.to_string())),
))
}
#[test]
fn proxy_overlays_set_or_unset_environment_for_network() -> anyhow::Result<()> {
let codex_home = tempfile::tempdir()?;
write_overlay(
codex_home.path(),
".env.10-proxy-on",
r#"# codex-env-if: {"type":"tcp_connect","from":"HTTPS_PROXY","timeout_ms":500}
HTTPS_PROXY=http://user:password@proxy.example.com:8080
HTTP_PROXY=http://proxy.example.com:8080
ALL_PROXY=http://proxy.example.com:8080
NO_PROXY=localhost,127.0.0.1,.example.com
"#,
)?;
write_overlay(
codex_home.path(),
".env.20-proxy-off",
r#"# codex-env-if: {"not":{"type":"tcp_connect","host":"proxy.example.com","port":8080,"timeout_ms":500}}
# codex-env-unset: ["HTTPS_PROXY","HTTP_PROXY","ALL_PROXY","NO_PROXY"]
"#,
)?;
let on_network_calls = Cell::new(0);
let on_network_connector = |host: &str, port: u16, timeout: Duration| {
on_network_calls.set(on_network_calls.get() + 1);
assert_eq!(host, "proxy.example.com");
assert_eq!(port, 8080);
assert_eq!(timeout, Duration::from_millis(500));
true
};
let mut on_network_environment = test_environment([
("HTTPS_PROXY", "http://stale.example.com:8080"),
("UNRELATED", "preserved"),
]);
let warnings = load_conditional_dotenv_overlays(
codex_home.path(),
&mut on_network_environment,
&on_network_connector,
);
assert_eq!(on_network_calls.get(), 2);
assert_eq!(warnings, Vec::<String>::new());
assert_eq!(
on_network_environment,
test_environment([
("ALL_PROXY", "http://proxy.example.com:8080"),
("HTTPS_PROXY", "http://user:password@proxy.example.com:8080"),
("HTTP_PROXY", "http://proxy.example.com:8080"),
("NO_PROXY", "localhost,127.0.0.1,.example.com"),
("UNRELATED", "preserved"),
])
);
let off_network_calls = Cell::new(0);
let off_network_connector = |host: &str, port: u16, timeout: Duration| {
off_network_calls.set(off_network_calls.get() + 1);
assert_eq!(host, "proxy.example.com");
assert_eq!(port, 8080);
assert_eq!(timeout, Duration::from_millis(500));
false
};
let mut off_network_environment = test_environment([
("ALL_PROXY", "http://inherited.example.com:8080"),
("HTTPS_PROXY", "http://inherited.example.com:8080"),
("HTTP_PROXY", "http://inherited.example.com:8080"),
("NO_PROXY", "localhost"),
("UNRELATED", "preserved"),
]);
let warnings = load_conditional_dotenv_overlays(
codex_home.path(),
&mut off_network_environment,
&off_network_connector,
);
assert_eq!(off_network_calls.get(), 2);
assert_eq!(warnings, Vec::<String>::new());
assert_eq!(
off_network_environment,
test_environment([("UNRELATED", "preserved")])
);
Ok(())
}
#[test]
fn overlay_filters_reserved_names_for_set_and_unset() -> anyhow::Result<()> {
let codex_home = tempfile::tempdir()?;
write_overlay(
codex_home.path(),
".env.proxy",
r#"# codex-env-if: {"type":"tcp_connect","host":"proxy.example.com","port":8080}
# codex-env-unset: ["ALL_PROXY","codex_internal"]
HTTP_PROXY=http://proxy.example.com:8080
codex_internal=changed
"#,
)?;
let mut environment = test_environment([
("ALL_PROXY", "http://old.example.com:8080"),
("codex_internal", "preserved"),
]);
let warnings =
load_conditional_dotenv_overlays(codex_home.path(), &mut environment, &|_, _, _| true);
assert_eq!(warnings, Vec::<String>::new());
assert_eq!(
environment,
test_environment([
("HTTP_PROXY", "http://proxy.example.com:8080"),
("codex_internal", "preserved"),
])
);
Ok(())
}
#[test]
fn invalid_timeout_fails_closed_without_blocking_valid_overlays() -> anyhow::Result<()> {
let codex_home = tempfile::tempdir()?;
write_overlay(codex_home.path(), ".env.example", "SHOULD_NOT_LOAD=1\n")?;
write_overlay(
codex_home.path(),
".env.10-timeout",
r#"# codex-env-if: {"type":"tcp_connect","host":"proxy.example.com","port":8080,"timeout_ms":5001}
SHOULD_NOT_LOAD=2
"#,
)?;
write_overlay(
codex_home.path(),
".env.20-maximum-timeout",
r#"# codex-env-if: {"type":"tcp_connect","host":"proxy.example.com","port":8080,"timeout_ms":5000}
MAXIMUM_TIMEOUT=loaded
"#,
)?;
write_overlay(
codex_home.path(),
".env.30-default-timeout",
r#"# codex-env-if: {"type":"tcp_connect","host":"proxy.example.com","port":8080}
DEFAULT_TIMEOUT=loaded
"#,
)?;
let observed_timeouts = RefCell::new(Vec::new());
let connector = |_: &str, _: u16, timeout: Duration| {
observed_timeouts.borrow_mut().push(timeout);
true
};
let mut environment = TestEnvironment::default();
let warnings =
load_conditional_dotenv_overlays(codex_home.path(), &mut environment, &connector);
assert_eq!(warnings.len(), 1);
assert!(warnings[0].contains(".env.10-timeout"));
assert_eq!(
observed_timeouts.into_inner(),
vec![Duration::from_millis(5000), Duration::from_millis(500)]
);
assert_eq!(
environment,
test_environment([("DEFAULT_TIMEOUT", "loaded"), ("MAXIMUM_TIMEOUT", "loaded"),])
);
Ok(())
}
#[test]
fn invalid_from_sources_fail_closed_without_connecting() -> anyhow::Result<()> {
let codex_home = tempfile::tempdir()?;
write_overlay(
codex_home.path(),
".env.10-missing-source",
r#"# codex-env-if: {"type":"tcp_connect","from":"HTTPS_PROXY"}
SHOULD_NOT_LOAD=1
"#,
)?;
write_overlay(
codex_home.path(),
".env.20-invalid-source",
r#"# codex-env-if: {"type":"tcp_connect","from":"HTTPS_PROXY"}
HTTPS_PROXY="not a url"
"#,
)?;
let connector_called = Cell::new(false);
let connector = |_: &str, _: u16, _: Duration| {
connector_called.set(true);
true
};
let mut environment = TestEnvironment::default();
let warnings =
load_conditional_dotenv_overlays(codex_home.path(), &mut environment, &connector);
assert!(!connector_called.get());
assert_eq!(warnings.len(), 2);
assert_eq!(environment, TestEnvironment::default());
Ok(())
}
#[test]
fn directives_inside_multiline_values_are_not_scanned() -> anyhow::Result<()> {
let codex_home = tempfile::tempdir()?;
write_overlay(
codex_home.path(),
".env.multiline",
r#"# codex-env-if: {"type":"tcp_connect","host":"proxy.example.com","port":8080}
# codex-env-unset: ["HTTP_PROXY"]
MESSAGE='before
# codex-env-unset: ["ALL_PROXY"]
# codex-env-if: {"not":{"type":"tcp_connect","host":"other.example.com","port":8080}}
after'
"#,
)?;
let mut environment = test_environment([
("ALL_PROXY", "http://proxy.example.com:8080"),
("HTTP_PROXY", "http://proxy.example.com:8080"),
]);
let warnings =
load_conditional_dotenv_overlays(codex_home.path(), &mut environment, &|_, _, _| true);
assert_eq!(warnings, Vec::<String>::new());
assert_eq!(
environment,
test_environment([
("ALL_PROXY", "http://proxy.example.com:8080"),
(
"MESSAGE",
"before\n# codex-env-unset: [\"ALL_PROXY\"]\n# codex-env-if: {\"not\":{\"type\":\"tcp_connect\",\"host\":\"other.example.com\",\"port\":8080}}\nafter",
),
])
);
Ok(())
}
#[test]
fn endpoint_parser_supports_urls_bare_authorities_and_known_default_ports() {
assert_eq!(
[
"http://user:password@proxy.example.com:8080",
"proxy.example.com:8080",
"https://proxy.example.com",
"http://[::1]:8080",
]
.map(parse_endpoint),
[
Some(("proxy.example.com".to_string(), 8080)),
Some(("proxy.example.com".to_string(), 8080)),
Some(("proxy.example.com".to_string(), 443)),
Some(("::1".to_string(), 8080)),
]
);
}

View File

@@ -0,0 +1,465 @@
//! Loads TCP-gated dotenv overlays from `CODEX_HOME` during single-threaded startup.
//!
//! Example `~/.codex/.env.corporate-proxy`:
//!
//! ```dotenv
//! # codex-env-if: {"type":"tcp_connect","from":"HTTPS_PROXY","timeout_ms":500}
//! HTTPS_PROXY=http://proxy.example.com:8080
//! HTTP_PROXY=http://proxy.example.com:8080
//! ALL_PROXY=http://proxy.example.com:8080
//! NO_PROXY=localhost,127.0.0.1,.example.com
//! ```
//!
//! A TCP condition can be inverted with `{"not":{"type":"tcp_connect",...}}`, which allows a
//! second overlay to unset proxy variables when the endpoint is unreachable.
use crate::is_reserved_env_var;
use serde::Deserialize;
use std::ffi::OsString;
use std::net::ToSocketAddrs;
use std::path::Path;
use std::path::PathBuf;
use std::process::Command;
use std::process::Stdio;
use std::time::Duration;
use std::time::Instant;
use url::Url;
pub(crate) const TCP_CONNECT_HELPER_ARG1: &str = "--codex-run-as-tcp-connect-probe";
const CONDITIONAL_DOTENV_PREFIX: &str = ".env.";
const CONDITION_DIRECTIVE_PREFIX: &str = "# codex-env-if:";
const UNSET_DIRECTIVE_PREFIX: &str = "# codex-env-unset:";
const DEFAULT_TCP_CONNECT_TIMEOUT_MS: u64 = 500;
const MAX_TCP_CONNECT_TIMEOUT_MS: u64 = 5_000;
const TCP_CONNECT_HELPER_POLL_INTERVAL: Duration = Duration::from_millis(10);
const IGNORED_CONDITIONAL_DOTENV_SUFFIXES: &[&str] = &[
"bak", "back", "backup", "bkp", "old", "orig", "original", "save", "saved", "disable",
"disabled", "inactive", "off", "tmp", "temp", "swp", "swo", "example", "sample", "template",
"dist",
];
type TcpConnector<'a> = dyn Fn(&str, u16, Duration) -> bool + 'a;
pub(crate) fn load(codex_home: &Path) {
let mut environment = ProcessEnvironment;
for warning in
load_conditional_dotenv_overlays(codex_home, &mut environment, &system_tcp_connect)
{
eprintln!("WARNING: skipped conditional dotenv overlay: {warning}");
}
}
/// Runs an OS-resolved TCP probe in the child process used by [`system_tcp_connect`].
///
/// This exits before dotenv loading, so its resolver and connection tasks cannot race with the
/// parent's environment mutation.
pub(crate) fn run_tcp_connect_helper(mut args: impl Iterator<Item = OsString>) -> ! {
let host = args.next().and_then(|value| value.into_string().ok());
let port = args
.next()
.and_then(|value| value.into_string().ok())
.and_then(|value| value.parse::<u16>().ok());
let timeout = args
.next()
.and_then(|value| value.into_string().ok())
.and_then(|value| value.parse::<u64>().ok())
.and_then(|timeout_ms| tcp_connect_timeout(Some(timeout_ms)).ok());
let connected = match (host, port, timeout) {
(Some(host), Some(port), Some(timeout)) => direct_tcp_connect(&host, port, timeout),
_ => false,
};
let exit_code = if connected { 0 } else { 1 };
std::process::exit(exit_code);
}
fn load_conditional_dotenv_overlays(
codex_home: &Path,
environment: &mut dyn StartupEnvironment,
tcp_connect: &TcpConnector<'_>,
) -> Vec<String> {
let paths = match conditional_dotenv_paths(codex_home) {
Ok(paths) => paths,
Err(err) => {
return vec![format!(
"could not discover overlays in {}: {err}",
codex_home.display()
)];
}
};
let mut warnings = Vec::new();
for path in paths {
let overlay = match parse_conditional_dotenv(&path) {
Ok(Some(overlay)) => overlay,
Ok(None) => continue,
Err(err) => {
warnings.push(format!("{}: {err}", path.display()));
continue;
}
};
match evaluate_condition(&overlay.condition, &overlay.entries, tcp_connect) {
Ok(true) => apply_overlay(overlay, environment),
Ok(false) => {}
Err(err) => warnings.push(format!("{}: {err}", path.display())),
}
}
warnings
}
fn conditional_dotenv_paths(codex_home: &Path) -> std::io::Result<Vec<PathBuf>> {
let entries = match std::fs::read_dir(codex_home) {
Ok(entries) => entries,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
Err(err) => return Err(err),
};
let mut paths = Vec::new();
for entry in entries {
let entry = entry?;
let file_name = entry.file_name();
let Some(file_name) = file_name.to_str() else {
continue;
};
let is_ignored = file_name.ends_with('~')
|| file_name.rsplit_once('.').is_some_and(|(_, suffix)| {
IGNORED_CONDITIONAL_DOTENV_SUFFIXES
.iter()
.any(|ignored| suffix.eq_ignore_ascii_case(ignored))
});
if file_name.starts_with(CONDITIONAL_DOTENV_PREFIX)
&& file_name.len() > CONDITIONAL_DOTENV_PREFIX.len()
&& !is_ignored
&& entry.path().is_file()
{
paths.push(entry.path());
}
}
paths.sort();
Ok(paths)
}
fn parse_conditional_dotenv(path: &Path) -> Result<Option<ConditionalDotenv>, String> {
let contents =
std::fs::read_to_string(path).map_err(|err| format!("could not read file: {err}"))?;
let contents = contents.strip_prefix('\u{feff}').unwrap_or(&contents);
let mut lines = contents
.lines()
.map(|line| line.trim_start_matches('\u{feff}').trim());
let Some(first_line) = lines.find(|line| !line.is_empty()) else {
return Ok(None);
};
let Some(condition_json) = first_line.strip_prefix(CONDITION_DIRECTIVE_PREFIX) else {
return Ok(None);
};
let condition_json = condition_json.trim();
if condition_json.is_empty() {
return Err("condition directive is empty".to_string());
}
let condition = serde_json::from_str(condition_json)
.map_err(|err| format!("condition directive is invalid: {err}"))?;
let mut unset = None;
for line in lines {
if !line.is_empty() && !line.starts_with('#') {
break;
}
if line.starts_with(CONDITION_DIRECTIVE_PREFIX) {
return Err("exactly one condition directive is required".to_string());
}
if let Some(unset_json) = line.strip_prefix(UNSET_DIRECTIVE_PREFIX) {
if unset.is_some() {
return Err("multiple unset directives are not supported".to_string());
}
let keys: Vec<String> = serde_json::from_str(unset_json.trim())
.map_err(|err| format!("unset directive is invalid: {err}"))?;
if let Some(key) = keys.iter().find(|key| !is_valid_env_var_name(key)) {
return Err(format!(
"unset directive contains invalid environment variable name `{key}`"
));
}
unset = Some(keys);
}
}
let entries = dotenvy::from_read_iter(contents.as_bytes())
.collect::<Result<Vec<_>, _>>()
.map_err(|_| "file contains an invalid dotenv assignment".to_string())?;
if entries.iter().any(|(_, value)| value.contains('\0')) {
return Err("file contains a dotenv value with a NUL byte".to_string());
}
Ok(Some(ConditionalDotenv {
condition,
unset: unset.unwrap_or_default(),
entries,
}))
}
fn evaluate_condition(
condition: &Condition,
overlay_entries: &[(String, String)],
tcp_connect: &TcpConnector<'_>,
) -> Result<bool, String> {
match condition {
Condition::TcpConnect(condition) => {
evaluate_tcp_connect(condition, overlay_entries, tcp_connect)
}
Condition::Not { not } => {
evaluate_tcp_connect(not, overlay_entries, tcp_connect).map(|connected| !connected)
}
}
}
fn evaluate_tcp_connect(
condition: &TcpConnectCondition,
overlay_entries: &[(String, String)],
tcp_connect: &TcpConnector<'_>,
) -> Result<bool, String> {
match condition {
TcpConnectCondition::TcpConnect {
from,
host,
port,
timeout_ms,
} => {
let target = parse_tcp_connect_target(from.as_deref(), host.as_deref(), *port)?;
let (host, port) = match target {
TcpConnectTarget::FromVariable(from) => {
let value = overlay_entries
.iter()
.rev()
.find_map(|(key, value)| {
(key == from || (cfg!(windows) && key.eq_ignore_ascii_case(from)))
.then_some(value)
})
.ok_or_else(|| {
format!(
"tcp_connect source variable `{from}` is not defined in the overlay"
)
})?;
parse_endpoint(value).ok_or_else(|| {
format!(
"tcp_connect source variable `{from}` does not contain a valid endpoint"
)
})?
}
TcpConnectTarget::Explicit { host, port } => (host.to_string(), port),
};
let timeout = tcp_connect_timeout(*timeout_ms)?;
Ok((tcp_connect)(&host, port, timeout))
}
}
}
fn apply_overlay(overlay: ConditionalDotenv, environment: &mut dyn StartupEnvironment) {
for key in overlay.unset {
if !is_reserved_env_var(&key) {
environment.remove(&key);
}
}
for (key, value) in overlay.entries {
if !is_reserved_env_var(&key) {
environment.set(&key, &value);
}
}
}
enum TcpConnectTarget<'a> {
FromVariable(&'a str),
Explicit { host: &'a str, port: u16 },
}
fn parse_tcp_connect_target<'a>(
from: Option<&'a str>,
host: Option<&'a str>,
port: Option<u16>,
) -> Result<TcpConnectTarget<'a>, String> {
match (from, host, port) {
(Some(from), None, None) => {
if !is_valid_env_var_name(from) {
return Err(format!(
"tcp_connect contains invalid source variable name `{from}`"
));
}
Ok(TcpConnectTarget::FromVariable(from))
}
(None, Some(host), Some(port)) => {
if host.is_empty() {
return Err("tcp_connect host must not be empty".to_string());
}
if port == 0 {
return Err("tcp_connect port must be greater than zero".to_string());
}
Ok(TcpConnectTarget::Explicit { host, port })
}
(Some(_), _, _) => Err(
"tcp_connect must specify either `from` or `host` and `port`, but not both".to_string(),
),
(None, _, _) => {
Err("tcp_connect requires either `from` or both `host` and `port`".to_string())
}
}
}
fn tcp_connect_timeout(timeout_ms: Option<u64>) -> Result<Duration, String> {
let timeout_ms = timeout_ms.unwrap_or(DEFAULT_TCP_CONNECT_TIMEOUT_MS);
if !(1..=MAX_TCP_CONNECT_TIMEOUT_MS).contains(&timeout_ms) {
return Err(format!(
"tcp_connect timeout_ms must be between 1 and {MAX_TCP_CONNECT_TIMEOUT_MS}"
));
}
Ok(Duration::from_millis(timeout_ms))
}
fn parse_endpoint(value: &str) -> Option<(String, u16)> {
let value = value.trim();
let parsed = if value.contains("://") {
Url::parse(value).ok()?
} else {
Url::parse(&format!("http://{value}")).ok()?
};
let host = match parsed.host()? {
url::Host::Ipv6(address) => address.to_string(),
host => host.to_string(),
};
let port = parsed.port_or_known_default()?;
(port != 0).then_some((host, port))
}
fn system_tcp_connect(host: &str, port: u16, timeout: Duration) -> bool {
// `getaddrinfo` cannot be cancelled portably. Isolate it in a child that can be killed and
// reaped without introducing a resolver thread into the environment-mutating parent.
let Ok(current_exe) = std::env::current_exe() else {
return false;
};
let Ok(mut child) = Command::new(current_exe)
.arg(TCP_CONNECT_HELPER_ARG1)
.arg(host)
.arg(port.to_string())
.arg(timeout.as_millis().to_string())
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn()
else {
return false;
};
let deadline = Instant::now() + timeout;
loop {
match child.try_wait() {
Ok(Some(status)) => return status.success(),
Ok(None) => {}
Err(_) => break,
}
let remaining = deadline.saturating_duration_since(Instant::now());
if remaining.is_zero() {
break;
}
std::thread::sleep(remaining.min(TCP_CONNECT_HELPER_POLL_INTERVAL));
}
let _ = child.kill();
let _ = child.wait();
false
}
fn direct_tcp_connect(host: &str, port: u16, timeout: Duration) -> bool {
let deadline = Instant::now() + timeout;
let Ok(addresses) = (host, port)
.to_socket_addrs()
.map(Iterator::collect::<Vec<_>>)
else {
return false;
};
if deadline.saturating_duration_since(Instant::now()).is_zero() {
return false;
}
let Ok(runtime) = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
else {
return false;
};
runtime.block_on(async move {
tokio::time::timeout_at(tokio::time::Instant::from_std(deadline), async move {
let mut attempts = tokio::task::JoinSet::new();
for address in addresses {
attempts.spawn(tokio::net::TcpStream::connect(address));
}
while let Some(result) = attempts.join_next().await {
if matches!(result, Ok(Ok(_))) {
return true;
}
}
false
})
.await
.unwrap_or(false)
})
}
fn is_valid_env_var_name(key: &str) -> bool {
let mut chars = key.chars();
chars
.next()
.is_some_and(|first| first.is_ascii_alphabetic() || first == '_')
&& chars.all(|character| {
character.is_ascii_alphanumeric() || character == '_' || character == '.'
})
}
/// Minimal environment interface used to test overlay application without mutating the test
/// process environment.
trait StartupEnvironment {
fn set(&mut self, key: &str, value: &str);
fn remove(&mut self, key: &str);
}
struct ProcessEnvironment;
impl StartupEnvironment for ProcessEnvironment {
fn set(&mut self, key: &str, value: &str) {
// Safety: this loader runs from arg0_dispatch before Codex creates any threads.
unsafe { std::env::set_var(key, value) };
}
fn remove(&mut self, key: &str) {
// Safety: this loader runs from arg0_dispatch before Codex creates any threads.
unsafe { std::env::remove_var(key) };
}
}
struct ConditionalDotenv {
condition: Condition,
unset: Vec<String>,
entries: Vec<(String, String)>,
}
#[derive(Debug, Deserialize)]
#[serde(untagged, deny_unknown_fields)]
enum Condition {
TcpConnect(TcpConnectCondition),
Not { not: TcpConnectCondition },
}
#[derive(Debug, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)]
enum TcpConnectCondition {
TcpConnect {
from: Option<String>,
host: Option<String>,
port: Option<u16>,
timeout_ms: Option<u64>,
},
}
#[cfg(test)]
#[path = "conditional_dotenv_tests.rs"]
mod tests;

View File

@@ -15,6 +15,8 @@ use codex_windows_sandbox::CODEX_WINDOWS_SANDBOX_ARG1;
use std::os::unix::fs::symlink;
use tempfile::TempDir;
mod conditional_dotenv;
const APPLY_PATCH_ARG0: &str = "apply_patch";
const MISSPELLED_APPLY_PATCH_ARG0: &str = "applypatch";
#[cfg(unix)]
@@ -98,6 +100,9 @@ pub fn arg0_dispatch() -> Option<Arg0PathEntryGuard> {
}
let argv1 = args.next().unwrap_or_default();
if argv1 == conditional_dotenv::TCP_CONNECT_HELPER_ARG1 {
conditional_dotenv::run_tcp_connect_helper(args);
}
if argv1 == CODEX_FS_HELPER_ARG1 {
codex_exec_server::run_fs_helper_main();
}
@@ -191,7 +196,7 @@ fn prepare_path_env_var_with_aliases(
/// `codex-linux-sandbox` we *directly* execute
/// [`codex_linux_sandbox::run_main`] (which never returns). Otherwise we:
///
/// 1. Load `.env` values from `~/.codex/.env` before creating any threads.
/// 1. Load `.env` and matching conditional dotenv values from `~/.codex` before creating threads.
/// 2. Spawn a main runtime thread with a controlled stack size.
/// 3. Construct a Tokio multi-thread runtime.
/// 4. Capture the current executable path and derive the
@@ -285,16 +290,18 @@ fn build_runtime() -> anyhow::Result<tokio::runtime::Runtime> {
const ILLEGAL_ENV_VAR_PREFIX: &str = "CODEX_";
/// Load env vars from ~/.codex/.env.
/// Load base and conditional env vars from the startup Codex home.
///
/// Security: Do not allow `.env` files to create or modify any variables
/// with names starting with `CODEX_`.
fn load_dotenv() {
if let Ok(codex_home) = find_codex_home()
&& let Ok(iter) = dotenvy::from_path_iter(codex_home.join(".env"))
{
let Ok(codex_home) = find_codex_home() else {
return;
};
if let Ok(iter) = dotenvy::from_path_iter(codex_home.join(".env")) {
set_filtered(iter);
}
conditional_dotenv::load(codex_home.as_path());
}
/// Helper to set vars from a dotenvy iterator while filtering out `CODEX_` keys.
@@ -303,7 +310,7 @@ where
I: IntoIterator<Item = Result<(String, String), dotenvy::Error>>,
{
for (key, value) in iter.into_iter().flatten() {
if !key.to_ascii_uppercase().starts_with(ILLEGAL_ENV_VAR_PREFIX) {
if !is_reserved_env_var(&key) {
// It is safe to call set_var() because our process is
// single-threaded at this point in its execution.
unsafe { std::env::set_var(&key, &value) };
@@ -311,6 +318,11 @@ where
}
}
fn is_reserved_env_var(key: &str) -> bool {
key.get(..ILLEGAL_ENV_VAR_PREFIX.len())
.is_some_and(|prefix| prefix.eq_ignore_ascii_case(ILLEGAL_ENV_VAR_PREFIX))
}
/// Creates a temporary directory with either:
///
/// - UNIX: `apply_patch` symlink to the current executable