Compare commits

...

1 Commits

Author SHA1 Message Date
Eric Traut
4a117c50ee Add app-server originator override 2026-03-19 13:34:22 -06:00
22 changed files with 353 additions and 3 deletions

View File

@@ -190,6 +190,8 @@ pub struct InProcessClientStartArgs {
pub enable_codex_api_key_env: bool,
/// Client name reported during initialize.
pub client_name: String,
/// Optional backend originator override reported during initialize.
pub originator_override: Option<String>,
/// Client version reported during initialize.
pub client_version: String,
/// Whether experimental APIs are requested at initialize time.
@@ -242,6 +244,7 @@ impl InProcessClientStartArgs {
title: None,
version: self.client_version.clone(),
},
originator_override: self.originator_override.clone(),
capabilities: Some(capabilities),
}
}
@@ -891,6 +894,7 @@ mod tests {
session_source,
enable_codex_api_key_env: false,
client_name: "codex-app-server-client-test".to_string(),
originator_override: None,
client_version: "0.0.0-test".to_string(),
experimental_api: true,
opt_out_notification_methods: Vec::new(),
@@ -988,6 +992,7 @@ mod tests {
RemoteAppServerConnectArgs {
websocket_url,
client_name: "codex-app-server-client-test".to_string(),
originator_override: None,
client_version: "0.0.0-test".to_string(),
experimental_api: true,
opt_out_notification_methods: Vec::new(),
@@ -995,6 +1000,56 @@ mod tests {
}
}
#[tokio::test]
async fn initialize_params_include_originator_override_when_set() {
let args = InProcessClientStartArgs {
arg0_paths: Arg0DispatchPaths::default(),
config: Arc::new(build_test_config().await),
cli_overrides: Vec::new(),
loader_overrides: LoaderOverrides::default(),
cloud_requirements: CloudRequirementsLoader::default(),
feedback: CodexFeedback::new(),
config_warnings: Vec::new(),
session_source: SessionSource::Cli,
enable_codex_api_key_env: false,
client_name: "codex-tui".to_string(),
originator_override: Some("codex_cli_rs".to_string()),
client_version: "0.0.0-test".to_string(),
experimental_api: true,
opt_out_notification_methods: Vec::new(),
channel_capacity: 1,
};
let params = args.initialize_params();
assert_eq!(params.originator_override, Some("codex_cli_rs".to_string()));
}
#[tokio::test]
async fn initialize_params_omit_originator_override_when_absent() {
let args = InProcessClientStartArgs {
arg0_paths: Arg0DispatchPaths::default(),
config: Arc::new(build_test_config().await),
cli_overrides: Vec::new(),
loader_overrides: LoaderOverrides::default(),
cloud_requirements: CloudRequirementsLoader::default(),
feedback: CodexFeedback::new(),
config_warnings: Vec::new(),
session_source: SessionSource::Cli,
enable_codex_api_key_env: false,
client_name: "codex-tui".to_string(),
originator_override: None,
client_version: "0.0.0-test".to_string(),
experimental_api: true,
opt_out_notification_methods: Vec::new(),
channel_capacity: 1,
};
let params = args.initialize_params();
assert_eq!(params.originator_override, None);
}
#[tokio::test]
async fn typed_request_roundtrip_works() {
let client = start_test_client(SessionSource::Exec).await;

View File

@@ -57,6 +57,7 @@ const INITIALIZE_TIMEOUT: Duration = Duration::from_secs(10);
pub struct RemoteAppServerConnectArgs {
pub websocket_url: String,
pub client_name: String,
pub originator_override: Option<String>,
pub client_version: String,
pub experimental_api: bool,
pub opt_out_notification_methods: Vec<String>,
@@ -80,11 +81,52 @@ impl RemoteAppServerConnectArgs {
title: None,
version: self.client_version.clone(),
},
originator_override: self.originator_override.clone(),
capabilities: Some(capabilities),
}
}
}
#[cfg(test)]
mod tests {
use super::RemoteAppServerConnectArgs;
use pretty_assertions::assert_eq;
#[test]
fn initialize_params_include_originator_override_when_set() {
let args = RemoteAppServerConnectArgs {
websocket_url: "ws://127.0.0.1:1234".to_string(),
client_name: "codex-tui".to_string(),
originator_override: Some("codex_cli_rs".to_string()),
client_version: "0.0.0-test".to_string(),
experimental_api: true,
opt_out_notification_methods: Vec::new(),
channel_capacity: 1,
};
let params = args.initialize_params();
assert_eq!(params.originator_override, Some("codex_cli_rs".to_string()));
}
#[test]
fn initialize_params_omit_originator_override_when_absent() {
let args = RemoteAppServerConnectArgs {
websocket_url: "ws://127.0.0.1:1234".to_string(),
client_name: "codex-tui".to_string(),
originator_override: None,
client_version: "0.0.0-test".to_string(),
experimental_api: true,
opt_out_notification_methods: Vec::new(),
channel_capacity: 1,
};
let params = args.initialize_params();
assert_eq!(params.originator_override, None);
}
}
enum RemoteClientCommand {
Request {
request: Box<ClientRequest>,

View File

@@ -981,6 +981,12 @@
},
"clientInfo": {
"$ref": "#/definitions/ClientInfo"
},
"originatorOverride": {
"type": [
"string",
"null"
]
}
},
"required": [

View File

@@ -2246,6 +2246,12 @@
},
"clientInfo": {
"$ref": "#/definitions/ClientInfo"
},
"originatorOverride": {
"type": [
"string",
"null"
]
}
},
"required": [

View File

@@ -4887,6 +4887,12 @@
},
"clientInfo": {
"$ref": "#/definitions/ClientInfo"
},
"originatorOverride": {
"type": [
"string",
"null"
]
}
},
"required": [

View File

@@ -57,6 +57,12 @@
},
"clientInfo": {
"$ref": "#/definitions/ClientInfo"
},
"originatorOverride": {
"type": [
"string",
"null"
]
}
},
"required": [

View File

@@ -4,4 +4,4 @@
import type { ClientInfo } from "./ClientInfo";
import type { InitializeCapabilities } from "./InitializeCapabilities";
export type InitializeParams = { clientInfo: ClientInfo, capabilities: InitializeCapabilities | null, };
export type InitializeParams = { clientInfo: ClientInfo, originatorOverride?: string | null, capabilities: InitializeCapabilities | null, };

View File

@@ -1001,6 +1001,7 @@ mod tests {
title: Some("Codex VS Code Extension".to_string()),
version: "0.1.0".to_string(),
},
originator_override: None,
capabilities: Some(v1::InitializeCapabilities {
experimental_api: true,
opt_out_notification_methods: Some(vec![
@@ -1066,6 +1067,7 @@ mod tests {
title: Some("Codex VS Code Extension".to_string()),
version: "0.1.0".to_string(),
},
originator_override: None,
capabilities: Some(v1::InitializeCapabilities {
experimental_api: true,
opt_out_notification_methods: Some(vec![
@@ -1079,6 +1081,39 @@ mod tests {
Ok(())
}
#[test]
fn serialize_initialize_with_originator_override() -> Result<()> {
let request = ClientRequest::Initialize {
request_id: RequestId::Integer(7),
params: v1::InitializeParams {
client_info: v1::ClientInfo {
name: "codex-tui".to_string(),
title: None,
version: "0.1.0".to_string(),
},
originator_override: Some("codex_cli_rs".to_string()),
capabilities: None,
},
};
assert_eq!(
json!({
"method": "initialize",
"id": 7,
"params": {
"clientInfo": {
"name": "codex-tui",
"title": null,
"version": "0.1.0"
},
"originatorOverride": "codex_cli_rs"
}
}),
serde_json::to_value(&request)?,
);
Ok(())
}
#[test]
fn conversation_id_serializes_as_plain_string() -> Result<()> {
let id = ThreadId::from_string("67e55044-10b1-426f-9247-bb680e5fe0c8")?;

View File

@@ -28,6 +28,9 @@ use crate::protocol::common::GitSha;
pub struct InitializeParams {
pub client_info: ClientInfo,
#[serde(skip_serializing_if = "Option::is_none")]
#[ts(optional = nullable)]
pub originator_override: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub capabilities: Option<InitializeCapabilities>,
}

View File

@@ -1526,6 +1526,7 @@ impl CodexClient {
title: Some("Codex Toy App Server".to_string()),
version: env!("CARGO_PKG_VERSION").to_string(),
},
originator_override: None,
capabilities: Some(InitializeCapabilities {
experimental_api,
opt_out_notification_methods: Some(

View File

@@ -80,6 +80,7 @@ Clients must send a single `initialize` request per transport connection before
`initialize.params.capabilities` also supports per-connection notification opt-out via `optOutNotificationMethods`, which is a list of exact method names to suppress for that connection. Matching is exact (no wildcards/prefixes). Unknown method names are accepted and ignored.
Applications building on top of `codex app-server` should identify themselves via the `clientInfo` parameter.
If you need backend requests to use a different `originator` header than the app-server client identity, set `originatorOverride`. When omitted, the backend originator defaults to `clientInfo.name`. When provided, the returned `userAgent` string also uses the override as its prefix.
**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
@@ -113,6 +114,7 @@ Example with notification opt-out:
"title": "My Client",
"version": "0.1.0"
},
"originatorOverride": "codex_cli_rs",
"capabilities": {
"experimentalApi": true,
"optOutNotificationMethods": ["thread/started", "item/agentMessage/delta"]

View File

@@ -771,6 +771,7 @@ mod tests {
title: None,
version: "0.0.0".to_string(),
},
originator_override: None,
capabilities: None,
},
channel_capacity,

View File

@@ -17,6 +17,7 @@ use crate::outgoing_message::OutgoingMessageSender;
use crate::outgoing_message::RequestContext;
use crate::transport::AppServerTransport;
use async_trait::async_trait;
use axum::http::HeaderValue;
use codex_app_server_protocol::ChatgptAuthTokensRefreshParams;
use codex_app_server_protocol::ChatgptAuthTokensRefreshReason;
use codex_app_server_protocol::ChatgptAuthTokensRefreshResponse;
@@ -548,15 +549,30 @@ impl MessageProcessor {
title: _title,
version,
} = params.client_info;
let effective_originator =
params.originator_override.unwrap_or_else(|| name.clone());
if HeaderValue::from_str(&name).is_err() {
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(connection_request_id.clone(), error)
.await;
return;
}
session.app_server_client_name = Some(name.clone());
session.client_version = Some(version.clone());
if let Err(error) = set_default_originator(name.clone()) {
if let Err(error) = set_default_originator(effective_originator.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."
"Invalid originatorOverride: '{effective_originator}'. Must be a valid HTTP header value."
),
data: None,
};

View File

@@ -140,6 +140,7 @@ impl TracingHarness {
title: None,
version: "0.1.0".to_string(),
},
originator_override: None,
capabilities: Some(InitializeCapabilities {
experimental_api: true,
..Default::default()

View File

@@ -214,11 +214,28 @@ impl McpProcess {
) -> anyhow::Result<JSONRPCMessage> {
self.initialize_with_params(InitializeParams {
client_info,
originator_override: None,
capabilities,
})
.await
}
pub async fn initialize_with_originator_override(
&mut self,
client_info: ClientInfo,
originator_override: Option<String>,
) -> anyhow::Result<JSONRPCMessage> {
self.initialize_with_params(InitializeParams {
client_info,
originator_override,
capabilities: Some(InitializeCapabilities {
experimental_api: true,
..Default::default()
}),
})
.await
}
async fn initialize_with_params(
&mut self,
params: InitializeParams,

View File

@@ -280,6 +280,7 @@ pub(super) async fn send_initialize_request(
title: Some("WebSocket Test Client".to_string()),
version: "0.1.0".to_string(),
},
originator_override: None,
capabilities: None,
};
send_request(

View File

@@ -58,6 +58,36 @@ async fn initialize_uses_client_info_name_as_originator() -> Result<()> {
Ok(())
}
#[tokio::test]
async fn initialize_uses_originator_override_when_present() -> 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_originator_override(
ClientInfo {
name: "codex-tui".to_string(),
title: Some("Codex TUI".to_string()),
version: "0.1.0".to_string(),
},
Some("codex_cli_rs".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_cli_rs/"));
Ok(())
}
#[tokio::test]
async fn initialize_respects_originator_override_env_var() -> Result<()> {
let responses = Vec::new();
@@ -133,6 +163,44 @@ async fn initialize_rejects_invalid_client_name() -> Result<()> {
Ok(())
}
#[tokio::test]
async fn initialize_rejects_invalid_originator_override() -> 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_originator_override(
ClientInfo {
name: "codex-tui".to_string(),
title: Some("Codex TUI".to_string()),
version: "0.1.0".to_string(),
},
Some("bad\rname".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 originatorOverride: 'bad\rname'. Must be a valid HTTP header value."
);
assert_eq!(error.error.data, None);
Ok(())
}
#[tokio::test]
async fn initialize_opt_out_notification_methods_filters_notifications() -> Result<()> {
let responses = Vec::new();

View File

@@ -152,6 +152,84 @@ async fn turn_start_sends_originator_header() -> Result<()> {
Ok(())
}
#[tokio::test]
async fn turn_start_uses_originator_override_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",
&BTreeMap::from([(Feature::Personality, true)]),
)?;
let mut mcp = McpProcess::new(codex_home.path()).await?;
timeout(
DEFAULT_READ_TIMEOUT,
mcp.initialize_with_originator_override(
ClientInfo {
name: "codex-tui".to_string(),
title: Some("Codex TUI".to_string()),
version: "0.1.0".to_string(),
},
Some("codex_cli_rs".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(),
text_elements: Vec::new(),
}],
..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()?, "codex_cli_rs");
}
Ok(())
}
#[tokio::test]
async fn turn_start_emits_user_message_item_with_text_elements() -> Result<()> {
let responses = vec![create_final_assistant_message_sse_response("Done")?];

View File

@@ -100,6 +100,7 @@ impl AppServerClient {
title: Some("Debug Client".to_string()),
version: env!("CARGO_PKG_VERSION").to_string(),
},
originator_override: None,
capabilities: Some(InitializeCapabilities {
experimental_api: true,
opt_out_notification_methods: None,

View File

@@ -440,6 +440,7 @@ pub async fn run_main(cli: Cli, arg0_paths: Arg0DispatchPaths) -> anyhow::Result
session_source: SessionSource::Exec,
enable_codex_api_key_env: true,
client_name: "codex-exec".to_string(),
originator_override: None,
client_version: env!("CARGO_PKG_VERSION").to_string(),
experimental_api: true,
opt_out_notification_methods: Vec::new(),

View File

@@ -33,6 +33,7 @@ use codex_core::config_loader::CloudRequirementsLoader;
use codex_core::config_loader::ConfigLoadError;
use codex_core::config_loader::LoaderOverrides;
use codex_core::config_loader::format_config_error_with_source;
use codex_core::default_client::DEFAULT_ORIGINATOR;
use codex_core::default_client::set_default_client_residency_requirement;
use codex_core::format_exec_policy_error_with_source;
use codex_core::path_utils;
@@ -340,6 +341,7 @@ async fn connect_remote_app_server(websocket_url: String) -> color_eyre::Result<
let app_server = RemoteAppServerClient::connect(RemoteAppServerConnectArgs {
websocket_url,
client_name: "codex-tui".to_string(),
originator_override: Some(DEFAULT_ORIGINATOR.to_string()),
client_version: env!("CARGO_PKG_VERSION").to_string(),
experimental_api: true,
opt_out_notification_methods: Vec::new(),
@@ -434,6 +436,7 @@ where
session_source: codex_protocol::protocol::SessionSource::Cli,
enable_codex_api_key_env: false,
client_name: "codex-tui".to_string(),
originator_override: Some(DEFAULT_ORIGINATOR.to_string()),
client_version: env!("CARGO_PKG_VERSION").to_string(),
experimental_api: true,
opt_out_notification_methods: Vec::new(),

View File

@@ -920,6 +920,7 @@ mod tests {
session_source: SessionSource::Cli,
enable_codex_api_key_env: false,
client_name: "test".to_string(),
originator_override: None,
client_version: "test".to_string(),
experimental_api: true,
opt_out_notification_methods: Vec::new(),