fix(app-server): set originator header from initialize (re-revert) (#8988)

Reapplies https://github.com/openai/codex/pull/8873 which was reverted
due to merge conflicts
This commit is contained in:
Owen Lin 2026-01-09 12:09:30 -08:00 committed by GitHub
parent 2a06d64bc9
commit fbe883318d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 353 additions and 46 deletions

View file

@ -52,6 +52,10 @@ Clients must send a single `initialize` request before invoking any other method
Applications building on top of `codex app-server` should identify themselves via the `clientInfo` parameter.
**Important**: `clientInfo.name` is used to identify the client for the OpenAI Compliance Logs Platform. If
you are developing a new Codex integration that is intended for enterprise use, please contact us to get it
added to a known clients list. For more context: https://chatgpt.com/admin/api-reference#tag/Logs:-Codex
Example (from OpenAI's official VSCode extension):
```json
@ -60,7 +64,7 @@ Example (from OpenAI's official VSCode extension):
"id": 0,
"params": {
"clientInfo": {
"name": "codex-vscode",
"name": "codex_vscode",
"title": "Codex VS Code Extension",
"version": "0.1.0"
}

View file

@ -92,13 +92,18 @@ pub async fn run_main(
let feedback = CodexFeedback::new();
let otel = codex_core::otel_init::build_provider(&config, env!("CARGO_PKG_VERSION"), false)
.map_err(|e| {
std::io::Error::new(
ErrorKind::InvalidData,
format!("error loading otel config: {e}"),
)
})?;
let otel = codex_core::otel_init::build_provider(
&config,
env!("CARGO_PKG_VERSION"),
Some("codex_app_server"),
false,
)
.map_err(|e| {
std::io::Error::new(
ErrorKind::InvalidData,
format!("error loading otel config: {e}"),
)
})?;
// Install a simple subscriber so `tracing` output is visible. Users can
// control the log level with `RUST_LOG`.

View file

@ -21,8 +21,10 @@ use codex_core::AuthManager;
use codex_core::ThreadManager;
use codex_core::config::Config;
use codex_core::config_loader::LoaderOverrides;
use codex_core::default_client::SetOriginatorError;
use codex_core::default_client::USER_AGENT_SUFFIX;
use codex_core::default_client::get_codex_user_agent;
use codex_core::default_client::set_default_originator;
use codex_feedback::CodexFeedback;
use codex_protocol::protocol::SessionSource;
use toml::Value as TomlValue;
@ -121,6 +123,27 @@ impl MessageProcessor {
title: _title,
version,
} = params.client_info;
if let Err(error) = set_default_originator(name.clone()) {
match error {
SetOriginatorError::InvalidHeaderValue => {
let error = JSONRPCErrorError {
code: INVALID_REQUEST_ERROR_CODE,
message: format!(
"Invalid clientInfo.name: '{name}'. Must be a valid HTTP header value."
),
data: None,
};
self.outgoing.send_error(request_id, error).await;
return;
}
SetOriginatorError::AlreadyInitialized => {
// No-op. This is expected to happen if the originator is already set via env var.
// TODO(owen): Once we remove support for CODEX_INTERNAL_ORIGINATOR_OVERRIDE,
// this will be an unexpected state and we can return a JSON-RPC error indicating
// internal server error.
}
}
}
let user_agent_suffix = format!("{name}; {version}");
if let Ok(mut suffix) = USER_AGENT_SUFFIX.lock() {
*suffix = Some(user_agent_suffix);

View file

@ -17,6 +17,7 @@ pub use core_test_support::format_with_current_shell_non_login;
pub use core_test_support::test_path_buf_with_windows;
pub use core_test_support::test_tmp_path;
pub use core_test_support::test_tmp_path_buf;
pub use mcp_process::DEFAULT_CLIENT_NAME;
pub use mcp_process::McpProcess;
pub use mock_model_server::create_mock_responses_server_repeating_assistant;
pub use mock_model_server::create_mock_responses_server_sequence;

View file

@ -66,6 +66,8 @@ pub struct McpProcess {
pending_messages: VecDeque<JSONRPCMessage>,
}
pub const DEFAULT_CLIENT_NAME: &str = "codex-app-server-tests";
impl McpProcess {
pub async fn new(codex_home: &Path) -> anyhow::Result<Self> {
Self::new_with_env(codex_home, &[]).await
@ -136,33 +138,62 @@ impl McpProcess {
/// Performs the initialization handshake with the MCP server.
pub async fn initialize(&mut self) -> anyhow::Result<()> {
let params = Some(serde_json::to_value(InitializeParams {
client_info: ClientInfo {
name: "codex-app-server-tests".to_string(),
let initialized = self
.initialize_with_client_info(ClientInfo {
name: DEFAULT_CLIENT_NAME.to_string(),
title: None,
version: "0.1.0".to_string(),
},
})?);
let req_id = self.send_request("initialize", params).await?;
let initialized = self.read_jsonrpc_message().await?;
let JSONRPCMessage::Response(response) = initialized else {
})
.await?;
let JSONRPCMessage::Response(_) = initialized else {
unreachable!("expected JSONRPCMessage::Response for initialize, got {initialized:?}");
};
if response.id != RequestId::Integer(req_id) {
anyhow::bail!(
"initialize response id mismatch: expected {}, got {:?}",
req_id,
response.id
);
}
// Send notifications/initialized to ack the response.
self.send_notification(ClientNotification::Initialized)
.await?;
Ok(())
}
/// Sends initialize with the provided client info and returns the response/error message.
pub async fn initialize_with_client_info(
&mut self,
client_info: ClientInfo,
) -> anyhow::Result<JSONRPCMessage> {
let params = Some(serde_json::to_value(InitializeParams { client_info })?);
let request_id = self.send_request("initialize", params).await?;
let message = self.read_jsonrpc_message().await?;
match message {
JSONRPCMessage::Response(response) => {
if response.id != RequestId::Integer(request_id) {
anyhow::bail!(
"initialize response id mismatch: expected {}, got {:?}",
request_id,
response.id
);
}
// Send notifications/initialized to ack the response.
self.send_notification(ClientNotification::Initialized)
.await?;
Ok(JSONRPCMessage::Response(response))
}
JSONRPCMessage::Error(error) => {
if error.id != RequestId::Integer(request_id) {
anyhow::bail!(
"initialize error id mismatch: expected {}, got {:?}",
request_id,
error.id
);
}
Ok(JSONRPCMessage::Error(error))
}
JSONRPCMessage::Notification(notification) => {
anyhow::bail!("unexpected JSONRPCMessage::Notification: {notification:?}");
}
JSONRPCMessage::Request(request) => {
anyhow::bail!("unexpected JSONRPCMessage::Request: {request:?}");
}
}
}
/// Send a `newConversation` JSON-RPC request.
pub async fn send_new_conversation_request(
&mut self,

View file

@ -1,4 +1,5 @@
use anyhow::Result;
use app_test_support::DEFAULT_CLIENT_NAME;
use app_test_support::McpProcess;
use app_test_support::to_response;
use codex_app_server_protocol::GetUserAgentResponse;
@ -25,13 +26,13 @@ async fn get_user_agent_returns_current_codex_user_agent() -> Result<()> {
.await??;
let os_info = os_info::get();
let originator = codex_core::default_client::originator().value.as_str();
let originator = DEFAULT_CLIENT_NAME;
let os_type = os_info.os_type();
let os_version = os_info.version();
let architecture = os_info.architecture().unwrap_or("unknown");
let terminal_ua = codex_core::terminal::user_agent();
let user_agent = format!(
"{originator}/0.0.0 ({os_type} {os_version}; {architecture}) {terminal_ua} (codex-app-server-tests; 0.1.0)"
"{originator}/0.0.0 ({os_type} {os_version}; {architecture}) {terminal_ua} ({DEFAULT_CLIENT_NAME}; 0.1.0)"
);
let received: GetUserAgentResponse = to_response(response)?;

View file

@ -0,0 +1,137 @@
use anyhow::Result;
use app_test_support::McpProcess;
use app_test_support::create_mock_responses_server_sequence_unchecked;
use app_test_support::to_response;
use codex_app_server_protocol::ClientInfo;
use codex_app_server_protocol::InitializeResponse;
use codex_app_server_protocol::JSONRPCMessage;
use pretty_assertions::assert_eq;
use std::path::Path;
use tempfile::TempDir;
use tokio::time::timeout;
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
#[tokio::test]
async fn initialize_uses_client_info_name_as_originator() -> Result<()> {
let responses = Vec::new();
let server = create_mock_responses_server_sequence_unchecked(responses).await;
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path(), &server.uri(), "never")?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
let message = timeout(
DEFAULT_READ_TIMEOUT,
mcp.initialize_with_client_info(ClientInfo {
name: "codex_vscode".to_string(),
title: Some("Codex VS Code Extension".to_string()),
version: "0.1.0".to_string(),
}),
)
.await??;
let JSONRPCMessage::Response(response) = message else {
anyhow::bail!("expected initialize response, got {message:?}");
};
let InitializeResponse { user_agent } = to_response::<InitializeResponse>(response)?;
assert!(user_agent.starts_with("codex_vscode/"));
Ok(())
}
#[tokio::test]
async fn initialize_respects_originator_override_env_var() -> Result<()> {
let responses = Vec::new();
let server = create_mock_responses_server_sequence_unchecked(responses).await;
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path(), &server.uri(), "never")?;
let mut mcp = McpProcess::new_with_env(
codex_home.path(),
&[(
"CODEX_INTERNAL_ORIGINATOR_OVERRIDE",
Some("codex_originator_via_env_var"),
)],
)
.await?;
let message = timeout(
DEFAULT_READ_TIMEOUT,
mcp.initialize_with_client_info(ClientInfo {
name: "codex_vscode".to_string(),
title: Some("Codex VS Code Extension".to_string()),
version: "0.1.0".to_string(),
}),
)
.await??;
let JSONRPCMessage::Response(response) = message else {
anyhow::bail!("expected initialize response, got {message:?}");
};
let InitializeResponse { user_agent } = to_response::<InitializeResponse>(response)?;
assert!(user_agent.starts_with("codex_originator_via_env_var/"));
Ok(())
}
#[tokio::test]
async fn initialize_rejects_invalid_client_name() -> Result<()> {
let responses = Vec::new();
let server = create_mock_responses_server_sequence_unchecked(responses).await;
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path(), &server.uri(), "never")?;
let mut mcp = McpProcess::new_with_env(
codex_home.path(),
&[("CODEX_INTERNAL_ORIGINATOR_OVERRIDE", None)],
)
.await?;
let message = timeout(
DEFAULT_READ_TIMEOUT,
mcp.initialize_with_client_info(ClientInfo {
name: "bad\rname".to_string(),
title: Some("Bad Client".to_string()),
version: "0.1.0".to_string(),
}),
)
.await??;
let JSONRPCMessage::Error(error) = message else {
anyhow::bail!("expected initialize error, got {message:?}");
};
assert_eq!(error.error.code, -32600);
assert_eq!(
error.error.message,
"Invalid clientInfo.name: 'bad\rname'. Must be a valid HTTP header value."
);
assert_eq!(error.error.data, None);
Ok(())
}
// Helper to create a config.toml pointing at the mock model server.
fn create_config_toml(
codex_home: &Path,
server_uri: &str,
approval_policy: &str,
) -> std::io::Result<()> {
let config_toml = codex_home.join("config.toml");
std::fs::write(
config_toml,
format!(
r#"
model = "mock-model"
approval_policy = "{approval_policy}"
sandbox_mode = "read-only"
model_provider = "mock_provider"
[model_providers.mock_provider]
name = "Mock provider for test"
base_url = "{server_uri}/v1"
wire_api = "responses"
request_max_retries = 0
stream_max_retries = 0
"#
),
)
}

View file

@ -1,5 +1,6 @@
mod account;
mod config_rpc;
mod initialize;
mod model_list;
mod output_schema;
mod rate_limits;

View file

@ -8,6 +8,7 @@ use app_test_support::create_mock_responses_server_sequence_unchecked;
use app_test_support::create_shell_command_sse_response;
use app_test_support::format_with_current_shell_display;
use app_test_support::to_response;
use codex_app_server_protocol::ClientInfo;
use codex_app_server_protocol::CommandExecutionApprovalDecision;
use codex_app_server_protocol::CommandExecutionRequestApprovalResponse;
use codex_app_server_protocol::CommandExecutionStatus;
@ -40,6 +41,76 @@ use tempfile::TempDir;
use tokio::time::timeout;
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
const TEST_ORIGINATOR: &str = "codex_vscode";
#[tokio::test]
async fn turn_start_sends_originator_header() -> Result<()> {
let responses = vec![create_final_assistant_message_sse_response("Done")?];
let server = create_mock_responses_server_sequence_unchecked(responses).await;
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path(), &server.uri(), "never")?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(
DEFAULT_READ_TIMEOUT,
mcp.initialize_with_client_info(ClientInfo {
name: TEST_ORIGINATOR.to_string(),
title: Some("Codex VS Code Extension".to_string()),
version: "0.1.0".to_string(),
}),
)
.await??;
let thread_req = mcp
.send_thread_start_request(ThreadStartParams {
model: Some("mock-model".to_string()),
..Default::default()
})
.await?;
let thread_resp: JSONRPCResponse = timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(thread_req)),
)
.await??;
let ThreadStartResponse { thread, .. } = to_response::<ThreadStartResponse>(thread_resp)?;
let turn_req = mcp
.send_turn_start_request(TurnStartParams {
thread_id: thread.id.clone(),
input: vec![V2UserInput::Text {
text: "Hello".to_string(),
}],
..Default::default()
})
.await?;
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_response_message(RequestId::Integer(turn_req)),
)
.await??;
timeout(
DEFAULT_READ_TIMEOUT,
mcp.read_stream_until_notification_message("turn/completed"),
)
.await??;
let requests = server
.received_requests()
.await
.expect("failed to fetch received requests");
assert!(!requests.is_empty());
for request in requests {
let originator = request
.headers
.get("originator")
.expect("originator header missing");
assert_eq!(originator.to_str()?, TEST_ORIGINATOR);
}
Ok(())
}
#[tokio::test]
async fn turn_start_emits_notifications_and_accepts_model_override() -> Result<()> {

View file

@ -4,7 +4,7 @@ pub use codex_client::CodexRequestBuilder;
use reqwest::header::HeaderValue;
use std::sync::LazyLock;
use std::sync::Mutex;
use std::sync::OnceLock;
use std::sync::RwLock;
/// Set this to add a suffix to the User-Agent string.
///
@ -30,7 +30,7 @@ pub struct Originator {
pub value: String,
pub header_value: HeaderValue,
}
static ORIGINATOR: OnceLock<Originator> = OnceLock::new();
static ORIGINATOR: LazyLock<RwLock<Option<Originator>>> = LazyLock::new(|| RwLock::new(None));
#[derive(Debug)]
pub enum SetOriginatorError {
@ -60,22 +60,48 @@ fn get_originator_value(provided: Option<String>) -> Originator {
}
pub fn set_default_originator(value: String) -> Result<(), SetOriginatorError> {
if HeaderValue::from_str(&value).is_err() {
return Err(SetOriginatorError::InvalidHeaderValue);
}
let originator = get_originator_value(Some(value));
ORIGINATOR
.set(originator)
.map_err(|_| SetOriginatorError::AlreadyInitialized)
let Ok(mut guard) = ORIGINATOR.write() else {
return Err(SetOriginatorError::AlreadyInitialized);
};
if guard.is_some() {
return Err(SetOriginatorError::AlreadyInitialized);
}
*guard = Some(originator);
Ok(())
}
pub fn originator() -> &'static Originator {
ORIGINATOR.get_or_init(|| get_originator_value(None))
pub fn originator() -> Originator {
if let Ok(guard) = ORIGINATOR.read()
&& let Some(originator) = guard.as_ref()
{
return originator.clone();
}
if std::env::var(CODEX_INTERNAL_ORIGINATOR_OVERRIDE_ENV_VAR).is_ok() {
let originator = get_originator_value(None);
if let Ok(mut guard) = ORIGINATOR.write() {
match guard.as_ref() {
Some(originator) => return originator.clone(),
None => *guard = Some(originator.clone()),
}
}
return originator;
}
get_originator_value(None)
}
pub fn get_codex_user_agent() -> String {
let build_version = env!("CARGO_PKG_VERSION");
let os_info = os_info::get();
let originator = originator();
let prefix = format!(
"{}/{build_version} ({} {}; {}) {}",
originator().value.as_str(),
originator.value.as_str(),
os_info.os_type(),
os_info.version(),
os_info.architecture().unwrap_or("unknown"),
@ -123,7 +149,7 @@ fn sanitize_user_agent(candidate: String, fallback: &str) -> String {
tracing::warn!(
"Falling back to default Codex originator because base user agent string is invalid"
);
originator().value.clone()
originator().value
}
}
@ -137,7 +163,7 @@ pub fn build_reqwest_client() -> reqwest::Client {
use reqwest::header::HeaderMap;
let mut headers = HeaderMap::new();
headers.insert("originator", originator().header_value.clone());
headers.insert("originator", originator().header_value);
let ua = get_codex_user_agent();
let mut builder = reqwest::Client::builder()
@ -163,7 +189,7 @@ mod tests {
#[test]
fn test_get_codex_user_agent() {
let user_agent = get_codex_user_agent();
let originator = originator().value.as_str();
let originator = originator().value;
let prefix = format!("{originator}/");
assert!(user_agent.starts_with(&prefix));
}

View file

@ -15,6 +15,7 @@ use std::error::Error;
pub fn build_provider(
config: &Config,
service_version: &str,
service_name_override: Option<&str>,
default_analytics_enabled: bool,
) -> Result<Option<OtelProvider>, Box<dyn Error>> {
let to_otel_exporter = |kind: &Kind| match kind {
@ -74,8 +75,11 @@ pub fn build_provider(
OtelExporter::None
};
let originator = originator();
let service_name = service_name_override.unwrap_or(originator.value.as_str());
OtelProvider::from(&OtelSettings {
service_name: originator().value.to_owned(),
service_name: service_name.to_string(),
service_version: service_version.to_string(),
codex_home: config.codex_home.clone(),
environment: config.otel.environment.to_string(),

View file

@ -143,7 +143,7 @@ impl RolloutRecorder {
id: session_id,
timestamp,
cwd: config.cwd.clone(),
originator: originator().value.clone(),
originator: originator().value,
cli_version: env!("CARGO_PKG_VERSION").to_string(),
instructions,
source,

View file

@ -223,7 +223,8 @@ pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option<PathBuf>) -> any
std::process::exit(1);
}
let otel = codex_core::otel_init::build_provider(&config, env!("CARGO_PKG_VERSION"), false);
let otel =
codex_core::otel_init::build_provider(&config, env!("CARGO_PKG_VERSION"), None, false);
#[allow(clippy::print_stderr)]
let otel = match otel {

View file

@ -300,7 +300,8 @@ pub async fn run_main(
ensure_oss_provider_ready(provider_id, &config).await?;
}
let otel = codex_core::otel_init::build_provider(&config, env!("CARGO_PKG_VERSION"), true);
let otel =
codex_core::otel_init::build_provider(&config, env!("CARGO_PKG_VERSION"), None, true);
#[allow(clippy::print_stderr)]
let otel = match otel {

View file

@ -315,7 +315,8 @@ pub async fn run_main(
ensure_oss_provider_ready(provider_id, &config).await?;
}
let otel = codex_core::otel_init::build_provider(&config, env!("CARGO_PKG_VERSION"), true);
let otel =
codex_core::otel_init::build_provider(&config, env!("CARGO_PKG_VERSION"), None, true);
#[allow(clippy::print_stderr)]
let otel = match otel {