diff --git a/codex-rs/Cargo.lock b/codex-rs/Cargo.lock index 1884dc7210..3375f6d89f 100644 --- a/codex-rs/Cargo.lock +++ b/codex-rs/Cargo.lock @@ -992,7 +992,7 @@ dependencies = [ "tokio-stream", "tracing", "tracing-subscriber", - "unicode-width 0.1.14", + "unicode-width 0.2.1", ] [[package]] diff --git a/codex-rs/core/src/default_client.rs b/codex-rs/core/src/default_client.rs index 5a6ea8d826..42690e04cc 100644 --- a/codex-rs/core/src/default_client.rs +++ b/codex-rs/core/src/default_client.rs @@ -20,7 +20,7 @@ use std::sync::OnceLock; /// The full user agent string is returned from the mcp initialize response. /// Parenthesis will be added by Codex. This should only specify what goes inside of the parenthesis. pub static USER_AGENT_SUFFIX: LazyLock>> = LazyLock::new(|| Mutex::new(None)); - +pub const DEFAULT_ORIGINATOR: &str = "codex_cli_rs"; pub const CODEX_INTERNAL_ORIGINATOR_OVERRIDE_ENV_VAR: &str = "CODEX_INTERNAL_ORIGINATOR_OVERRIDE"; #[derive(Debug, Clone)] pub struct Originator { @@ -35,10 +35,11 @@ pub enum SetOriginatorError { AlreadyInitialized, } -fn init_originator_from_env() -> Originator { - let default = "codex_cli_rs"; +fn get_originator_value(provided: Option) -> Originator { let value = std::env::var(CODEX_INTERNAL_ORIGINATOR_OVERRIDE_ENV_VAR) - .unwrap_or_else(|_| default.to_string()); + .ok() + .or(provided) + .unwrap_or(DEFAULT_ORIGINATOR.to_string()); match HeaderValue::from_str(&value) { Ok(header_value) => Originator { @@ -48,31 +49,22 @@ fn init_originator_from_env() -> Originator { Err(e) => { tracing::error!("Unable to turn originator override {value} into header value: {e}"); Originator { - value: default.to_string(), - header_value: HeaderValue::from_static(default), + value: DEFAULT_ORIGINATOR.to_string(), + header_value: HeaderValue::from_static(DEFAULT_ORIGINATOR), } } } } -fn build_originator(value: String) -> Result { - let header_value = - HeaderValue::from_str(&value).map_err(|_| SetOriginatorError::InvalidHeaderValue)?; - Ok(Originator { - value, - header_value, - }) -} - -pub fn set_default_originator(value: &str) -> Result<(), SetOriginatorError> { - let originator = build_originator(value.to_string())?; +pub fn set_default_originator(value: String) -> Result<(), SetOriginatorError> { + let originator = get_originator_value(Some(value)); ORIGINATOR .set(originator) .map_err(|_| SetOriginatorError::AlreadyInitialized) } pub fn originator() -> &'static Originator { - ORIGINATOR.get_or_init(init_originator_from_env) + ORIGINATOR.get_or_init(|| get_originator_value(None)) } pub fn get_codex_user_agent() -> String { diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index 18e097782d..967da52b8a 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -48,7 +48,7 @@ use codex_core::default_client::set_default_originator; use codex_core::find_conversation_path_by_id_str; pub async fn run_main(cli: Cli, codex_linux_sandbox_exe: Option) -> anyhow::Result<()> { - if let Err(err) = set_default_originator("codex_exec") { + if let Err(err) = set_default_originator("codex_exec".to_string()) { tracing::warn!(?err, "Failed to set codex exec originator override {err:?}"); } diff --git a/codex-rs/exec/tests/suite/mod.rs b/codex-rs/exec/tests/suite/mod.rs index d04ecd2c93..052c43bf38 100644 --- a/codex-rs/exec/tests/suite/mod.rs +++ b/codex-rs/exec/tests/suite/mod.rs @@ -1,6 +1,7 @@ // Aggregates all former standalone integration tests as modules. mod apply_patch; mod auth_env; +mod originator; mod output_schema; mod resume; mod sandbox; diff --git a/codex-rs/exec/tests/suite/originator.rs b/codex-rs/exec/tests/suite/originator.rs new file mode 100644 index 0000000000..cf0954447a --- /dev/null +++ b/codex-rs/exec/tests/suite/originator.rs @@ -0,0 +1,52 @@ +#![cfg(not(target_os = "windows"))] +#![allow(clippy::expect_used, clippy::unwrap_used)] + +use core_test_support::responses; +use core_test_support::test_codex_exec::test_codex_exec; +use wiremock::matchers::header; + +/// Verify that when the server reports an error, `codex-exec` exits with a +/// non-zero status code so automation can detect failures. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn send_codex_exec_originator() -> anyhow::Result<()> { + let test = test_codex_exec(); + + let server = responses::start_mock_server().await; + let body = responses::sse(vec![ + responses::ev_response_created("response_1"), + responses::ev_assistant_message("response_1", "Hello, world!"), + responses::ev_completed("response_1"), + ]); + responses::mount_sse_once_match(&server, header("Originator", "codex_exec"), body).await; + + test.cmd_with_server(&server) + .arg("--skip-git-repo-check") + .arg("tell me something") + .assert() + .code(0); + + Ok(()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn supports_originator_override() -> anyhow::Result<()> { + let test = test_codex_exec(); + + let server = responses::start_mock_server().await; + let body = responses::sse(vec![ + responses::ev_response_created("response_1"), + responses::ev_assistant_message("response_1", "Hello, world!"), + responses::ev_completed("response_1"), + ]); + responses::mount_sse_once_match(&server, header("Originator", "codex_exec_override"), body) + .await; + + test.cmd_with_server(&server) + .env("CODEX_INTERNAL_ORIGINATOR_OVERRIDE", "codex_exec_override") + .arg("--skip-git-repo-check") + .arg("tell me something") + .assert() + .code(0); + + Ok(()) +} diff --git a/sdk/typescript/src/exec.ts b/sdk/typescript/src/exec.ts index ac9e548b93..b1cf4c414a 100644 --- a/sdk/typescript/src/exec.ts +++ b/sdk/typescript/src/exec.ts @@ -23,6 +23,9 @@ export type CodexExecArgs = { outputSchemaFile?: string; }; +const INTERNAL_ORIGINATOR_ENV = "CODEX_INTERNAL_ORIGINATOR_OVERRIDE"; +const TYPESCRIPT_SDK_ORIGINATOR = "codex_sdk_ts"; + export class CodexExec { private executablePath: string; constructor(executablePath: string | null = null) { @@ -59,6 +62,9 @@ export class CodexExec { const env = { ...process.env, }; + if (!env[INTERNAL_ORIGINATOR_ENV]) { + env[INTERNAL_ORIGINATOR_ENV] = TYPESCRIPT_SDK_ORIGINATOR; + } if (args.baseUrl) { env.OPENAI_BASE_URL = args.baseUrl; } diff --git a/sdk/typescript/tests/responsesProxy.ts b/sdk/typescript/tests/responsesProxy.ts index 9f0060d37f..6b8ee9bcac 100644 --- a/sdk/typescript/tests/responsesProxy.ts +++ b/sdk/typescript/tests/responsesProxy.ts @@ -54,6 +54,7 @@ export type ResponsesApiRequest = { export type RecordedRequest = { body: string; json: ResponsesApiRequest; + headers: http.IncomingHttpHeaders; }; function formatSseEvent(event: SseEvent): string { @@ -90,7 +91,7 @@ export async function startResponsesTestProxy( if (req.method === "POST" && req.url === "/responses") { const body = await readRequestBody(req); const json = JSON.parse(body); - requests.push({ body, json }); + requests.push({ body, json, headers: { ...req.headers } }); const status = options.statusCode ?? 200; res.statusCode = status; diff --git a/sdk/typescript/tests/run.test.ts b/sdk/typescript/tests/run.test.ts index b711e5c506..de7ef5555d 100644 --- a/sdk/typescript/tests/run.test.ts +++ b/sdk/typescript/tests/run.test.ts @@ -345,6 +345,30 @@ describe("Codex", () => { await close(); } }); + + it("sets the codex sdk originator header", async () => { + const { url, close, requests } = await startResponsesTestProxy({ + statusCode: 200, + responseBodies: [sse(responseStarted(), assistantMessage("Hi!"), responseCompleted())], + }); + + try { + const client = new Codex({ codexPathOverride: codexExecPath, baseUrl: url, apiKey: "test" }); + + const thread = client.startThread(); + await thread.run("Hello, originator!"); + + expect(requests.length).toBeGreaterThan(0); + const originatorHeader = requests[0]!.headers["originator"]; + if (Array.isArray(originatorHeader)) { + expect(originatorHeader).toContain("codex_sdk_ts"); + } else { + expect(originatorHeader).toBe("codex_sdk_ts"); + } + } finally { + await close(); + } + }); it("throws ThreadRunError on turn failures", async () => { const { url, close } = await startResponsesTestProxy({ statusCode: 200,