Harden managed proxy listener handoff (#40999)

## Why

Linux managed networking routed proxy connections through filesystem-backed Unix sockets, requiring an extra readable sandbox path and stale socket cleanup.

## What changed

- Transfer loopback TCP listeners from the sandbox namespace to host bridge processes over anonymous Unix socket pairs.
- Validate transferred descriptors, keep bootstrap channels private to each sandbox launch, and close them before running untrusted commands.
- Tie proxy bridge lifetime to the sandbox so cancellation closes active upstream connections.

## Testing

- Cover descriptor validation and interrupted handoffs.
- Verify concurrent sandboxes keep proxy endpoints isolated and do not inherit privileged sockets.
- Exercise managed networking end to end through `app-server`, confirming proxied access succeeds while direct host access remains blocked.

GitOrigin-RevId: 6561e09c83b927c604951bf2aa00ff532d179da5
This commit is contained in:
andrewgu-oai authored and copyberry committed 2026-08-26 23:41:18 +00:00
1 parent 528fd7ace5
commit f3741880f6
8 files changed
+839 -462

No files matched your search

@@ -1662,6 +1662,227 @@ allow_local_binding = false
Ok(())
}
#[cfg(target_os = "linux")]
#[tokio::test]
async fn turn_start_enforces_managed_network_sandbox() -> Result<()> {
skip_if_remote!(
Ok(()),
"network probe uses a host-local test executable and loopback server"
);
let server = responses::start_mock_server().await;
let codex_home = TempDir::new()?;
MockResponsesConfig::new(&server.uri())
.enable_feature(Feature::NetworkProxy)
.enable_feature(Feature::UnifiedExec)
.write(codex_home.path())?;
std::fs::write(
codex_home.path().join("requirements.toml"),
r#"
default_permissions = "managed-network"
[allowed_permission_profiles]
managed-network = true
":read-only" = true
[permissions.managed-network]
extends = ":read-only"
[permissions.managed-network.network]
enabled = true
allow_local_binding = false
allow_upstream_proxy = false
[permissions.managed-network.network.domains]
"127.0.0.2" = "allow"
"#,
)?;
let mut app_server = TestAppServer::builder()
.with_codex_home(codex_home.path())
.build_initialized()
.await?;
let ThreadStartResponse { thread, .. } = app_server
.start_thread(ThreadStartParams {
model: Some("mock-model".to_string()),
..Default::default()
})
.await?;
// A different loopback address avoids confusing the host's target port with
// a coincidentally equal 127.0.0.1 listener port inside the sandbox namespace.
let listener = tokio::net::TcpListener::bind("127.0.0.2:0").await?;
let target = listener.local_addr()?;
let router = axum::Router::new().route(
"/sandbox",
axum::routing::get(|| async { "sandbox proxy response" }),
);
// Keep the real origin listening throughout both the proxied and direct
// requests, and clean it up even if an assertion fails.
let _origin = tokio_util::task::AbortOnDropHandle::new(tokio::spawn(async move {
axum::serve(listener, router).await
}));
let test_executable = std::env::current_exe()?;
let test_executable = test_executable
.to_str()
.context("integration test executable path must be UTF-8")?;
// Libtest selectors omit the integration crate name.
let (_, test_module) = module_path!()
.split_once("::")
.context("network sandbox test must be in an integration test module")?;
let child_test = format!("{test_module}::managed_network_sandbox_probe");
let target_env = format!("CODEX_TEST_MANAGED_NETWORK_TARGET={target}");
let command = shlex::try_join([
"env",
&target_env,
test_executable,
"--exact",
&child_test,
"--ignored",
"--nocapture",
"--test-threads=1",
])?;
let call_id = "managed-network-probe";
let probe_timeout = std::time::Duration::from_secs(/*secs*/ 30);
let _command_response = responses::mount_sse_once(
&server,
responses::sse(vec![
responses::ev_response_created("resp-network-command"),
responses::ev_exec_command_call_with_args(
call_id,
&json!({
"cmd": command,
"shell": "/bin/sh",
"login": false,
"yield_time_ms": probe_timeout.as_millis(),
}),
),
responses::ev_completed("resp-network-command"),
]),
)
.await;
let final_response = responses::mount_sse_once(
&server,
create_final_assistant_message_sse_response("Network probe complete")?,
)
.await;
let _: TurnStartResponse = app_server
.request(|request_id| ClientRequest::TurnStart {
request_id,
params: TurnStartParams {
thread_id: thread.id,
input: vec![V2UserInput::Text {
text: "Verify managed sandbox networking".to_string(),
text_elements: Vec::new(),
}],
permissions: Some("managed-network".to_string()),
..Default::default()
},
})
.await?;
let expected_output = "proxied request succeeded; direct access blocked\n";
timeout(probe_timeout, async {
loop {
let completed: ItemCompletedNotification =
app_server.read_notification("item/completed").await?;
if let ThreadItem::CommandExecution {
id,
status,
exit_code,
..
} = completed.item
&& id == call_id
{
assert_eq!(
(status, exit_code),
(CommandExecutionStatus::Completed, Some(0))
);
break;
}
}
anyhow::Ok(())
})
.await??;
let completed: TurnCompletedNotification = timeout(
probe_timeout,
app_server.read_notification("turn/completed"),
)
.await??;
assert_eq!(completed.turn.status, TurnStatus::Completed);
// Early stdout can reach the tool-result buffer before the separate event
// subscriber attaches, so verify it in the model continuation instead.
// The marker also proves the child ran: libtest succeeds if no selector matches.
let output = final_response
.single_request()
.function_call_output_text(call_id)
.context("model continuation should contain the sandboxed command output")?;
assert!(
output.contains(expected_output),
"unexpected tool output: {output}"
);
Ok(())
}
/// Runs as the model-controlled command inside app-server's managed network sandbox.
#[cfg(target_os = "linux")]
#[test]
#[ignore = "invoked through app-server's managed network sandbox"]
fn managed_network_sandbox_probe() -> Result<()> {
use std::io::ErrorKind;
use std::io::Read;
use std::io::Write;
use std::net::SocketAddr;
use std::net::TcpStream;
use std::time::Duration;
let target: SocketAddr = std::env::var("CODEX_TEST_MANAGED_NETWORK_TARGET")?.parse()?;
let proxy = url::Url::parse(&std::env::var("HTTP_PROXY")?)?;
let proxy_address = SocketAddr::new(
proxy.host_str().context("missing proxy host")?.parse()?,
proxy
.port_or_known_default()
.context("missing proxy port")?,
);
let proxy_timeout = Duration::from_secs(/*secs*/ 5);
let mut connection = TcpStream::connect_timeout(&proxy_address, proxy_timeout)?;
connection.set_read_timeout(Some(proxy_timeout))?;
connection.set_write_timeout(Some(proxy_timeout))?;
// HTTP/1.0 keeps this small fixture response close-delimited, without chunked encoding.
write!(
connection,
"GET http://{target}/sandbox HTTP/1.0\r\nHost: {target}\r\nConnection: close\r\n\r\n"
)?;
let mut response = String::new();
connection
.take(/*limit*/ 4096)
.read_to_string(&mut response)?;
let (headers, body) = response
.split_once("\r\n\r\n")
.context("incomplete proxy HTTP response")?;
let status = headers
.split_whitespace()
.nth(/*n*/ 1)
.context("missing HTTP response status")?;
assert_eq!((status, body), ("200", "sandbox proxy response"));
let direct_error = TcpStream::connect_timeout(&target, Duration::from_secs(/*secs*/ 2))
.expect_err("sandbox allowed direct host access");
assert!(
matches!(
direct_error.kind(),
ErrorKind::ConnectionRefused
| ErrorKind::NetworkUnreachable
| ErrorKind::PermissionDenied
),
"unexpected direct connection error: {direct_error}"
);
println!("proxied request succeeded; direct access blocked");
Ok(())
}
#[tokio::test]
async fn turn_start_rejects_unknown_environment_before_starting_turn() -> Result<()> {
let server = create_mock_responses_server_repeating_assistant("Done").await;