mirror of
https://github.com/openai/codex.git
synced 2026-10-16 16:58:46 +00:00
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:
Generated
+5
-2
@@ -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",
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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?;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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(®istry, &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(®istry, &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(®istry, &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(®istry, &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(®istry, &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(®istry, &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;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user