Enable git attribution across Codex entry points (#34819)

## What changed

- Install the git attribution extension in the app server, MCP server, and `codex debug prompt-input` so authenticated workspace policy controls the commit and pull request attribution instructions sent to the model.
- Resolve attribution settings from the process-level ChatGPT base URL, independent of per-thread or per-tool configuration overrides.
- Treat git attribution as contextual developer content when mapping model events.

## Testing

- Cover policy fetch retries, workspace switches, rollbacks, cold thread resumes with legacy instructions, MCP tool calls, and prompt-debug output.

GitOrigin-RevId: 57d182c432c20d7c1c6c429057b6163c9f32088d
This commit is contained in:
jay
2026-07-22 20:23:35 +00:00
committed by copyberry
parent 4ebd976312
commit 88eb3a2b8a
21 changed files with 600 additions and 166 deletions
+5 -2
View File
@@ -1976,6 +1976,7 @@ dependencies = [
"codex-feedback",
"codex-file-search",
"codex-file-watcher",
"codex-git-attribution",
"codex-git-utils",
"codex-goal-extension",
"codex-guardian",
@@ -2330,7 +2331,9 @@ dependencies = [
"codex-exec",
"codex-exec-server",
"codex-execpolicy",
"codex-extension-api",
"codex-features",
"codex-git-attribution",
"codex-git-utils",
"codex-home",
"codex-http-client",
@@ -3121,8 +3124,6 @@ dependencies = [
"codex-extension-api",
"codex-http-client",
"codex-login",
"codex-protocol",
"pretty_assertions",
"serde_json",
"tokio",
"wiremock",
@@ -3442,11 +3443,13 @@ name = "codex-mcp-server"
version = "0.0.0"
dependencies = [
"anyhow",
"app_test_support",
"codex-arg0",
"codex-config",
"codex-core",
"codex-exec-server",
"codex-extension-api",
"codex-git-attribution",
"codex-home",
"codex-image-generation-extension",
"codex-login",
+1
View File
@@ -185,6 +185,7 @@ codex-execpolicy = { path = "execpolicy" }
codex-extension-api = { path = "ext/extension-api" }
codex-extension-items = { path = "ext/items" }
codex-goal-extension = { path = "ext/goal" }
codex-git-attribution = { path = "ext/git-attribution" }
codex-guardian = { path = "ext/guardian" }
codex-image-generation-extension = { path = "ext/image-generation" }
codex-external-agent-migration = { path = "external-agent-migration" }
+1
View File
@@ -47,6 +47,7 @@ codex-extension-api = { workspace = true }
codex-external-agent-migration = { workspace = true }
codex-features = { workspace = true }
codex-goal-extension = { workspace = true }
codex-git-attribution = { workspace = true }
codex-guardian = { workspace = true }
codex-git-utils = { workspace = true }
codex-file-watcher = { workspace = true }
+11
View File
@@ -16,6 +16,7 @@ use codex_extension_api::ExtensionEventSink;
use codex_extension_api::ExtensionRegistry;
use codex_extension_api::ExtensionRegistryBuilder;
use codex_goal_extension::GoalService;
use codex_http_client::HttpClientFactory;
use codex_login::AuthManager;
use codex_protocol::ThreadId;
use codex_protocol::error::CodexErr;
@@ -37,6 +38,8 @@ pub(crate) struct ThreadExtensionDependencies {
pub(crate) goal_service: Arc<GoalService>,
pub(crate) environment_manager: Arc<EnvironmentManager>,
pub(crate) executor_skill_provider: Arc<dyn codex_skills_extension::SkillProvider>,
pub(crate) git_attribution_base_url: String,
pub(crate) http_client_factory: HttpClientFactory,
/// Process-scoped persistence backend for extensions that need stored thread history.
pub(crate) thread_store: Arc<dyn ThreadStore>,
}
@@ -57,6 +60,8 @@ where
goal_service,
environment_manager,
executor_skill_provider,
git_attribution_base_url,
http_client_factory,
thread_store: _thread_store,
} = dependencies;
let mut builder = ExtensionRegistryBuilder::<Config>::with_event_sink(event_sink);
@@ -71,6 +76,12 @@ where
|config: &Config| config.features.enabled(codex_features::Feature::Goals),
);
}
codex_git_attribution::install(
&mut builder,
auth_manager.clone(),
git_attribution_base_url,
http_client_factory,
);
codex_guardian::install(&mut builder, guardian_agent_spawner);
codex_memories_extension::install(&mut builder, codex_otel::global());
codex_mcp_extension::install(&mut builder);
+2
View File
@@ -326,6 +326,8 @@ enabled = false
goal_service: Arc::new(codex_goal_extension::GoalService::new()),
environment_manager: Arc::clone(&environment_manager),
executor_skill_provider: Arc::clone(&executor_skill_provider),
git_attribution_base_url: good_config.chatgpt_base_url.clone(),
http_client_factory: good_config.http_client_factory(),
thread_store: Arc::clone(&thread_store),
},
),
@@ -274,6 +274,8 @@ impl MessageProcessor {
goal_service: Arc::clone(&goal_service),
environment_manager: Arc::clone(&environment_manager_for_extensions),
executor_skill_provider: Arc::clone(&executor_skill_provider),
git_attribution_base_url: config.chatgpt_base_url.clone(),
http_client_factory: config.http_client_factory(),
thread_store: Arc::clone(&thread_store),
},
),
@@ -130,6 +130,11 @@ use core_test_support::test_codex::test_env;
use serde::de::DeserializeOwned;
use tempfile::TempDir;
use tokio::process::Command;
use wiremock::Mock;
use wiremock::MockServer;
use wiremock::ResponseTemplate;
use wiremock::matchers::method;
use wiremock::matchers::path;
use crate::json_logging::JsonLogCapture;
use crate::local_websocket_exec_server::LocalWebsocketExecServer;
@@ -150,6 +155,7 @@ pub struct TestAppServer {
// Fields drop in declaration order. Tear down the delayed child before
// removing an owned CODEX_HOME that may still be its cwd on Windows.
_delayed_exec_server: Option<(LocalWebsocketExecServer, WebsocketDelayInterposer)>,
_attribution_settings_server: Option<MockServer>,
_owned_codex_home: Option<TempDir>,
}
@@ -285,6 +291,7 @@ impl TestAppServer {
auto_env: None,
json_logs,
_delayed_exec_server: None,
_attribution_settings_server: None,
_owned_codex_home: None,
})
}
@@ -1879,6 +1886,35 @@ impl TestAppServerBuilder {
)
}
};
let attribution_settings_server = if codex_home.join("auth.json").is_file() {
let config_path = codex_home.join("config.toml");
let config = std::fs::read_to_string(&config_path)?;
if config
.lines()
.any(|line| line.trim_start().starts_with("chatgpt_base_url"))
{
None
} else {
let settings_server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/backend-api/wham/settings/user"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"commit_attribution_enabled": false,
})))
.mount(&settings_server)
.await;
std::fs::write(
&config_path,
format!(
"chatgpt_base_url = \"{}/backend-api\"\n{config}",
settings_server.uri()
),
)?;
Some(settings_server)
}
} else {
None
};
let (auto_env, delayed_exec_server) = match environment {
TestAppServerEnvironment::Auto => {
let environments_toml = codex_home.join("environments.toml");
@@ -1985,6 +2021,7 @@ impl TestAppServerBuilder {
app_server.auto_env = auto_env;
app_server._owned_codex_home = owned_codex_home;
app_server._delayed_exec_server = delayed_exec_server;
app_server._attribution_settings_server = attribution_settings_server;
Ok(app_server)
}
}
+26 -2
View File
@@ -84,6 +84,7 @@ struct CreateConfigTomlParams {
forced_workspace_ids: Option<Vec<String>>,
requires_openai_auth: Option<bool>,
base_url: Option<String>,
chatgpt_base_url: Option<String>,
model_provider_id: Option<String>,
extra_provider_config: Option<String>,
}
@@ -115,6 +116,10 @@ fn create_config_toml(codex_home: &Path, params: CreateConfigTomlParams) -> std:
Some(false) => String::new(),
None => String::new(),
};
let chatgpt_base_url_line = params
.chatgpt_base_url
.map(|url| format!("chatgpt_base_url = \"{url}\"\n"))
.unwrap_or_default();
let model_provider_id = params
.model_provider_id
.unwrap_or_else(|| "mock_provider".to_string());
@@ -137,6 +142,7 @@ stream_max_retries = 0
model = "mock-model"
approval_policy = "never"
sandbox_mode = "danger-full-access"
{chatgpt_base_url_line}
{forced_line}
{forced_workspace_line}
@@ -532,6 +538,16 @@ async fn respond_to_refresh_request(
Ok(())
}
async fn mount_disabled_attribution_settings(mock_server: &MockServer) {
Mock::given(method("GET"))
.and(path("/backend-api/wham/settings/user"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"commit_attribution_enabled": false,
})))
.mount(mock_server)
.await;
}
#[tokio::test]
// 401 response triggers account/chatgptAuthTokens/refresh and retries with new tokens.
async fn external_auth_refreshes_on_unauthorized() -> Result<()> {
@@ -542,6 +558,7 @@ async fn external_auth_refreshes_on_unauthorized() -> Result<()> {
CreateConfigTomlParams {
requires_openai_auth: Some(true),
base_url: Some(format!("{}/v1", mock_server.uri())),
chatgpt_base_url: Some(format!("{}/backend-api", mock_server.uri())),
..Default::default()
},
)?;
@@ -560,6 +577,7 @@ async fn external_auth_refreshes_on_unauthorized() -> Result<()> {
vec![unauthorized, responses::sse_response(success_sse)],
)
.await;
mount_disabled_attribution_settings(&mock_server).await;
let initial_access_token = encode_id_token(
&ChatGptIdTokenClaims::new()
@@ -655,6 +673,7 @@ async fn external_auth_refresh_error_fails_turn() -> Result<()> {
CreateConfigTomlParams {
requires_openai_auth: Some(true),
base_url: Some(format!("{}/v1", mock_server.uri())),
chatgpt_base_url: Some(format!("{}/backend-api", mock_server.uri())),
..Default::default()
},
)?;
@@ -665,6 +684,7 @@ async fn external_auth_refresh_error_fails_turn() -> Result<()> {
}));
let _responses_mock =
responses::mount_response_sequence(&mock_server, vec![unauthorized]).await;
mount_disabled_attribution_settings(&mock_server).await;
let initial_access_token = encode_id_token(
&ChatGptIdTokenClaims::new()
@@ -764,6 +784,7 @@ async fn external_auth_refresh_mismatched_workspace_fails_turn() -> Result<()> {
forced_workspace_id: Some(WORKSPACE_ID_ALLOWED.to_string()),
requires_openai_auth: Some(true),
base_url: Some(format!("{}/v1", mock_server.uri())),
chatgpt_base_url: Some(format!("{}/backend-api", mock_server.uri())),
..Default::default()
},
)?;
@@ -774,6 +795,7 @@ async fn external_auth_refresh_mismatched_workspace_fails_turn() -> Result<()> {
}));
let _responses_mock =
responses::mount_response_sequence(&mock_server, vec![unauthorized]).await;
mount_disabled_attribution_settings(&mock_server).await;
let initial_access_token = encode_id_token(
&ChatGptIdTokenClaims::new()
@@ -878,6 +900,7 @@ async fn external_auth_refresh_invalid_access_token_fails_turn() -> Result<()> {
CreateConfigTomlParams {
requires_openai_auth: Some(true),
base_url: Some(format!("{}/v1", mock_server.uri())),
chatgpt_base_url: Some(format!("{}/backend-api", mock_server.uri())),
..Default::default()
},
)?;
@@ -888,6 +911,7 @@ async fn external_auth_refresh_invalid_access_token_fails_turn() -> Result<()> {
}));
let _responses_mock =
responses::mount_response_sequence(&mock_server, vec![unauthorized]).await;
mount_disabled_attribution_settings(&mock_server).await;
let initial_access_token = encode_id_token(
&ChatGptIdTokenClaims::new()
@@ -1110,7 +1134,6 @@ async fn login_amazon_bedrock_rejects_non_bedrock_provider_override_without_chan
AuthKeyringBackendKind::default(),
)?;
let expected_auth = load_file_auth(codex_home.path())?;
let expected_config = read_config_toml(codex_home.path())?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
@@ -1119,6 +1142,7 @@ async fn login_amazon_bedrock_rejects_non_bedrock_provider_override_without_chan
.with_args(&["-c", "model_provider=\"mock_provider\""])
.build_initialized_with_timeout(DEFAULT_READ_TIMEOUT)
.await?;
let expected_config = read_config_toml(codex_home.path())?;
let request_id = mcp
.send_login_account_amazon_bedrock_request("managed-bedrock-api-key", "us-west-2")
@@ -1294,7 +1318,6 @@ async fn logout_aws_managed_bedrock_errors_without_changing_auth_or_config() ->
AuthKeyringBackendKind::default(),
)?;
let expected_auth = load_file_auth(codex_home.path())?;
let expected_config = read_config_toml(codex_home.path())?;
let mut mcp = TestAppServer::builder()
.with_codex_home(codex_home.path())
@@ -1302,6 +1325,7 @@ async fn logout_aws_managed_bedrock_errors_without_changing_auth_or_config() ->
.with_env_overrides(&[("OPENAI_API_KEY", None)])
.build_initialized_with_timeout(DEFAULT_READ_TIMEOUT)
.await?;
let expected_config = read_config_toml(codex_home.path())?;
let request_id = mcp.send_logout_account_request().await?;
let error = timeout(
DEFAULT_READ_TIMEOUT,
@@ -0,0 +1,379 @@
use std::collections::HashMap;
use std::path::Path;
use std::sync::Arc;
use std::sync::atomic::AtomicUsize;
use std::sync::atomic::Ordering;
use anyhow::Context;
use anyhow::Result;
use app_test_support::ChatGptAuthFixture;
use app_test_support::TestAppServer;
use app_test_support::create_final_assistant_message_sse_response;
use app_test_support::to_response;
use app_test_support::write_chatgpt_auth;
use app_test_support::write_mock_responses_config_toml_with_chatgpt_base_url;
use codex_app_server_protocol::LoginAccountResponse;
use codex_app_server_protocol::RequestId;
use codex_app_server_protocol::ThreadResumeParams;
use codex_app_server_protocol::ThreadResumeResponse;
use codex_app_server_protocol::ThreadRollbackParams;
use codex_app_server_protocol::ThreadRollbackResponse;
use codex_app_server_protocol::ThreadStartParams;
use codex_app_server_protocol::ThreadStartResponse;
use codex_app_server_protocol::TurnStartParams;
use codex_app_server_protocol::UserInput;
use codex_config::types::AuthCredentialsStoreMode;
use codex_protocol::models::ContentItem;
use codex_protocol::models::ResponseItem;
use codex_protocol::protocol::RolloutItem;
use codex_protocol::protocol::RolloutLine;
use core_test_support::responses;
use core_test_support::skip_if_no_network;
use pretty_assertions::assert_eq;
use serde::de::DeserializeOwned;
use serde_json::json;
use tempfile::TempDir;
use tokio::time::Duration;
use tokio::time::timeout;
use wiremock::Mock;
use wiremock::ResponseTemplate;
use wiremock::matchers::header;
use wiremock::matchers::method;
use wiremock::matchers::path;
// macOS and Windows Bazel CI can spend tens of seconds starting app-server
// subprocesses or processing test RPCs under load.
#[cfg(any(target_os = "macos", windows))]
const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(60);
#[cfg(not(any(target_os = "macos", windows)))]
const DEFAULT_READ_TIMEOUT: Duration = Duration::from_secs(10);
const COMMIT_ATTRIBUTION: &str = "Co-authored-by: Codex <noreply@openai.com>";
const PR_ATTRIBUTION: &str = "Generated with Codex.";
const ATTRIBUTION_DISABLED: &str = "attribution is disabled for the current workspace";
const LEGACY_COMMIT_ATTRIBUTION_INSTRUCTIONS: &str = "\
When you write or edit a git commit message, ensure the message ends with this trailer exactly once:
Co-authored-by: Codex <noreply@openai.com>
Rules:
- Keep existing trailers and append this trailer at the end if missing.
- Do not duplicate this trailer if it already exists.
- Keep one blank line between the commit body and trailer block.";
#[tokio::test]
async fn git_attribution_follows_authenticated_workspace_policy() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = responses::start_mock_server().await;
let settings_server = responses::start_mock_server().await;
let response_mock = responses::mount_sse_sequence(
&server,
[
"Unavailable",
"Recovered",
"Cached",
"After switch",
"After rollback",
]
.into_iter()
.map(create_final_assistant_message_sse_response)
.collect::<Result<Vec<_>>>()?,
)
.await;
Mock::given(method("GET"))
.and(path("/backend-api/wham/config/bundle"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({})))
.mount(&server)
.await;
let enabled_settings_requests = Arc::new(AtomicUsize::new(0));
Mock::given(method("GET"))
.and(path("/backend-api/wham/settings/user"))
.and(header("chatgpt-account-id", "workspace-enabled"))
.respond_with({
let enabled_settings_requests = enabled_settings_requests.clone();
move |_request: &wiremock::Request| {
if enabled_settings_requests.fetch_add(1, Ordering::SeqCst) == 0 {
ResponseTemplate::new(503)
} else {
ResponseTemplate::new(200).set_body_json(json!({
"commit_attribution_enabled": true,
}))
}
}
})
.expect(3)
.mount(&server)
.await;
Mock::given(method("GET"))
.and(path("/backend-api/wham/settings/user"))
.and(header("chatgpt-account-id", "workspace-disabled"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"commit_attribution_enabled": false,
})))
.expect(1)
.mount(&server)
.await;
let codex_home = TempDir::new()?;
write_mock_responses_config_toml_with_chatgpt_base_url(
codex_home.path(),
&server.uri(),
&format!("{}/backend-api", server.uri()),
)?;
write_chatgpt_auth(
codex_home.path(),
ChatGptAuthFixture::new("chatgpt-token")
.account_id("workspace-enabled")
.plan_type("enterprise"),
AuthCredentialsStoreMode::File,
)?;
let mut app_server = TestAppServer::builder()
.with_codex_home(codex_home.path())
.with_env_overrides(&[("OPENAI_API_KEY", None), ("CODEX_ACCESS_TOKEN", None)])
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, app_server.initialize()).await??;
let request_id = app_server
.send_thread_start_request_with_auto_env(ThreadStartParams {
config: Some(HashMap::from([(
"chatgpt_base_url".to_string(),
json!(format!("{}/backend-api", settings_server.uri())),
)])),
..Default::default()
})
.await?;
let ThreadStartResponse { thread, .. } = read_response(&mut app_server, request_id).await?;
run_turn(&mut app_server, &thread.id, "First turn").await?;
run_turn(&mut app_server, &thread.id, "Second turn").await?;
run_turn(&mut app_server, &thread.id, "Third turn").await?;
let request_id = app_server
.send_chatgpt_auth_tokens_login_request(
"e30.e30.c2ln".to_string(),
"workspace-disabled".to_string(),
Some("enterprise".to_string()),
)
.await?;
let _: LoginAccountResponse = read_response(&mut app_server, request_id).await?;
run_turn(&mut app_server, &thread.id, "Turn after workspace switch").await?;
let request_id = app_server
.send_thread_rollback_request(ThreadRollbackParams {
thread_id: thread.id.clone(),
num_turns: 1,
})
.await?;
let _: ThreadRollbackResponse = read_response(&mut app_server, request_id).await?;
let request_id = app_server
.send_chatgpt_auth_tokens_login_request(
"e30.e30.c2ln".to_string(),
"workspace-enabled".to_string(),
Some("enterprise".to_string()),
)
.await?;
let _: LoginAccountResponse = read_response(&mut app_server, request_id).await?;
run_turn(&mut app_server, &thread.id, "Turn after rollback").await?;
let requests = response_mock.requests();
assert_eq!(requests.len(), 5);
for (request, expected) in requests
.into_iter()
.zip([(0, 0), (1, 0), (1, 0), (1, 1), (1, 0)])
{
let developer_text = request.message_input_texts("developer").join("\n");
assert_eq!(
(
developer_text.matches(COMMIT_ATTRIBUTION).count(),
developer_text.matches(PR_ATTRIBUTION).count(),
developer_text.matches(ATTRIBUTION_DISABLED).count(),
),
(expected.0, expected.0, expected.1)
);
}
server.verify().await;
assert!(
settings_server
.received_requests()
.await
.context("failed to fetch thread-override requests")?
.iter()
.all(|request| request.url.path() != "/backend-api/wham/settings/user"),
"attribution settings must use the process-level base URL"
);
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn cold_resume_replaces_legacy_attribution_without_duplication() -> Result<()> {
skip_if_no_network!(Ok(()));
let server = responses::start_mock_server().await;
let response_mock = responses::mount_sse_sequence(
&server,
["Initial", "Resumed", "Resumed again"]
.into_iter()
.map(create_final_assistant_message_sse_response)
.collect::<Result<Vec<_>>>()?,
)
.await;
Mock::given(method("GET"))
.and(path("/backend-api/wham/config/bundle"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({})))
.mount(&server)
.await;
let settings_requests = Arc::new(AtomicUsize::new(0));
Mock::given(method("GET"))
.and(path("/backend-api/wham/settings/user"))
.and(header("chatgpt-account-id", "workspace-resume"))
.respond_with({
let settings_requests = Arc::clone(&settings_requests);
move |_request: &wiremock::Request| {
ResponseTemplate::new(200).set_body_json(json!({
"commit_attribution_enabled": settings_requests
.fetch_add(1, Ordering::SeqCst)
== 0,
}))
}
})
.expect(2)
.mount(&server)
.await;
let codex_home = TempDir::new()?;
write_mock_responses_config_toml_with_chatgpt_base_url(
codex_home.path(),
&server.uri(),
&format!("{}/backend-api", server.uri()),
)?;
write_chatgpt_auth(
codex_home.path(),
ChatGptAuthFixture::new("chatgpt-token")
.account_id("workspace-resume")
.plan_type("enterprise"),
AuthCredentialsStoreMode::File,
)?;
let mut app_server = TestAppServer::builder()
.with_codex_home(codex_home.path())
.with_env_overrides(&[("OPENAI_API_KEY", None), ("CODEX_ACCESS_TOKEN", None)])
.without_auto_env()
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, app_server.initialize()).await??;
let request_id = app_server
.send_thread_start_request(ThreadStartParams::default())
.await?;
let ThreadStartResponse { thread, .. } = read_response(&mut app_server, request_id).await?;
run_turn(&mut app_server, &thread.id, "persist enabled attribution").await?;
let rollout_path = thread
.path
.context("initial thread should have a rollout path")?;
let status = timeout(DEFAULT_READ_TIMEOUT, app_server.shutdown_gracefully()).await??;
anyhow::ensure!(
status.success(),
"initial app-server did not exit successfully"
);
replace_attribution_fragment_with_legacy(&rollout_path)?;
let mut app_server = TestAppServer::builder()
.with_codex_home(codex_home.path())
.with_env_overrides(&[("OPENAI_API_KEY", None), ("CODEX_ACCESS_TOKEN", None)])
.without_auto_env()
.build()
.await?;
timeout(DEFAULT_READ_TIMEOUT, app_server.initialize()).await??;
let request_id = app_server
.send_thread_resume_request(ThreadResumeParams {
thread_id: thread.id,
..Default::default()
})
.await?;
let ThreadResumeResponse { thread, .. } = read_response(&mut app_server, request_id).await?;
run_turn(&mut app_server, &thread.id, "resume disabled attribution").await?;
run_turn(&mut app_server, &thread.id, "continue disabled attribution").await?;
let requests = response_mock.requests();
assert_eq!(requests.len(), 3);
let initial_text = requests[0].message_input_texts("developer").join("\n");
assert_eq!(initial_text.matches(COMMIT_ATTRIBUTION).count(), 1);
assert_eq!(initial_text.matches(PR_ATTRIBUTION).count(), 1);
assert_eq!(initial_text.matches(ATTRIBUTION_DISABLED).count(), 0);
for request in &requests[1..] {
let developer_text = request.message_input_texts("developer").join("\n");
assert_eq!(developer_text.matches(COMMIT_ATTRIBUTION).count(), 1);
assert_eq!(developer_text.matches(PR_ATTRIBUTION).count(), 0);
assert_eq!(developer_text.matches(ATTRIBUTION_DISABLED).count(), 1);
}
server.verify().await;
Ok(())
}
fn replace_attribution_fragment_with_legacy(rollout_path: &Path) -> Result<()> {
let rollout = std::fs::read_to_string(rollout_path)?;
let mut replaced = false;
let mut removed_saved_attribution = false;
let lines = rollout
.lines()
.filter(|line| !line.trim().is_empty())
.map(|line| {
let mut line = serde_json::from_str::<RolloutLine>(line)?;
if let RolloutItem::ResponseItem(ResponseItem::Message { role, content, .. }) =
&mut line.item
&& role == "developer"
{
for item in content {
if let ContentItem::InputText { text } = item
&& text.contains("<git_attribution>")
{
*text = LEGACY_COMMIT_ATTRIBUTION_INSTRUCTIONS.to_string();
replaced = true;
}
}
}
if let RolloutItem::WorldState(world_state) = &mut line.item
&& let Some(state) = world_state.state.as_object_mut()
&& state.remove("git_attribution").is_some()
{
removed_saved_attribution = true;
}
serde_json::to_string(&line)
})
.collect::<std::result::Result<Vec<_>, _>>()?;
anyhow::ensure!(replaced, "rollout did not contain git attribution context");
anyhow::ensure!(
removed_saved_attribution,
"rollout did not contain saved git attribution state"
);
std::fs::write(rollout_path, format!("{}\n", lines.join("\n")))?;
Ok(())
}
async fn read_response<T: DeserializeOwned>(
app_server: &mut TestAppServer,
request_id: i64,
) -> Result<T> {
let response = timeout(
DEFAULT_READ_TIMEOUT,
app_server.read_stream_until_response_message(RequestId::Integer(request_id)),
)
.await??;
to_response(response)
}
async fn run_turn(app_server: &mut TestAppServer, thread_id: &str, text: &str) -> Result<()> {
timeout(
DEFAULT_READ_TIMEOUT,
app_server.start_turn_and_wait_for_completion(TurnStartParams {
thread_id: thread_id.to_string(),
input: vec![UserInput::Text {
text: text.to_string(),
text_elements: Vec::new(),
}],
..Default::default()
}),
)
.await??;
Ok(())
}
@@ -27,6 +27,7 @@ mod experimental_api;
mod experimental_feature_list;
mod external_agent_config;
mod fs;
mod git_attribution;
mod hooks_list;
mod host_skills;
mod imagegen_extension;
+2
View File
@@ -38,7 +38,9 @@ codex-http-client = { workspace = true }
codex-exec = { workspace = true }
codex-exec-server = { workspace = true }
codex-execpolicy = { workspace = true }
codex-extension-api = { workspace = true }
codex-features = { workspace = true }
codex-git-attribution = { workspace = true }
codex-git-utils = { workspace = true }
codex-install-context = { workspace = true }
codex-login = { workspace = true }
+10
View File
@@ -1999,10 +1999,20 @@ async fn run_debug_prompt_input_command(
let user_instructions_provider = Arc::new(CodexHomeUserInstructionsProvider::new(
config.codex_home.clone(),
));
let auth_manager =
AuthManager::shared_from_config(&config, /*enable_codex_api_key_env*/ false).await;
let mut extensions = codex_extension_api::ExtensionRegistryBuilder::new();
codex_git_attribution::install(
&mut extensions,
auth_manager,
config.chatgpt_base_url.clone(),
config.http_client_factory(),
);
let prompt_input = codex_core::build_prompt_input(
config,
input,
/*state_db*/ None,
Arc::new(extensions.build()),
user_instructions_provider,
)
.await?;
+57
View File
@@ -1,7 +1,13 @@
use std::path::Path;
use std::sync::Arc;
use std::sync::atomic::AtomicUsize;
use std::sync::atomic::Ordering;
use anyhow::Context;
use anyhow::Result;
use app_test_support::ChatGptAuthFixture;
use app_test_support::write_chatgpt_auth;
use codex_config::types::AuthCredentialsStoreMode;
use codex_login::CLIENT_ID;
use codex_login::REVOKE_TOKEN_URL_OVERRIDE_ENV_VAR;
use predicates::str::contains;
@@ -12,6 +18,7 @@ use tempfile::TempDir;
use wiremock::Mock;
use wiremock::MockServer;
use wiremock::ResponseTemplate;
use wiremock::matchers::header;
use wiremock::matchers::method;
use wiremock::matchers::path;
@@ -74,6 +81,56 @@ fn login_with_access_token_rejects_invalid_jwt() -> Result<()> {
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn debug_prompt_input_follows_authenticated_attribution_setting() -> Result<()> {
let server = MockServer::start().await;
let request_count = Arc::new(AtomicUsize::new(0));
Mock::given(method("GET"))
.and(path("/backend-api/wham/settings/user"))
.and(header("chatgpt-account-id", "workspace-123"))
.respond_with(move |_request: &wiremock::Request| {
ResponseTemplate::new(200).set_body_json(json!({
"commit_attribution_enabled": request_count.fetch_add(1, Ordering::SeqCst) == 0,
}))
})
.expect(2)
.mount(&server)
.await;
let codex_home = TempDir::new()?;
std::fs::write(
codex_home.path().join("config.toml"),
format!(
"cli_auth_credentials_store = \"file\"\nchatgpt_base_url = \"{}/backend-api\"\n",
server.uri()
),
)?;
write_chatgpt_auth(
codex_home.path(),
ChatGptAuthFixture::new("chatgpt-token")
.account_id("workspace-123")
.plan_type("enterprise"),
AuthCredentialsStoreMode::File,
)?;
for enabled in [true, false] {
let output = codex_command(codex_home.path())?
.env("NO_PROXY", "127.0.0.1,localhost")
.env("no_proxy", "127.0.0.1,localhost")
.env_remove("CODEX_ACCESS_TOKEN")
.env_remove("OPENAI_API_KEY")
.args(["debug", "prompt-input"])
.output()?;
assert!(output.status.success());
let prompt = String::from_utf8(output.stdout)?;
assert_eq!(
prompt.contains("Co-authored-by: Codex <noreply@openai.com>"),
enabled
);
assert!(!prompt.contains("attribution is disabled for the current workspace"));
}
server.verify().await;
Ok(())
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn device_login_revokes_existing_auth_before_requesting_new_tokens() -> Result<()> {
let server = MockServer::start().await;
+1
View File
@@ -42,6 +42,7 @@ const CONTEXTUAL_DEVELOPER_PREFIXES: &[&str] = &[
COLLABORATION_MODE_OPEN_TAG,
MULTI_AGENT_MODE_OPEN_TAG,
ENVIRONMENTS_INSTRUCTIONS_OPEN_TAG,
"<git_attribution>",
PLUGINS_INSTRUCTIONS_OPEN_TAG,
REALTIME_CONVERSATION_OPEN_TAG,
SKILLS_INSTRUCTIONS_OPEN_TAG,
+3 -2
View File
@@ -2,6 +2,7 @@ use std::sync::Arc;
use codex_exec_server::EnvironmentManager;
use codex_exec_server::ExecServerRuntimePaths;
use codex_extension_api::ExtensionRegistry;
use codex_extension_api::UserInstructionsProvider;
use codex_login::AuthManager;
use codex_protocol::error::CodexErr;
@@ -20,7 +21,6 @@ use crate::state_db_bridge::StateDbHandle;
use crate::thread_manager::StartThreadOptions;
use crate::thread_manager::ThreadManager;
use crate::thread_manager::thread_store_from_config;
use codex_extension_api::empty_extension_registry;
/// Build the model-visible `input` list for a single debug turn.
#[doc(hidden)]
@@ -28,6 +28,7 @@ pub async fn build_prompt_input(
mut config: Config,
input: Vec<UserInput>,
state_db: Option<StateDbHandle>,
extensions: Arc<ExtensionRegistry<Config>>,
user_instructions_provider: Arc<dyn UserInstructionsProvider>,
) -> CodexResult<Vec<ResponseItem>> {
config.ephemeral = true;
@@ -56,7 +57,7 @@ pub async fn build_prompt_input(
.await
.map_err(|err| CodexErr::Fatal(err.to_string()))?,
),
empty_extension_registry(),
extensions,
user_instructions_provider,
/*analytics_events_client*/ None,
thread_store,
@@ -4,6 +4,7 @@ use anyhow::Result;
use codex_core::build_prompt_input;
use codex_core::config::ConfigBuilder;
use codex_core::config::ConfigOverrides;
use codex_extension_api::ExtensionRegistryBuilder;
use codex_home::CodexHomeUserInstructionsProvider;
use codex_protocol::models::ContentItem;
use codex_protocol::models::ResponseItem;
@@ -32,7 +33,6 @@ async fn build_prompt_input_includes_context_and_user_message() -> Result<()> {
let user_instructions_provider = Arc::new(CodexHomeUserInstructionsProvider::new(
config.codex_home.clone(),
));
let input = build_prompt_input(
config,
vec![UserInput::Text {
@@ -40,6 +40,7 @@ async fn build_prompt_input_includes_context_and_user_message() -> Result<()> {
text_elements: Vec::new(),
}],
/*state_db*/ None,
Arc::new(ExtensionRegistryBuilder::new().build()),
user_instructions_provider,
)
.await?;
@@ -74,6 +75,5 @@ async fn build_prompt_input_includes_context_and_user_message() -> Result<()> {
text.contains(TEST_INSTRUCTIONS)
})
}));
Ok(())
}
-2
View File
@@ -21,7 +21,5 @@ serde_json = { workspace = true }
tokio = { workspace = true, features = ["time"] }
[dev-dependencies]
codex-protocol = { workspace = true }
pretty_assertions = { workspace = true }
tokio = { workspace = true, features = ["macros", "rt"] }
wiremock = { workspace = true }
@@ -3,12 +3,6 @@ use std::sync::atomic::AtomicUsize;
use std::sync::atomic::Ordering;
use std::time::Duration;
use codex_extension_api::ExtensionData;
use codex_extension_api::ExtensionRegistry;
use codex_extension_api::ExtensionRegistryBuilder;
use codex_extension_api::PreviousWorldStateSection;
use codex_extension_api::WorldStateContributionInput;
use codex_extension_api::WorldStateSectionContribution;
use codex_http_client::HttpClientFactory;
use codex_http_client::OutboundProxyPolicy;
use codex_login::AuthManager;
@@ -16,9 +10,6 @@ use codex_login::CodexAuth;
use codex_login::ExternalAuth;
use codex_login::ExternalAuthFuture;
use codex_login::ExternalAuthRefreshContext;
use codex_protocol::ThreadId;
use pretty_assertions::assert_eq;
use serde_json::Value;
use tokio::sync::Notify;
use wiremock::Mock;
use wiremock::MockServer;
@@ -26,32 +17,7 @@ use wiremock::ResponseTemplate;
use wiremock::matchers::method;
use wiremock::matchers::path;
use super::install;
use super::policy::resolve_attribution_policy;
use super::world_state::DISABLED_INSTRUCTIONS;
use super::world_state::ENABLED_INSTRUCTIONS;
use super::world_state::LEGACY_COMMIT_ATTRIBUTION_INSTRUCTIONS;
async fn contribute(
registry: &ExtensionRegistry<String>,
thread_store: &ExtensionData,
) -> WorldStateSectionContribution {
let session_store = ExtensionData::new("session");
let turn_store = ExtensionData::new("turn");
registry.context_contributors()[0]
.contribute_world_state(WorldStateContributionInput {
thread_id: ThreadId::new(),
turn_id: "turn",
environments: &[],
ready_selected_capability_roots: &[],
executor_capability_discovery: None,
session_store: &session_store,
thread_store,
turn_store: &turn_store,
})
.await
.remove(0)
}
fn enterprise_auth_manager() -> Arc<AuthManager> {
AuthManager::from_auth_for_testing(enterprise_auth("workspace-123"))
@@ -66,15 +32,6 @@ fn enterprise_auth(account_id: &str) -> CodexAuth {
.expect("fake ChatGPT auth should parse")
}
async fn mount_settings(server: &MockServer, response: ResponseTemplate) {
Mock::given(method("GET"))
.and(path("/backend-api/wham/settings/user"))
.respond_with(response)
.expect(1)
.mount(server)
.await;
}
struct StaticExternalAuth(CodexAuth);
impl ExternalAuth for StaticExternalAuth {
@@ -94,116 +51,6 @@ async fn set_auth(auth_manager: &AuthManager, account_id: &str) {
.expect("auth refresh should succeed");
}
#[tokio::test]
async fn installed_contributor_composes_policy_changes() {
let server = MockServer::start().await;
let base_url = format!("{}/backend-api", server.uri());
let api_key = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("test"));
assert!(
!resolve_attribution_policy(&api_key, &base_url, &http_client_factory())
.await
.expect("API key policy should not time out")
.expect("API key policy should resolve")
.enabled
);
let auth_manager = enterprise_auth_manager();
let mut builder = ExtensionRegistryBuilder::<String>::new();
install(
&mut builder,
auth_manager.clone(),
base_url.clone(),
http_client_factory(),
);
let registry = builder.build();
let thread_store = ExtensionData::new("thread");
mount_settings(
&server,
ResponseTemplate::new(200)
.set_delay(Duration::from_secs(1))
.set_body_json(serde_json::json!({"commit_attribution_enabled": true})),
)
.await;
let unavailable = contribute(&registry, &thread_store).await;
assert_eq!(unavailable.snapshot(), &Value::Bool(false));
assert!(
unavailable
.render_diff(PreviousWorldStateSection::Absent)
.is_none()
);
assert!(!unavailable.has_retained_fragment_matcher());
assert!(
unavailable.matches_legacy_fragment("developer", LEGACY_COMMIT_ATTRIBUTION_INSTRUCTIONS)
);
assert_eq!(
unavailable
.render_diff(PreviousWorldStateSection::Unknown)
.map(|fragment| fragment.body().to_string()),
Some(DISABLED_INSTRUCTIONS.to_string())
);
assert_eq!(
contribute(&registry, &thread_store).await.snapshot(),
&Value::Bool(false)
);
server.verify().await;
server.reset().await;
mount_settings(&server, ResponseTemplate::new(503)).await;
set_auth(auth_manager.as_ref(), "workspace-456").await;
let failed = contribute(&registry, &thread_store).await;
assert_eq!(failed.snapshot(), &Value::Bool(false));
assert!(
failed
.render_diff(PreviousWorldStateSection::Absent)
.is_none()
);
server.verify().await;
server.reset().await;
mount_settings(
&server,
ResponseTemplate::new(200)
.set_body_json(serde_json::json!({"commit_attribution_enabled": true})),
)
.await;
set_auth(auth_manager.as_ref(), "workspace-789").await;
let enabled = contribute(&registry, &thread_store).await;
let enabled_fragment = enabled
.render_diff(PreviousWorldStateSection::Known(&Value::Bool(false)))
.expect("enabled policy should replace disabled policy");
assert_eq!(enabled_fragment.body(), ENABLED_INSTRUCTIONS);
let (start, end) = enabled_fragment.markers();
let rendered = format!("{start}{}{end}", enabled_fragment.body());
assert!(enabled.matches_legacy_fragment(enabled_fragment.role(), &rendered));
assert!(enabled.matches_retained_fragment(enabled_fragment.role(), &rendered));
assert!(!enabled.matches_legacy_fragment("developer", LEGACY_COMMIT_ATTRIBUTION_INSTRUCTIONS));
assert!(
contribute(&registry, &thread_store)
.await
.render_diff(PreviousWorldStateSection::Known(&Value::Bool(true)))
.is_none()
);
server.verify().await;
server.reset().await;
mount_settings(
&server,
ResponseTemplate::new(200)
.set_body_json(serde_json::json!({"commit_attribution_enabled": false})),
)
.await;
set_auth(auth_manager.as_ref(), "workspace-disabled").await;
let disabled = contribute(&registry, &thread_store).await;
assert_eq!(
disabled
.render_diff(PreviousWorldStateSection::Known(&Value::Bool(true)))
.map(|fragment| fragment.body().to_string()),
Some(DISABLED_INSTRUCTIONS.to_string())
);
assert!(
disabled
.render_diff(PreviousWorldStateSection::Absent)
.is_none()
);
server.verify().await;
}
#[tokio::test]
async fn policy_resolution_recovers_after_unauthorized() {
let server = MockServer::start().await;
+2
View File
@@ -25,6 +25,7 @@ codex-home = { workspace = true }
codex-image-generation-extension = { workspace = true }
codex-exec-server = { workspace = true }
codex-extension-api = { workspace = true }
codex-git-attribution = { workspace = true }
codex-login = { workspace = true }
codex-protocol = { workspace = true }
codex-utils-cli = { workspace = true }
@@ -45,6 +46,7 @@ tracing = { workspace = true, features = ["log"] }
tracing-subscriber = { workspace = true, features = ["env-filter", "fmt"] }
[dev-dependencies]
app_test_support = { workspace = true }
codex-utils-absolute-path = { workspace = true }
codex-shell-command = { workspace = true }
core_test_support = { workspace = true }
@@ -67,6 +67,12 @@ impl MessageProcessor {
config.codex_home.clone(),
));
let mut extensions = ExtensionRegistryBuilder::<Config>::new();
codex_git_attribution::install(
&mut extensions,
auth_manager.clone(),
config.chatgpt_base_url.clone(),
config.http_client_factory(),
);
codex_image_generation_extension::install(
&mut extensions,
auth_manager.clone(),
+52 -3
View File
@@ -3,6 +3,9 @@ use std::env;
use std::path::Path;
use std::path::PathBuf;
use app_test_support::ChatGptAuthFixture;
use app_test_support::write_chatgpt_auth;
use codex_config::types::AuthCredentialsStoreMode;
use codex_core::spawn::CODEX_SANDBOX_NETWORK_DISABLED_ENV_VAR;
use codex_mcp_server::CodexToolCallParam;
use codex_mcp_server::ExecApprovalElicitRequestParams;
@@ -19,7 +22,11 @@ use rmcp::model::RequestId;
use serde_json::json;
use tempfile::TempDir;
use tokio::time::timeout;
use wiremock::Mock;
use wiremock::MockServer;
use wiremock::ResponseTemplate;
use wiremock::matchers::method;
use wiremock::matchers::path;
use core_test_support::skip_if_no_network;
use mcp_test_support::McpProcess;
@@ -366,17 +373,39 @@ async fn codex_tool_passes_base_instructions() -> anyhow::Result<()> {
let server =
create_mock_responses_server(vec![create_final_assistant_message_sse_response("Enjoy!")?])
.await;
let caller_server = MockServer::start().await;
// Run `codex mcp` with a specific config.toml.
let codex_home = TempDir::new()?;
create_config_toml(codex_home.path(), &server.uri())?;
let mut mcp_process = McpProcess::new(codex_home.path()).await?;
write_chatgpt_auth(
codex_home.path(),
ChatGptAuthFixture::new("chatgpt-token").account_id("workspace-123"),
AuthCredentialsStoreMode::File,
)?;
Mock::given(method("GET"))
.and(path("/backend-api/wham/settings/user"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"commit_attribution_enabled": true,
})))
.expect(1)
.mount(&server)
.await;
let mut mcp_process = McpProcess::new_with_env(
codex_home.path(),
&[("OPENAI_API_KEY", None), ("CODEX_ACCESS_TOKEN", None)],
)
.await?;
timeout(DEFAULT_READ_TIMEOUT, mcp_process.initialize()).await??;
// Send a "codex" tool request, which should hit the responses endpoint.
let codex_request_id = mcp_process
.send_codex_tool_call(CodexToolCallParam {
prompt: "How are you?".to_string(),
config: Some(HashMap::from([(
"chatgpt_base_url".to_string(),
json!(format!("{}/backend-api", caller_server.uri())),
)])),
base_instructions: Some("You are a helpful assistant.".to_string()),
developer_instructions: Some("Foreshadow upcoming tool calls.".to_string()),
..Default::default()
@@ -412,12 +441,15 @@ async fn codex_tool_passes_base_instructions() -> anyhow::Result<()> {
);
let requests = server.received_requests().await.unwrap();
let request = requests[0].body_json::<serde_json::Value>()?;
let request = requests
.iter()
.find(|request| request.url.path() == "/v1/responses")
.expect("mock model request should be recorded")
.body_json::<serde_json::Value>()?;
let instructions = request["instructions"]
.as_str()
.expect("responses request should include instructions");
assert!(instructions.starts_with("You are a helpful assistant."));
let developer_messages: Vec<&serde_json::Value> = request["input"]
.as_array()
.expect("responses request should include input items")
@@ -431,6 +463,14 @@ async fn codex_tool_passes_base_instructions() -> anyhow::Result<()> {
.filter(|span| span.get("type").and_then(serde_json::Value::as_str) == Some("input_text"))
.filter_map(|span| span.get("text").and_then(serde_json::Value::as_str))
.collect();
let developer_text = developer_contents.join("\n");
assert_eq!(
developer_text
.matches("Co-authored-by: Codex <noreply@openai.com>")
.count(),
1
);
assert_eq!(developer_text.matches("Generated with Codex.").count(), 1);
assert!(
developer_contents
.iter()
@@ -441,6 +481,13 @@ async fn codex_tool_passes_base_instructions() -> anyhow::Result<()> {
developer_contents.contains(&"Foreshadow upcoming tool calls."),
"expected developer instructions in developer messages, got {developer_contents:?}"
);
let caller_requests = caller_server.received_requests().await.unwrap();
assert!(
caller_requests
.iter()
.all(|request| request.url.path() != "/backend-api/wham/settings/user"),
"attribution settings must use the process-level base URL"
);
Ok(())
}
@@ -513,6 +560,8 @@ approval_policy = "untrusted"
sandbox_policy = "workspace-write"
model_provider = "mock_provider"
chatgpt_base_url = "{server_uri}/backend-api"
cli_auth_credentials_store = "file"
[model_providers.mock_provider]
name = "Mock provider for test"