fix(app-server): set originator header from initialize JSON-RPC request (#8873)

**Motivation**
The `originator` header is important for codex-backend’s Responses API
proxy because it identifies the real end client (codex cli, codex vscode
extension, codex exec, future IDEs) and is used to categorize requests
by client for our enterprise compliance API.

Today the `originator` header is set by either:
- the `CODEX_INTERNAL_ORIGINATOR_OVERRIDE` env var (our VSCode extension
does this)
- calling `set_default_originator()` which sets a global immutable
singleton (`codex exec` does this)

For `codex app-server`, we want the `initialize` JSON-RPC request to set
that header because it is a natural place to do so. Example:
```json
{
  "method": "initialize",
  "id": 0,
  "params": {
    "clientInfo": {
      "name": "codex_vscode",
      "title": "Codex VS Code Extension",
      "version": "0.1.0"
    }
  }
}
```
and when app-server receives that request, it can call
`set_default_originator()`. This is a much more natural interface than
asking third party developers to set an env var.

One hiccup is that `originator()` reads the global singleton and locks
in the value, preventing a later `set_default_originator()` call from
setting it. This would be fine but is brittle, since any codepath that
calls `originator()` before app-server can process an `initialize`
JSON-RPC call would prevent app-server from setting it. This was
actually the case with OTEL initialization which runs on boot, but I
also saw this behavior in certain tests.

Instead, what we now do is:
- [unchanged] If `CODEX_INTERNAL_ORIGINATOR_OVERRIDE` env var is set,
`originator()` would return that value and `set_default_originator()`
with some other value does NOT override it.
- [new] If no env var is set, `originator()` would return the default
value which is `codex_cli_rs` UNTIL `set_default_originator()` is called
once, in which case it is set to the new value and becomes immutable.
Later calls to `set_default_originator()` returns
`SetOriginatorError::AlreadyInitialized`.

**Other notes**
- I updated `codex_core::otel_init::build_provider` to accepts a service
name override, and app-server sends a hardcoded `codex_app_server`
service name to distinguish it from `codex_cli_rs` used by default (e.g.
TUI).

**Next steps**
- Update VSCE to set the proper value for `clientInfo.name` on
`initialize` and drop the `CODEX_INTERNAL_ORIGINATOR_OVERRIDE` env var.
- Delete support for `CODEX_INTERNAL_ORIGINATOR_OVERRIDE` in codex-rs.
This commit is contained in:
Owen Lin
2026-01-09 08:17:13 -08:00
committed by GitHub
parent cacdae8c05
commit ea56186c2b
15 changed files with 294 additions and 27 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,17 @@ pub async fn run_main(
let feedback = CodexFeedback::new();
let otel =
codex_core::otel_init::build_provider(&config, env!("CARGO_PKG_VERSION")).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"),
)
.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
@@ -138,7 +140,7 @@ impl McpProcess {
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(),
name: DEFAULT_CLIENT_NAME.to_string(),
title: None,
version: "0.1.0".to_string(),
},
@@ -163,6 +165,38 @@ impl McpProcess {
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 request_id = RequestId::Integer(request_id);
loop {
let message = self.read_jsonrpc_message().await?;
match message {
JSONRPCMessage::Notification(notification) => {
self.enqueue_user_message(notification);
}
JSONRPCMessage::Response(response) => {
if response.id == request_id {
return Ok(JSONRPCMessage::Response(response));
}
}
JSONRPCMessage::Error(error) => {
if error.id == request_id {
return Ok(JSONRPCMessage::Error(error));
}
}
JSONRPCMessage::Request(_) => {
anyhow::bail!("unexpected JSONRPCMessage::Request: {message:?}");
}
}
}
}
/// 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,98 @@
use anyhow::Result;
use app_test_support::McpProcess;
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 tempfile::TempDir;
use tokio::time::timeout;
const DEFAULT_READ_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn initialize_uses_client_info_name_as_originator() -> Result<()> {
let codex_home = TempDir::new()?;
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(flavor = "multi_thread", worker_threads = 2)]
async fn initialize_respects_originator_override_env_var() -> Result<()> {
let codex_home = TempDir::new()?;
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(flavor = "multi_thread", worker_threads = 2)]
async fn initialize_rejects_invalid_client_name() -> Result<()> {
let codex_home = TempDir::new()?;
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(())
}

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_chat_completions_server_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>,
) -> Result<Option<OtelProvider>, Box<dyn Error>> {
let to_otel_exporter = |kind: &Kind| match kind {
Kind::None => OtelExporter::None,
@@ -70,8 +71,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,7 @@ 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"));
let otel = codex_core::otel_init::build_provider(&config, env!("CARGO_PKG_VERSION"), None);
#[allow(clippy::print_stderr)]
let otel = match otel {

View File

@@ -298,7 +298,7 @@ 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"));
let otel = codex_core::otel_init::build_provider(&config, env!("CARGO_PKG_VERSION"), None);
#[allow(clippy::print_stderr)]
let otel = match otel {

View File

@@ -313,7 +313,7 @@ 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"));
let otel = codex_core::otel_init::build_provider(&config, env!("CARGO_PKG_VERSION"), None);
#[allow(clippy::print_stderr)]
let otel = match otel {