Compare commits

...

3 Commits

Author SHA1 Message Date
jif-oai
b5029a6736 extension: move git attribution into an extension 2026-05-08 14:39:54 +02:00
jif-oai
a57a747eb6 extension: wire extension registries 2026-05-08 14:37:38 +02:00
jif-oai
291af4c6d5 extension: add codex-extension-api 2026-05-08 14:33:20 +02:00
45 changed files with 973 additions and 87 deletions

25
codex-rs/Cargo.lock generated
View File

@@ -1895,11 +1895,13 @@ dependencies = [
"codex-core",
"codex-core-plugins",
"codex-exec-server",
"codex-extension-api",
"codex-external-agent-migration",
"codex-external-agent-sessions",
"codex-features",
"codex-feedback",
"codex-file-search",
"codex-git-attribution",
"codex-git-utils",
"codex-hooks",
"codex-login",
@@ -2471,6 +2473,7 @@ dependencies = [
"codex-core-skills",
"codex-exec-server",
"codex-execpolicy",
"codex-extension-api",
"codex-features",
"codex-feedback",
"codex-git-utils",
@@ -2575,6 +2578,7 @@ dependencies = [
"codex-config",
"codex-core",
"codex-exec-server",
"codex-extension-api",
"codex-features",
"codex-login",
"codex-model-provider-info",
@@ -2792,6 +2796,16 @@ dependencies = [
"syn 2.0.114",
]
[[package]]
name = "codex-extension-api"
version = "0.0.0"
dependencies = [
"codex-protocol",
"codex-tools",
"serde_json",
"thiserror 2.0.18",
]
[[package]]
name = "codex-external-agent-migration"
version = "0.0.0"
@@ -2870,6 +2884,15 @@ dependencies = [
"serde",
]
[[package]]
name = "codex-git-attribution"
version = "0.0.0"
dependencies = [
"codex-core",
"codex-extension-api",
"codex-features",
]
[[package]]
name = "codex-git-utils"
version = "0.0.0"
@@ -3055,6 +3078,7 @@ dependencies = [
"codex-config",
"codex-core",
"codex-exec-server",
"codex-extension-api",
"codex-login",
"codex-protocol",
"codex-shell-command",
@@ -4263,6 +4287,7 @@ dependencies = [
"codex-config",
"codex-core",
"codex-exec-server",
"codex-extension-api",
"codex-features",
"codex-hooks",
"codex-login",

View File

@@ -44,6 +44,8 @@ members = [
"exec-server",
"execpolicy",
"execpolicy-legacy",
"ext/extension-api",
"ext/git-attribution",
"external-agent-migration",
"external-agent-sessions",
"keyring-store",
@@ -157,6 +159,8 @@ codex-exec = { path = "exec" }
codex-file-system = { path = "file-system" }
codex-exec-server = { path = "exec-server" }
codex-execpolicy = { path = "execpolicy" }
codex-extension-api = { path = "ext/extension-api" }
codex-git-attribution = { path = "ext/git-attribution" }
codex-external-agent-migration = { path = "external-agent-migration" }
codex-external-agent-sessions = { path = "external-agent-sessions" }
codex-experimental-api-macros = { path = "codex-experimental-api-macros" }
@@ -464,6 +468,7 @@ unwrap_used = "deny"
[workspace.metadata.cargo-shear]
ignored = [
"codex-agent-graph-store",
"codex-extension-api",
"codex-memories-mcp",
"icu_provider",
"openssl-sys",

View File

@@ -37,9 +37,11 @@ codex-config = { workspace = true }
codex-core = { workspace = true }
codex-core-plugins = { workspace = true }
codex-exec-server = { workspace = true }
codex-extension-api = { workspace = true }
codex-external-agent-migration = { workspace = true }
codex-external-agent-sessions = { workspace = true }
codex-features = { workspace = true }
codex-git-attribution = { workspace = true }
codex-git-utils = { workspace = true }
codex-hooks = { workspace = true }
codex-otel = { workspace = true }

View File

@@ -0,0 +1,13 @@
use std::sync::Arc;
use codex_core::config::Config;
use codex_extension_api::ExtensionRegistry;
use codex_extension_api::ExtensionRegistryBuilder;
pub(crate) fn thread_extensions() -> Arc<ExtensionRegistry<Config>> {
Arc::new(
ExtensionRegistryBuilder::<Config>::new()
.with_extension(codex_git_attribution::extension())
.build(),
)
}

View File

@@ -82,6 +82,7 @@ mod config_manager_service;
mod connection_rpc_gate;
mod dynamic_tools;
mod error_code;
mod extensions;
mod filters;
mod fs_watch;
mod fuzzy_file_search;

View File

@@ -99,6 +99,7 @@ async fn queue_refresh(
#[cfg(test)]
mod tests {
use super::*;
use crate::extensions::thread_extensions;
use async_trait::async_trait;
use codex_arg0::Arg0DispatchPaths;
use codex_config::CloudRequirementsLoader;
@@ -183,6 +184,7 @@ mod tests {
auth_manager,
SessionSource::Exec,
Arc::new(EnvironmentManager::default_for_tests()),
thread_extensions(),
/*analytics_events_client*/ None,
thread_store,
Some(state_db.clone()),

View File

@@ -7,6 +7,7 @@ use std::sync::atomic::AtomicBool;
use crate::config_manager::ConfigManager;
use crate::connection_rpc_gate::ConnectionRpcGate;
use crate::error_code::invalid_request;
use crate::extensions::thread_extensions;
use crate::fs_watch::FsWatchManager;
use crate::outgoing_message::ConnectionId;
use crate::outgoing_message::ConnectionRequestId;
@@ -289,6 +290,7 @@ impl MessageProcessor {
auth_manager.clone(),
session_source,
environment_manager,
thread_extensions(),
Some(analytics_events_client.clone()),
Arc::clone(&thread_store),
state_db.clone(),

View File

@@ -19,6 +19,7 @@ codex-arg0 = { workspace = true }
codex-analytics = { workspace = true }
codex-config = { workspace = true }
codex-core = { workspace = true }
codex-extension-api = { workspace = true }
codex-exec-server = { workspace = true }
codex-features = { workspace = true }
codex-login = { workspace = true }

View File

@@ -46,6 +46,7 @@ pub use codex_core::thread_store_from_config;
pub use codex_exec_server::EnvironmentManager;
pub use codex_exec_server::EnvironmentManagerArgs;
pub use codex_exec_server::ExecServerRuntimePaths;
pub use codex_extension_api::empty_extension_registry;
pub use codex_features::Feature;
pub use codex_features::Features;
pub use codex_login::AuthManager;

View File

@@ -35,6 +35,7 @@ codex-config = { workspace = true }
codex-core-plugins = { workspace = true }
codex-core-skills = { workspace = true }
codex-exec-server = { workspace = true }
codex-extension-api = { workspace = true }
codex-features = { workspace = true }
codex-feedback = { workspace = true }
codex-login = { workspace = true }

View File

@@ -83,6 +83,7 @@ pub(crate) async fn run_codex_thread_interactive(
skills_manager: Arc::clone(&parent_session.services.skills_manager),
plugins_manager: Arc::clone(&parent_session.services.plugins_manager),
mcp_manager: Arc::clone(&parent_session.services.mcp_manager),
extensions: Arc::clone(&parent_session.services.extensions),
skills_watcher: Arc::clone(&parent_session.services.skills_watcher),
conversation_history: initial_history.unwrap_or(InitialHistory::New),
session_source: SessionSource::SubAgent(subagent_source.clone()),

View File

@@ -1,33 +0,0 @@
const DEFAULT_ATTRIBUTION_VALUE: &str = "Codex <noreply@openai.com>";
fn build_commit_message_trailer(config_attribution: Option<&str>) -> Option<String> {
let value = resolve_attribution_value(config_attribution)?;
Some(format!("Co-authored-by: {value}"))
}
pub(crate) fn commit_message_trailer_instruction(
config_attribution: Option<&str>,
) -> Option<String> {
let trailer = build_commit_message_trailer(config_attribution)?;
Some(format!(
"When you write or edit a git commit message, ensure the message ends with this trailer exactly once:\n{trailer}\n\nRules:\n- Keep existing trailers and append this trailer at the end if missing.\n- Do not duplicate this trailer if it already exists.\n- Keep one blank line between the commit body and trailer block."
))
}
fn resolve_attribution_value(config_attribution: Option<&str>) -> Option<String> {
match config_attribution {
Some(value) => {
let trimmed = value.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
}
None => Some(DEFAULT_ATTRIBUTION_VALUE.to_string()),
}
}
#[cfg(test)]
#[path = "commit_attribution_tests.rs"]
mod tests;

View File

@@ -1,43 +0,0 @@
use super::build_commit_message_trailer;
use super::commit_message_trailer_instruction;
use super::resolve_attribution_value;
#[test]
fn blank_attribution_disables_trailer_prompt() {
assert_eq!(build_commit_message_trailer(Some("")), None);
assert_eq!(commit_message_trailer_instruction(Some(" ")), None);
}
#[test]
fn default_attribution_uses_codex_trailer() {
assert_eq!(
build_commit_message_trailer(/*config_attribution*/ None).as_deref(),
Some("Co-authored-by: Codex <noreply@openai.com>")
);
}
#[test]
fn resolve_value_handles_default_custom_and_blank() {
assert_eq!(
resolve_attribution_value(/*config_attribution*/ None),
Some("Codex <noreply@openai.com>".to_string())
);
assert_eq!(
resolve_attribution_value(Some("MyAgent <me@example.com>")),
Some("MyAgent <me@example.com>".to_string())
);
assert_eq!(
resolve_attribution_value(Some("MyAgent")),
Some("MyAgent".to_string())
);
assert_eq!(resolve_attribution_value(Some(" ")), None);
}
#[test]
fn instruction_mentions_trailer_and_omits_generated_with() {
let instruction = commit_message_trailer_instruction(Some("AgentX <agent@example.com>"))
.expect("instruction expected");
assert!(instruction.contains("Co-authored-by: AgentX <agent@example.com>"));
assert!(instruction.contains("exactly once"));
assert!(!instruction.contains("Generated-with"));
}

View File

@@ -22,10 +22,10 @@ mod config_lock;
pub use codex_thread::CodexThread;
pub use codex_thread::CodexThreadTurnContextOverrides;
pub use codex_thread::ThreadConfigSnapshot;
pub use session::turn_context::TurnContext;
mod agent;
mod codex_delegate;
mod command_canonicalization;
mod commit_attribution;
pub mod config;
pub mod connectors;
pub mod context;

View File

@@ -20,6 +20,7 @@ use crate::session::turn::built_tools;
use crate::state_db_bridge::StateDbHandle;
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)]
@@ -45,6 +46,7 @@ pub async fn build_prompt_input(
Arc::clone(&auth_manager),
SessionSource::Exec,
Arc::new(EnvironmentManager::new(EnvironmentManagerArgs::new(local_runtime_paths)).await),
empty_extension_registry(),
/*analytics_events_client*/ None,
thread_store,
state_db.clone(),

View File

@@ -15,7 +15,6 @@ use crate::agent::MailboxReceiver;
use crate::agent::agent_status_from_event;
use crate::agent::status::is_final;
use crate::build_available_skills;
use crate::commit_attribution::commit_message_trailer_instruction;
use crate::compact;
use crate::config::ManagedFeatures;
use crate::config::resolve_tool_suggest_config_from_layer_stack;
@@ -52,6 +51,7 @@ use codex_config::types::OAuthCredentialsStoreMode;
use codex_exec_server::Environment;
use codex_exec_server::EnvironmentManager;
use codex_exec_server::FileSystemSandboxContext;
use codex_extension_api::PromptSlot;
use codex_features::FEATURES;
use codex_features::Feature;
use codex_features::unstable_features_warning_event;
@@ -392,6 +392,7 @@ pub(crate) struct CodexSpawnArgs {
pub(crate) skills_manager: Arc<SkillsManager>,
pub(crate) plugins_manager: Arc<PluginsManager>,
pub(crate) mcp_manager: Arc<McpManager>,
pub(crate) extensions: Arc<codex_extension_api::ExtensionRegistry<crate::config::Config>>,
pub(crate) skills_watcher: Arc<SkillsWatcher>,
pub(crate) conversation_history: InitialHistory,
pub(crate) session_source: SessionSource,
@@ -455,6 +456,7 @@ impl Codex {
skills_manager,
plugins_manager,
mcp_manager,
extensions,
skills_watcher,
conversation_history,
session_source,
@@ -650,6 +652,7 @@ impl Codex {
skills_manager,
plugins_manager,
mcp_manager.clone(),
extensions,
skills_watcher,
agent_control,
environment_manager,
@@ -2588,6 +2591,7 @@ impl Session {
) -> Vec<ResponseItem> {
let mut developer_sections = Vec::<String>::with_capacity(8);
let mut contextual_user_sections = Vec::<String>::with_capacity(2);
let mut separate_developer_sections = Vec::<String>::new();
let (
reference_context_item,
previous_turn_settings,
@@ -2725,12 +2729,23 @@ impl Session {
{
developer_sections.push(plugin_instructions.render());
}
if turn_context.features.enabled(Feature::CodexGitCommit)
&& let Some(commit_message_instruction) = commit_message_trailer_instruction(
turn_context.config.commit_attribution.as_deref(),
)
{
developer_sections.push(commit_message_instruction);
for contributor in self.services.extensions.context_contributors() {
for fragment in contributor.contribute(
&self.services.session_extension_data,
&self.services.thread_extension_data,
) {
match fragment.slot() {
PromptSlot::DeveloperPolicy | PromptSlot::DeveloperCapabilities => {
developer_sections.push(fragment.text().to_string());
}
PromptSlot::ContextualUser => {
contextual_user_sections.push(fragment.text().to_string());
}
PromptSlot::SeparateDeveloper => {
separate_developer_sections.push(fragment.text().to_string());
}
}
}
}
if let Some(user_instructions) = turn_context.user_instructions.as_deref() {
contextual_user_sections.push(
@@ -2764,6 +2779,13 @@ impl Session {
{
items.push(developer_message);
}
for section in separate_developer_sections {
if let Some(developer_message) =
crate::context_manager::updates::build_developer_update_item(vec![section])
{
items.push(developer_message);
}
}
if let Some(usage_hint_text) = multi_agent_v2_usage_hint_text
&& let Some(usage_hint_message) =
crate::context_manager::updates::build_developer_update_item(vec![

View File

@@ -364,6 +364,7 @@ impl Session {
skills_manager: Arc<SkillsManager>,
plugins_manager: Arc<PluginsManager>,
mcp_manager: Arc<McpManager>,
extensions: Arc<codex_extension_api::ExtensionRegistry<crate::config::Config>>,
skills_watcher: Arc<SkillsWatcher>,
agent_control: AgentControl,
environment_manager: Arc<EnvironmentManager>,
@@ -810,6 +811,16 @@ impl Session {
SessionId::from(thread_id)
};
let agent_control = agent_control.with_session_id(session_id);
let session_extension_data = codex_extension_api::ExtensionData::new();
let thread_extension_data = codex_extension_api::ExtensionData::new();
for contributor in extensions.thread_start_contributors() {
contributor.contribute(
config.as_ref(),
&session_extension_data,
&thread_extension_data,
);
}
let services = SessionServices {
// Initialize the MCP connection manager with an uninitialized
// instance. It will be replaced with one created via
@@ -845,6 +856,10 @@ impl Session {
skills_manager,
plugins_manager: Arc::clone(&plugins_manager),
mcp_manager: Arc::clone(&mcp_manager),
extensions,
// TODO(jif): extract session to share between sub-agents
session_extension_data,
thread_extension_data,
skills_watcher,
agent_control,
network_proxy,

View File

@@ -3724,6 +3724,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() {
skills_manager,
plugins_manager,
mcp_manager,
Arc::new(codex_extension_api::ExtensionRegistryBuilder::new().build()),
Arc::new(SkillsWatcher::noop()),
AgentControl::default(),
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
@@ -3871,6 +3872,9 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) {
skills_manager,
plugins_manager,
mcp_manager,
extensions: Arc::new(codex_extension_api::ExtensionRegistryBuilder::new().build()),
session_extension_data: codex_extension_api::ExtensionData::new(),
thread_extension_data: codex_extension_api::ExtensionData::new(),
skills_watcher,
agent_control,
network_proxy: None,
@@ -4060,6 +4064,7 @@ async fn make_session_with_config_and_rx(
skills_manager,
plugins_manager,
mcp_manager,
Arc::new(codex_extension_api::ExtensionRegistryBuilder::new().build()),
Arc::new(SkillsWatcher::noop()),
AgentControl::default(),
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
@@ -4162,6 +4167,7 @@ async fn make_session_with_history_source_and_agent_control_and_rx(
skills_manager,
plugins_manager,
mcp_manager,
Arc::new(codex_extension_api::ExtensionRegistryBuilder::new().build()),
Arc::new(SkillsWatcher::noop()),
agent_control,
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
@@ -5586,6 +5592,9 @@ where
skills_manager,
plugins_manager,
mcp_manager,
extensions: Arc::new(codex_extension_api::ExtensionRegistryBuilder::new().build()),
session_extension_data: codex_extension_api::ExtensionData::new(),
thread_extension_data: codex_extension_api::ExtensionData::new(),
skills_watcher,
agent_control,
network_proxy: None,
@@ -6132,6 +6141,73 @@ async fn make_multi_agent_v2_usage_hint_test_session(
(session, turn_context)
}
struct GitAttributionTestContributor;
struct GitAttributionTestState;
impl codex_extension_api::ContextContributor for GitAttributionTestContributor {
fn contribute(
&self,
_session_store: &codex_extension_api::ExtensionData,
thread_store: &codex_extension_api::ExtensionData,
) -> Vec<codex_extension_api::PromptFragment> {
thread_store
.get::<GitAttributionTestState>()
.is_some()
.then(|| {
codex_extension_api::PromptFragment::developer_policy(
"git attribution extension enabled",
)
})
.into_iter()
.collect()
}
}
fn git_attribution_test_registry()
-> Arc<codex_extension_api::ExtensionRegistry<crate::config::Config>> {
let mut builder = codex_extension_api::ExtensionRegistryBuilder::new();
builder.prompt_contributor(Arc::new(GitAttributionTestContributor));
Arc::new(builder.build())
}
#[tokio::test]
async fn build_initial_context_includes_git_attribution_from_extensions() {
let (mut session, turn_context) = make_session_and_context().await;
session.services.extensions = git_attribution_test_registry();
session
.services
.thread_extension_data
.insert(GitAttributionTestState);
let initial_context = session.build_initial_context(&turn_context).await;
let developer_messages = developer_message_texts(&initial_context);
assert!(
developer_messages
.iter()
.flatten()
.any(|text| *text == "git attribution extension enabled"),
"expected git attribution developer text, got {developer_messages:?}"
);
}
#[tokio::test]
async fn build_initial_context_omits_git_attribution_when_feature_is_disabled() {
let (mut session, turn_context) = make_session_and_context().await;
session.services.extensions = git_attribution_test_registry();
let initial_context = session.build_initial_context(&turn_context).await;
let developer_messages = developer_message_texts(&initial_context);
assert!(
!developer_messages
.iter()
.flatten()
.any(|text| *text == "git attribution extension enabled"),
"did not expect git attribution developer text, got {developer_messages:?}"
);
}
#[tokio::test]
async fn build_initial_context_adds_multi_agent_v2_root_usage_hint_as_developer_message() {
let (session, turn_context) =

View File

@@ -743,6 +743,7 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() {
skills_manager,
plugins_manager,
mcp_manager,
extensions: codex_extension_api::empty_extension_registry(),
skills_watcher,
conversation_history: InitialHistory::New,
session_source: SessionSource::SubAgent(SubAgentSource::Other(

View File

@@ -52,11 +52,11 @@ impl TurnEnvironment {
/// The context needed for a single turn of the thread.
#[derive(Debug)]
pub(crate) struct TurnContext {
pub struct TurnContext {
pub(crate) sub_id: String,
pub(crate) trace_id: Option<String>,
pub(crate) realtime_active: bool,
pub(crate) config: Arc<Config>,
pub config: Arc<Config>,
pub(crate) auth_manager: Option<Arc<AuthManager>>,
pub(crate) model_info: ModelInfo,
pub(crate) session_telemetry: SessionTelemetry,
@@ -84,7 +84,7 @@ pub(crate) struct TurnContext {
pub(crate) windows_sandbox_level: WindowsSandboxLevel,
pub(crate) shell_environment_policy: ShellEnvironmentPolicy,
pub(crate) tools_config: ToolsConfig,
pub(crate) features: ManagedFeatures,
pub features: ManagedFeatures,
pub(crate) ghost_snapshot: GhostSnapshotConfig,
pub(crate) final_output_json_schema: Option<Value>,
pub(crate) codex_self_exe: Option<PathBuf>,

View File

@@ -18,6 +18,8 @@ use arc_swap::ArcSwap;
use codex_analytics::AnalyticsEventsClient;
use codex_core_plugins::PluginsManager;
use codex_exec_server::EnvironmentManager;
use codex_extension_api::ExtensionData;
use codex_extension_api::ExtensionRegistry;
use codex_hooks::Hooks;
use codex_login::AuthManager;
use codex_mcp::McpConnectionManager;
@@ -59,6 +61,9 @@ pub(crate) struct SessionServices {
pub(crate) skills_manager: Arc<SkillsManager>,
pub(crate) plugins_manager: Arc<PluginsManager>,
pub(crate) mcp_manager: Arc<McpManager>,
pub(crate) extensions: Arc<ExtensionRegistry<crate::config::Config>>,
pub(crate) session_extension_data: ExtensionData,
pub(crate) thread_extension_data: ExtensionData,
pub(crate) skills_watcher: Arc<SkillsWatcher>,
pub(crate) agent_control: AgentControl,
pub(crate) network_proxy: Option<StartedNetworkProxy>,

View File

@@ -22,6 +22,8 @@ use codex_app_server_protocol::ThreadHistoryBuilder;
use codex_app_server_protocol::TurnStatus;
use codex_core_plugins::PluginsManager;
use codex_exec_server::EnvironmentManager;
use codex_extension_api::ExtensionRegistry;
use codex_extension_api::empty_extension_registry;
use codex_login::AuthManager;
use codex_login::CodexAuth;
use codex_model_provider::create_model_provider;
@@ -246,6 +248,7 @@ pub(crate) struct ThreadManagerState {
skills_manager: Arc<SkillsManager>,
plugins_manager: Arc<PluginsManager>,
mcp_manager: Arc<McpManager>,
extensions: Arc<ExtensionRegistry<Config>>,
skills_watcher: Arc<SkillsWatcher>,
thread_store: Arc<dyn ThreadStore>,
session_source: SessionSource,
@@ -287,6 +290,7 @@ impl ThreadManager {
auth_manager: Arc<AuthManager>,
session_source: SessionSource,
environment_manager: Arc<EnvironmentManager>,
extensions: Arc<ExtensionRegistry<Config>>,
analytics_events_client: Option<AnalyticsEventsClient>,
thread_store: Arc<dyn ThreadStore>,
state_db: Option<StateDbHandle>,
@@ -315,6 +319,7 @@ impl ThreadManager {
skills_manager,
plugins_manager,
mcp_manager,
extensions,
skills_watcher,
thread_store,
auth_manager,
@@ -416,6 +421,7 @@ impl ThreadManager {
skills_manager,
plugins_manager,
mcp_manager,
extensions: empty_extension_registry(),
skills_watcher,
thread_store,
auth_manager,
@@ -1188,6 +1194,7 @@ impl ThreadManagerState {
skills_manager: Arc::clone(&self.skills_manager),
plugins_manager: Arc::clone(&self.plugins_manager),
mcp_manager: Arc::clone(&self.mcp_manager),
extensions: Arc::clone(&self.extensions),
skills_watcher: Arc::clone(&self.skills_watcher),
conversation_history: initial_history,
session_source,

View File

@@ -7,6 +7,7 @@ use crate::session::session::SessionSettingsUpdate;
use crate::session::tests::make_session_and_context;
use crate::tasks::InterruptedTurnHistoryMarker;
use crate::tasks::interrupted_turn_history_marker;
use codex_extension_api::empty_extension_registry;
use codex_features::Feature;
use codex_models_manager::manager::RefreshStrategy;
use codex_protocol::models::ContentItem;
@@ -397,6 +398,7 @@ async fn resume_and_fork_do_not_restore_thread_environments_from_rollout() {
auth_manager.clone(),
SessionSource::Exec,
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
empty_extension_registry(),
/*analytics_events_client*/ None,
thread_store_from_config(&config, /*state_db*/ None),
/*state_db*/ None,
@@ -513,6 +515,7 @@ async fn explicit_installation_id_skips_codex_home_file() {
auth_manager,
SessionSource::Exec,
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
empty_extension_registry(),
/*analytics_events_client*/ None,
thread_store,
state_db.clone(),
@@ -550,6 +553,7 @@ async fn resume_active_thread_from_rollout_returns_running_thread() {
auth_manager.clone(),
SessionSource::Exec,
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
empty_extension_registry(),
/*analytics_events_client*/ None,
thread_store_from_config(&config, /*state_db*/ None),
/*state_db*/ None,
@@ -605,6 +609,7 @@ async fn resume_stopped_thread_from_rollout_spawns_new_thread() {
auth_manager.clone(),
SessionSource::Exec,
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
empty_extension_registry(),
/*analytics_events_client*/ None,
thread_store_from_config(&config, /*state_db*/ None),
/*state_db*/ None,
@@ -667,6 +672,7 @@ async fn resume_stopped_thread_from_rollout_preserves_thread_source() {
auth_manager.clone(),
SessionSource::Exec,
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
empty_extension_registry(),
/*analytics_events_client*/ None,
thread_store,
state_db.clone(),
@@ -755,6 +761,7 @@ async fn rollout_path_resume_and_fork_read_history_through_thread_store() {
auth_manager.clone(),
SessionSource::Exec,
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
empty_extension_registry(),
/*analytics_events_client*/ None,
thread_store.clone(),
state_db,
@@ -856,6 +863,7 @@ async fn new_uses_active_provider_for_model_refresh() {
auth_manager,
SessionSource::Exec,
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
empty_extension_registry(),
/*analytics_events_client*/ None,
thread_store_from_config(&config, /*state_db*/ None),
/*state_db*/ None,
@@ -1070,6 +1078,7 @@ async fn interrupted_fork_snapshot_does_not_synthesize_turn_id_for_legacy_histor
auth_manager.clone(),
SessionSource::Exec,
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
empty_extension_registry(),
/*analytics_events_client*/ None,
thread_store_from_config(&config, state_db.clone()),
state_db.clone(),
@@ -1176,6 +1185,7 @@ async fn interrupted_fork_snapshot_preserves_explicit_turn_id() {
auth_manager.clone(),
SessionSource::Exec,
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
empty_extension_registry(),
/*analytics_events_client*/ None,
thread_store_from_config(&config, state_db.clone()),
state_db.clone(),
@@ -1271,6 +1281,7 @@ async fn interrupted_fork_snapshot_uses_persisted_mid_turn_history_without_live_
auth_manager.clone(),
SessionSource::Exec,
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
empty_extension_registry(),
/*analytics_events_client*/ None,
thread_store_from_config(&config, state_db.clone()),
state_db.clone(),
@@ -1412,6 +1423,7 @@ async fn resumed_thread_keeps_paused_goal_paused() -> anyhow::Result<()> {
auth_manager.clone(),
SessionSource::Exec,
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
empty_extension_registry(),
/*analytics_events_client*/ None,
thread_store_from_config(&config, state_db.clone()),
state_db.clone(),

View File

@@ -15,6 +15,7 @@ use crate::tools::handlers::multi_agents_v2::SendMessageHandler as SendMessageHa
use crate::tools::handlers::multi_agents_v2::SpawnAgentHandler as SpawnAgentHandlerV2;
use crate::tools::handlers::multi_agents_v2::WaitAgentHandler as WaitAgentHandlerV2;
use crate::turn_diff_tracker::TurnDiffTracker;
use codex_extension_api::empty_extension_registry;
use codex_features::Feature;
use codex_login::AuthManager;
use codex_login::CodexAuth;
@@ -3163,6 +3164,7 @@ async fn tool_handlers_cascade_close_and_resume_and_keep_explicitly_closed_subtr
AuthManager::from_auth_for_testing(CodexAuth::from_api_key("dummy")),
SessionSource::Exec,
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
empty_extension_registry(),
/*analytics_events_client*/ None,
thread_store_from_config(&config, state_db.clone()),
state_db.clone(),

View File

@@ -18,6 +18,7 @@ base64 = { workspace = true }
codex-arg0 = { workspace = true }
codex-config = { workspace = true }
codex-core = { workspace = true }
codex-extension-api = { workspace = true }
codex-exec-server = { workspace = true }
codex-features = { workspace = true }
codex-hooks = { workspace = true }

View File

@@ -23,6 +23,7 @@ use codex_core::thread_store_from_config;
use codex_exec_server::CreateDirectoryOptions;
use codex_exec_server::ExecutorFileSystem;
use codex_exec_server::RemoveOptions;
use codex_extension_api::empty_extension_registry;
use codex_features::Feature;
use codex_login::CodexAuth;
use codex_model_provider_info::ModelProviderInfo;
@@ -431,6 +432,7 @@ impl TestCodexBuilder {
codex_core::test_support::auth_manager_from_auth(auth.clone()),
SessionSource::Exec,
Arc::clone(&environment_manager),
empty_extension_registry(),
/*analytics_events_client*/ None,
thread_store,
state_db.clone(),

View File

@@ -7,6 +7,7 @@ use codex_core::ResponseEvent;
use codex_core::ThreadManager;
use codex_core::resolve_installation_id;
use codex_core::thread_store_from_config;
use codex_extension_api::empty_extension_registry;
use codex_features::Feature;
use codex_login::AuthManager;
use codex_login::CodexAuth;
@@ -1122,6 +1123,7 @@ async fn prefers_apikey_when_config_prefers_apikey_even_with_chatgpt_tokens() {
auth_manager,
SessionSource::Exec,
Arc::new(codex_exec_server::EnvironmentManager::default_for_tests()),
empty_extension_registry(),
/*analytics_events_client*/ None,
thread_store_from_config(&config, /*state_db*/ None),
/*state_db*/ None,

View File

@@ -0,0 +1,6 @@
load("//:defs.bzl", "codex_rust_crate")
codex_rust_crate(
name = "extension-api",
crate_name = "codex_extension_api",
)

View File

@@ -0,0 +1,18 @@
[package]
edition.workspace = true
license.workspace = true
name = "codex-extension-api"
version.workspace = true
[lib]
name = "codex_extension_api"
path = "src/lib.rs"
[lints]
workspace = true
[dependencies]
codex-protocol = { workspace = true }
codex-tools = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }

View File

@@ -0,0 +1,68 @@
#[path = "enabled_extensions/shared_state_extension.rs"]
mod shared_state_extension;
use std::sync::Arc;
use codex_extension_api::ExtensionData;
use codex_extension_api::ExtensionRegistryBuilder;
use shared_state_extension::SharedStateExtension;
use shared_state_extension::recorded_style_contributions;
use shared_state_extension::recorded_usage_contributions;
fn main() {
// 1. Build the extension value owned by the host.
let extension = Arc::new(SharedStateExtension);
// 2. Install it into the registry for the thread-start input type this host exposes.
let registry = ExtensionRegistryBuilder::<()>::new()
.with_extension(extension)
.build();
// 3. The host decides which stores are shared.
let session_store = ExtensionData::new();
let first_thread_store = ExtensionData::new();
let second_thread_store = ExtensionData::new();
// 4. Reusing the same session store shares session state across threads.
let first_thread_fragments = contribute_prompt(&registry, &session_store, &first_thread_store);
contribute_prompt(&registry, &session_store, &first_thread_store);
contribute_prompt(&registry, &session_store, &second_thread_store);
println!("first prompt fragments: {}", first_thread_fragments.len());
println!(
"session style contributions: {}",
recorded_style_contributions(&session_store)
);
println!(
"session usage contributions: {}",
recorded_usage_contributions(&session_store)
);
println!(
"first thread style contributions: {}",
recorded_style_contributions(&first_thread_store)
);
println!(
"first thread usage contributions: {}",
recorded_usage_contributions(&first_thread_store)
);
println!(
"second thread style contributions: {}",
recorded_style_contributions(&second_thread_store)
);
println!(
"second thread usage contributions: {}",
recorded_usage_contributions(&second_thread_store)
);
}
fn contribute_prompt(
registry: &codex_extension_api::ExtensionRegistry<()>,
session_store: &ExtensionData,
thread_store: &ExtensionData,
) -> Vec<codex_extension_api::PromptFragment> {
registry
.context_contributors()
.iter()
.flat_map(|contributor| contributor.contribute(session_store, thread_store))
.collect()
}

View File

@@ -0,0 +1,100 @@
use std::sync::Arc;
use std::sync::atomic::AtomicU64;
use std::sync::atomic::Ordering;
use codex_extension_api::CodexExtension;
use codex_extension_api::ContextContributor;
use codex_extension_api::ExtensionData;
use codex_extension_api::ExtensionRegistryBuilder;
use codex_extension_api::PromptFragment;
/// Small tutorial extension that installs two prompt contributors.
#[derive(Debug, Default)]
pub struct SharedStateExtension;
impl CodexExtension<()> for SharedStateExtension {
fn install(self: Arc<Self>, registry: &mut ExtensionRegistryBuilder<()>) {
registry.prompt_contributor(Arc::new(StyleContributor));
registry.prompt_contributor(Arc::new(UsageContributor));
}
}
#[derive(Debug)]
struct StyleContributor;
impl ContextContributor for StyleContributor {
fn contribute(
&self,
session_store: &ExtensionData,
thread_store: &ExtensionData,
) -> Vec<PromptFragment> {
contribution_counts(session_store).record_style();
contribution_counts(thread_store).record_style();
vec![PromptFragment::developer_policy(
"Prefer short answers unless the user asks for detail.",
)]
}
}
#[derive(Debug)]
struct UsageContributor;
impl ContextContributor for UsageContributor {
fn contribute(
&self,
session_store: &ExtensionData,
thread_store: &ExtensionData,
) -> Vec<PromptFragment> {
contribution_counts(session_store).record_usage();
contribution_counts(thread_store).record_usage();
vec![PromptFragment::developer_capability(
"This extension can contribute more than one prompt fragment.",
)]
}
}
/// Returns how many style contributions were recorded in `store`.
pub fn recorded_style_contributions(store: &ExtensionData) -> u64 {
store
.get::<ContributionCounts>()
.map(|counts| counts.style())
.unwrap_or_default()
}
/// Returns how many usage contributions were recorded in `store`.
pub fn recorded_usage_contributions(store: &ExtensionData) -> u64 {
store
.get::<ContributionCounts>()
.map(|counts| counts.usage())
.unwrap_or_default()
}
#[derive(Debug, Default)]
struct ContributionCounts {
style: AtomicU64,
usage: AtomicU64,
}
impl ContributionCounts {
fn record_style(&self) {
self.style.fetch_add(1, Ordering::Relaxed);
}
fn record_usage(&self) {
self.usage.fetch_add(1, Ordering::Relaxed);
}
fn style(&self) -> u64 {
self.style.load(Ordering::Relaxed)
}
fn usage(&self) -> u64 {
self.usage.load(Ordering::Relaxed)
}
}
fn contribution_counts(store: &ExtensionData) -> Arc<ContributionCounts> {
store.get_or_init::<ContributionCounts>(Default::default)
}

View File

@@ -0,0 +1,14 @@
Everything becomes a good contributor design, which contributors do we need?
git attribution Context
memories Context + Tool + Output
guardian Context + Request
goal Tool + Runtime
image generation Tool + Output
skills Context + Turn
personality Context
plugins / apps / connectors Context + Turn
shell snapshot Runtime
web search Tool
AGENTS.md Context (Runtime too only if you want eager refresh/cache behavior)
future sandboxing probably Request + Runtime

View File

@@ -0,0 +1,65 @@
use std::future::Future;
use codex_protocol::items::TurnItem;
use crate::ExtensionData;
mod prompt;
mod tool;
pub use prompt::PromptFragment;
pub use prompt::PromptSlot;
pub use tool::ToolCallError;
pub use tool::ToolContribution;
pub use tool::ToolHandler;
/// Contributor that receives host-owned thread-start input before later
/// contributors read from extension stores.
pub trait ThreadStartContributor<C>: Send + Sync {
fn contribute(&self, input: &C, session_store: &ExtensionData, thread_store: &ExtensionData);
}
/// Extension contribution that adds prompt fragments during prompt assembly.
pub trait ContextContributor: Send + Sync {
fn contribute(
&self,
session_store: &ExtensionData,
thread_store: &ExtensionData,
) -> Vec<PromptFragment>;
}
/// Extension contribution that exposes native tools owned by a feature.
pub trait ToolContributor: Send + Sync {
/// Returns the native tools visible for the supplied runtime context.
fn tools(&self, thread_store: &ExtensionData) -> Vec<ToolContribution>;
}
/// Future returned by one ordered turn-item contribution.
pub type TurnItemContributionFuture<'a> =
std::pin::Pin<Box<dyn Future<Output = Result<(), String>> + Send + 'a>>;
/// Ordered post-processing contribution for one parsed turn item.
///
/// Implementations may mutate the item before it is emitted and may use the
/// explicitly exposed thread- and turn-lifetime stores when they need durable
/// extension-private state.
pub trait TurnItemContributor: Send + Sync {
fn contribute<'a>(
&'a self,
thread_store: &'a ExtensionData,
turn_store: &'a ExtensionData,
item: &'a mut TurnItem,
) -> TurnItemContributionFuture<'a>;
}
// TODO: WIP (do not consider)
/// Extension contribution that can claim approval requests for a runtime context.
/// (ideally we can replace it by a session lifecycle thing or a request contributor?)
pub trait ApprovalInterceptorContributor: Send + Sync {
/// Returns whether this contributor should intercept approvals in `context`.
fn intercepts_approvals(
&self,
thread_store: &ExtensionData,
turn_store: &ExtensionData,
) -> bool;
}

View File

@@ -0,0 +1,50 @@
// All this file should be replaced by the existing fragment implementation ofc
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum PromptSlot {
DeveloperPolicy,
DeveloperCapabilities,
ContextualUser,
SeparateDeveloper,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PromptFragment {
slot: PromptSlot,
text: String,
}
impl PromptFragment {
/// Creates a prompt fragment for the given slot.
pub fn new(slot: PromptSlot, text: impl Into<String>) -> Self {
Self {
slot,
text: text.into(),
}
}
/// Creates a developer-policy prompt fragment.
pub fn developer_policy(text: impl Into<String>) -> Self {
Self::new(PromptSlot::DeveloperPolicy, text)
}
/// Creates a developer-capabilities prompt fragment.
pub fn developer_capability(text: impl Into<String>) -> Self {
Self::new(PromptSlot::DeveloperCapabilities, text)
}
/// Creates a separate top-level developer prompt fragment.
pub fn separate_developer(text: impl Into<String>) -> Self {
Self::new(PromptSlot::SeparateDeveloper, text)
}
/// Returns the target prompt slot.
pub fn slot(&self) -> PromptSlot {
self.slot
}
/// Returns the model-visible text.
pub fn text(&self) -> &str {
&self.text
}
}

View File

@@ -0,0 +1,68 @@
use std::future::Future;
use std::pin::Pin;
use std::sync::Arc;
use codex_tools::ResponsesApiTool;
use serde_json::Value;
use thiserror::Error;
// TMP while we don't have the fully extracted tools
#[derive(Clone)]
pub struct ToolContribution {
spec: ResponsesApiTool,
handler: Arc<dyn ToolHandler>,
supports_parallel_tool_calls: bool,
}
impl ToolContribution {
pub fn new(spec: ResponsesApiTool, handler: Arc<dyn ToolHandler>) -> Self {
Self {
spec,
handler,
supports_parallel_tool_calls: false,
}
}
#[must_use]
pub fn allow_parallel_calls(mut self) -> Self {
self.supports_parallel_tool_calls = true;
self
}
pub fn spec(&self) -> &ResponsesApiTool {
&self.spec
}
pub fn supports_parallel_tool_calls(&self) -> bool {
self.supports_parallel_tool_calls
}
pub fn handler(&self) -> Arc<dyn ToolHandler> {
Arc::clone(&self.handler)
}
}
//////// Just to make it compile ////////////////////////////////
pub trait ToolHandler: Send + Sync {
/// Handles one JSON-encoded invocation for this tool.
fn handle<'a>(
&'a self,
arguments: Value,
) -> Pin<Box<dyn Future<Output = Result<Value, ToolCallError>> + Send + 'a>>;
}
/// Error returned by a contributed native tool handler.
#[derive(Clone, Debug, Error, PartialEq, Eq)]
#[error("{message}")]
pub struct ToolCallError {
message: String,
}
impl ToolCallError {
/// Creates a contributed-tool error with the supplied model-visible text.
pub fn new(message: impl Into<String>) -> Self {
Self {
message: message.into(),
}
}
}

View File

@@ -0,0 +1,12 @@
use std::sync::Arc;
use crate::ExtensionRegistryBuilder;
/// First-party extension that can install one or more typed runtime contributions.
///
/// Implementations should use [`Self::install`] only to register the concrete
/// providers they own.
pub trait CodexExtension<C>: Send + Sync {
/// Registers this extension's concrete typed contributions.
fn install(self: Arc<Self>, registry: &mut ExtensionRegistryBuilder<C>);
}

View File

@@ -0,0 +1,21 @@
mod contributors;
mod extension;
mod registry;
mod state;
pub use contributors::ApprovalInterceptorContributor;
pub use contributors::ContextContributor;
pub use contributors::PromptFragment;
pub use contributors::PromptSlot;
pub use contributors::ThreadStartContributor;
pub use contributors::ToolCallError;
pub use contributors::ToolContribution;
pub use contributors::ToolContributor;
pub use contributors::ToolHandler;
pub use contributors::TurnItemContributionFuture;
pub use contributors::TurnItemContributor;
pub use extension::CodexExtension;
pub use registry::ExtensionRegistry;
pub use registry::ExtensionRegistryBuilder;
pub use registry::empty_extension_registry;
pub use state::ExtensionData;

View File

@@ -0,0 +1,134 @@
use std::sync::Arc;
use crate::ApprovalInterceptorContributor;
use crate::CodexExtension;
use crate::ContextContributor;
use crate::ThreadStartContributor;
use crate::ToolContributor;
use crate::TurnItemContributor;
/// Mutable registry used while extensions install their typed contributions.
pub struct ExtensionRegistryBuilder<C> {
thread_start_contributors: Vec<Arc<dyn ThreadStartContributor<C>>>,
context_contributors: Vec<Arc<dyn ContextContributor>>,
tool_contributors: Vec<Arc<dyn ToolContributor>>,
turn_item_contributors: Vec<Arc<dyn TurnItemContributor>>,
approval_interceptor_contributors: Vec<Arc<dyn ApprovalInterceptorContributor>>,
}
impl<C> Default for ExtensionRegistryBuilder<C> {
fn default() -> Self {
Self {
thread_start_contributors: Vec::new(),
approval_interceptor_contributors: Vec::new(),
context_contributors: Vec::new(),
tool_contributors: Vec::new(),
turn_item_contributors: Vec::new(),
}
}
}
impl<C> ExtensionRegistryBuilder<C> {
/// Creates an empty registry builder.
pub fn new() -> Self {
Self::default()
}
/// Installs one extension and returns the builder.
#[must_use]
pub fn with_extension<E>(mut self, extension: Arc<E>) -> Self
where
E: CodexExtension<C> + 'static,
{
self.install_extension(extension);
self
}
/// Installs one extension into the registry under construction.
pub fn install_extension<E>(&mut self, extension: Arc<E>)
where
E: CodexExtension<C> + 'static,
{
extension.install(self);
}
/// Registers one approval interceptor contributor.
pub fn approval_interceptor_contributor(
&mut self,
contributor: Arc<dyn ApprovalInterceptorContributor>,
) {
self.approval_interceptor_contributors.push(contributor);
}
/// Registers one thread-start contributor.
pub fn thread_start_contributor(&mut self, contributor: Arc<dyn ThreadStartContributor<C>>) {
self.thread_start_contributors.push(contributor);
}
/// Registers one prompt contributor.
pub fn prompt_contributor(&mut self, contributor: Arc<dyn ContextContributor>) {
self.context_contributors.push(contributor);
}
/// Registers one native tool contributor.
pub fn tool_contributor(&mut self, contributor: Arc<dyn ToolContributor>) {
self.tool_contributors.push(contributor);
}
/// Registers one ordered turn-item contributor.
pub fn turn_item_contributor(&mut self, contributor: Arc<dyn TurnItemContributor>) {
self.turn_item_contributors.push(contributor);
}
/// Finishes construction and returns the immutable registry.
pub fn build(self) -> ExtensionRegistry<C> {
ExtensionRegistry {
thread_start_contributors: self.thread_start_contributors,
approval_interceptor_contributors: self.approval_interceptor_contributors,
context_contributors: self.context_contributors,
tool_contributors: self.tool_contributors,
turn_item_contributors: self.turn_item_contributors,
}
}
}
/// Immutable typed registry produced after extensions are installed.
pub struct ExtensionRegistry<C> {
thread_start_contributors: Vec<Arc<dyn ThreadStartContributor<C>>>,
context_contributors: Vec<Arc<dyn ContextContributor>>,
tool_contributors: Vec<Arc<dyn ToolContributor>>,
turn_item_contributors: Vec<Arc<dyn TurnItemContributor>>,
approval_interceptor_contributors: Vec<Arc<dyn ApprovalInterceptorContributor>>,
}
impl<C> ExtensionRegistry<C> {
/// Returns the registered thread-start contributors.
pub fn thread_start_contributors(&self) -> &[Arc<dyn ThreadStartContributor<C>>] {
&self.thread_start_contributors
}
/// Returns the registered approval interceptor contributors.
pub fn approval_interceptor_contributors(&self) -> &[Arc<dyn ApprovalInterceptorContributor>] {
&self.approval_interceptor_contributors
}
/// Returns the registered prompt contributors.
pub fn context_contributors(&self) -> &[Arc<dyn ContextContributor>] {
&self.context_contributors
}
/// Returns the registered native tool contributors.
pub fn tool_contributors(&self) -> &[Arc<dyn ToolContributor>] {
&self.tool_contributors
}
/// Returns the registered ordered turn-item contributors.
pub fn turn_item_contributors(&self) -> &[Arc<dyn TurnItemContributor>] {
&self.turn_item_contributors
}
}
/// Creates an empty shared registry for hosts that do not install extensions.
pub fn empty_extension_registry<C>() -> Arc<ExtensionRegistry<C>> {
Arc::new(ExtensionRegistryBuilder::new().build())
}

View File

@@ -0,0 +1,77 @@
use std::any::Any;
use std::any::TypeId;
use std::collections::HashMap;
use std::sync::Arc;
use std::sync::Mutex;
use std::sync::PoisonError;
type ErasedData = Arc<dyn Any + Send + Sync>;
/// Typed extension-owned data attached to one host object.
#[derive(Default, Debug)]
pub struct ExtensionData {
entries: Mutex<HashMap<TypeId, ErasedData>>,
}
impl ExtensionData {
/// Creates an empty attachment map.
pub fn new() -> Self {
Self::default()
}
/// Returns the attached value of type `T`, if one exists.
pub fn get<T>(&self) -> Option<Arc<T>>
where
T: Any + Send + Sync,
{
let value = self.entries().get(&TypeId::of::<T>())?.clone();
Some(downcast_data(value))
}
/// Returns the attached value of type `T`, inserting one from `init` when absent.
///
/// The initializer runs while this map is locked, so it should stay cheap;
/// heavyweight lazy work belongs inside the attached value itself.
pub fn get_or_init<T>(&self, init: impl FnOnce() -> T) -> Arc<T>
where
T: Any + Send + Sync,
{
let mut entries = self.entries();
let value = entries
.entry(TypeId::of::<T>())
.or_insert_with(|| Arc::new(init()));
downcast_data(Arc::clone(value))
}
/// Stores `value` as the attachment of type `T`, returning any previous value.
pub fn insert<T>(&self, value: T) -> Option<Arc<T>>
where
T: Any + Send + Sync,
{
self.entries()
.insert(TypeId::of::<T>(), Arc::new(value))
.map(downcast_data)
}
/// Removes and returns the attached value of type `T`, if one exists.
pub fn remove<T>(&self) -> Option<Arc<T>>
where
T: Any + Send + Sync,
{
self.entries().remove(&TypeId::of::<T>()).map(downcast_data)
}
fn entries(&self) -> std::sync::MutexGuard<'_, HashMap<TypeId, ErasedData>> {
self.entries.lock().unwrap_or_else(PoisonError::into_inner)
}
}
fn downcast_data<T>(value: ErasedData) -> Arc<T>
where
T: Any + Send + Sync,
{
let Ok(value) = value.downcast::<T>() else {
unreachable!("typed extension data stored an incompatible value");
};
value
}

View File

@@ -0,0 +1,6 @@
load("//:defs.bzl", "codex_rust_crate")
codex_rust_crate(
name = "git-attribution",
crate_name = "codex_git_attribution",
)

View File

@@ -0,0 +1,17 @@
[package]
edition.workspace = true
license.workspace = true
name = "codex-git-attribution"
version.workspace = true
[lib]
name = "codex_git_attribution"
path = "src/lib.rs"
[lints]
workspace = true
[dependencies]
codex-core = { workspace = true }
codex-extension-api = { workspace = true }
codex-features = { workspace = true }

View File

@@ -0,0 +1,100 @@
use std::sync::Arc;
use codex_core::config::Config;
use codex_extension_api::CodexExtension;
use codex_extension_api::ContextContributor;
use codex_extension_api::ExtensionData;
use codex_extension_api::ExtensionRegistryBuilder;
use codex_extension_api::PromptFragment;
use codex_extension_api::ThreadStartContributor;
use codex_features::Feature;
const DEFAULT_ATTRIBUTION_VALUE: &str = "Codex <noreply@openai.com>";
/// Prompt-only extension that contributes the configured git-attribution instruction.
#[derive(Clone, Copy, Debug, Default)]
pub struct GitAttributionExtension;
impl GitAttributionExtension {
/// Creates an extension instance.
pub fn new() -> Self {
Self
}
}
impl ContextContributor for GitAttributionExtension {
fn contribute(
&self,
_session_store: &ExtensionData,
thread_store: &ExtensionData,
) -> Vec<PromptFragment> {
let Some(config_store) = thread_store.get::<GitAttributionConfig>() else {
return Vec::new();
};
if !config_store.enabled {
return Vec::new();
}
build_instruction(config_store.prompt.as_deref())
.map(PromptFragment::developer_policy)
.into_iter()
.collect()
}
}
#[derive(Clone, Debug, Default)]
struct GitAttributionConfig {
enabled: bool,
prompt: Option<String>,
}
impl ThreadStartContributor<Config> for GitAttributionExtension {
fn contribute(
&self,
config: &Config,
_session_store: &ExtensionData,
thread_store: &ExtensionData,
) {
thread_store.insert(GitAttributionConfig {
enabled: config.features.enabled(Feature::CodexGitCommit),
prompt: config.commit_attribution.clone(),
});
}
}
impl CodexExtension<Config> for GitAttributionExtension {
fn install(self: Arc<Self>, registry: &mut ExtensionRegistryBuilder<Config>) {
registry.thread_start_contributor(self.clone());
registry.prompt_contributor(self);
}
}
/// Creates a shared git-attribution extension instance.
pub fn extension() -> Arc<GitAttributionExtension> {
Arc::new(GitAttributionExtension::new())
}
fn build_commit_message_trailer(config_attribution: Option<&str>) -> Option<String> {
let value = resolve_attribution_value(config_attribution)?;
Some(format!("Co-authored-by: {value}"))
}
fn build_instruction(config_attribution: Option<&str>) -> Option<String> {
let trailer = build_commit_message_trailer(config_attribution)?;
Some(format!(
"When you write or edit a git commit message, ensure the message ends with this trailer exactly once:\n{trailer}\n\nRules:\n- Keep existing trailers and append this trailer at the end if missing.\n- Do not duplicate this trailer if it already exists.\n- Keep one blank line between the commit body and trailer block."
))
}
fn resolve_attribution_value(config_attribution: Option<&str>) -> Option<String> {
match config_attribution {
Some(value) => {
let trimmed = value.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
}
None => Some(DEFAULT_ATTRIBUTION_VALUE.to_string()),
}
}

View File

@@ -22,6 +22,7 @@ codex-arg0 = { workspace = true }
codex-config = { workspace = true }
codex-core = { workspace = true }
codex-exec-server = { workspace = true }
codex-extension-api = { workspace = true }
codex-login = { workspace = true }
codex-protocol = { workspace = true }
codex-utils-cli = { workspace = true }

View File

@@ -7,6 +7,7 @@ use codex_core::ThreadManager;
use codex_core::config::Config;
use codex_core::thread_store_from_config;
use codex_exec_server::EnvironmentManager;
use codex_extension_api::empty_extension_registry;
use codex_login::AuthManager;
use codex_login::default_client::USER_AGENT_SUFFIX;
use codex_login::default_client::get_codex_user_agent;
@@ -68,6 +69,7 @@ impl MessageProcessor {
auth_manager,
SessionSource::Mcp,
environment_manager,
empty_extension_registry(),
/*analytics_events_client*/ None,
thread_store_from_config(config.as_ref(), state_db.clone()),
state_db.clone(),

View File

@@ -54,6 +54,7 @@ use codex_core_api::UserInput;
use codex_core_api::WebSearchMode;
use codex_core_api::arg0_dispatch_or_else;
use codex_core_api::built_in_model_providers;
use codex_core_api::empty_extension_registry;
use codex_core_api::find_codex_home;
use codex_core_api::init_state_db;
use codex_core_api::item_event_to_server_notification;
@@ -122,6 +123,7 @@ async fn run_main(arg0_paths: Arg0DispatchPaths) -> anyhow::Result<()> {
auth_manager,
SessionSource::Exec,
environment_manager,
empty_extension_registry(),
/*analytics_events_client*/ None,
Arc::clone(&thread_store),
state_db,