diff --git a/codex-rs/codex-mcp/src/mcp_connection_manager.rs b/codex-rs/codex-mcp/src/mcp_connection_manager.rs index aca8828af8..4dd703c389 100644 --- a/codex-rs/codex-mcp/src/mcp_connection_manager.rs +++ b/codex-rs/codex-mcp/src/mcp_connection_manager.rs @@ -38,6 +38,8 @@ use codex_async_utils::OrCancelExt; use codex_config::Constrained; use codex_config::types::OAuthCredentialsStoreMode; use codex_exec_server::Environment; +use codex_exec_server::HttpClient; +use codex_exec_server::ReqwestHttpClient; use codex_protocol::ToolName; use codex_protocol::approvals::ElicitationRequest; use codex_protocol::approvals::ElicitationRequestEvent; @@ -1552,7 +1554,14 @@ async fn make_rmcp_client( } = config; let remote_environment = match experimental_environment.as_deref() { None | Some("local") => false, - Some("remote") => true, + Some("remote") => { + if !runtime_environment.environment().is_remote() { + return Err(StartupOutcomeError::from(anyhow!( + "remote MCP server `{server_name}` requires a remote environment" + ))); + } + true + } Some(environment) => { return Err(StartupOutcomeError::from(anyhow!( "unsupported experimental_environment `{environment}` for MCP server `{server_name}`" @@ -1576,14 +1585,8 @@ async fn make_rmcp_client( .collect::>() }); let launcher = if remote_environment { - let exec_environment = runtime_environment.environment(); - if !exec_environment.is_remote() { - return Err(StartupOutcomeError::from(anyhow!( - "remote MCP server `{server_name}` requires a remote executor environment" - ))); - } Arc::new(ExecutorStdioServerLauncher::new( - exec_environment.get_exec_backend(), + runtime_environment.environment().get_exec_backend(), runtime_environment.fallback_cwd(), )) } else { @@ -1605,11 +1608,11 @@ async fn make_rmcp_client( env_http_headers, bearer_token_env_var, } => { - if remote_environment && !runtime_environment.environment().is_remote() { - return Err(StartupOutcomeError::from(anyhow!( - "remote MCP server `{server_name}` requires a remote environment" - ))); - } + let http_client: Arc = if remote_environment { + runtime_environment.environment().get_http_client() + } else { + Arc::new(ReqwestHttpClient) + }; let resolved_bearer_token = match resolve_bearer_token(server_name, bearer_token_env_var.as_deref()) { Ok(token) => token, @@ -1622,7 +1625,7 @@ async fn make_rmcp_client( http_headers, env_http_headers, store_mode, - runtime_environment.environment().get_http_client(), + http_client, runtime_auth_provider, ) .await diff --git a/codex-rs/core/tests/suite/rmcp_client.rs b/codex-rs/core/tests/suite/rmcp_client.rs index cabeb91ae2..db8ad21aa5 100644 --- a/codex-rs/core/tests/suite/rmcp_client.rs +++ b/codex-rs/core/tests/suite/rmcp_client.rs @@ -6,6 +6,7 @@ use std::collections::HashMap; use std::ffi::OsStr; use std::ffi::OsString; use std::fs; +use std::net::SocketAddr; use std::net::TcpListener; use std::path::Path; use std::path::PathBuf; @@ -21,6 +22,8 @@ use codex_config::types::McpServerEnvVar; use codex_config::types::McpServerTransportConfig; use codex_core::config::Config; use codex_exec_server::CreateDirectoryOptions; +use codex_exec_server::Environment; +use codex_exec_server::HttpRequestParams; use codex_features::Feature; use codex_login::CodexAuth; use codex_mcp::MCP_SANDBOX_STATE_META_CAPABILITY; @@ -115,12 +118,9 @@ fn remote_aware_experimental_environment() -> Option { /// container and return that in-container path instead. fn remote_aware_stdio_server_bin() -> anyhow::Result { let bin = stdio_server_bin()?; - let Some(container_name) = std::env::var_os(remote_env_env_var()) else { + let Some(container_name) = remote_env_container_name()? else { return Ok(bin); }; - let container_name = container_name - .into_string() - .map_err(|value| anyhow::anyhow!("remote env container name must be utf-8: {value:?}"))?; // Keep the Docker path rewrite scoped to tests that use `build_remote_aware`. // Other MCP tests still start their stdio server from the orchestrator test @@ -131,32 +131,78 @@ fn remote_aware_stdio_server_bin() -> anyhow::Result { // path instead of the host build artifact path. // Several remote-aware MCP tests can run in parallel; give each copied // binary its own path so one test cannot replace another test's executable. + copy_binary_to_remote_env(&container_name, Path::new(&bin), "test_stdio_server") +} + +/// Returns the Docker container used by remote-aware MCP tests, when active. +fn remote_env_container_name() -> anyhow::Result> { + let Some(container_name) = std::env::var_os(remote_env_env_var()) else { + return Ok(None); + }; + Ok(Some(container_name.into_string().map_err(|value| { + anyhow::anyhow!("remote env container name must be utf-8: {value:?}") + })?)) +} + +/// Builds a collision-resistant in-container path for copied test binaries. +fn unique_remote_path(binary_name: &str) -> anyhow::Result { let unique_suffix = SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos(); - let remote_path = format!( - "/tmp/codex-remote-env/test_stdio_server-{}-{unique_suffix}", + Ok(format!( + "/tmp/codex-remote-env/{binary_name}-{}-{unique_suffix}", std::process::id() + )) +} + +/// Copies a host-built helper binary into the remote test container. +fn copy_binary_to_remote_env( + container_name: &str, + host_path: &Path, + binary_name: &str, +) -> anyhow::Result { + let remote_path = unique_remote_path(binary_name)?; + let mkdir_output = StdCommand::new("docker") + .args([ + "exec", + container_name, + "mkdir", + "-p", + "/tmp/codex-remote-env", + ]) + .output() + .context("create remote MCP test binary directory")?; + ensure!( + mkdir_output.status.success(), + "docker mkdir remote MCP test binary directory failed: stdout={} stderr={}", + String::from_utf8_lossy(&mkdir_output.stdout).trim(), + String::from_utf8_lossy(&mkdir_output.stderr).trim() ); + let container_target = format!("{container_name}:{remote_path}"); let copy_output = StdCommand::new("docker") .arg("cp") - .arg(&bin) + .arg(host_path) .arg(&container_target) .output() - .with_context(|| format!("copy {bin} to remote MCP test env"))?; + .with_context(|| { + format!( + "copy {} to remote MCP test env", + host_path.to_string_lossy() + ) + })?; ensure!( copy_output.status.success(), - "docker cp test_stdio_server failed: stdout={} stderr={}", + "docker cp {binary_name} failed: stdout={} stderr={}", String::from_utf8_lossy(©_output.stdout).trim(), String::from_utf8_lossy(©_output.stderr).trim() ); let chmod_output = StdCommand::new("docker") - .args(["exec", &container_name, "chmod", "+x", remote_path.as_str()]) + .args(["exec", container_name, "chmod", "+x", remote_path.as_str()]) .output() - .context("mark remote test_stdio_server executable")?; + .with_context(|| format!("mark remote {binary_name} executable"))?; ensure!( chmod_output.status.success(), - "docker chmod test_stdio_server failed: stdout={} stderr={}", + "docker chmod {binary_name} failed: stdout={} stderr={}", String::from_utf8_lossy(&chmod_output.stdout).trim(), String::from_utf8_lossy(&chmod_output.stderr).trim() ); @@ -1880,10 +1926,93 @@ async fn remote_stdio_env_var_source_does_not_copy_local_env() -> anyhow::Result Ok(()) } +/// Remote runtime websocket URL used by remote-aware MCP integration tests. +const REMOTE_EXEC_SERVER_URL_ENV_VAR: &str = "CODEX_TEST_REMOTE_EXEC_SERVER_URL"; +/// OAuth metadata path served by the Streamable HTTP MCP test server. +const STREAMABLE_HTTP_METADATA_PATH: &str = "/.well-known/oauth-authorization-server/mcp"; + +/// Streamable HTTP test server plus the process handle needed for cleanup. +struct StreamableHttpTestServer { + server_url: String, + process: StreamableHttpTestServerProcess, +} + +/// Tracks whether the Streamable HTTP test server runs on the host or remotely. +enum StreamableHttpTestServerProcess { + Local(Child), + Remote(RemoteStreamableHttpServer), +} + +/// Remote Streamable HTTP server process and copied files to remove on drop. +struct RemoteStreamableHttpServer { + container_name: String, + pid: String, + paths_to_remove: Vec, +} + +impl Drop for RemoteStreamableHttpServer { + /// Stops the remote process and removes copied test artifacts best-effort. + fn drop(&mut self) { + self.kill(); + if self.paths_to_remove.is_empty() { + return; + } + let script = format!("rm -f {}", self.paths_to_remove.join(" ")); + let _ = StdCommand::new("docker") + .args(["exec", &self.container_name, "sh", "-lc", &script]) + .output(); + } +} + +impl RemoteStreamableHttpServer { + /// Stops the remote Streamable HTTP test server process. + fn kill(&self) { + let _ = StdCommand::new("docker") + .args(["exec", &self.container_name, "kill", &self.pid]) + .output(); + } +} + +impl StreamableHttpTestServer { + /// Returns the MCP endpoint URL that Codex should connect to. + fn url(&self) -> &str { + &self.server_url + } + + /// Stops the local or remote test server and waits for local process exit. + async fn shutdown(mut self) { + match &mut self.process { + StreamableHttpTestServerProcess::Local(child) => match child.try_wait() { + Ok(Some(_)) => {} + Ok(None) => { + let _ = child.kill().await; + } + Err(error) => { + eprintln!("failed to check streamable http server status: {error}"); + let _ = child.kill().await; + } + }, + StreamableHttpTestServerProcess::Remote(server) => { + server.kill(); + } + } + if let StreamableHttpTestServerProcess::Local(child) = &mut self.process + && let Err(error) = child.wait().await + { + eprintln!("failed to await streamable http server shutdown: {error}"); + } + } +} + +/// What this tests: Codex can discover and call a Streamable HTTP MCP tool in +/// both local and remote-aware placements, and the tool observes the expected +/// environment value from the server process that actually handled the request. #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn streamable_http_tool_call_round_trip() -> anyhow::Result<()> { skip_if_no_network!(Ok(())); + // Phase 1: script the model responses so Codex will call the MCP echo tool + // and then complete the turn after the tool result is returned. let server = responses::start_mock_server().await; let call_id = "call-456"; @@ -1916,30 +2045,20 @@ async fn streamable_http_tool_call_round_trip() -> anyhow::Result<()> { ) .await; + // Phase 2: start the Streamable HTTP MCP test server in the active + // placement. In full CI this may be the remote executor container; locally + // it is a host process. let expected_env_value = "propagated-env-http"; - let rmcp_http_server_bin = match cargo_bin("test_streamable_http_server") { - Ok(path) => path, - Err(err) => { - eprintln!("test_streamable_http_server binary not available, skipping test: {err}"); - return Ok(()); - } + let Some(http_server) = + start_streamable_http_test_server(expected_env_value, /*expected_token*/ None).await? + else { + return Ok(()); }; + let server_url = http_server.url().to_string(); - let listener = TcpListener::bind("127.0.0.1:0")?; - let port = listener.local_addr()?.port(); - drop(listener); - let bind_addr = format!("127.0.0.1:{port}"); - let server_url = format!("http://{bind_addr}/mcp"); - - let mut http_server_child = Command::new(&rmcp_http_server_bin) - .kill_on_drop(true) - .env("MCP_STREAMABLE_HTTP_BIND_ADDR", &bind_addr) - .env("MCP_TEST_VALUE", expected_env_value) - .spawn()?; - - wait_for_streamable_http_server(&mut http_server_child, &bind_addr, Duration::from_secs(5)) - .await?; - + // Phase 3: configure Codex with the Streamable HTTP MCP server and build a + // fixture that selects remote MCP placement only when the remote test + // environment is active. let fixture = test_codex() .with_config(move |config| { insert_mcp_server( @@ -1951,13 +2070,17 @@ async fn streamable_http_tool_call_round_trip() -> anyhow::Result<()> { http_headers: None, env_http_headers: None, }, - TestMcpServerOptions::default(), + TestMcpServerOptions { + experimental_environment: remote_aware_experimental_environment(), + ..Default::default() + }, ); }) - .build(&server) + .build_remote_aware(&server) .await?; let session_model = fixture.session_configured.model.clone(); + // Phase 4: submit the user turn that should trigger the MCP tool call. fixture .codex .submit(Op::UserTurn { @@ -1981,6 +2104,7 @@ async fn streamable_http_tool_call_round_trip() -> anyhow::Result<()> { }) .await?; + // Phase 5: assert Codex begins the expected tool invocation. let begin_event = wait_for_event(&fixture.codex, |ev| { matches!(ev, EventMsg::McpToolCallBegin(_)) }) @@ -1992,6 +2116,8 @@ async fn streamable_http_tool_call_round_trip() -> anyhow::Result<()> { assert_eq!(begin.invocation.server, server_name); assert_eq!(begin.invocation.tool, "echo"); + // Phase 6: assert the tool result proves the server handled the request and + // propagated the expected environment value. let end_event = wait_for_event(&fixture.codex, |ev| { matches!(ev, EventMsg::McpToolCallEnd(_)) }) @@ -2028,23 +2154,13 @@ async fn streamable_http_tool_call_round_trip() -> anyhow::Result<()> { .expect("env snapshot inserted"); assert_eq!(env_value, expected_env_value); + // Phase 7: verify the scripted model calls were consumed and clean up the + // placement-aware MCP server. wait_for_event(&fixture.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; server.verify().await; - match http_server_child.try_wait() { - Ok(Some(_)) => {} - Ok(None) => { - let _ = http_server_child.kill().await; - } - Err(error) => { - eprintln!("failed to check streamable http server status: {error}"); - let _ = http_server_child.kill().await; - } - } - if let Err(error) = http_server_child.wait().await { - eprintln!("failed to await streamable http server shutdown: {error}"); - } + http_server.shutdown().await; Ok(()) } @@ -2079,6 +2195,8 @@ fn streamable_http_with_oauth_round_trip() -> anyhow::Result<()> { async fn streamable_http_with_oauth_round_trip_impl() -> anyhow::Result<()> { skip_if_no_network!(Ok(())); + // Phase 1: script the model responses so Codex will call the OAuth-backed + // MCP echo tool and then finish the turn after receiving the result. let server = responses::start_mock_server().await; let call_id = "call-789"; @@ -2112,34 +2230,21 @@ async fn streamable_http_with_oauth_round_trip_impl() -> anyhow::Result<()> { ) .await; + // Phase 2: start the Streamable HTTP MCP test server with bearer-token + // enforcement enabled so the client must use stored OAuth credentials. let expected_env_value = "propagated-env-http-oauth"; let expected_token = "initial-access-token"; let client_id = "test-client-id"; let refresh_token = "initial-refresh-token"; - let rmcp_http_server_bin = match cargo_bin("test_streamable_http_server") { - Ok(path) => path, - Err(err) => { - eprintln!("test_streamable_http_server binary not available, skipping test: {err}"); - return Ok(()); - } + let Some(http_server) = + start_streamable_http_test_server(expected_env_value, Some(expected_token)).await? + else { + return Ok(()); }; + let server_url = http_server.url().to_string(); - let listener = TcpListener::bind("127.0.0.1:0")?; - let port = listener.local_addr()?.port(); - drop(listener); - let bind_addr = format!("127.0.0.1:{port}"); - let server_url = format!("http://{bind_addr}/mcp"); - - let mut http_server_child = Command::new(&rmcp_http_server_bin) - .kill_on_drop(true) - .env("MCP_STREAMABLE_HTTP_BIND_ADDR", &bind_addr) - .env("MCP_EXPECT_BEARER", expected_token) - .env("MCP_TEST_VALUE", expected_env_value) - .spawn()?; - - wait_for_streamable_http_server(&mut http_server_child, &bind_addr, Duration::from_secs(5)) - .await?; - + // Phase 3: seed an isolated CODEX_HOME with fallback OAuth tokens for this + // server so the test does not share credentials with other suite cases. let temp_home = Arc::new(tempdir()?); let _codex_home_guard = EnvVarGuard::set("CODEX_HOME", temp_home.path().as_os_str()); write_fallback_oauth_tokens( @@ -2151,6 +2256,8 @@ async fn streamable_http_with_oauth_round_trip_impl() -> anyhow::Result<()> { refresh_token, )?; + // Phase 4: configure Codex with the OAuth-backed Streamable HTTP MCP + // server and build the fixture in the active local or remote-aware mode. let fixture = test_codex() .with_home(temp_home.clone()) .with_config(move |config| { @@ -2167,15 +2274,21 @@ async fn streamable_http_with_oauth_round_trip_impl() -> anyhow::Result<()> { http_headers: None, env_http_headers: None, }, - TestMcpServerOptions::default(), + TestMcpServerOptions { + experimental_environment: remote_aware_experimental_environment(), + ..Default::default() + }, ); }) - .build(&server) + .build_remote_aware(&server) .await?; let session_model = fixture.session_configured.model.clone(); + // Phase 5: wait for MCP discovery to publish the expected tool before the + // turn is submitted, which keeps failures tied to server startup/discovery. wait_for_mcp_tool(&fixture, &tool_name).await?; + // Phase 6: submit the user turn that should invoke the OAuth-backed tool. fixture .codex .submit(Op::UserTurn { @@ -2199,6 +2312,7 @@ async fn streamable_http_with_oauth_round_trip_impl() -> anyhow::Result<()> { }) .await?; + // Phase 7: assert Codex begins the expected tool invocation. let begin_event = wait_for_event(&fixture.codex, |ev| { matches!(ev, EventMsg::McpToolCallBegin(_)) }) @@ -2210,6 +2324,8 @@ async fn streamable_http_with_oauth_round_trip_impl() -> anyhow::Result<()> { assert_eq!(begin.invocation.server, server_name); assert_eq!(begin.invocation.tool, "echo"); + // Phase 8: assert the tool result proves the authenticated request reached + // the server and preserved the expected environment value. let end_event = wait_for_event(&fixture.codex, |ev| { matches!(ev, EventMsg::McpToolCallEnd(_)) }) @@ -2246,34 +2362,220 @@ async fn streamable_http_with_oauth_round_trip_impl() -> anyhow::Result<()> { .expect("env snapshot inserted"); assert_eq!(env_value, expected_env_value); + // Phase 9: verify the scripted model calls were consumed and clean up the + // placement-aware MCP server. wait_for_event(&fixture.codex, |ev| matches!(ev, EventMsg::TurnComplete(_))).await; server.verify().await; - match http_server_child.try_wait() { - Ok(Some(_)) => {} - Ok(None) => { - let _ = http_server_child.kill().await; - } - Err(error) => { - eprintln!("failed to check streamable http oauth server status: {error}"); - let _ = http_server_child.kill().await; - } - } - if let Err(error) = http_server_child.wait().await { - eprintln!("failed to await streamable http oauth server shutdown: {error}"); - } + http_server.shutdown().await; Ok(()) } -async fn wait_for_streamable_http_server( +/// Starts the Streamable HTTP MCP test server in the active test placement. +async fn start_streamable_http_test_server( + expected_env_value: &str, + expected_token: Option<&str>, +) -> anyhow::Result> { + let rmcp_http_server_bin = match cargo_bin("test_streamable_http_server") { + Ok(path) => path, + Err(err) => { + eprintln!("test_streamable_http_server binary not available, skipping test: {err}"); + return Ok(None); + } + }; + + if let Some(container_name) = remote_env_container_name()? { + return Ok(Some( + start_remote_streamable_http_test_server( + &container_name, + &rmcp_http_server_bin, + expected_env_value, + expected_token, + ) + .await?, + )); + } + + let listener = TcpListener::bind("127.0.0.1:0")?; + let port = listener.local_addr()?.port(); + drop(listener); + let bind_addr = format!("127.0.0.1:{port}"); + let server_url = format!("http://{bind_addr}/mcp"); + + let mut command = Command::new(&rmcp_http_server_bin); + command + .kill_on_drop(true) + .env("MCP_STREAMABLE_HTTP_BIND_ADDR", &bind_addr) + .env("MCP_TEST_VALUE", expected_env_value); + if let Some(expected_token) = expected_token { + command.env("MCP_EXPECT_BEARER", expected_token); + } + let mut child = command.spawn()?; + + wait_for_local_streamable_http_server(&mut child, &server_url, Duration::from_secs(5)).await?; + Ok(Some(StreamableHttpTestServer { + server_url, + process: StreamableHttpTestServerProcess::Local(child), + })) +} + +/// Starts the Streamable HTTP MCP test server inside the remote test container. +async fn start_remote_streamable_http_test_server( + container_name: &str, + rmcp_http_server_bin: &Path, + expected_env_value: &str, + expected_token: Option<&str>, +) -> anyhow::Result { + let remote_path = copy_binary_to_remote_env( + container_name, + rmcp_http_server_bin, + "test_streamable_http_server", + )?; + let bound_addr_file = format!("{remote_path}.addr"); + let log_file = format!("{remote_path}.log"); + let mut env_assignments = vec![ + format!( + "MCP_STREAMABLE_HTTP_BIND_ADDR={}", + sh_single_quote("0.0.0.0:0") + ), + format!( + "MCP_STREAMABLE_HTTP_BOUND_ADDR_FILE={}", + sh_single_quote(&bound_addr_file) + ), + format!("MCP_TEST_VALUE={}", sh_single_quote(expected_env_value)), + ]; + if let Some(expected_token) = expected_token { + env_assignments.push(format!( + "MCP_EXPECT_BEARER={}", + sh_single_quote(expected_token) + )); + } + + let script = format!( + "{} nohup {} > {} 2>&1 < /dev/null & echo $!", + env_assignments.join(" "), + sh_single_quote(&remote_path), + sh_single_quote(&log_file) + ); + let start_output = StdCommand::new("docker") + .args(["exec", container_name, "sh", "-lc", &script]) + .output() + .context("start remote streamable HTTP MCP test server")?; + ensure!( + start_output.status.success(), + "docker start streamable HTTP MCP test server failed: stdout={} stderr={}", + String::from_utf8_lossy(&start_output.stdout).trim(), + String::from_utf8_lossy(&start_output.stderr).trim() + ); + let pid = String::from_utf8(start_output.stdout) + .context("remote streamable HTTP server pid must be utf-8")? + .trim() + .to_string(); + ensure!( + !pid.is_empty(), + "remote streamable HTTP server pid is empty" + ); + + let remote_bind_addr = + wait_for_remote_bound_addr(container_name, &bound_addr_file, Duration::from_secs(5)) + .await?; + let container_ip = remote_container_ip(container_name)?; + let server_url = format!("http://{}:{}/mcp", container_ip, remote_bind_addr.port()); + // The orchestrator can see the Docker container IP, but the behavior under + // test is whether the remote-side MCP client can reach it. Probe through + // remote HTTP before handing the URL to the Codex fixture. + wait_for_remote_streamable_http_server(&server_url, Duration::from_secs(5)).await?; + if expected_token.is_some() { + wait_for_streamable_http_metadata(&server_url, Duration::from_secs(5)).await?; + } + + Ok(StreamableHttpTestServer { + server_url, + process: StreamableHttpTestServerProcess::Remote(RemoteStreamableHttpServer { + container_name: container_name.to_string(), + pid, + paths_to_remove: vec![remote_path, bound_addr_file, log_file], + }), + }) +} + +/// Single-quotes a value for the small shell snippets sent through Docker. +fn sh_single_quote(value: &str) -> String { + format!("'{}'", value.replace('\'', "'\\''")) +} + +/// Waits until the remote test server writes the socket address it bound to. +async fn wait_for_remote_bound_addr( + container_name: &str, + bound_addr_file: &str, + timeout: Duration, +) -> anyhow::Result { + let deadline = Instant::now() + timeout; + loop { + let output = StdCommand::new("docker") + .args(["exec", container_name, "cat", bound_addr_file]) + .output() + .context("read remote streamable HTTP server bound address")?; + if output.status.success() { + let bound_addr = String::from_utf8(output.stdout) + .context("remote streamable HTTP bound address must be utf-8")?; + return bound_addr + .trim() + .parse() + .context("parse remote streamable HTTP bound address"); + } + if Instant::now() >= deadline { + return Err(anyhow::anyhow!( + "timed out waiting for remote streamable HTTP bound address: {}", + String::from_utf8_lossy(&output.stderr).trim() + )); + } + sleep(Duration::from_millis(50)).await; + } +} + +/// Reads the container IP that the host-side test process can use. +fn remote_container_ip(container_name: &str) -> anyhow::Result { + let output = StdCommand::new("docker") + .args([ + "inspect", + "-f", + "{{range .NetworkSettings.Networks}}{{println .IPAddress}}{{end}}", + container_name, + ]) + .output() + .context("inspect remote MCP test container IP")?; + ensure!( + output.status.success(), + "docker inspect remote MCP test container IP failed: stdout={} stderr={}", + String::from_utf8_lossy(&output.stdout).trim(), + String::from_utf8_lossy(&output.stderr).trim() + ); + let inspect_output = + String::from_utf8(output.stdout).context("remote MCP test container IP must be utf-8")?; + let ip = inspect_output + .lines() + .map(str::trim) + .find(|line| !line.is_empty()) + .unwrap_or_default() + .to_string(); + if ip.is_empty() { + Ok("127.0.0.1".to_string()) + } else { + Ok(ip) + } +} + +/// Waits for the local Streamable HTTP test server to publish OAuth metadata. +async fn wait_for_local_streamable_http_server( server_child: &mut Child, - address: &str, + server_url: &str, timeout: Duration, ) -> anyhow::Result<()> { let deadline = Instant::now() + timeout; - let metadata_url = format!("http://{address}/.well-known/oauth-authorization-server/mcp"); + let metadata_url = streamable_http_metadata_url(server_url); let client = Client::builder().no_proxy().build()?; loop { if let Some(status) = server_child.try_wait()? { @@ -2318,6 +2620,108 @@ async fn wait_for_streamable_http_server( } } +/// Waits for the remote Streamable HTTP test server via remote HTTP. +async fn wait_for_remote_streamable_http_server( + server_url: &str, + timeout: Duration, +) -> anyhow::Result<()> { + let websocket_url = std::env::var(REMOTE_EXEC_SERVER_URL_ENV_VAR).with_context(|| { + format!("{REMOTE_EXEC_SERVER_URL_ENV_VAR} must be set for remote streamable HTTP MCP tests") + })?; + let environment = Environment::create_for_tests(Some(websocket_url))?; + let http_client = environment.get_http_client(); + let metadata_url = streamable_http_metadata_url(server_url); + let deadline = Instant::now() + timeout; + loop { + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + return Err(anyhow::anyhow!( + "timed out waiting for remote streamable HTTP server metadata at {metadata_url}: deadline reached" + )); + } + + let request = HttpRequestParams { + method: "GET".to_string(), + url: metadata_url.clone(), + headers: Vec::new(), + body: None, + timeout_ms: Some(remaining.as_millis().clamp(1, 1_000) as u64), + request_id: "buffered-request".to_string(), + stream_response: false, + }; + match http_client.http_request(request).await { + Ok(response) if response.status == StatusCode::OK.as_u16() => return Ok(()), + Ok(response) => { + if Instant::now() >= deadline { + return Err(anyhow::anyhow!( + "timed out waiting for remote streamable HTTP server metadata at {metadata_url}: HTTP {}", + response.status + )); + } + } + Err(error) => { + if Instant::now() >= deadline { + return Err(anyhow::anyhow!( + "timed out waiting for remote streamable HTTP server metadata at {metadata_url}: {error}" + )); + } + } + } + + sleep(Duration::from_millis(50)).await; + } +} + +/// Waits for OAuth metadata from the host-side test process. +async fn wait_for_streamable_http_metadata( + server_url: &str, + timeout: Duration, +) -> anyhow::Result<()> { + let deadline = Instant::now() + timeout; + let metadata_url = streamable_http_metadata_url(server_url); + let client = Client::builder().no_proxy().build()?; + loop { + let remaining = deadline.saturating_duration_since(Instant::now()); + if remaining.is_zero() { + return Err(anyhow::anyhow!( + "timed out waiting for streamable HTTP server metadata at {metadata_url}: deadline reached" + )); + } + + match tokio::time::timeout(remaining, client.get(&metadata_url).send()).await { + Ok(Ok(response)) if response.status() == StatusCode::OK => return Ok(()), + Ok(Ok(response)) => { + if Instant::now() >= deadline { + return Err(anyhow::anyhow!( + "timed out waiting for streamable HTTP server metadata at {metadata_url}: HTTP {}", + response.status() + )); + } + } + Ok(Err(error)) => { + if Instant::now() >= deadline { + return Err(anyhow::anyhow!( + "timed out waiting for streamable HTTP server metadata at {metadata_url}: {error}" + )); + } + } + Err(_) => { + return Err(anyhow::anyhow!( + "timed out waiting for streamable HTTP server metadata at {metadata_url}: request timed out" + )); + } + } + + sleep(Duration::from_millis(50)).await; + } +} + +/// Builds the OAuth metadata URL for the test Streamable HTTP MCP endpoint. +fn streamable_http_metadata_url(server_url: &str) -> String { + let base_url = server_url.strip_suffix("/mcp").unwrap_or(server_url); + format!("{base_url}{STREAMABLE_HTTP_METADATA_PATH}") +} + fn write_fallback_oauth_tokens( home: &Path, server_name: &str, diff --git a/codex-rs/rmcp-client/src/bin/test_streamable_http_server.rs b/codex-rs/rmcp-client/src/bin/test_streamable_http_server.rs index b948cab461..d1c22f430c 100644 --- a/codex-rs/rmcp-client/src/bin/test_streamable_http_server.rs +++ b/codex-rs/rmcp-client/src/bin/test_streamable_http_server.rs @@ -1,5 +1,6 @@ use std::borrow::Cow; use std::collections::HashMap; +use std::fs; use std::io::ErrorKind; use std::net::SocketAddr; use std::sync::Arc; @@ -9,11 +10,13 @@ use axum::Router; use axum::body::Body; use axum::extract::Json; use axum::extract::State; +use axum::http::HeaderMap; use axum::http::Method; use axum::http::Request; use axum::http::StatusCode; use axum::http::header::AUTHORIZATION; use axum::http::header::CONTENT_TYPE; +use axum::http::header::HOST; use axum::middleware; use axum::middleware::Next; use axum::response::Response; @@ -60,90 +63,6 @@ const MEMO_CONTENT: &str = "This is a sample MCP resource served by the rmcp tes const MCP_SESSION_ID_HEADER: &str = "mcp-session-id"; const SESSION_POST_FAILURE_CONTROL_PATH: &str = "/test/control/session-post-failure"; -impl TestToolServer { - fn new() -> Self { - let tools = vec![Self::echo_tool()]; - let resources = vec![Self::memo_resource()]; - let resource_templates = vec![Self::memo_template()]; - Self { - tools: Arc::new(tools), - resources: Arc::new(resources), - resource_templates: Arc::new(resource_templates), - } - } - - fn echo_tool() -> Tool { - #[expect(clippy::expect_used)] - let schema: JsonObject = serde_json::from_value(json!({ - "type": "object", - "properties": { - "message": { "type": "string" }, - "env_var": { "type": "string" } - }, - "required": ["message"], - "additionalProperties": false - })) - .expect("echo tool schema should deserialize"); - - let mut tool = Tool::new( - Cow::Borrowed("echo"), - Cow::Borrowed("Echo back the provided message and include environment data."), - Arc::new(schema), - ); - #[expect(clippy::expect_used)] - let output_schema: JsonObject = serde_json::from_value(json!({ - "type": "object", - "properties": { - "echo": { "type": "string" }, - "env": { - "anyOf": [ - { "type": "string" }, - { "type": "null" } - ] - } - }, - "required": ["echo", "env"], - "additionalProperties": false - })) - .expect("echo tool output schema should deserialize"); - tool.output_schema = Some(Arc::new(output_schema)); - tool.annotations = Some(ToolAnnotations::new().read_only(true)); - tool - } - - fn memo_resource() -> Resource { - let raw = RawResource { - uri: MEMO_URI.to_string(), - name: "example-note".to_string(), - title: Some("Example Note".to_string()), - description: Some("A sample MCP resource exposed for integration tests.".to_string()), - mime_type: Some("text/plain".to_string()), - size: None, - icons: None, - meta: None, - }; - Resource::new(raw, None) - } - - fn memo_template() -> ResourceTemplate { - let raw = RawResourceTemplate { - uri_template: "memo://codex/{slug}".to_string(), - name: "codex-memo".to_string(), - title: Some("Codex Memo".to_string()), - description: Some( - "Template for memo://codex/{slug} resources used in tests.".to_string(), - ), - mime_type: Some("text/plain".to_string()), - icons: None, - }; - ResourceTemplate::new(raw, None) - } - - fn memo_text() -> &'static str { - MEMO_CONTENT - } -} - #[derive(Clone, Default)] struct SessionFailureState { armed_failure: Arc>>, @@ -168,6 +87,91 @@ struct EchoArgs { env_var: Option, } +#[tokio::main] +async fn main() -> Result<(), Box> { + let bind_addr = parse_bind_addr()?; + let session_failure_state = SessionFailureState::default(); + const MAX_BIND_RETRIES: u32 = 20; + const BIND_RETRY_DELAY: Duration = Duration::from_millis(50); + + let mut bind_retries = 0; + let listener = loop { + match tokio::net::TcpListener::bind(&bind_addr).await { + Ok(listener) => break listener, + Err(err) if err.kind() == ErrorKind::PermissionDenied => { + eprintln!( + "failed to bind to {bind_addr}: {err}. make sure the process has network access" + ); + return Ok(()); + } + Err(err) if err.kind() == ErrorKind::AddrInUse && bind_retries < MAX_BIND_RETRIES => { + bind_retries += 1; + sleep(BIND_RETRY_DELAY).await; + } + Err(err) => return Err(err.into()), + } + }; + let actual_bind_addr = listener.local_addr()?; + if let Ok(bound_addr_file) = std::env::var("MCP_STREAMABLE_HTTP_BOUND_ADDR_FILE") { + fs::write(bound_addr_file, actual_bind_addr.to_string())?; + } + eprintln!("starting rmcp streamable http test server on http://{actual_bind_addr}/mcp"); + + let router = Router::new() + .route( + SESSION_POST_FAILURE_CONTROL_PATH, + post(arm_session_post_failure), + ) + .route( + "/.well-known/oauth-authorization-server/mcp", + get({ + move |headers: HeaderMap| async move { + let metadata_base = headers + .get(HOST) + .and_then(|value| value.to_str().ok()) + .map(|host| format!("http://{host}")) + .unwrap_or_else(|| format!("http://{actual_bind_addr}")); + #[expect(clippy::expect_used)] + Response::builder() + .status(StatusCode::OK) + .header(CONTENT_TYPE, "application/json") + .body(Body::from( + serde_json::to_vec(&json!({ + "authorization_endpoint": format!("{metadata_base}/oauth/authorize"), + "token_endpoint": format!("{metadata_base}/oauth/token"), + "scopes_supported": [""], + })).expect("failed to serialize metadata"), + )) + .expect("valid metadata response") + } + }), + ) + .nest_service( + "/mcp", + StreamableHttpService::new( + || Ok(TestToolServer::new()), + Arc::new(LocalSessionManager::default()), + StreamableHttpServerConfig::default(), + ), + ) + .layer(middleware::from_fn_with_state( + session_failure_state.clone(), + fail_session_post_when_armed, + )) + .with_state(session_failure_state); + + let router = if let Ok(token) = std::env::var("MCP_EXPECT_BEARER") { + let expected = Arc::new(format!("Bearer {token}")); + router.layer(middleware::from_fn_with_state(expected, require_bearer)) + } else { + router + }; + + axum::serve(listener, router).await?; + task::yield_now().await; + Ok(()) +} + impl ServerHandler for TestToolServer { fn get_info(&self) -> ServerInfo { ServerInfo { @@ -285,6 +289,90 @@ impl ServerHandler for TestToolServer { } } +impl TestToolServer { + fn new() -> Self { + let tools = vec![Self::echo_tool()]; + let resources = vec![Self::memo_resource()]; + let resource_templates = vec![Self::memo_template()]; + Self { + tools: Arc::new(tools), + resources: Arc::new(resources), + resource_templates: Arc::new(resource_templates), + } + } + + fn echo_tool() -> Tool { + #[expect(clippy::expect_used)] + let schema: JsonObject = serde_json::from_value(json!({ + "type": "object", + "properties": { + "message": { "type": "string" }, + "env_var": { "type": "string" } + }, + "required": ["message"], + "additionalProperties": false + })) + .expect("echo tool schema should deserialize"); + + let mut tool = Tool::new( + Cow::Borrowed("echo"), + Cow::Borrowed("Echo back the provided message and include environment data."), + Arc::new(schema), + ); + #[expect(clippy::expect_used)] + let output_schema: JsonObject = serde_json::from_value(json!({ + "type": "object", + "properties": { + "echo": { "type": "string" }, + "env": { + "anyOf": [ + { "type": "string" }, + { "type": "null" } + ] + } + }, + "required": ["echo", "env"], + "additionalProperties": false + })) + .expect("echo tool output schema should deserialize"); + tool.output_schema = Some(Arc::new(output_schema)); + tool.annotations = Some(ToolAnnotations::new().read_only(true)); + tool + } + + fn memo_resource() -> Resource { + let raw = RawResource { + uri: MEMO_URI.to_string(), + name: "example-note".to_string(), + title: Some("Example Note".to_string()), + description: Some("A sample MCP resource exposed for integration tests.".to_string()), + mime_type: Some("text/plain".to_string()), + size: None, + icons: None, + meta: None, + }; + Resource::new(raw, None) + } + + fn memo_template() -> ResourceTemplate { + let raw = RawResourceTemplate { + uri_template: "memo://codex/{slug}".to_string(), + name: "codex-memo".to_string(), + title: Some("Codex Memo".to_string()), + description: Some( + "Template for memo://codex/{slug} resources used in tests.".to_string(), + ), + mime_type: Some("text/plain".to_string()), + icons: None, + }; + ResourceTemplate::new(raw, None) + } + + fn memo_text() -> &'static str { + MEMO_CONTENT + } +} + fn parse_bind_addr() -> Result> { let default_addr = "127.0.0.1:3920"; let bind_addr = std::env::var("MCP_STREAMABLE_HTTP_BIND_ADDR") @@ -293,83 +381,6 @@ fn parse_bind_addr() -> Result> { Ok(bind_addr.parse()?) } -#[tokio::main] -async fn main() -> Result<(), Box> { - let bind_addr = parse_bind_addr()?; - let session_failure_state = SessionFailureState::default(); - const MAX_BIND_RETRIES: u32 = 20; - const BIND_RETRY_DELAY: Duration = Duration::from_millis(50); - - let mut bind_retries = 0; - let listener = loop { - match tokio::net::TcpListener::bind(&bind_addr).await { - Ok(listener) => break listener, - Err(err) if err.kind() == ErrorKind::PermissionDenied => { - eprintln!( - "failed to bind to {bind_addr}: {err}. make sure the process has network access" - ); - return Ok(()); - } - Err(err) if err.kind() == ErrorKind::AddrInUse && bind_retries < MAX_BIND_RETRIES => { - bind_retries += 1; - sleep(BIND_RETRY_DELAY).await; - } - Err(err) => return Err(err.into()), - } - }; - eprintln!("starting rmcp streamable http test server on http://{bind_addr}/mcp"); - - let router = Router::new() - .route( - SESSION_POST_FAILURE_CONTROL_PATH, - post(arm_session_post_failure), - ) - .route( - "/.well-known/oauth-authorization-server/mcp", - get({ - move || async move { - let metadata_base = format!("http://{bind_addr}"); - #[expect(clippy::expect_used)] - Response::builder() - .status(StatusCode::OK) - .header(CONTENT_TYPE, "application/json") - .body(Body::from( - serde_json::to_vec(&json!({ - "authorization_endpoint": format!("{metadata_base}/oauth/authorize"), - "token_endpoint": format!("{metadata_base}/oauth/token"), - "scopes_supported": [""], - })).expect("failed to serialize metadata"), - )) - .expect("valid metadata response") - } - }), - ) - .nest_service( - "/mcp", - StreamableHttpService::new( - || Ok(TestToolServer::new()), - Arc::new(LocalSessionManager::default()), - StreamableHttpServerConfig::default(), - ), - ) - .layer(middleware::from_fn_with_state( - session_failure_state.clone(), - fail_session_post_when_armed, - )) - .with_state(session_failure_state); - - let router = if let Ok(token) = std::env::var("MCP_EXPECT_BEARER") { - let expected = Arc::new(format!("Bearer {token}")); - router.layer(middleware::from_fn_with_state(expected, require_bearer)) - } else { - router - }; - - axum::serve(listener, router).await?; - task::yield_now().await; - Ok(()) -} - async fn require_bearer( State(expected): State>, request: Request, diff --git a/codex-rs/skills/src/assets/samples/openai-docs/references/latest-model.md b/codex-rs/skills/src/assets/samples/openai-docs/references/latest-model.md index 04aa84bad9..a1ffbfbdc9 100644 --- a/codex-rs/skills/src/assets/samples/openai-docs/references/latest-model.md +++ b/codex-rs/skills/src/assets/samples/openai-docs/references/latest-model.md @@ -16,7 +16,8 @@ This file is a curated helper. Every recommendation here must be verified agains | `gpt-4.1-nano` | Fastest and cheapest no-reasoning text | | `gpt-5.3-codex` | Agentic coding, code editing, and tool-heavy coding workflows | | `gpt-5.1-codex-mini` | Cheaper coding workflows | -| `gpt-image-1.5` | Best image generation and edit quality | +| `gpt-image-2` | Best image generation and edit quality | +| `gpt-image-1.5` | Less expensive image generation and edit quality | | `gpt-image-1-mini` | Cost-optimized image generation | | `gpt-4o-mini-tts` | Text-to-speech | | `gpt-4o-mini-transcribe` | Speech-to-text, fast and cost-efficient | diff --git a/sdk/python-runtime/README.md b/sdk/python-runtime/README.md index 22c59ef156..0a23307a85 100644 --- a/sdk/python-runtime/README.md +++ b/sdk/python-runtime/README.md @@ -1,9 +1,9 @@ -# Codex CLI Runtime for Python SDK +# Codex App Server Runtime for Python SDK Platform-specific runtime package consumed by the published `codex-app-server-sdk`. -This package is staged during release so the SDK can pin an exact Codex CLI +This package is staged during release so the SDK can pin an exact Codex app-server version without checking platform binaries into the repo. -`openai-codex-cli-bin` is intentionally wheel-only. Do not build or publish an +`openai-codex-app-server-bin` is intentionally wheel-only. Do not build or publish an sdist for this package. diff --git a/sdk/python-runtime/hatch_build.py b/sdk/python-runtime/hatch_build.py index d49e3e106a..2722123151 100644 --- a/sdk/python-runtime/hatch_build.py +++ b/sdk/python-runtime/hatch_build.py @@ -16,12 +16,14 @@ class RuntimeBuildHook(BuildHookInterface): del version if self.target_name == "sdist": raise RuntimeError( - "openai-codex-cli-bin is wheel-only; build and publish platform wheels only." + "openai-codex-app-server-bin is wheel-only; build and publish platform wheels only." ) platform_tag = self.config.get("platform-tag") or os.environ.get( - "CODEX_CLI_BIN_PLATFORM_TAG" + "CODEX_APP_SERVER_BIN_PLATFORM_TAG" ) + if not isinstance(platform_tag, str) or not platform_tag: + platform_tag = os.environ.get("CODEX_CLI_BIN_PLATFORM_TAG") if not isinstance(platform_tag, str) or not platform_tag: platform_tag = _platform_tag() diff --git a/sdk/python-runtime/pyproject.toml b/sdk/python-runtime/pyproject.toml index 789453d059..2f954c39bf 100644 --- a/sdk/python-runtime/pyproject.toml +++ b/sdk/python-runtime/pyproject.toml @@ -3,9 +3,9 @@ requires = ["hatchling>=1.24.0"] build-backend = "hatchling.build" [project] -name = "openai-codex-cli-bin" +name = "openai-codex-app-server-bin" version = "0.0.0-dev" -description = "Pinned Codex CLI runtime for the Python SDK" +description = "Pinned Codex app-server runtime for the Python SDK" readme = "README.md" requires-python = ">=3.10" license = { text = "Apache-2.0" } @@ -35,8 +35,8 @@ exclude = [ ] [tool.hatch.build.targets.wheel] -packages = ["src/codex_cli_bin"] -include = ["src/codex_cli_bin/bin/**"] +packages = ["src/codex_app_server_bin", "src/codex_cli_bin"] +include = ["src/codex_app_server_bin/bin/**"] [tool.hatch.build.targets.wheel.hooks.custom] diff --git a/sdk/python-runtime/src/codex_app_server_bin/__init__.py b/sdk/python-runtime/src/codex_app_server_bin/__init__.py new file mode 100644 index 0000000000..daf945970e --- /dev/null +++ b/sdk/python-runtime/src/codex_app_server_bin/__init__.py @@ -0,0 +1,31 @@ +from __future__ import annotations + +import os +from pathlib import Path + +PACKAGE_NAME = "openai-codex-app-server-bin" + + +def bundled_app_server_path() -> Path: + package_root = Path(__file__).resolve().parent + for exe in _candidate_binary_names(): + path = package_root / "bin" / exe + if path.is_file(): + return path + + candidate_list = ", ".join( + str(package_root / "bin" / exe) for exe in _candidate_binary_names() + ) + raise FileNotFoundError( + f"{PACKAGE_NAME} is installed but missing its packaged app-server binary. " + f"Checked: {candidate_list}" + ) + + +def _candidate_binary_names() -> tuple[str, str]: + if os.name == "nt": + return ("codex-app-server.exe", "codex.exe") + return ("codex-app-server", "codex") + + +__all__ = ["PACKAGE_NAME", "bundled_app_server_path"] diff --git a/sdk/python-runtime/src/codex_cli_bin/__init__.py b/sdk/python-runtime/src/codex_cli_bin/__init__.py index dbd9a6f660..fbbf5a3e40 100644 --- a/sdk/python-runtime/src/codex_cli_bin/__init__.py +++ b/sdk/python-runtime/src/codex_cli_bin/__init__.py @@ -1,19 +1,13 @@ from __future__ import annotations -import os from pathlib import Path -PACKAGE_NAME = "openai-codex-cli-bin" +from codex_app_server_bin import PACKAGE_NAME +from codex_app_server_bin import bundled_app_server_path def bundled_codex_path() -> Path: - exe = "codex.exe" if os.name == "nt" else "codex" - path = Path(__file__).resolve().parent / "bin" / exe - if not path.is_file(): - raise FileNotFoundError( - f"{PACKAGE_NAME} is installed but missing its packaged codex binary at {path}" - ) - return path + return bundled_app_server_path() -__all__ = ["PACKAGE_NAME", "bundled_codex_path"] +__all__ = ["PACKAGE_NAME", "bundled_app_server_path", "bundled_codex_path"] diff --git a/sdk/python-runtime/uv.lock b/sdk/python-runtime/uv.lock index 2487eacdef..9536297e9b 100644 --- a/sdk/python-runtime/uv.lock +++ b/sdk/python-runtime/uv.lock @@ -7,6 +7,6 @@ exclude-newer = "2026-04-16T16:29:01.518541933Z" exclude-newer-span = "P7D" [[package]] -name = "openai-codex-cli-bin" +name = "openai-codex-app-server-bin" version = "0.0.0.dev0" source = { editable = "." } diff --git a/sdk/python/README.md b/sdk/python/README.md index 7d69e23357..e812e80589 100644 --- a/sdk/python/README.md +++ b/sdk/python/README.md @@ -12,8 +12,8 @@ uv sync source .venv/bin/activate ``` -Published SDK builds pin an exact `openai-codex-cli-bin` runtime dependency. For local -repo development, either pass `AppServerConfig(codex_bin=...)` to point at a +Published SDK builds pin an exact `openai-codex-app-server-bin` runtime dependency. For local +repo development, either pass `AppServerConfig(app_server_bin=...)` to point at a local build explicitly, or use the repo examples/notebook bootstrap which installs the pinned runtime package automatically. @@ -54,13 +54,13 @@ python examples/01_quickstart_constructor/async.py The repo no longer checks `codex` binaries into `sdk/python`. -Published SDK builds are pinned to an exact `openai-codex-cli-bin` package version, +Published SDK builds are pinned to an exact `openai-codex-app-server-bin` package version, and that runtime package carries the platform-specific binary for the target wheel. For local repo development, the checked-in `sdk/python-runtime` package is only a template for staged release artifacts. Editable installs should use an -explicit `codex_bin` override for manual SDK usage; the repo examples and +explicit `app_server_bin` override for manual SDK usage; the repo examples and notebook bootstrap the pinned runtime package automatically. ## Maintainer workflow @@ -74,26 +74,26 @@ python scripts/update_sdk_artifacts.py \ --runtime-version 1.2.3 python scripts/update_sdk_artifacts.py \ stage-runtime \ - /tmp/codex-python-release/openai-codex-cli-bin \ - /path/to/codex \ + /tmp/codex-python-release/openai-codex-app-server-bin \ + /path/to/codex-app-server \ --runtime-version 1.2.3 ``` This supports the CI release flow: - run `generate-types` before packaging -- stage `codex-app-server-sdk` once with an exact `openai-codex-cli-bin==...` dependency -- stage `openai-codex-cli-bin` on each supported platform runner with the same pinned runtime version -- build and publish `openai-codex-cli-bin` as platform wheels only; do not publish an sdist +- stage `codex-app-server-sdk` once with an exact `openai-codex-app-server-bin==...` dependency +- stage `openai-codex-app-server-bin` on each supported platform runner with the same pinned runtime version +- build and publish `openai-codex-app-server-bin` as platform wheels only; do not publish an sdist ## Compatibility and versioning - Package: `codex-app-server-sdk` -- Runtime package: `openai-codex-cli-bin` +- Runtime package: `openai-codex-app-server-bin` - Current SDK version in this repo: `0.2.0` - Python: `>=3.10` - Target protocol: Codex `app-server` JSON-RPC v2 -- Recommendation: keep SDK and `codex` CLI reasonably up to date together +- Recommendation: keep SDK and `codex-app-server` runtime reasonably up to date together ## Notes diff --git a/sdk/python/_runtime_setup.py b/sdk/python/_runtime_setup.py index 6c4cf457ad..d9e70cbe53 100644 --- a/sdk/python/_runtime_setup.py +++ b/sdk/python/_runtime_setup.py @@ -15,7 +15,7 @@ import urllib.request import zipfile from pathlib import Path -PACKAGE_NAME = "openai-codex-cli-bin" +PACKAGE_NAME = "openai-codex-app-server-bin" PINNED_RUNTIME_VERSION = "0.116.0-alpha.1" REPO_SLUG = "openai/codex" @@ -39,7 +39,10 @@ def ensure_runtime_package_installed( installed_version = _installed_runtime_version(python_executable) normalized_requested = _normalized_package_version(requested_version) - if installed_version is not None and _normalized_package_version(installed_version) == normalized_requested: + if ( + installed_version is not None + and _normalized_package_version(installed_version) == normalized_requested + ): return requested_version with tempfile.TemporaryDirectory(prefix="codex-python-runtime-") as temp_root_str: @@ -61,7 +64,10 @@ def ensure_runtime_package_installed( importlib.invalidate_caches() installed_version = _installed_runtime_version(python_executable) - if installed_version is None or _normalized_package_version(installed_version) != normalized_requested: + if ( + installed_version is None + or _normalized_package_version(installed_version) != normalized_requested + ): raise RuntimeSetupError( f"Expected {PACKAGE_NAME} {requested_version} in {python_executable}, " f"but found {installed_version!r} after installation." @@ -70,24 +76,46 @@ def ensure_runtime_package_installed( def platform_asset_name() -> str: + return platform_asset_names()[0] + + +def platform_asset_names() -> tuple[str, str]: system = platform.system().lower() machine = platform.machine().lower() if system == "darwin": if machine in {"arm64", "aarch64"}: - return "codex-aarch64-apple-darwin.tar.gz" + return ( + "codex-app-server-aarch64-apple-darwin.tar.gz", + "codex-aarch64-apple-darwin.tar.gz", + ) if machine in {"x86_64", "amd64"}: - return "codex-x86_64-apple-darwin.tar.gz" + return ( + "codex-app-server-x86_64-apple-darwin.tar.gz", + "codex-x86_64-apple-darwin.tar.gz", + ) elif system == "linux": if machine in {"aarch64", "arm64"}: - return "codex-aarch64-unknown-linux-musl.tar.gz" + return ( + "codex-app-server-aarch64-unknown-linux-musl.tar.gz", + "codex-aarch64-unknown-linux-musl.tar.gz", + ) if machine in {"x86_64", "amd64"}: - return "codex-x86_64-unknown-linux-musl.tar.gz" + return ( + "codex-app-server-x86_64-unknown-linux-musl.tar.gz", + "codex-x86_64-unknown-linux-musl.tar.gz", + ) elif system == "windows": if machine in {"aarch64", "arm64"}: - return "codex-aarch64-pc-windows-msvc.exe.zip" + return ( + "codex-app-server-aarch64-pc-windows-msvc.exe.zip", + "codex-aarch64-pc-windows-msvc.exe.zip", + ) if machine in {"x86_64", "amd64"}: - return "codex-x86_64-pc-windows-msvc.exe.zip" + return ( + "codex-app-server-x86_64-pc-windows-msvc.exe.zip", + "codex-x86_64-pc-windows-msvc.exe.zip", + ) raise RuntimeSetupError( f"Unsupported runtime artifact platform: system={platform.system()!r}, " @@ -96,6 +124,18 @@ def platform_asset_name() -> str: def runtime_binary_name() -> str: + return ( + "codex-app-server.exe" + if platform.system().lower() == "windows" + else "codex-app-server" + ) + + +def runtime_binary_names() -> tuple[str, str]: + return (runtime_binary_name(), legacy_runtime_binary_name()) + + +def legacy_runtime_binary_name() -> str: return "codex.exe" if platform.system().lower() == "windows" else "codex" @@ -103,8 +143,8 @@ def _installed_runtime_version(python_executable: str | Path) -> str | None: snippet = ( "import importlib.metadata, json, sys\n" "try:\n" - " from codex_cli_bin import bundled_codex_path\n" - " bundled_codex_path()\n" + " from codex_app_server_bin import bundled_app_server_path\n" + " bundled_app_server_path()\n" f" print(json.dumps({{'version': importlib.metadata.version({PACKAGE_NAME!r})}}))\n" "except Exception:\n" " sys.exit(1)\n" @@ -152,88 +192,115 @@ def _release_metadata(version: str) -> dict[str, object]: def _download_release_archive(version: str, temp_root: Path) -> Path: - asset_name = platform_asset_name() - archive_path = temp_root / asset_name - - browser_download_url = ( - f"https://github.com/{REPO_SLUG}/releases/download/rust-v{version}/{asset_name}" - ) - request = urllib.request.Request( - browser_download_url, - headers={"User-Agent": "codex-python-runtime-setup"}, - ) - try: - with urllib.request.urlopen(request) as response, archive_path.open("wb") as fh: - shutil.copyfileobj(response, fh) - return archive_path - except urllib.error.HTTPError: - pass + asset_names = platform_asset_names() + for asset_name in asset_names: + archive_path = temp_root / asset_name + browser_download_url = f"https://github.com/{REPO_SLUG}/releases/download/rust-v{version}/{asset_name}" + request = urllib.request.Request( + browser_download_url, + headers={"User-Agent": "codex-python-runtime-setup"}, + ) + try: + with ( + urllib.request.urlopen(request) as response, + archive_path.open("wb") as fh, + ): + shutil.copyfileobj(response, fh) + return archive_path + except urllib.error.HTTPError: + continue metadata = _release_metadata(version) assets = metadata.get("assets") if not isinstance(assets, list): - raise RuntimeSetupError(f"Release rust-v{version} returned malformed assets metadata.") - asset = next( - ( - item - for item in assets - if isinstance(item, dict) and item.get("name") == asset_name - ), - None, - ) - if asset is None: raise RuntimeSetupError( - f"Release rust-v{version} does not contain asset {asset_name} for this platform." + f"Release rust-v{version} returned malformed assets metadata." ) - api_url = asset.get("url") - if not isinstance(api_url, str): - api_url = None + matched_assets = [ + item + for item in assets + if isinstance(item, dict) and item.get("name") in asset_names + ] + if not matched_assets: + supported_assets = ", ".join(asset_names) + raise RuntimeSetupError( + f"Release rust-v{version} does not contain a supported runtime asset for this platform. " + f"Tried: {supported_assets}." + ) - if api_url is not None: - token = _github_token() - if token is not None: - request = urllib.request.Request( - api_url, - headers=_github_api_headers("application/octet-stream"), - ) - try: - with urllib.request.urlopen(request) as response, archive_path.open("wb") as fh: - shutil.copyfileobj(response, fh) - return archive_path - except urllib.error.HTTPError: - pass + for asset_name in asset_names: + asset = next( + ( + item + for item in matched_assets + if isinstance(item, dict) and item.get("name") == asset_name + ), + None, + ) + if asset is None: + continue + + archive_path = temp_root / asset_name + api_url = asset.get("url") + if not isinstance(api_url, str): + api_url = None + + if api_url is not None: + token = _github_token() + if token is not None: + request = urllib.request.Request( + api_url, + headers=_github_api_headers("application/octet-stream"), + ) + try: + with ( + urllib.request.urlopen(request) as response, + archive_path.open("wb") as fh, + ): + shutil.copyfileobj(response, fh) + return archive_path + except urllib.error.HTTPError: + pass if shutil.which("gh") is None: + supported_assets = ", ".join(asset_names) raise RuntimeSetupError( - f"Unable to download {asset_name} for rust-v{version}. " + f"Unable to download a supported runtime asset ({supported_assets}) for rust-v{version}. " "Provide GH_TOKEN/GITHUB_TOKEN or install/authenticate GitHub CLI." ) - try: - subprocess.run( - [ - "gh", - "release", - "download", - f"rust-v{version}", - "--repo", - REPO_SLUG, - "--pattern", - asset_name, - "--dir", - str(temp_root), - ], - check=True, - text=True, - capture_output=True, - ) - except subprocess.CalledProcessError as exc: - raise RuntimeSetupError( - f"gh release download failed for rust-v{version} asset {asset_name}.\n" - f"STDOUT:\n{exc.stdout}\nSTDERR:\n{exc.stderr}" - ) from exc - return archive_path + last_error: subprocess.CalledProcessError | None = None + for asset_name in asset_names: + archive_path = temp_root / asset_name + try: + subprocess.run( + [ + "gh", + "release", + "download", + f"rust-v{version}", + "--repo", + REPO_SLUG, + "--pattern", + asset_name, + "--dir", + str(temp_root), + ], + check=True, + text=True, + capture_output=True, + ) + return archive_path + except subprocess.CalledProcessError as exc: + last_error = exc + + assert last_error is not None + supported_assets = ", ".join(asset_names) + raise RuntimeSetupError( + f"gh release download failed for rust-v{version} runtime assets ({supported_assets}).\n" + f"STDOUT:\n{last_error.stdout}\nSTDERR:\n{last_error.stderr}" + ) from last_error def _extract_runtime_binary(archive_path: Path, temp_root: Path) -> Path: @@ -249,24 +316,33 @@ def _extract_runtime_binary(archive_path: Path, temp_root: Path) -> Path: with zipfile.ZipFile(archive_path) as zip_file: zip_file.extractall(extract_dir) else: - raise RuntimeSetupError(f"Unsupported release archive format: {archive_path.name}") + raise RuntimeSetupError( + f"Unsupported release archive format: {archive_path.name}" + ) - binary_name = runtime_binary_name() archive_stem = archive_path.name.removesuffix(".tar.gz").removesuffix(".zip") candidates = [ path for path in extract_dir.rglob("*") if path.is_file() and ( - path.name == binary_name + path.name in runtime_binary_names() or path.name == archive_stem or path.name.startswith("codex-") ) ] if not candidates: + supported_binaries = ", ".join(runtime_binary_names()) raise RuntimeSetupError( - f"Failed to find {binary_name} in extracted runtime archive {archive_path.name}." + f"Failed to find one of {supported_binaries} in extracted runtime archive " + f"{archive_path.name}." ) + + for binary_name in runtime_binary_names(): + for candidate in candidates: + if candidate.name == binary_name: + return candidate + return candidates[0] @@ -356,4 +432,5 @@ __all__ = [ "ensure_runtime_package_installed", "pinned_runtime_version", "platform_asset_name", + "platform_asset_names", ] diff --git a/sdk/python/docs/faq.md b/sdk/python/docs/faq.md index af688a3a19..a948cba863 100644 --- a/sdk/python/docs/faq.md +++ b/sdk/python/docs/faq.md @@ -54,13 +54,13 @@ This avoids duplicate ways to do the same operation and keeps behavior explicit. Common causes: -- published runtime package (`openai-codex-cli-bin`) is not installed -- local `codex_bin` override points to a missing file +- published runtime package (`openai-codex-app-server-bin`) is not installed +- local `app_server_bin` override points to a missing file - local auth/session is missing - incompatible/old app-server Maintainers stage releases by building the SDK once and the runtime once per -platform with the same pinned runtime version. Publish `openai-codex-cli-bin` as +platform with the same pinned runtime version. Publish `openai-codex-app-server-bin` as platform wheels only; do not publish an sdist: ```bash @@ -72,8 +72,8 @@ python scripts/update_sdk_artifacts.py \ --runtime-version 1.2.3 python scripts/update_sdk_artifacts.py \ stage-runtime \ - /tmp/codex-python-release/openai-codex-cli-bin \ - /path/to/codex \ + /tmp/codex-python-release/openai-codex-app-server-bin \ + /path/to/codex-app-server \ --runtime-version 1.2.3 ``` diff --git a/sdk/python/docs/getting-started.md b/sdk/python/docs/getting-started.md index 45ad1eb51f..7c90a4d279 100644 --- a/sdk/python/docs/getting-started.md +++ b/sdk/python/docs/getting-started.md @@ -18,7 +18,7 @@ Requirements: - Python `>=3.10` - uv -- installed `openai-codex-cli-bin` runtime package, or an explicit `codex_bin` override +- installed `openai-codex-app-server-bin` runtime package, or an explicit `app_server_bin` override - local Codex auth/session configured ## 2) Run your first turn (sync) @@ -40,7 +40,7 @@ with Codex() as codex: What happened: -- `Codex()` started and initialized `codex app-server`. +- `Codex()` started and initialized `codex-app-server`. - `thread_start(...)` created a thread. - `thread.run("...")` started a turn, consumed events until completion, and returned the final assistant response plus collected items and usage. - `result.final_response` is `None` when no final-answer or phase-less assistant message item completes for the turn. diff --git a/sdk/python/examples/README.md b/sdk/python/examples/README.md index 99ea0a31f5..f940087bcc 100644 --- a/sdk/python/examples/README.md +++ b/sdk/python/examples/README.md @@ -21,12 +21,12 @@ source .venv/bin/activate When running examples from this repo checkout, the SDK source uses the local tree and does not bundle a runtime binary. The helper in `examples/_bootstrap.py` -uses the installed `openai-codex-cli-bin` runtime package. +uses the installed `openai-codex-app-server-bin` runtime package. -If the pinned `openai-codex-cli-bin` runtime is not already installed, the bootstrap -will download the matching GitHub release artifact, stage a temporary local -`openai-codex-cli-bin` package, install it into your active interpreter, and clean up -the temporary files afterward. +If the pinned `openai-codex-app-server-bin` runtime is not already installed, the +bootstrap will download the matching GitHub release artifact, stage a temporary +local `openai-codex-app-server-bin` package, install it into your active +interpreter, and clean up the temporary files afterward. Current pinned runtime version: `0.116.0-alpha.1` @@ -41,8 +41,8 @@ python examples//async.py The examples bootstrap local imports from `sdk/python/src` automatically, so no SDK wheel install is required. You only need the Python dependencies for your -active interpreter and an installed `openai-codex-cli-bin` runtime package (either -already present or automatically provisioned by the bootstrap). +active interpreter and an installed `openai-codex-app-server-bin` runtime +package (either already present or automatically provisioned by the bootstrap). ## Recommended first run diff --git a/sdk/python/scripts/update_sdk_artifacts.py b/sdk/python/scripts/update_sdk_artifacts.py index 42c1ec091f..db7ea0ffe5 100755 --- a/sdk/python/scripts/update_sdk_artifacts.py +++ b/sdk/python/scripts/update_sdk_artifacts.py @@ -17,7 +17,8 @@ from dataclasses import dataclass from pathlib import Path from typing import Any, Callable, Sequence, get_args, get_origin -RUNTIME_DISTRIBUTION_NAME = "openai-codex-cli-bin" +RUNTIME_DISTRIBUTION_NAME = "openai-codex-app-server-bin" +RUNTIME_PACKAGE_DIR = "codex_app_server_bin" def repo_root() -> Path: @@ -52,11 +53,21 @@ def _is_windows() -> bool: def runtime_binary_name() -> str: + return "codex-app-server.exe" if _is_windows() else "codex-app-server" + + +def legacy_runtime_binary_name() -> str: return "codex.exe" if _is_windows() else "codex" -def staged_runtime_bin_path(root: Path) -> Path: - return root / "src" / "codex_cli_bin" / "bin" / runtime_binary_name() +def staged_runtime_bin_path(root: Path, binary_name: str | None = None) -> Path: + return ( + root + / "src" + / RUNTIME_PACKAGE_DIR + / "bin" + / (binary_name or runtime_binary_name()) + ) def run(cmd: list[str], cwd: Path) -> None: @@ -178,7 +189,11 @@ def _rewrite_sdk_runtime_dependency(pyproject_text: str, runtime_version: str) - ) raw_items = [item.strip() for item in match.group(1).split(",") if item.strip()] - raw_items = [item for item in raw_items if "codex-cli-bin" not in item] + raw_items = [ + item + for item in raw_items + if "codex-cli-bin" not in item and "codex-app-server-bin" not in item + ] raw_items.append(f'"{RUNTIME_DISTRIBUTION_NAME}=={runtime_version}"') replacement = "dependencies = [\n " + ",\n ".join(raw_items) + ",\n]" return pyproject_text[: match.start()] + replacement + pyproject_text[match.end() :] @@ -217,7 +232,7 @@ def stage_python_runtime_package( pyproject_text = _rewrite_runtime_platform_tag(pyproject_text, platform_tag) pyproject_path.write_text(pyproject_text) - out_bin = staged_runtime_bin_path(staging_dir) + out_bin = staged_runtime_bin_path(staging_dir, binary_path.name) out_bin.parent.mkdir(parents=True, exist_ok=True) shutil.copy2(binary_path, out_bin) if not _is_windows(): @@ -995,7 +1010,7 @@ def build_parser() -> argparse.ArgumentParser: stage_sdk_parser.add_argument( "--runtime-version", required=True, - help="Pinned openai-codex-cli-bin version for the staged SDK package", + help="Pinned openai-codex-app-server-bin version for the staged SDK package", ) stage_sdk_parser.add_argument( "--sdk-version", @@ -1014,7 +1029,7 @@ def build_parser() -> argparse.ArgumentParser: stage_runtime_parser.add_argument( "runtime_binary", type=Path, - help="Path to the codex binary to package for this platform", + help="Path to the app-server binary to package for this platform", ) stage_runtime_parser.add_argument( "--codex-version", diff --git a/sdk/python/src/codex_app_server/client.py b/sdk/python/src/codex_app_server/client.py index db7cf77cd5..42b434d41c 100644 --- a/sdk/python/src/codex_app_server/client.py +++ b/sdk/python/src/codex_app_server/client.py @@ -47,7 +47,7 @@ from .retry import retry_on_overload ModelT = TypeVar("ModelT", bound=BaseModel) ApprovalHandler = Callable[[str, JsonObject | None], JsonObject] -RUNTIME_PKG_NAME = "openai-codex-cli-bin" +RUNTIME_PKG_NAME = "openai-codex-app-server-bin" def _params_dict( @@ -74,54 +74,75 @@ def _params_dict( return dumped if isinstance(params, dict): return params - raise TypeError(f"Expected generated params model or dict, got {type(params).__name__}") + raise TypeError( + f"Expected generated params model or dict, got {type(params).__name__}" + ) -def _installed_codex_path() -> Path: +def _installed_app_server_path() -> Path: try: - from codex_cli_bin import bundled_codex_path + from codex_app_server_bin import bundled_app_server_path except ImportError as exc: raise FileNotFoundError( "Unable to locate the pinned Codex runtime. Install the published SDK build " - f"with its {RUNTIME_PKG_NAME} dependency, or set AppServerConfig.codex_bin " + f"with its {RUNTIME_PKG_NAME} dependency, or set AppServerConfig.app_server_bin " "explicitly." ) from exc - return bundled_codex_path() + return bundled_app_server_path() @dataclass(frozen=True) -class CodexBinResolverOps: - installed_codex_path: Callable[[], Path] +class AppServerBinResolverOps: + installed_app_server_path: Callable[[], Path] path_exists: Callable[[Path], bool] -def _default_codex_bin_resolver_ops() -> CodexBinResolverOps: - return CodexBinResolverOps( - installed_codex_path=_installed_codex_path, +CodexBinResolverOps = AppServerBinResolverOps + + +def _default_app_server_bin_resolver_ops() -> AppServerBinResolverOps: + return AppServerBinResolverOps( + installed_app_server_path=_installed_app_server_path, path_exists=lambda path: path.exists(), ) -def resolve_codex_bin(config: "AppServerConfig", ops: CodexBinResolverOps) -> Path: - if config.codex_bin is not None: - codex_bin = Path(config.codex_bin) - if not ops.path_exists(codex_bin): +def resolve_app_server_bin( + config: "AppServerConfig", ops: AppServerBinResolverOps +) -> Path: + configured_bin = _configured_app_server_bin(config) + if configured_bin is not None: + app_server_bin = Path(configured_bin) + if not ops.path_exists(app_server_bin): raise FileNotFoundError( - f"Codex binary not found at {codex_bin}. Set AppServerConfig.codex_bin " - "to a valid binary path." + f"Codex app-server binary not found at {app_server_bin}. Set " + "AppServerConfig.app_server_bin to a valid binary path." ) - return codex_bin + return app_server_bin - return ops.installed_codex_path() + return ops.installed_app_server_path() -def _resolve_codex_bin(config: "AppServerConfig") -> Path: - return resolve_codex_bin(config, _default_codex_bin_resolver_ops()) +def _configured_app_server_bin(config: "AppServerConfig") -> str | None: + if config.app_server_bin is not None and config.codex_bin is not None: + raise ValueError( + "Set only one of AppServerConfig.app_server_bin or AppServerConfig.codex_bin." + ) + return config.app_server_bin or config.codex_bin + + +def _resolve_app_server_bin(config: "AppServerConfig") -> Path: + return resolve_app_server_bin(config, _default_app_server_bin_resolver_ops()) + + +def resolve_codex_bin(config: "AppServerConfig", ops: AppServerBinResolverOps) -> Path: + return resolve_app_server_bin(config, ops) @dataclass(slots=True) class AppServerConfig: + app_server_bin: str | None = None codex_bin: str | None = None launch_args_override: tuple[str, ...] | None = None config_overrides: tuple[str, ...] = () @@ -133,8 +154,31 @@ class AppServerConfig: experimental_api: bool = True +def _default_launch_args(app_server_bin: Path, config: AppServerConfig) -> list[str]: + if _is_legacy_codex_cli_binary(app_server_bin): + args = [str(app_server_bin)] + for kv in config.config_overrides: + args.extend(["--config", kv]) + args.extend(["app-server", "--listen", "stdio://"]) + return args + + if config.config_overrides: + raise ValueError( + "AppServerConfig.config_overrides is only supported when launching the legacy " + "`codex` CLI runtime. With standalone `codex-app-server`, use " + "`thread_start(..., config=...)`, `thread_resume(..., config=...)`, or " + "`launch_args_override`." + ) + + return [str(app_server_bin), "--listen", "stdio://"] + + +def _is_legacy_codex_cli_binary(app_server_bin: Path) -> bool: + return app_server_bin.name.lower() == "codex.exe" or app_server_bin.name == "codex" + + class AppServerClient: - """Synchronous typed JSON-RPC client for `codex app-server` over stdio.""" + """Synchronous typed JSON-RPC client for Codex app-server over stdio.""" def __init__( self, @@ -165,11 +209,8 @@ class AppServerClient: if self.config.launch_args_override is not None: args = list(self.config.launch_args_override) else: - codex_bin = _resolve_codex_bin(self.config) - args = [str(codex_bin)] - for kv in self.config.config_overrides: - args.extend(["--config", kv]) - args.extend(["app-server", "--listen", "stdio://"]) + app_server_bin = _resolve_app_server_bin(self.config) + args = _default_launch_args(app_server_bin, self.config) env = os.environ.copy() if self.config.env: @@ -239,7 +280,9 @@ class AppServerClient: def _request_raw(self, method: str, params: JsonObject | None = None) -> JsonValue: request_id = str(uuid.uuid4()) - self._write_message({"id": request_id, "method": method, "params": params or {}}) + self._write_message( + {"id": request_id, "method": method, "params": params or {}} + ) while True: msg = self._read_message() @@ -301,8 +344,12 @@ class AppServerClient: if self._active_turn_consumer == turn_id: self._active_turn_consumer = None - def thread_start(self, params: V2ThreadStartParams | JsonObject | None = None) -> ThreadStartResponse: - return self.request("thread/start", _params_dict(params), response_model=ThreadStartResponse) + def thread_start( + self, params: V2ThreadStartParams | JsonObject | None = None + ) -> ThreadStartResponse: + return self.request( + "thread/start", _params_dict(params), response_model=ThreadStartResponse + ) def thread_resume( self, @@ -310,12 +357,20 @@ class AppServerClient: params: V2ThreadResumeParams | JsonObject | None = None, ) -> ThreadResumeResponse: payload = {"threadId": thread_id, **_params_dict(params)} - return self.request("thread/resume", payload, response_model=ThreadResumeResponse) + return self.request( + "thread/resume", payload, response_model=ThreadResumeResponse + ) - def thread_list(self, params: V2ThreadListParams | JsonObject | None = None) -> ThreadListResponse: - return self.request("thread/list", _params_dict(params), response_model=ThreadListResponse) + def thread_list( + self, params: V2ThreadListParams | JsonObject | None = None + ) -> ThreadListResponse: + return self.request( + "thread/list", _params_dict(params), response_model=ThreadListResponse + ) - def thread_read(self, thread_id: str, include_turns: bool = False) -> ThreadReadResponse: + def thread_read( + self, thread_id: str, include_turns: bool = False + ) -> ThreadReadResponse: return self.request( "thread/read", {"threadId": thread_id, "includeTurns": include_turns}, @@ -331,10 +386,18 @@ class AppServerClient: return self.request("thread/fork", payload, response_model=ThreadForkResponse) def thread_archive(self, thread_id: str) -> ThreadArchiveResponse: - return self.request("thread/archive", {"threadId": thread_id}, response_model=ThreadArchiveResponse) + return self.request( + "thread/archive", + {"threadId": thread_id}, + response_model=ThreadArchiveResponse, + ) def thread_unarchive(self, thread_id: str) -> ThreadUnarchiveResponse: - return self.request("thread/unarchive", {"threadId": thread_id}, response_model=ThreadUnarchiveResponse) + return self.request( + "thread/unarchive", + {"threadId": thread_id}, + response_model=ThreadUnarchiveResponse, + ) def thread_set_name(self, thread_id: str, name: str) -> ThreadSetNameResponse: return self.request( @@ -458,12 +521,16 @@ class AppServerClient: model = NOTIFICATION_MODELS.get(method) if model is None: - return Notification(method=method, payload=UnknownNotification(params=params_dict)) + return Notification( + method=method, payload=UnknownNotification(params=params_dict) + ) try: payload = model.model_validate(params_dict) except Exception: # noqa: BLE001 - return Notification(method=method, payload=UnknownNotification(params=params_dict)) + return Notification( + method=method, payload=UnknownNotification(params=params_dict) + ) return Notification(method=method, payload=payload) def _normalize_input_items( @@ -476,7 +543,9 @@ class AppServerClient: return [input_items] return input_items - def _default_approval_handler(self, method: str, params: JsonObject | None) -> JsonObject: + def _default_approval_handler( + self, method: str, params: JsonObject | None + ) -> JsonObject: if method == "item/commandExecution/requestApproval": return {"decision": "accept"} if method == "item/fileChange/requestApproval": diff --git a/sdk/python/tests/test_artifact_workflow_and_binaries.py b/sdk/python/tests/test_artifact_workflow_and_binaries.py index 03252154e7..5b60c7199c 100644 --- a/sdk/python/tests/test_artifact_workflow_and_binaries.py +++ b/sdk/python/tests/test_artifact_workflow_and_binaries.py @@ -29,7 +29,9 @@ def _load_runtime_setup_module(): runtime_setup_path = ROOT / "_runtime_setup.py" spec = importlib.util.spec_from_file_location("_runtime_setup", runtime_setup_path) if spec is None or spec.loader is None: - raise AssertionError(f"Failed to load runtime setup module: {runtime_setup_path}") + raise AssertionError( + f"Failed to load runtime setup module: {runtime_setup_path}" + ) module = importlib.util.module_from_spec(spec) sys.modules[spec.name] = module spec.loader.exec_module(module) @@ -151,12 +153,12 @@ def test_generate_v2_all_uses_titles_for_generated_names() -> None: def test_runtime_package_template_has_no_checked_in_binaries() -> None: - runtime_root = ROOT.parent / "python-runtime" / "src" / "codex_cli_bin" + runtime_root = ROOT.parent / "python-runtime" / "src" assert sorted( - path.name + str(path.relative_to(runtime_root)) for path in runtime_root.rglob("*") if path.is_file() and "__pycache__" not in path.parts - ) == ["__init__.py"] + ) == ["codex_app_server_bin/__init__.py", "codex_cli_bin/__init__.py"] def test_examples_readme_matches_pinned_runtime_version() -> None: @@ -173,15 +175,19 @@ def test_runtime_distribution_name_is_consistent() -> None: runtime_setup = _load_runtime_setup_module() from codex_app_server import client as client_module - assert script.RUNTIME_DISTRIBUTION_NAME == "openai-codex-cli-bin" - assert runtime_setup.PACKAGE_NAME == "openai-codex-cli-bin" - assert client_module.RUNTIME_PKG_NAME == "openai-codex-cli-bin" - assert "importlib.metadata.version('codex-cli-bin')" not in ( - ROOT / "_runtime_setup.py" - ).read_text() + assert script.RUNTIME_DISTRIBUTION_NAME == "openai-codex-app-server-bin" + assert runtime_setup.PACKAGE_NAME == "openai-codex-app-server-bin" + assert client_module.RUNTIME_PKG_NAME == "openai-codex-app-server-bin" + assert ( + "importlib.metadata.version('codex-cli-bin')" + not in (ROOT / "_runtime_setup.py").read_text() + ) + assert runtime_setup.platform_asset_name().startswith("codex-app-server-") -def test_release_metadata_retries_without_invalid_auth(monkeypatch: pytest.MonkeyPatch) -> None: +def test_release_metadata_retries_without_invalid_auth( + monkeypatch: pytest.MonkeyPatch, +) -> None: runtime_setup = _load_runtime_setup_module() authorizations: list[str | None] = [] @@ -252,10 +258,10 @@ def test_runtime_package_is_wheel_only_and_builds_platform_specific_wheels() -> elif isinstance(node.value, ast.JoinedStr): build_data_assignments[node.targets[0].slice.value] = "joined-string" - assert pyproject["project"]["name"] == "openai-codex-cli-bin" + assert pyproject["project"]["name"] == "openai-codex-app-server-bin" assert pyproject["tool"]["hatch"]["build"]["targets"]["wheel"] == { - "packages": ["src/codex_cli_bin"], - "include": ["src/codex_cli_bin/bin/**"], + "packages": ["src/codex_app_server_bin", "src/codex_cli_bin"], + "include": ["src/codex_app_server_bin/bin/**"], "hooks": {"custom": {}}, } assert pyproject["tool"]["hatch"]["build"]["targets"]["sdist"] == { @@ -281,11 +287,35 @@ def test_stage_runtime_release_copies_binary_and_sets_version(tmp_path: Path) -> ) assert staged == tmp_path / "runtime-stage" - assert script.staged_runtime_bin_path(staged).read_text() == "fake codex\n" - assert 'name = "openai-codex-cli-bin"' in (staged / "pyproject.toml").read_text() + assert ( + script.staged_runtime_bin_path(staged, fake_binary.name).read_text() + == "fake codex\n" + ) + assert ( + 'name = "openai-codex-app-server-bin"' + in (staged / "pyproject.toml").read_text() + ) assert 'version = "1.2.3"' in (staged / "pyproject.toml").read_text() +def test_stage_runtime_release_preserves_legacy_binary_name(tmp_path: Path) -> None: + script = _load_update_script_module() + legacy_binary = tmp_path / script.legacy_runtime_binary_name() + legacy_binary.write_text("fake legacy codex\n") + + staged = script.stage_python_runtime_package( + tmp_path / "runtime-stage", + "1.2.3", + legacy_binary, + ) + + assert staged == tmp_path / "runtime-stage" + assert script.staged_runtime_bin_path(staged, legacy_binary.name).read_text() == ( + "fake legacy codex\n" + ) + assert not script.staged_runtime_bin_path(staged).exists() + + def test_normalize_codex_version_accepts_release_tags_and_pep440_versions() -> None: script = _load_update_script_module() @@ -313,7 +343,10 @@ def test_stage_runtime_release_replaces_existing_staging_dir(tmp_path: Path) -> assert staged == staging_dir assert not old_file.exists() - assert script.staged_runtime_bin_path(staged).read_text() == "fake codex\n" + assert ( + script.staged_runtime_bin_path(staged, fake_binary.name).read_text() + == "fake codex\n" + ) def test_stage_runtime_release_can_pin_wheel_platform_tag(tmp_path: Path) -> None: @@ -338,7 +371,7 @@ def test_stage_sdk_release_injects_exact_runtime_pin(tmp_path: Path) -> None: pyproject = (staged / "pyproject.toml").read_text() assert 'version = "0.2.1"' in pyproject - assert '"openai-codex-cli-bin==1.2.3"' in pyproject + assert '"openai-codex-app-server-bin==1.2.3"' in pyproject assert '"codex-cli-bin==1.2.3"' not in pyproject assert not any((staged / "src" / "codex_app_server").glob("bin/**")) @@ -454,61 +487,155 @@ def test_default_runtime_is_resolved_from_installed_runtime_package( ) -> None: from codex_app_server import client as client_module - fake_binary = tmp_path / ("codex.exe" if client_module.os.name == "nt" else "codex") + fake_binary = tmp_path / ( + "codex-app-server.exe" if client_module.os.name == "nt" else "codex-app-server" + ) fake_binary.write_text("") - ops = client_module.CodexBinResolverOps( - installed_codex_path=lambda: fake_binary, + ops = client_module.AppServerBinResolverOps( + installed_app_server_path=lambda: fake_binary, path_exists=lambda path: path == fake_binary, ) config = client_module.AppServerConfig() + assert config.app_server_bin is None assert config.codex_bin is None + assert client_module.resolve_app_server_bin(config, ops) == fake_binary assert client_module.resolve_codex_bin(config, ops) == fake_binary -def test_explicit_codex_bin_override_takes_priority(tmp_path: Path) -> None: +def test_explicit_app_server_bin_override_takes_priority(tmp_path: Path) -> None: + from codex_app_server import client as client_module + + explicit_binary = tmp_path / ( + "custom-app-server.exe" + if client_module.os.name == "nt" + else "custom-app-server" + ) + explicit_binary.write_text("") + ops = client_module.AppServerBinResolverOps( + installed_app_server_path=lambda: (_ for _ in ()).throw( + AssertionError("packaged runtime should not be used") + ), + path_exists=lambda path: path == explicit_binary, + ) + + config = client_module.AppServerConfig(app_server_bin=str(explicit_binary)) + assert client_module.resolve_app_server_bin(config, ops) == explicit_binary + assert client_module.resolve_codex_bin(config, ops) == explicit_binary + + +def test_legacy_codex_bin_override_remains_supported(tmp_path: Path) -> None: from codex_app_server import client as client_module explicit_binary = tmp_path / ( "custom-codex.exe" if client_module.os.name == "nt" else "custom-codex" ) explicit_binary.write_text("") - ops = client_module.CodexBinResolverOps( - installed_codex_path=lambda: (_ for _ in ()).throw( + ops = client_module.AppServerBinResolverOps( + installed_app_server_path=lambda: (_ for _ in ()).throw( AssertionError("packaged runtime should not be used") ), path_exists=lambda path: path == explicit_binary, ) config = client_module.AppServerConfig(codex_bin=str(explicit_binary)) - assert client_module.resolve_codex_bin(config, ops) == explicit_binary + assert client_module.resolve_app_server_bin(config, ops) == explicit_binary -def test_missing_runtime_package_requires_explicit_codex_bin() -> None: +def test_conflicting_runtime_bin_overrides_fail(tmp_path: Path) -> None: from codex_app_server import client as client_module - ops = client_module.CodexBinResolverOps( - installed_codex_path=lambda: (_ for _ in ()).throw( + explicit_binary = tmp_path / "codex-app-server" + explicit_binary.write_text("") + ops = client_module.AppServerBinResolverOps( + installed_app_server_path=lambda: explicit_binary, + path_exists=lambda path: path == explicit_binary, + ) + + config = client_module.AppServerConfig( + app_server_bin=str(explicit_binary), + codex_bin=str(explicit_binary), + ) + with pytest.raises(ValueError, match="Set only one"): + client_module.resolve_app_server_bin(config, ops) + + +def test_missing_runtime_package_requires_explicit_app_server_bin() -> None: + from codex_app_server import client as client_module + + ops = client_module.AppServerBinResolverOps( + installed_app_server_path=lambda: (_ for _ in ()).throw( FileNotFoundError("missing packaged runtime") ), path_exists=lambda _path: False, ) with pytest.raises(FileNotFoundError, match="missing packaged runtime"): - client_module.resolve_codex_bin(client_module.AppServerConfig(), ops) + client_module.resolve_app_server_bin(client_module.AppServerConfig(), ops) def test_broken_runtime_package_does_not_fall_back() -> None: from codex_app_server import client as client_module - ops = client_module.CodexBinResolverOps( - installed_codex_path=lambda: (_ for _ in ()).throw( + ops = client_module.AppServerBinResolverOps( + installed_app_server_path=lambda: (_ for _ in ()).throw( FileNotFoundError("missing packaged binary") ), path_exists=lambda _path: False, ) with pytest.raises(FileNotFoundError) as exc_info: - client_module.resolve_codex_bin(client_module.AppServerConfig(), ops) + client_module.resolve_app_server_bin(client_module.AppServerConfig(), ops) assert str(exc_info.value) == ("missing packaged binary") + + +def test_legacy_codex_binary_launch_args_keep_config_overrides() -> None: + from codex_app_server import client as client_module + + legacy_binary = Path("codex.exe" if client_module.os.name == "nt" else "codex") + config = client_module.AppServerConfig( + codex_bin=str(legacy_binary), + config_overrides=("model=gpt-5.4", "sandbox=workspace-write"), + ) + + assert client_module._default_launch_args(legacy_binary, config) == [ + str(legacy_binary), + "--config", + "model=gpt-5.4", + "--config", + "sandbox=workspace-write", + "app-server", + "--listen", + "stdio://", + ] + + +def test_standalone_app_server_launch_args_use_direct_binary() -> None: + from codex_app_server import client as client_module + + standalone_binary = Path( + "codex-app-server.exe" if client_module.os.name == "nt" else "codex-app-server" + ) + + assert client_module._default_launch_args( + standalone_binary, + client_module.AppServerConfig(app_server_bin=str(standalone_binary)), + ) == [str(standalone_binary), "--listen", "stdio://"] + + +def test_standalone_app_server_rejects_legacy_config_overrides() -> None: + from codex_app_server import client as client_module + + standalone_binary = Path( + "codex-app-server.exe" if client_module.os.name == "nt" else "codex-app-server" + ) + + with pytest.raises(ValueError, match="config_overrides"): + client_module._default_launch_args( + standalone_binary, + client_module.AppServerConfig( + app_server_bin=str(standalone_binary), + config_overrides=("model=gpt-5.4",), + ), + )