mirror of
https://github.com/openai/codex.git
synced 2026-10-24 18:18:41 +00:00
Merge 6aa4aed202 into sapling-pr-archive-bolinfest
This commit is contained in:
@@ -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::<HashMap<_, _>>()
|
||||
});
|
||||
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<dyn HttpClient> = 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
|
||||
|
||||
@@ -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<String> {
|
||||
/// container and return that in-container path instead.
|
||||
fn remote_aware_stdio_server_bin() -> anyhow::Result<String> {
|
||||
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<String> {
|
||||
// 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<Option<String>> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
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<String>,
|
||||
}
|
||||
|
||||
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<Option<StreamableHttpTestServer>> {
|
||||
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<StreamableHttpTestServer> {
|
||||
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<SocketAddr> {
|
||||
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<String> {
|
||||
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,
|
||||
|
||||
@@ -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<Mutex<Option<ArmedFailure>>>,
|
||||
@@ -168,6 +87,91 @@ struct EchoArgs {
|
||||
env_var: Option<String>,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
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<SocketAddr, Box<dyn std::error::Error>> {
|
||||
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<SocketAddr, Box<dyn std::error::Error>> {
|
||||
Ok(bind_addr.parse()?)
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
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<Arc<String>>,
|
||||
request: Request<Body>,
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
|
||||
Generated
+1
-1
@@ -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 = "." }
|
||||
|
||||
+11
-11
@@ -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
|
||||
|
||||
|
||||
+160
-83
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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/<example-folder>/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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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",),
|
||||
),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user