mirror of
https://github.com/openai/codex.git
synced 2026-10-29 19:08:43 +00:00
rebase on thread branch
This commit is contained in:
@@ -252,7 +252,7 @@ mod reload {
|
||||
|
||||
fn reload_overrides(config: &Config, preserve_current_provider: bool) -> ConfigOverrides {
|
||||
ConfigOverrides {
|
||||
cwd: Some(config.cwd.clone()),
|
||||
cwd: Some(config.cwd.to_path_buf()),
|
||||
model_provider: preserve_current_provider.then(|| config.model_provider_id.clone()),
|
||||
codex_linux_sandbox_exe: config.codex_linux_sandbox_exe.clone(),
|
||||
main_execve_wrapper_exe: config.main_execve_wrapper_exe.clone(),
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
use super::*;
|
||||
use crate::SkillsManager;
|
||||
use crate::config::CONFIG_TOML_FILE;
|
||||
use crate::config::ConfigBuilder;
|
||||
use crate::config_loader::ConfigLayerStackOrdering;
|
||||
use crate::plugins::PluginsManager;
|
||||
use crate::skills::SkillsManager;
|
||||
use crate::skills_load_input_from_config;
|
||||
use codex_protocol::config_types::ReasoningSummary;
|
||||
use codex_protocol::config_types::Verbosity;
|
||||
use codex_protocol::openai_models::ReasoningEffort;
|
||||
@@ -629,8 +630,11 @@ enabled = false
|
||||
.expect("custom role should apply");
|
||||
|
||||
let plugins_manager = Arc::new(PluginsManager::new(home.path().to_path_buf()));
|
||||
let skills_manager = SkillsManager::new(home.path().to_path_buf(), plugins_manager, true);
|
||||
let outcome = skills_manager.skills_for_config(&config);
|
||||
let skills_manager = SkillsManager::new(home.path().to_path_buf(), true);
|
||||
let plugin_outcome = plugins_manager.plugins_for_config(&config);
|
||||
let effective_skill_roots = plugin_outcome.effective_skill_roots();
|
||||
let skills_input = skills_load_input_from_config(&config, effective_skill_roots);
|
||||
let outcome = skills_manager.skills_for_config(&skills_input);
|
||||
let skill = outcome
|
||||
.skills
|
||||
.iter()
|
||||
|
||||
@@ -1,931 +0,0 @@
|
||||
use crate::AuthManager;
|
||||
use crate::config::Config;
|
||||
use crate::default_client::create_client;
|
||||
use crate::plugins::PluginTelemetryMetadata;
|
||||
use codex_git_utils::collect_git_info;
|
||||
use codex_git_utils::get_git_repo_root;
|
||||
use codex_protocol::config_types::ApprovalsReviewer;
|
||||
use codex_protocol::config_types::ModeKind;
|
||||
use codex_protocol::config_types::Personality;
|
||||
use codex_protocol::config_types::ReasoningSummary;
|
||||
use codex_protocol::config_types::ServiceTier;
|
||||
use codex_protocol::openai_models::ReasoningEffort;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use codex_protocol::protocol::SkillScope;
|
||||
use serde::Serialize;
|
||||
use sha1::Digest;
|
||||
use sha1::Sha1;
|
||||
use std::collections::HashSet;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::sync::Mutex;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct TrackEventsContext {
|
||||
pub(crate) model_slug: String,
|
||||
pub(crate) thread_id: String,
|
||||
pub(crate) turn_id: String,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct CodexTurnEvent {
|
||||
pub(crate) model_provider: String,
|
||||
pub(crate) sandbox_policy: SandboxPolicy,
|
||||
pub(crate) reasoning_effort: Option<ReasoningEffort>,
|
||||
pub(crate) reasoning_summary: Option<ReasoningSummary>,
|
||||
pub(crate) service_tier: Option<ServiceTier>,
|
||||
pub(crate) approval_policy: AskForApproval,
|
||||
pub(crate) approvals_reviewer: ApprovalsReviewer,
|
||||
pub(crate) sandbox_network_access: bool,
|
||||
pub(crate) collaboration_mode: ModeKind,
|
||||
pub(crate) personality: Option<Personality>,
|
||||
pub(crate) num_input_images: usize,
|
||||
pub(crate) is_first_turn: bool,
|
||||
}
|
||||
|
||||
pub(crate) fn build_track_events_context(
|
||||
model_slug: String,
|
||||
thread_id: String,
|
||||
turn_id: String,
|
||||
) -> TrackEventsContext {
|
||||
TrackEventsContext {
|
||||
model_slug,
|
||||
thread_id,
|
||||
turn_id,
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct SkillInvocation {
|
||||
pub(crate) skill_name: String,
|
||||
pub(crate) skill_scope: SkillScope,
|
||||
pub(crate) skill_path: PathBuf,
|
||||
pub(crate) invocation_type: InvocationType,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Serialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub(crate) enum InvocationType {
|
||||
Explicit,
|
||||
Implicit,
|
||||
}
|
||||
|
||||
pub(crate) struct AppInvocation {
|
||||
pub(crate) connector_id: Option<String>,
|
||||
pub(crate) app_name: Option<String>,
|
||||
pub(crate) invocation_type: Option<InvocationType>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct AnalyticsEventsQueue {
|
||||
sender: mpsc::Sender<TrackEventsJob>,
|
||||
app_used_emitted_keys: Arc<Mutex<HashSet<(String, String)>>>,
|
||||
plugin_used_emitted_keys: Arc<Mutex<HashSet<(String, String)>>>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct AnalyticsEventsClient {
|
||||
queue: AnalyticsEventsQueue,
|
||||
config: Arc<Config>,
|
||||
}
|
||||
|
||||
impl AnalyticsEventsQueue {
|
||||
pub(crate) fn new(auth_manager: Arc<AuthManager>) -> Self {
|
||||
let (sender, mut receiver) = mpsc::channel(ANALYTICS_EVENTS_QUEUE_SIZE);
|
||||
tokio::spawn(async move {
|
||||
while let Some(job) = receiver.recv().await {
|
||||
match job {
|
||||
TrackEventsJob::SkillInvocations(job) => {
|
||||
send_track_skill_invocations(&auth_manager, job).await;
|
||||
}
|
||||
TrackEventsJob::AppMentioned(job) => {
|
||||
send_track_app_mentioned(&auth_manager, job).await;
|
||||
}
|
||||
TrackEventsJob::AppUsed(job) => {
|
||||
send_track_app_used(&auth_manager, job).await;
|
||||
}
|
||||
TrackEventsJob::TurnEvent(job) => {
|
||||
send_track_turn_event(&auth_manager, job).await;
|
||||
}
|
||||
TrackEventsJob::PluginUsed(job) => {
|
||||
send_track_plugin_used(&auth_manager, job).await;
|
||||
}
|
||||
TrackEventsJob::PluginInstalled(job) => {
|
||||
send_track_plugin_installed(&auth_manager, job).await;
|
||||
}
|
||||
TrackEventsJob::PluginUninstalled(job) => {
|
||||
send_track_plugin_uninstalled(&auth_manager, job).await;
|
||||
}
|
||||
TrackEventsJob::PluginEnabled(job) => {
|
||||
send_track_plugin_enabled(&auth_manager, job).await;
|
||||
}
|
||||
TrackEventsJob::PluginDisabled(job) => {
|
||||
send_track_plugin_disabled(&auth_manager, job).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
Self {
|
||||
sender,
|
||||
app_used_emitted_keys: Arc::new(Mutex::new(HashSet::new())),
|
||||
plugin_used_emitted_keys: Arc::new(Mutex::new(HashSet::new())),
|
||||
}
|
||||
}
|
||||
|
||||
fn try_send(&self, job: TrackEventsJob) {
|
||||
if self.sender.try_send(job).is_err() {
|
||||
//TODO: add a metric for this
|
||||
tracing::warn!("dropping analytics events: queue is full");
|
||||
}
|
||||
}
|
||||
|
||||
fn should_enqueue_app_used(&self, tracking: &TrackEventsContext, app: &AppInvocation) -> bool {
|
||||
let Some(connector_id) = app.connector_id.as_ref() else {
|
||||
return true;
|
||||
};
|
||||
let mut emitted = self
|
||||
.app_used_emitted_keys
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if emitted.len() >= ANALYTICS_EVENT_DEDUPE_MAX_KEYS {
|
||||
emitted.clear();
|
||||
}
|
||||
emitted.insert((tracking.turn_id.clone(), connector_id.clone()))
|
||||
}
|
||||
|
||||
fn should_enqueue_plugin_used(
|
||||
&self,
|
||||
tracking: &TrackEventsContext,
|
||||
plugin: &PluginTelemetryMetadata,
|
||||
) -> bool {
|
||||
let mut emitted = self
|
||||
.plugin_used_emitted_keys
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
if emitted.len() >= ANALYTICS_EVENT_DEDUPE_MAX_KEYS {
|
||||
emitted.clear();
|
||||
}
|
||||
emitted.insert((tracking.turn_id.clone(), plugin.plugin_id.as_key()))
|
||||
}
|
||||
}
|
||||
|
||||
impl AnalyticsEventsClient {
|
||||
pub fn new(config: Arc<Config>, auth_manager: Arc<AuthManager>) -> Self {
|
||||
Self {
|
||||
queue: AnalyticsEventsQueue::new(Arc::clone(&auth_manager)),
|
||||
config,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn track_skill_invocations(
|
||||
&self,
|
||||
tracking: TrackEventsContext,
|
||||
invocations: Vec<SkillInvocation>,
|
||||
) {
|
||||
track_skill_invocations(
|
||||
&self.queue,
|
||||
Arc::clone(&self.config),
|
||||
Some(tracking),
|
||||
invocations,
|
||||
);
|
||||
}
|
||||
|
||||
pub(crate) fn track_app_mentioned(
|
||||
&self,
|
||||
tracking: TrackEventsContext,
|
||||
mentions: Vec<AppInvocation>,
|
||||
) {
|
||||
track_app_mentioned(
|
||||
&self.queue,
|
||||
Arc::clone(&self.config),
|
||||
Some(tracking),
|
||||
mentions,
|
||||
);
|
||||
}
|
||||
|
||||
pub(crate) fn track_app_used(&self, tracking: TrackEventsContext, app: AppInvocation) {
|
||||
track_app_used(&self.queue, Arc::clone(&self.config), Some(tracking), app);
|
||||
}
|
||||
|
||||
pub(crate) fn track_plugin_used(
|
||||
&self,
|
||||
tracking: TrackEventsContext,
|
||||
plugin: PluginTelemetryMetadata,
|
||||
) {
|
||||
track_plugin_used(
|
||||
&self.queue,
|
||||
Arc::clone(&self.config),
|
||||
Some(tracking),
|
||||
plugin,
|
||||
);
|
||||
}
|
||||
|
||||
pub(crate) fn track_turn_event(
|
||||
&self,
|
||||
tracking: TrackEventsContext,
|
||||
turn_event: CodexTurnEvent,
|
||||
) {
|
||||
track_turn_event(
|
||||
&self.queue,
|
||||
Arc::clone(&self.config),
|
||||
Some(tracking),
|
||||
turn_event,
|
||||
);
|
||||
}
|
||||
|
||||
pub fn track_plugin_installed(&self, plugin: PluginTelemetryMetadata) {
|
||||
track_plugin_management(
|
||||
&self.queue,
|
||||
Arc::clone(&self.config),
|
||||
PluginManagementEventType::Installed,
|
||||
plugin,
|
||||
);
|
||||
}
|
||||
|
||||
pub fn track_plugin_uninstalled(&self, plugin: PluginTelemetryMetadata) {
|
||||
track_plugin_management(
|
||||
&self.queue,
|
||||
Arc::clone(&self.config),
|
||||
PluginManagementEventType::Uninstalled,
|
||||
plugin,
|
||||
);
|
||||
}
|
||||
|
||||
pub fn track_plugin_enabled(&self, plugin: PluginTelemetryMetadata) {
|
||||
track_plugin_management(
|
||||
&self.queue,
|
||||
Arc::clone(&self.config),
|
||||
PluginManagementEventType::Enabled,
|
||||
plugin,
|
||||
);
|
||||
}
|
||||
|
||||
pub fn track_plugin_disabled(&self, plugin: PluginTelemetryMetadata) {
|
||||
track_plugin_management(
|
||||
&self.queue,
|
||||
Arc::clone(&self.config),
|
||||
PluginManagementEventType::Disabled,
|
||||
plugin,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
enum TrackEventsJob {
|
||||
SkillInvocations(TrackSkillInvocationsJob),
|
||||
AppMentioned(TrackAppMentionedJob),
|
||||
AppUsed(TrackAppUsedJob),
|
||||
TurnEvent(TrackTurnEventJob),
|
||||
PluginUsed(TrackPluginUsedJob),
|
||||
PluginInstalled(TrackPluginManagementJob),
|
||||
PluginUninstalled(TrackPluginManagementJob),
|
||||
PluginEnabled(TrackPluginManagementJob),
|
||||
PluginDisabled(TrackPluginManagementJob),
|
||||
}
|
||||
|
||||
struct TrackSkillInvocationsJob {
|
||||
config: Arc<Config>,
|
||||
tracking: TrackEventsContext,
|
||||
invocations: Vec<SkillInvocation>,
|
||||
}
|
||||
|
||||
struct TrackAppMentionedJob {
|
||||
config: Arc<Config>,
|
||||
tracking: TrackEventsContext,
|
||||
mentions: Vec<AppInvocation>,
|
||||
}
|
||||
|
||||
struct TrackAppUsedJob {
|
||||
config: Arc<Config>,
|
||||
tracking: TrackEventsContext,
|
||||
app: AppInvocation,
|
||||
}
|
||||
|
||||
struct TrackTurnEventJob {
|
||||
config: Arc<Config>,
|
||||
tracking: TrackEventsContext,
|
||||
turn_event: CodexTurnEvent,
|
||||
}
|
||||
|
||||
struct TrackPluginUsedJob {
|
||||
config: Arc<Config>,
|
||||
tracking: TrackEventsContext,
|
||||
plugin: PluginTelemetryMetadata,
|
||||
}
|
||||
|
||||
struct TrackPluginManagementJob {
|
||||
config: Arc<Config>,
|
||||
plugin: PluginTelemetryMetadata,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum PluginManagementEventType {
|
||||
Installed,
|
||||
Uninstalled,
|
||||
Enabled,
|
||||
Disabled,
|
||||
}
|
||||
|
||||
const ANALYTICS_EVENTS_QUEUE_SIZE: usize = 256;
|
||||
const ANALYTICS_EVENTS_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
const ANALYTICS_EVENT_DEDUPE_MAX_KEYS: usize = 4096;
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct TrackEventsRequest {
|
||||
events: Vec<TrackEventRequest>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
#[serde(untagged)]
|
||||
enum TrackEventRequest {
|
||||
SkillInvocation(SkillInvocationEventRequest),
|
||||
AppMentioned(CodexAppMentionedEventRequest),
|
||||
AppUsed(CodexAppUsedEventRequest),
|
||||
TurnEvent(CodexTurnEventRequest),
|
||||
PluginUsed(CodexPluginUsedEventRequest),
|
||||
PluginInstalled(CodexPluginEventRequest),
|
||||
PluginUninstalled(CodexPluginEventRequest),
|
||||
PluginEnabled(CodexPluginEventRequest),
|
||||
PluginDisabled(CodexPluginEventRequest),
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct SkillInvocationEventRequest {
|
||||
event_type: &'static str,
|
||||
skill_id: String,
|
||||
skill_name: String,
|
||||
event_params: SkillInvocationEventParams,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct SkillInvocationEventParams {
|
||||
product_client_id: Option<String>,
|
||||
skill_scope: Option<String>,
|
||||
repo_url: Option<String>,
|
||||
thread_id: Option<String>,
|
||||
invoke_type: Option<InvocationType>,
|
||||
model_slug: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct CodexAppMetadata {
|
||||
connector_id: Option<String>,
|
||||
thread_id: Option<String>,
|
||||
turn_id: Option<String>,
|
||||
app_name: Option<String>,
|
||||
product_client_id: Option<String>,
|
||||
invoke_type: Option<InvocationType>,
|
||||
model_slug: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct CodexAppMentionedEventRequest {
|
||||
event_type: &'static str,
|
||||
event_params: CodexAppMetadata,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct CodexAppUsedEventRequest {
|
||||
event_type: &'static str,
|
||||
event_params: CodexAppMetadata,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct CodexTurnEventParams {
|
||||
thread_id: String,
|
||||
turn_id: String,
|
||||
product_client_id: Option<String>,
|
||||
model: Option<String>,
|
||||
model_provider: String,
|
||||
sandbox_policy: Option<&'static str>,
|
||||
reasoning_effort: Option<String>,
|
||||
reasoning_summary: Option<String>,
|
||||
service_tier: String,
|
||||
approval_policy: String,
|
||||
approvals_reviewer: String,
|
||||
sandbox_network_access: bool,
|
||||
collaboration_mode: Option<&'static str>,
|
||||
personality: Option<String>,
|
||||
num_input_images: usize,
|
||||
is_first_turn: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct CodexTurnEventRequest {
|
||||
event_type: &'static str,
|
||||
event_params: CodexTurnEventParams,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct CodexPluginMetadata {
|
||||
plugin_id: Option<String>,
|
||||
plugin_name: Option<String>,
|
||||
marketplace_name: Option<String>,
|
||||
has_skills: Option<bool>,
|
||||
mcp_server_count: Option<usize>,
|
||||
connector_ids: Option<Vec<String>>,
|
||||
product_client_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct CodexPluginUsedMetadata {
|
||||
#[serde(flatten)]
|
||||
plugin: CodexPluginMetadata,
|
||||
thread_id: Option<String>,
|
||||
turn_id: Option<String>,
|
||||
model_slug: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct CodexPluginEventRequest {
|
||||
event_type: &'static str,
|
||||
event_params: CodexPluginMetadata,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct CodexPluginUsedEventRequest {
|
||||
event_type: &'static str,
|
||||
event_params: CodexPluginUsedMetadata,
|
||||
}
|
||||
|
||||
pub(crate) fn track_skill_invocations(
|
||||
queue: &AnalyticsEventsQueue,
|
||||
config: Arc<Config>,
|
||||
tracking: Option<TrackEventsContext>,
|
||||
invocations: Vec<SkillInvocation>,
|
||||
) {
|
||||
if config.analytics_enabled == Some(false) {
|
||||
return;
|
||||
}
|
||||
let Some(tracking) = tracking else {
|
||||
return;
|
||||
};
|
||||
if invocations.is_empty() {
|
||||
return;
|
||||
}
|
||||
let job = TrackEventsJob::SkillInvocations(TrackSkillInvocationsJob {
|
||||
config,
|
||||
tracking,
|
||||
invocations,
|
||||
});
|
||||
queue.try_send(job);
|
||||
}
|
||||
|
||||
pub(crate) fn track_app_mentioned(
|
||||
queue: &AnalyticsEventsQueue,
|
||||
config: Arc<Config>,
|
||||
tracking: Option<TrackEventsContext>,
|
||||
mentions: Vec<AppInvocation>,
|
||||
) {
|
||||
if config.analytics_enabled == Some(false) {
|
||||
return;
|
||||
}
|
||||
let Some(tracking) = tracking else {
|
||||
return;
|
||||
};
|
||||
if mentions.is_empty() {
|
||||
return;
|
||||
}
|
||||
let job = TrackEventsJob::AppMentioned(TrackAppMentionedJob {
|
||||
config,
|
||||
tracking,
|
||||
mentions,
|
||||
});
|
||||
queue.try_send(job);
|
||||
}
|
||||
|
||||
pub(crate) fn track_app_used(
|
||||
queue: &AnalyticsEventsQueue,
|
||||
config: Arc<Config>,
|
||||
tracking: Option<TrackEventsContext>,
|
||||
app: AppInvocation,
|
||||
) {
|
||||
if config.analytics_enabled == Some(false) {
|
||||
return;
|
||||
}
|
||||
let Some(tracking) = tracking else {
|
||||
return;
|
||||
};
|
||||
if !queue.should_enqueue_app_used(&tracking, &app) {
|
||||
return;
|
||||
}
|
||||
let job = TrackEventsJob::AppUsed(TrackAppUsedJob {
|
||||
config,
|
||||
tracking,
|
||||
app,
|
||||
});
|
||||
queue.try_send(job);
|
||||
}
|
||||
|
||||
pub(crate) fn track_turn_event(
|
||||
queue: &AnalyticsEventsQueue,
|
||||
config: Arc<Config>,
|
||||
tracking: Option<TrackEventsContext>,
|
||||
turn_event: CodexTurnEvent,
|
||||
) {
|
||||
if config.analytics_enabled == Some(false) {
|
||||
return;
|
||||
}
|
||||
let Some(tracking) = tracking else {
|
||||
return;
|
||||
};
|
||||
let job = TrackEventsJob::TurnEvent(TrackTurnEventJob {
|
||||
config,
|
||||
tracking,
|
||||
turn_event,
|
||||
});
|
||||
queue.try_send(job);
|
||||
}
|
||||
|
||||
pub(crate) fn track_plugin_used(
|
||||
queue: &AnalyticsEventsQueue,
|
||||
config: Arc<Config>,
|
||||
tracking: Option<TrackEventsContext>,
|
||||
plugin: PluginTelemetryMetadata,
|
||||
) {
|
||||
if config.analytics_enabled == Some(false) {
|
||||
return;
|
||||
}
|
||||
let Some(tracking) = tracking else {
|
||||
return;
|
||||
};
|
||||
if !queue.should_enqueue_plugin_used(&tracking, &plugin) {
|
||||
return;
|
||||
}
|
||||
let job = TrackEventsJob::PluginUsed(TrackPluginUsedJob {
|
||||
config,
|
||||
tracking,
|
||||
plugin,
|
||||
});
|
||||
queue.try_send(job);
|
||||
}
|
||||
|
||||
fn track_plugin_management(
|
||||
queue: &AnalyticsEventsQueue,
|
||||
config: Arc<Config>,
|
||||
event_type: PluginManagementEventType,
|
||||
plugin: PluginTelemetryMetadata,
|
||||
) {
|
||||
if config.analytics_enabled == Some(false) {
|
||||
return;
|
||||
}
|
||||
let job = TrackPluginManagementJob { config, plugin };
|
||||
let job = match event_type {
|
||||
PluginManagementEventType::Installed => TrackEventsJob::PluginInstalled(job),
|
||||
PluginManagementEventType::Uninstalled => TrackEventsJob::PluginUninstalled(job),
|
||||
PluginManagementEventType::Enabled => TrackEventsJob::PluginEnabled(job),
|
||||
PluginManagementEventType::Disabled => TrackEventsJob::PluginDisabled(job),
|
||||
};
|
||||
queue.try_send(job);
|
||||
}
|
||||
|
||||
async fn send_track_skill_invocations(auth_manager: &AuthManager, job: TrackSkillInvocationsJob) {
|
||||
let TrackSkillInvocationsJob {
|
||||
config,
|
||||
tracking,
|
||||
invocations,
|
||||
} = job;
|
||||
let mut events = Vec::with_capacity(invocations.len());
|
||||
for invocation in invocations {
|
||||
let skill_scope = match invocation.skill_scope {
|
||||
SkillScope::User => "user",
|
||||
SkillScope::Repo => "repo",
|
||||
SkillScope::System => "system",
|
||||
SkillScope::Admin => "admin",
|
||||
};
|
||||
let repo_root = get_git_repo_root(invocation.skill_path.as_path());
|
||||
let repo_url = if let Some(root) = repo_root.as_ref() {
|
||||
collect_git_info(root)
|
||||
.await
|
||||
.and_then(|info| info.repository_url)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let skill_id = skill_id_for_local_skill(
|
||||
repo_url.as_deref(),
|
||||
repo_root.as_deref(),
|
||||
invocation.skill_path.as_path(),
|
||||
invocation.skill_name.as_str(),
|
||||
);
|
||||
events.push(TrackEventRequest::SkillInvocation(
|
||||
SkillInvocationEventRequest {
|
||||
event_type: "skill_invocation",
|
||||
skill_id,
|
||||
skill_name: invocation.skill_name.clone(),
|
||||
event_params: SkillInvocationEventParams {
|
||||
thread_id: Some(tracking.thread_id.clone()),
|
||||
invoke_type: Some(invocation.invocation_type),
|
||||
model_slug: Some(tracking.model_slug.clone()),
|
||||
product_client_id: Some(crate::default_client::originator().value),
|
||||
repo_url,
|
||||
skill_scope: Some(skill_scope.to_string()),
|
||||
},
|
||||
},
|
||||
));
|
||||
}
|
||||
|
||||
send_track_events(auth_manager, config, events).await;
|
||||
}
|
||||
|
||||
async fn send_track_app_mentioned(auth_manager: &AuthManager, job: TrackAppMentionedJob) {
|
||||
let TrackAppMentionedJob {
|
||||
config,
|
||||
tracking,
|
||||
mentions,
|
||||
} = job;
|
||||
let events = mentions
|
||||
.into_iter()
|
||||
.map(|mention| {
|
||||
let event_params = codex_app_metadata(&tracking, mention);
|
||||
TrackEventRequest::AppMentioned(CodexAppMentionedEventRequest {
|
||||
event_type: "codex_app_mentioned",
|
||||
event_params,
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
send_track_events(auth_manager, config, events).await;
|
||||
}
|
||||
|
||||
async fn send_track_app_used(auth_manager: &AuthManager, job: TrackAppUsedJob) {
|
||||
let TrackAppUsedJob {
|
||||
config,
|
||||
tracking,
|
||||
app,
|
||||
} = job;
|
||||
let event_params = codex_app_metadata(&tracking, app);
|
||||
let events = vec![TrackEventRequest::AppUsed(CodexAppUsedEventRequest {
|
||||
event_type: "codex_app_used",
|
||||
event_params,
|
||||
})];
|
||||
|
||||
send_track_events(auth_manager, config, events).await;
|
||||
}
|
||||
|
||||
async fn send_track_turn_event(auth_manager: &AuthManager, job: TrackTurnEventJob) {
|
||||
let TrackTurnEventJob {
|
||||
config,
|
||||
tracking,
|
||||
turn_event,
|
||||
} = job;
|
||||
let events = vec![TrackEventRequest::TurnEvent(CodexTurnEventRequest {
|
||||
event_type: "codex_turn_event",
|
||||
event_params: codex_turn_event_params(&tracking, turn_event),
|
||||
})];
|
||||
|
||||
send_track_events(auth_manager, config, events).await;
|
||||
}
|
||||
|
||||
async fn send_track_plugin_used(auth_manager: &AuthManager, job: TrackPluginUsedJob) {
|
||||
let TrackPluginUsedJob {
|
||||
config,
|
||||
tracking,
|
||||
plugin,
|
||||
} = job;
|
||||
let events = vec![TrackEventRequest::PluginUsed(CodexPluginUsedEventRequest {
|
||||
event_type: "codex_plugin_used",
|
||||
event_params: codex_plugin_used_metadata(&tracking, plugin),
|
||||
})];
|
||||
|
||||
send_track_events(auth_manager, config, events).await;
|
||||
}
|
||||
|
||||
async fn send_track_plugin_installed(auth_manager: &AuthManager, job: TrackPluginManagementJob) {
|
||||
send_track_plugin_management_event(auth_manager, job, "codex_plugin_installed").await;
|
||||
}
|
||||
|
||||
async fn send_track_plugin_uninstalled(auth_manager: &AuthManager, job: TrackPluginManagementJob) {
|
||||
send_track_plugin_management_event(auth_manager, job, "codex_plugin_uninstalled").await;
|
||||
}
|
||||
|
||||
async fn send_track_plugin_enabled(auth_manager: &AuthManager, job: TrackPluginManagementJob) {
|
||||
send_track_plugin_management_event(auth_manager, job, "codex_plugin_enabled").await;
|
||||
}
|
||||
|
||||
async fn send_track_plugin_disabled(auth_manager: &AuthManager, job: TrackPluginManagementJob) {
|
||||
send_track_plugin_management_event(auth_manager, job, "codex_plugin_disabled").await;
|
||||
}
|
||||
|
||||
async fn send_track_plugin_management_event(
|
||||
auth_manager: &AuthManager,
|
||||
job: TrackPluginManagementJob,
|
||||
event_type: &'static str,
|
||||
) {
|
||||
let TrackPluginManagementJob { config, plugin } = job;
|
||||
let event_params = codex_plugin_metadata(plugin);
|
||||
let event = CodexPluginEventRequest {
|
||||
event_type,
|
||||
event_params,
|
||||
};
|
||||
let events = vec![match event_type {
|
||||
"codex_plugin_installed" => TrackEventRequest::PluginInstalled(event),
|
||||
"codex_plugin_uninstalled" => TrackEventRequest::PluginUninstalled(event),
|
||||
"codex_plugin_enabled" => TrackEventRequest::PluginEnabled(event),
|
||||
"codex_plugin_disabled" => TrackEventRequest::PluginDisabled(event),
|
||||
_ => unreachable!("unknown plugin management event type"),
|
||||
}];
|
||||
|
||||
send_track_events(auth_manager, config, events).await;
|
||||
}
|
||||
|
||||
fn codex_app_metadata(tracking: &TrackEventsContext, app: AppInvocation) -> CodexAppMetadata {
|
||||
CodexAppMetadata {
|
||||
connector_id: app.connector_id,
|
||||
thread_id: Some(tracking.thread_id.clone()),
|
||||
turn_id: Some(tracking.turn_id.clone()),
|
||||
app_name: app.app_name,
|
||||
product_client_id: Some(crate::default_client::originator().value),
|
||||
invoke_type: app.invocation_type,
|
||||
model_slug: Some(tracking.model_slug.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
fn codex_turn_event_params(
|
||||
tracking: &TrackEventsContext,
|
||||
turn_event: CodexTurnEvent,
|
||||
) -> CodexTurnEventParams {
|
||||
CodexTurnEventParams {
|
||||
thread_id: tracking.thread_id.clone(),
|
||||
turn_id: tracking.turn_id.clone(),
|
||||
product_client_id: Some(crate::default_client::originator().value),
|
||||
model: Some(tracking.model_slug.clone()),
|
||||
model_provider: turn_event.model_provider,
|
||||
sandbox_policy: Some(sandbox_policy_mode(&turn_event.sandbox_policy)),
|
||||
reasoning_effort: turn_event.reasoning_effort.map(|value| value.to_string()),
|
||||
reasoning_summary: reasoning_summary_mode(turn_event.reasoning_summary),
|
||||
service_tier: turn_event
|
||||
.service_tier
|
||||
.map(|value| value.to_string())
|
||||
.unwrap_or_else(|| "default".to_string()),
|
||||
approval_policy: turn_event.approval_policy.to_string(),
|
||||
approvals_reviewer: turn_event.approvals_reviewer.to_string(),
|
||||
sandbox_network_access: turn_event.sandbox_network_access,
|
||||
collaboration_mode: Some(collaboration_mode_mode(turn_event.collaboration_mode)),
|
||||
personality: personality_mode(turn_event.personality),
|
||||
num_input_images: turn_event.num_input_images,
|
||||
is_first_turn: turn_event.is_first_turn,
|
||||
}
|
||||
}
|
||||
|
||||
fn sandbox_policy_mode(sandbox_policy: &SandboxPolicy) -> &'static str {
|
||||
match sandbox_policy {
|
||||
SandboxPolicy::DangerFullAccess => "full_access",
|
||||
SandboxPolicy::ReadOnly { .. } => "read_only",
|
||||
SandboxPolicy::WorkspaceWrite { .. } => "workspace_write",
|
||||
SandboxPolicy::ExternalSandbox { .. } => "external_sandbox",
|
||||
}
|
||||
}
|
||||
|
||||
fn collaboration_mode_mode(mode: ModeKind) -> &'static str {
|
||||
match mode {
|
||||
ModeKind::Plan => "plan",
|
||||
ModeKind::Default | ModeKind::PairProgramming | ModeKind::Execute => "default",
|
||||
}
|
||||
}
|
||||
|
||||
fn reasoning_summary_mode(summary: Option<ReasoningSummary>) -> Option<String> {
|
||||
match summary {
|
||||
Some(ReasoningSummary::None) | None => None,
|
||||
Some(summary) => Some(summary.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn personality_mode(personality: Option<Personality>) -> Option<String> {
|
||||
match personality {
|
||||
Some(Personality::None) | None => None,
|
||||
Some(personality) => Some(personality.to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
fn codex_plugin_metadata(plugin: PluginTelemetryMetadata) -> CodexPluginMetadata {
|
||||
let capability_summary = plugin.capability_summary;
|
||||
CodexPluginMetadata {
|
||||
plugin_id: Some(plugin.plugin_id.as_key()),
|
||||
plugin_name: Some(plugin.plugin_id.plugin_name),
|
||||
marketplace_name: Some(plugin.plugin_id.marketplace_name),
|
||||
has_skills: capability_summary
|
||||
.as_ref()
|
||||
.map(|summary| summary.has_skills),
|
||||
mcp_server_count: capability_summary
|
||||
.as_ref()
|
||||
.map(|summary| summary.mcp_server_names.len()),
|
||||
connector_ids: capability_summary.map(|summary| {
|
||||
summary
|
||||
.app_connector_ids
|
||||
.into_iter()
|
||||
.map(|connector_id| connector_id.0)
|
||||
.collect()
|
||||
}),
|
||||
product_client_id: Some(crate::default_client::originator().value),
|
||||
}
|
||||
}
|
||||
|
||||
fn codex_plugin_used_metadata(
|
||||
tracking: &TrackEventsContext,
|
||||
plugin: PluginTelemetryMetadata,
|
||||
) -> CodexPluginUsedMetadata {
|
||||
CodexPluginUsedMetadata {
|
||||
plugin: codex_plugin_metadata(plugin),
|
||||
thread_id: Some(tracking.thread_id.clone()),
|
||||
turn_id: Some(tracking.turn_id.clone()),
|
||||
model_slug: Some(tracking.model_slug.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn send_track_events(
|
||||
auth_manager: &AuthManager,
|
||||
config: Arc<Config>,
|
||||
events: Vec<TrackEventRequest>,
|
||||
) {
|
||||
if events.is_empty() {
|
||||
return;
|
||||
}
|
||||
let Some(auth) = auth_manager.auth().await else {
|
||||
return;
|
||||
};
|
||||
if !auth.is_chatgpt_auth() {
|
||||
return;
|
||||
}
|
||||
let access_token = match auth.get_token() {
|
||||
Ok(token) => token,
|
||||
Err(_) => return,
|
||||
};
|
||||
let Some(account_id) = auth.get_account_id() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let base_url = config.chatgpt_base_url.trim_end_matches('/');
|
||||
let url = format!("{base_url}/codex/analytics-events/events");
|
||||
let payload = TrackEventsRequest { events };
|
||||
|
||||
let response = create_client()
|
||||
.post(&url)
|
||||
.timeout(ANALYTICS_EVENTS_TIMEOUT)
|
||||
.bearer_auth(&access_token)
|
||||
.header("chatgpt-account-id", &account_id)
|
||||
.header("Content-Type", "application/json")
|
||||
.json(&payload)
|
||||
.send()
|
||||
.await;
|
||||
|
||||
match response {
|
||||
Ok(response) if response.status().is_success() => {}
|
||||
Ok(response) => {
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
tracing::warn!("events failed with status {status}: {body}");
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::warn!("failed to send events request: {err}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn skill_id_for_local_skill(
|
||||
repo_url: Option<&str>,
|
||||
repo_root: Option<&Path>,
|
||||
skill_path: &Path,
|
||||
skill_name: &str,
|
||||
) -> String {
|
||||
let path = normalize_path_for_skill_id(repo_url, repo_root, skill_path);
|
||||
let prefix = if let Some(url) = repo_url {
|
||||
format!("repo_{url}")
|
||||
} else {
|
||||
"personal".to_string()
|
||||
};
|
||||
let raw_id = format!("{prefix}_{path}_{skill_name}");
|
||||
let mut hasher = Sha1::new();
|
||||
hasher.update(raw_id.as_bytes());
|
||||
format!("{:x}", hasher.finalize())
|
||||
}
|
||||
|
||||
/// Returns a normalized path for skill ID construction.
|
||||
///
|
||||
/// - Repo-scoped skills use a path relative to the repo root.
|
||||
/// - User/admin/system skills use an absolute path.
|
||||
fn normalize_path_for_skill_id(
|
||||
repo_url: Option<&str>,
|
||||
repo_root: Option<&Path>,
|
||||
skill_path: &Path,
|
||||
) -> String {
|
||||
let resolved_path =
|
||||
std::fs::canonicalize(skill_path).unwrap_or_else(|_| skill_path.to_path_buf());
|
||||
match (repo_url, repo_root) {
|
||||
(Some(_), Some(root)) => {
|
||||
let resolved_root = std::fs::canonicalize(root).unwrap_or_else(|_| root.to_path_buf());
|
||||
resolved_path
|
||||
.strip_prefix(&resolved_root)
|
||||
.unwrap_or(resolved_path.as_path())
|
||||
.to_string_lossy()
|
||||
.replace('\\', "/")
|
||||
}
|
||||
_ => resolved_path.to_string_lossy().replace('\\', "/"),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "analytics_client_tests.rs"]
|
||||
mod tests;
|
||||
@@ -1,356 +0,0 @@
|
||||
use super::AnalyticsEventsQueue;
|
||||
use super::AppInvocation;
|
||||
use super::CodexAppMentionedEventRequest;
|
||||
use super::CodexAppUsedEventRequest;
|
||||
use super::CodexPluginEventRequest;
|
||||
use super::CodexPluginUsedEventRequest;
|
||||
use super::CodexTurnEvent;
|
||||
use super::CodexTurnEventRequest;
|
||||
use super::InvocationType;
|
||||
use super::TrackEventRequest;
|
||||
use super::TrackEventsContext;
|
||||
use super::codex_app_metadata;
|
||||
use super::codex_plugin_metadata;
|
||||
use super::codex_plugin_used_metadata;
|
||||
use super::codex_turn_event_params;
|
||||
use super::normalize_path_for_skill_id;
|
||||
use crate::plugins::AppConnectorId;
|
||||
use crate::plugins::PluginCapabilitySummary;
|
||||
use crate::plugins::PluginId;
|
||||
use crate::plugins::PluginTelemetryMetadata;
|
||||
use codex_protocol::config_types::ApprovalsReviewer;
|
||||
use codex_protocol::config_types::ModeKind;
|
||||
use codex_protocol::config_types::Personality;
|
||||
use codex_protocol::config_types::ReasoningSummary;
|
||||
use codex_protocol::config_types::ServiceTier;
|
||||
use codex_protocol::openai_models::ReasoningEffort;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::json;
|
||||
use std::collections::HashSet;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::sync::Mutex;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
fn expected_absolute_path(path: &PathBuf) -> String {
|
||||
std::fs::canonicalize(path)
|
||||
.unwrap_or_else(|_| path.to_path_buf())
|
||||
.to_string_lossy()
|
||||
.replace('\\', "/")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_path_for_skill_id_repo_scoped_uses_relative_path() {
|
||||
let repo_root = PathBuf::from("/repo/root");
|
||||
let skill_path = PathBuf::from("/repo/root/.codex/skills/doc/SKILL.md");
|
||||
|
||||
let path = normalize_path_for_skill_id(
|
||||
Some("https://example.com/repo.git"),
|
||||
Some(repo_root.as_path()),
|
||||
skill_path.as_path(),
|
||||
);
|
||||
|
||||
assert_eq!(path, ".codex/skills/doc/SKILL.md");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_path_for_skill_id_user_scoped_uses_absolute_path() {
|
||||
let skill_path = PathBuf::from("/Users/abc/.codex/skills/doc/SKILL.md");
|
||||
|
||||
let path = normalize_path_for_skill_id(None, None, skill_path.as_path());
|
||||
let expected = expected_absolute_path(&skill_path);
|
||||
|
||||
assert_eq!(path, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_path_for_skill_id_admin_scoped_uses_absolute_path() {
|
||||
let skill_path = PathBuf::from("/etc/codex/skills/doc/SKILL.md");
|
||||
|
||||
let path = normalize_path_for_skill_id(None, None, skill_path.as_path());
|
||||
let expected = expected_absolute_path(&skill_path);
|
||||
|
||||
assert_eq!(path, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_path_for_skill_id_repo_root_not_in_skill_path_uses_absolute_path() {
|
||||
let repo_root = PathBuf::from("/repo/root");
|
||||
let skill_path = PathBuf::from("/other/path/.codex/skills/doc/SKILL.md");
|
||||
|
||||
let path = normalize_path_for_skill_id(
|
||||
Some("https://example.com/repo.git"),
|
||||
Some(repo_root.as_path()),
|
||||
skill_path.as_path(),
|
||||
);
|
||||
let expected = expected_absolute_path(&skill_path);
|
||||
|
||||
assert_eq!(path, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn app_mentioned_event_serializes_expected_shape() {
|
||||
let tracking = TrackEventsContext {
|
||||
model_slug: "gpt-5".to_string(),
|
||||
thread_id: "thread-1".to_string(),
|
||||
turn_id: "turn-1".to_string(),
|
||||
};
|
||||
let event = TrackEventRequest::AppMentioned(CodexAppMentionedEventRequest {
|
||||
event_type: "codex_app_mentioned",
|
||||
event_params: codex_app_metadata(
|
||||
&tracking,
|
||||
AppInvocation {
|
||||
connector_id: Some("calendar".to_string()),
|
||||
app_name: Some("Calendar".to_string()),
|
||||
invocation_type: Some(InvocationType::Explicit),
|
||||
},
|
||||
),
|
||||
});
|
||||
|
||||
let payload = serde_json::to_value(&event).expect("serialize app mentioned event");
|
||||
|
||||
assert_eq!(
|
||||
payload,
|
||||
json!({
|
||||
"event_type": "codex_app_mentioned",
|
||||
"event_params": {
|
||||
"connector_id": "calendar",
|
||||
"thread_id": "thread-1",
|
||||
"turn_id": "turn-1",
|
||||
"app_name": "Calendar",
|
||||
"product_client_id": crate::default_client::originator().value,
|
||||
"invoke_type": "explicit",
|
||||
"model_slug": "gpt-5"
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn app_used_event_serializes_expected_shape() {
|
||||
let tracking = TrackEventsContext {
|
||||
model_slug: "gpt-5".to_string(),
|
||||
thread_id: "thread-2".to_string(),
|
||||
turn_id: "turn-2".to_string(),
|
||||
};
|
||||
let event = TrackEventRequest::AppUsed(CodexAppUsedEventRequest {
|
||||
event_type: "codex_app_used",
|
||||
event_params: codex_app_metadata(
|
||||
&tracking,
|
||||
AppInvocation {
|
||||
connector_id: Some("drive".to_string()),
|
||||
app_name: Some("Google Drive".to_string()),
|
||||
invocation_type: Some(InvocationType::Implicit),
|
||||
},
|
||||
),
|
||||
});
|
||||
|
||||
let payload = serde_json::to_value(&event).expect("serialize app used event");
|
||||
|
||||
assert_eq!(
|
||||
payload,
|
||||
json!({
|
||||
"event_type": "codex_app_used",
|
||||
"event_params": {
|
||||
"connector_id": "drive",
|
||||
"thread_id": "thread-2",
|
||||
"turn_id": "turn-2",
|
||||
"app_name": "Google Drive",
|
||||
"product_client_id": crate::default_client::originator().value,
|
||||
"invoke_type": "implicit",
|
||||
"model_slug": "gpt-5"
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn app_used_dedupe_is_keyed_by_turn_and_connector() {
|
||||
let (sender, _receiver) = mpsc::channel(1);
|
||||
let queue = AnalyticsEventsQueue {
|
||||
sender,
|
||||
app_used_emitted_keys: Arc::new(Mutex::new(HashSet::new())),
|
||||
plugin_used_emitted_keys: Arc::new(Mutex::new(HashSet::new())),
|
||||
};
|
||||
let app = AppInvocation {
|
||||
connector_id: Some("calendar".to_string()),
|
||||
app_name: Some("Calendar".to_string()),
|
||||
invocation_type: Some(InvocationType::Implicit),
|
||||
};
|
||||
|
||||
let turn_1 = TrackEventsContext {
|
||||
model_slug: "gpt-5".to_string(),
|
||||
thread_id: "thread-1".to_string(),
|
||||
turn_id: "turn-1".to_string(),
|
||||
};
|
||||
let turn_2 = TrackEventsContext {
|
||||
model_slug: "gpt-5".to_string(),
|
||||
thread_id: "thread-1".to_string(),
|
||||
turn_id: "turn-2".to_string(),
|
||||
};
|
||||
|
||||
assert_eq!(queue.should_enqueue_app_used(&turn_1, &app), true);
|
||||
assert_eq!(queue.should_enqueue_app_used(&turn_1, &app), false);
|
||||
assert_eq!(queue.should_enqueue_app_used(&turn_2, &app), true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn turn_event_serializes_expected_shape() {
|
||||
let tracking = TrackEventsContext {
|
||||
model_slug: "gpt-5".to_string(),
|
||||
thread_id: "thread-2".to_string(),
|
||||
turn_id: "turn-2".to_string(),
|
||||
};
|
||||
let event = TrackEventRequest::TurnEvent(CodexTurnEventRequest {
|
||||
event_type: "codex_turn_event",
|
||||
event_params: codex_turn_event_params(
|
||||
&tracking,
|
||||
CodexTurnEvent {
|
||||
model_provider: "openai".to_string(),
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
reasoning_effort: Some(ReasoningEffort::High),
|
||||
reasoning_summary: Some(ReasoningSummary::Detailed),
|
||||
service_tier: Some(ServiceTier::Flex),
|
||||
approval_policy: AskForApproval::OnRequest,
|
||||
approvals_reviewer: ApprovalsReviewer::GuardianSubagent,
|
||||
sandbox_network_access: true,
|
||||
collaboration_mode: ModeKind::Plan,
|
||||
personality: Some(Personality::Pragmatic),
|
||||
num_input_images: 2,
|
||||
is_first_turn: true,
|
||||
},
|
||||
),
|
||||
});
|
||||
|
||||
let payload = serde_json::to_value(&event).expect("serialize turn event");
|
||||
|
||||
assert_eq!(
|
||||
payload,
|
||||
json!({
|
||||
"event_type": "codex_turn_event",
|
||||
"event_params": {
|
||||
"thread_id": "thread-2",
|
||||
"turn_id": "turn-2",
|
||||
"product_client_id": crate::default_client::originator().value,
|
||||
"model": "gpt-5",
|
||||
"model_provider": "openai",
|
||||
"sandbox_policy": "read_only",
|
||||
"reasoning_effort": "high",
|
||||
"reasoning_summary": "detailed",
|
||||
"service_tier": "flex",
|
||||
"approval_policy": "on-request",
|
||||
"approvals_reviewer": "guardian_subagent",
|
||||
"sandbox_network_access": true,
|
||||
"collaboration_mode": "plan",
|
||||
"personality": "pragmatic",
|
||||
"num_input_images": 2,
|
||||
"is_first_turn": true
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_used_event_serializes_expected_shape() {
|
||||
let tracking = TrackEventsContext {
|
||||
model_slug: "gpt-5".to_string(),
|
||||
thread_id: "thread-3".to_string(),
|
||||
turn_id: "turn-3".to_string(),
|
||||
};
|
||||
let event = TrackEventRequest::PluginUsed(CodexPluginUsedEventRequest {
|
||||
event_type: "codex_plugin_used",
|
||||
event_params: codex_plugin_used_metadata(&tracking, sample_plugin_metadata()),
|
||||
});
|
||||
|
||||
let payload = serde_json::to_value(&event).expect("serialize plugin used event");
|
||||
|
||||
assert_eq!(
|
||||
payload,
|
||||
json!({
|
||||
"event_type": "codex_plugin_used",
|
||||
"event_params": {
|
||||
"plugin_id": "sample@test",
|
||||
"plugin_name": "sample",
|
||||
"marketplace_name": "test",
|
||||
"has_skills": true,
|
||||
"mcp_server_count": 2,
|
||||
"connector_ids": ["calendar", "drive"],
|
||||
"product_client_id": crate::default_client::originator().value,
|
||||
"thread_id": "thread-3",
|
||||
"turn_id": "turn-3",
|
||||
"model_slug": "gpt-5"
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_management_event_serializes_expected_shape() {
|
||||
let event = TrackEventRequest::PluginInstalled(CodexPluginEventRequest {
|
||||
event_type: "codex_plugin_installed",
|
||||
event_params: codex_plugin_metadata(sample_plugin_metadata()),
|
||||
});
|
||||
|
||||
let payload = serde_json::to_value(&event).expect("serialize plugin installed event");
|
||||
|
||||
assert_eq!(
|
||||
payload,
|
||||
json!({
|
||||
"event_type": "codex_plugin_installed",
|
||||
"event_params": {
|
||||
"plugin_id": "sample@test",
|
||||
"plugin_name": "sample",
|
||||
"marketplace_name": "test",
|
||||
"has_skills": true,
|
||||
"mcp_server_count": 2,
|
||||
"connector_ids": ["calendar", "drive"],
|
||||
"product_client_id": crate::default_client::originator().value
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_used_dedupe_is_keyed_by_turn_and_plugin() {
|
||||
let (sender, _receiver) = mpsc::channel(1);
|
||||
let queue = AnalyticsEventsQueue {
|
||||
sender,
|
||||
app_used_emitted_keys: Arc::new(Mutex::new(HashSet::new())),
|
||||
plugin_used_emitted_keys: Arc::new(Mutex::new(HashSet::new())),
|
||||
};
|
||||
let plugin = sample_plugin_metadata();
|
||||
|
||||
let turn_1 = TrackEventsContext {
|
||||
model_slug: "gpt-5".to_string(),
|
||||
thread_id: "thread-1".to_string(),
|
||||
turn_id: "turn-1".to_string(),
|
||||
};
|
||||
let turn_2 = TrackEventsContext {
|
||||
model_slug: "gpt-5".to_string(),
|
||||
thread_id: "thread-1".to_string(),
|
||||
turn_id: "turn-2".to_string(),
|
||||
};
|
||||
|
||||
assert_eq!(queue.should_enqueue_plugin_used(&turn_1, &plugin), true);
|
||||
assert_eq!(queue.should_enqueue_plugin_used(&turn_1, &plugin), false);
|
||||
assert_eq!(queue.should_enqueue_plugin_used(&turn_2, &plugin), true);
|
||||
}
|
||||
|
||||
fn sample_plugin_metadata() -> PluginTelemetryMetadata {
|
||||
PluginTelemetryMetadata {
|
||||
plugin_id: PluginId::parse("sample@test").expect("valid plugin id"),
|
||||
capability_summary: Some(PluginCapabilitySummary {
|
||||
config_name: "sample@test".to_string(),
|
||||
display_name: "sample".to_string(),
|
||||
description: None,
|
||||
has_skills: true,
|
||||
mcp_server_names: vec!["mcp-1".to_string(), "mcp-2".to_string()],
|
||||
app_connector_ids: vec![
|
||||
AppConnectorId("calendar".to_string()),
|
||||
AppConnectorId("drive".to_string()),
|
||||
],
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,72 @@
|
||||
use crate::mcp::CODEX_APPS_MCP_SERVER_NAME;
|
||||
use codex_app_server_protocol::AppInfo;
|
||||
use codex_protocol::protocol::APPS_INSTRUCTIONS_CLOSE_TAG;
|
||||
use codex_protocol::protocol::APPS_INSTRUCTIONS_OPEN_TAG;
|
||||
|
||||
pub(crate) fn render_apps_section() -> String {
|
||||
pub(crate) fn render_apps_section(connectors: &[AppInfo]) -> Option<String> {
|
||||
if !connectors
|
||||
.iter()
|
||||
.any(|connector| connector.is_accessible && connector.is_enabled)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
|
||||
let body = format!(
|
||||
"## Apps (Connectors)\nApps (Connectors) can be explicitly triggered in user messages in the format `[$app-name](app://{{connector_id}})`. Apps can also be implicitly triggered as long as the context suggests usage of available apps, the available apps will be listed by the `tool_search` tool.\nAn app is equivalent to a set of MCP tools within the `{CODEX_APPS_MCP_SERVER_NAME}` MCP.\nAn installed app's MCP tools are either provided to you already, or can be lazy-loaded through the `tool_search` tool.\nDo not additionally call list_mcp_resources or list_mcp_resource_templates for apps."
|
||||
"## Apps (Connectors)\nApps (Connectors) can be explicitly triggered in user messages in the format `[$app-name](app://{{connector_id}})`. Apps can also be implicitly triggered as long as the context suggests usage of available apps.\nAn app is equivalent to a set of MCP tools within the `{CODEX_APPS_MCP_SERVER_NAME}` MCP.\nAn installed app's MCP tools are either provided to you already, or can be lazy-loaded through the `tool_search` tool. If `tool_search` is available, the apps that are searchable by `tools_search` will be listed by it.\nDo not additionally call list_mcp_resources or list_mcp_resource_templates for apps."
|
||||
);
|
||||
format!("{APPS_INSTRUCTIONS_OPEN_TAG}\n{body}\n{APPS_INSTRUCTIONS_CLOSE_TAG}")
|
||||
Some(format!(
|
||||
"{APPS_INSTRUCTIONS_OPEN_TAG}\n{body}\n{APPS_INSTRUCTIONS_CLOSE_TAG}"
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn connector(id: &str, is_accessible: bool, is_enabled: bool) -> AppInfo {
|
||||
AppInfo {
|
||||
id: id.to_string(),
|
||||
name: id.to_string(),
|
||||
description: None,
|
||||
logo_url: None,
|
||||
logo_url_dark: None,
|
||||
distribution_channel: None,
|
||||
branding: None,
|
||||
app_metadata: None,
|
||||
labels: None,
|
||||
install_url: None,
|
||||
is_accessible,
|
||||
is_enabled,
|
||||
plugin_display_names: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn omits_apps_section_without_accessible_and_enabled_apps() {
|
||||
assert_eq!(render_apps_section(&[]), None);
|
||||
assert_eq!(
|
||||
render_apps_section(&[connector(
|
||||
"calendar", /*is_accessible*/ true, /*is_enabled*/ false
|
||||
)]),
|
||||
None
|
||||
);
|
||||
assert_eq!(
|
||||
render_apps_section(&[connector(
|
||||
"calendar", /*is_accessible*/ false, /*is_enabled*/ true
|
||||
)]),
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renders_apps_section_with_an_accessible_and_enabled_app() {
|
||||
let rendered = render_apps_section(&[connector(
|
||||
"calendar", /*is_accessible*/ true, /*is_enabled*/ true,
|
||||
)])
|
||||
.expect("expected apps section");
|
||||
|
||||
assert!(rendered.starts_with(APPS_INSTRUCTIONS_OPEN_TAG));
|
||||
assert!(rendered.contains("## Apps (Connectors)"));
|
||||
assert!(rendered.ends_with(APPS_INSTRUCTIONS_CLOSE_TAG));
|
||||
}
|
||||
}
|
||||
|
||||
+220
-76
@@ -5,6 +5,8 @@ use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::AtomicU64;
|
||||
use std::time::SystemTime;
|
||||
use std::time::UNIX_EPOCH;
|
||||
|
||||
use crate::AuthManager;
|
||||
use crate::CodexAuth;
|
||||
@@ -12,11 +14,6 @@ use crate::SandboxState;
|
||||
use crate::agent::AgentControl;
|
||||
use crate::agent::AgentStatus;
|
||||
use crate::agent::agent_status_from_event;
|
||||
use crate::analytics_client::AnalyticsEventsClient;
|
||||
use crate::analytics_client::AppInvocation;
|
||||
use crate::analytics_client::CodexTurnEvent;
|
||||
use crate::analytics_client::InvocationType;
|
||||
use crate::analytics_client::build_track_events_context;
|
||||
use crate::apps::render_apps_section;
|
||||
use crate::auth_env_telemetry::collect_auth_env_telemetry;
|
||||
use crate::commit_attribution::commit_message_trailer_instruction;
|
||||
@@ -34,20 +31,21 @@ use crate::models_manager::manager::ModelsManager;
|
||||
use crate::models_manager::manager::RefreshStrategy;
|
||||
use crate::parse_command::parse_command;
|
||||
use crate::parse_turn_item;
|
||||
use crate::path_utils::normalize_for_native_workdir;
|
||||
use crate::realtime_conversation::RealtimeConversationManager;
|
||||
use crate::realtime_conversation::handle_audio as handle_realtime_conversation_audio;
|
||||
use crate::realtime_conversation::handle_close as handle_realtime_conversation_close;
|
||||
use crate::realtime_conversation::handle_start as handle_realtime_conversation_start;
|
||||
use crate::realtime_conversation::handle_text as handle_realtime_conversation_text;
|
||||
use crate::render_skills_section;
|
||||
use crate::rollout::session_index;
|
||||
use crate::skills::render_skills_section;
|
||||
use crate::skills_load_input_from_config;
|
||||
use crate::stream_events_utils::HandleOutputCtx;
|
||||
use crate::stream_events_utils::handle_non_tool_response_item;
|
||||
use crate::stream_events_utils::handle_output_item_done;
|
||||
use crate::stream_events_utils::last_assistant_message_from_item;
|
||||
use crate::stream_events_utils::raw_assistant_output_text_from_item;
|
||||
use crate::stream_events_utils::record_completed_response_item;
|
||||
use crate::truncate::TruncationPolicy;
|
||||
use crate::turn_metadata::TurnMetadataState;
|
||||
use crate::util::error_or_panic;
|
||||
use async_channel::Receiver;
|
||||
@@ -118,6 +116,7 @@ use codex_protocol::request_user_input::RequestUserInputResponse;
|
||||
use codex_rmcp_client::ElicitationResponse;
|
||||
use codex_rmcp_client::OAuthCredentialsStoreMode;
|
||||
use codex_terminal_detection::user_agent;
|
||||
use codex_utils_output_truncation::TruncationPolicy;
|
||||
use codex_utils_stream_parser::AssistantTextChunk;
|
||||
use codex_utils_stream_parser::AssistantTextStreamParser;
|
||||
use codex_utils_stream_parser::ProposedPlanSegment;
|
||||
@@ -235,6 +234,14 @@ pub(crate) struct PreviousTurnSettings {
|
||||
pub(crate) realtime_active: Option<bool>,
|
||||
}
|
||||
|
||||
use crate::SkillError;
|
||||
use crate::SkillInjections;
|
||||
use crate::SkillLoadOutcome;
|
||||
use crate::SkillMetadata;
|
||||
use crate::SkillsManager;
|
||||
use crate::build_skill_injections;
|
||||
use crate::collect_env_var_dependencies;
|
||||
use crate::collect_explicit_skill_mentions;
|
||||
use crate::exec_policy::ExecPolicyUpdateError;
|
||||
use crate::feedback_tags;
|
||||
use crate::guardian::GuardianReviewSessionManager;
|
||||
@@ -244,6 +251,9 @@ use crate::hook_runtime::record_additional_contexts;
|
||||
use crate::hook_runtime::record_pending_input;
|
||||
use crate::hook_runtime::run_pending_session_start_hooks;
|
||||
use crate::hook_runtime::run_user_prompt_submit_hooks;
|
||||
use crate::injection::ToolMentionKind;
|
||||
use crate::injection::app_id_from_path;
|
||||
use crate::injection::tool_kind_for_path;
|
||||
use crate::instructions::UserInstructions;
|
||||
use crate::mcp::CODEX_APPS_MCP_SERVER_NAME;
|
||||
use crate::mcp::McpManager;
|
||||
@@ -301,6 +311,7 @@ use crate::protocol::TokenUsage;
|
||||
use crate::protocol::TokenUsageInfo;
|
||||
use crate::protocol::TurnDiffEvent;
|
||||
use crate::protocol::WarningEvent;
|
||||
use crate::resolve_skill_dependencies_for_turn;
|
||||
use crate::rollout::RolloutRecorder;
|
||||
use crate::rollout::RolloutRecorderParams;
|
||||
use crate::rollout::map_session_init_error;
|
||||
@@ -309,18 +320,6 @@ use crate::rollout::policy::EventPersistenceMode;
|
||||
use crate::session_startup_prewarm::SessionStartupPrewarmHandle;
|
||||
use crate::shell;
|
||||
use crate::shell_snapshot::ShellSnapshot;
|
||||
use crate::skills::SkillError;
|
||||
use crate::skills::SkillInjections;
|
||||
use crate::skills::SkillLoadOutcome;
|
||||
use crate::skills::SkillMetadata;
|
||||
use crate::skills::SkillsManager;
|
||||
use crate::skills::build_skill_injections;
|
||||
use crate::skills::collect_env_var_dependencies;
|
||||
use crate::skills::collect_explicit_skill_mentions;
|
||||
use crate::skills::injection::ToolMentionKind;
|
||||
use crate::skills::injection::app_id_from_path;
|
||||
use crate::skills::injection::tool_kind_for_path;
|
||||
use crate::skills::resolve_skill_dependencies_for_turn;
|
||||
use crate::skills_watcher::SkillsWatcher;
|
||||
use crate::skills_watcher::SkillsWatcherEvent;
|
||||
use crate::state::ActiveTurn;
|
||||
@@ -350,6 +349,13 @@ use crate::turn_timing::record_turn_ttft_metric;
|
||||
use crate::unified_exec::UnifiedExecProcessManager;
|
||||
use crate::util::backoff;
|
||||
use crate::windows_sandbox::WindowsSandboxLevelExt;
|
||||
use codex_analytics::AnalyticsEventsClient;
|
||||
use codex_analytics::AppInvocation;
|
||||
use codex_analytics::CodexThreadInitializedEvent;
|
||||
use codex_analytics::CodexTurnEvent;
|
||||
use codex_analytics::InitializationMode;
|
||||
use codex_analytics::InvocationType;
|
||||
use codex_analytics::build_track_events_context;
|
||||
use codex_async_utils::OrCancelExt;
|
||||
use codex_git_utils::get_git_repo_root;
|
||||
use codex_otel::SessionTelemetry;
|
||||
@@ -473,7 +479,10 @@ impl Codex {
|
||||
let (tx_sub, rx_sub) = async_channel::bounded(SUBMISSION_CHANNEL_CAPACITY);
|
||||
let (tx_event, rx_event) = async_channel::unbounded();
|
||||
|
||||
let loaded_skills = skills_manager.skills_for_config(&config);
|
||||
let plugin_outcome = plugins_manager.plugins_for_config(&config);
|
||||
let effective_skill_roots = plugin_outcome.effective_skill_roots();
|
||||
let skills_input = skills_load_input_from_config(&config, effective_skill_roots);
|
||||
let loaded_skills = skills_manager.skills_for_config(&skills_input);
|
||||
|
||||
for err in &loaded_skills.errors {
|
||||
error!(
|
||||
@@ -629,6 +638,10 @@ impl Codex {
|
||||
user_shell_override,
|
||||
};
|
||||
|
||||
let initialization_mode = initialization_mode(&conversation_history);
|
||||
let thread_session_source = session_configuration.session_source.clone();
|
||||
let thread_initialized_configuration = session_configuration.clone();
|
||||
|
||||
// Generate a unique ID for the lifetime of this Codex session.
|
||||
let session_source_clone = session_configuration.session_source.clone();
|
||||
let (agent_status_tx, agent_status_rx) = watch::channel(AgentStatus::PendingInit);
|
||||
@@ -656,6 +669,48 @@ impl Codex {
|
||||
})?;
|
||||
let thread_id = session.conversation_id;
|
||||
|
||||
session
|
||||
.services
|
||||
.analytics_events_client
|
||||
.track_thread_initialized(CodexThreadInitializedEvent {
|
||||
thread_id: thread_id.to_string(),
|
||||
model: thread_initialized_configuration
|
||||
.collaboration_mode
|
||||
.model()
|
||||
.to_string(),
|
||||
model_provider: thread_initialized_configuration
|
||||
.original_config_do_not_use
|
||||
.model_provider_id
|
||||
.clone(),
|
||||
reasoning_effort: thread_initialized_configuration
|
||||
.collaboration_mode
|
||||
.reasoning_effort(),
|
||||
reasoning_summary: thread_initialized_configuration.model_reasoning_summary,
|
||||
service_tier: thread_initialized_configuration.service_tier,
|
||||
approval_policy: thread_initialized_configuration.approval_policy.value(),
|
||||
approvals_reviewer: thread_initialized_configuration.approvals_reviewer,
|
||||
sandbox_policy: thread_initialized_configuration
|
||||
.sandbox_policy
|
||||
.get()
|
||||
.clone(),
|
||||
sandbox_network_access: thread_initialized_configuration
|
||||
.network_sandbox_policy
|
||||
.is_enabled(),
|
||||
collaboration_mode: thread_initialized_configuration.collaboration_mode.mode,
|
||||
personality: thread_initialized_configuration.personality,
|
||||
ephemeral: thread_initialized_configuration
|
||||
.original_config_do_not_use
|
||||
.ephemeral,
|
||||
initialization_mode,
|
||||
subagent_source: session_source_subagent_source(&thread_session_source),
|
||||
parent_thread_id: session_source_parent_thread_id(&thread_session_source),
|
||||
session_source: thread_session_source,
|
||||
created_at: SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_secs(),
|
||||
});
|
||||
|
||||
// This task will run until Op::Shutdown is received.
|
||||
let session_for_loop = Arc::clone(&session);
|
||||
let session_loop_handle = tokio::spawn(async move {
|
||||
@@ -770,6 +825,30 @@ impl Codex {
|
||||
}
|
||||
}
|
||||
|
||||
fn initialization_mode(conversation_history: &InitialHistory) -> InitializationMode {
|
||||
match conversation_history {
|
||||
InitialHistory::New => InitializationMode::New,
|
||||
InitialHistory::Forked(_) => InitializationMode::Forked,
|
||||
InitialHistory::Resumed(_) => InitializationMode::Resumed,
|
||||
}
|
||||
}
|
||||
|
||||
fn session_source_subagent_source(session_source: &SessionSource) -> Option<SubAgentSource> {
|
||||
match session_source {
|
||||
SessionSource::SubAgent(subagent_source) => Some(subagent_source.clone()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn session_source_parent_thread_id(session_source: &SessionSource) -> Option<String> {
|
||||
match session_source {
|
||||
SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
|
||||
parent_thread_id, ..
|
||||
}) => Some(parent_thread_id.to_string()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn completed_session_loop_termination() -> SessionLoopTermination {
|
||||
futures::future::ready(()).boxed().shared()
|
||||
@@ -848,10 +927,10 @@ pub(crate) struct TurnContext {
|
||||
pub(crate) reasoning_summary: ReasoningSummaryConfig,
|
||||
pub(crate) session_source: SessionSource,
|
||||
pub(crate) environment: Arc<Environment>,
|
||||
/// The session's current working directory. All relative paths provided by
|
||||
/// the model as well as sandbox policies are resolved against this path
|
||||
/// The session's absolute working directory. All relative paths provided
|
||||
/// by the model as well as sandbox policies are resolved against this path
|
||||
/// instead of `std::env::current_dir()`.
|
||||
pub(crate) cwd: PathBuf,
|
||||
pub(crate) cwd: AbsolutePathBuf,
|
||||
pub(crate) current_date: Option<String>,
|
||||
pub(crate) timezone: Option<String>,
|
||||
pub(crate) app_server_client_name: Option<String>,
|
||||
@@ -992,7 +1071,7 @@ impl TurnContext {
|
||||
pub(crate) fn resolve_path(&self, path: Option<String>) -> PathBuf {
|
||||
path.as_ref()
|
||||
.map(PathBuf::from)
|
||||
.map_or_else(|| self.cwd.clone(), |p| self.cwd.join(p))
|
||||
.map_or_else(|| self.cwd.to_path_buf(), |p| self.cwd.as_path().join(p))
|
||||
}
|
||||
|
||||
pub(crate) fn compact_prompt(&self) -> &str {
|
||||
@@ -1005,7 +1084,7 @@ impl TurnContext {
|
||||
TurnContextItem {
|
||||
turn_id: Some(self.sub_id.clone()),
|
||||
trace_id: self.trace_id.clone(),
|
||||
cwd: self.cwd.clone(),
|
||||
cwd: self.cwd.to_path_buf(),
|
||||
current_date: self.current_date.clone(),
|
||||
timezone: self.timezone.clone(),
|
||||
approval_policy: self.approval_policy.value(),
|
||||
@@ -1020,7 +1099,7 @@ impl TurnContext {
|
||||
user_instructions: self.user_instructions.clone(),
|
||||
developer_instructions: self.developer_instructions.clone(),
|
||||
final_output_json_schema: self.final_output_json_schema.clone(),
|
||||
truncation_policy: Some(self.truncation_policy.into()),
|
||||
truncation_policy: Some(self.truncation_policy),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1081,14 +1160,11 @@ pub(crate) struct SessionConfiguration {
|
||||
network_sandbox_policy: NetworkSandboxPolicy,
|
||||
windows_sandbox_level: WindowsSandboxLevel,
|
||||
|
||||
/// Working directory that should be treated as the *root* of the
|
||||
/// Absolute working directory that should be treated as the *root* of the
|
||||
/// session. All relative paths supplied by the model as well as the
|
||||
/// execution sandbox are resolved against this directory **instead**
|
||||
/// of the process-wide current working directory. CLI front-ends are
|
||||
/// expected to expand this to an absolute path before sending the
|
||||
/// `ConfigureSession` operation so that the business-logic layer can
|
||||
/// operate deterministically.
|
||||
cwd: PathBuf,
|
||||
/// execution sandbox are resolved against this directory **instead** of
|
||||
/// the process-wide current working directory.
|
||||
cwd: AbsolutePathBuf,
|
||||
/// Directory containing all Codex state for this session.
|
||||
codex_home: PathBuf,
|
||||
/// Optional user-facing name for the thread, updated during the session.
|
||||
@@ -1120,7 +1196,7 @@ impl SessionConfiguration {
|
||||
approval_policy: self.approval_policy.value(),
|
||||
approvals_reviewer: self.approvals_reviewer,
|
||||
sandbox_policy: self.sandbox_policy.get().clone(),
|
||||
cwd: self.cwd.clone(),
|
||||
cwd: self.cwd.to_path_buf(),
|
||||
ephemeral: self.original_config_do_not_use.ephemeral,
|
||||
reasoning_effort: self.collaboration_mode.reasoning_effort(),
|
||||
personality: self.personality,
|
||||
@@ -1163,11 +1239,23 @@ impl SessionConfiguration {
|
||||
if let Some(windows_sandbox_level) = updates.windows_sandbox_level {
|
||||
next_configuration.windows_sandbox_level = windows_sandbox_level;
|
||||
}
|
||||
let mut cwd_changed = false;
|
||||
if let Some(cwd) = updates.cwd.clone() {
|
||||
next_configuration.cwd = cwd;
|
||||
cwd_changed = true;
|
||||
}
|
||||
|
||||
let absolute_cwd = updates
|
||||
.cwd
|
||||
.as_ref()
|
||||
.map(|cwd| {
|
||||
AbsolutePathBuf::relative_to_current_dir(normalize_for_native_workdir(
|
||||
cwd.as_path(),
|
||||
))
|
||||
.unwrap_or_else(|e| {
|
||||
warn!("failed to normalize update cwd: {cwd:?}: {e}");
|
||||
self.cwd.clone()
|
||||
})
|
||||
})
|
||||
.unwrap_or_else(|| self.cwd.clone());
|
||||
|
||||
let cwd_changed = absolute_cwd.as_path() != self.cwd.as_path();
|
||||
next_configuration.cwd = absolute_cwd;
|
||||
if sandbox_policy_changed || (cwd_changed && file_system_policy_matches_legacy) {
|
||||
// Preserve richer split policies across cwd-only updates; only
|
||||
// rederive when the session is already using the legacy bridge.
|
||||
@@ -1364,8 +1452,6 @@ impl Session {
|
||||
let auth_manager_for_context = auth_manager;
|
||||
let provider_for_context = provider;
|
||||
let session_telemetry_for_context = session_telemetry;
|
||||
let per_turn_config = Arc::new(per_turn_config);
|
||||
|
||||
let tools_config = ToolsConfig::new(&ToolsConfigParams {
|
||||
model_info: &model_info,
|
||||
available_models: &models_manager.try_list_models().unwrap_or_default(),
|
||||
@@ -1385,10 +1471,12 @@ impl Session {
|
||||
.with_agent_roles(per_turn_config.agent_roles.clone());
|
||||
|
||||
let cwd = session_configuration.cwd.clone();
|
||||
|
||||
let per_turn_config = Arc::new(per_turn_config);
|
||||
let turn_metadata_state = Arc::new(TurnMetadataState::new(
|
||||
conversation_id.to_string(),
|
||||
sub_id.clone(),
|
||||
cwd.clone(),
|
||||
cwd.to_path_buf(),
|
||||
session_configuration.sandbox_policy.get(),
|
||||
session_configuration.windows_sandbox_level,
|
||||
));
|
||||
@@ -1460,13 +1548,6 @@ impl Session {
|
||||
session_configuration.collaboration_mode.model(),
|
||||
session_configuration.provider
|
||||
);
|
||||
if !session_configuration.cwd.is_absolute() {
|
||||
return Err(anyhow::anyhow!(
|
||||
"cwd is not absolute: {:?}",
|
||||
session_configuration.cwd
|
||||
));
|
||||
}
|
||||
|
||||
let forked_from_id = initial_history.forked_from_id();
|
||||
|
||||
let (conversation_id, rollout_params) = match &initial_history {
|
||||
@@ -1583,7 +1664,7 @@ impl Session {
|
||||
})?;
|
||||
let rollout_path = rollout_recorder
|
||||
.as_ref()
|
||||
.map(|rec| rec.rollout_path.clone());
|
||||
.map(|rec| rec.rollout_path().to_path_buf());
|
||||
|
||||
let mut post_session_configured_events = Vec::<Event>::new();
|
||||
|
||||
@@ -1736,7 +1817,7 @@ impl Session {
|
||||
ShellSnapshot::start_snapshotting(
|
||||
config.codex_home.clone(),
|
||||
conversation_id,
|
||||
session_configuration.cwd.clone(),
|
||||
session_configuration.cwd.to_path_buf(),
|
||||
&mut default_shell,
|
||||
session_telemetry.clone(),
|
||||
)
|
||||
@@ -1854,8 +1935,9 @@ impl Session {
|
||||
shell_zsh_path: config.zsh_path.clone(),
|
||||
main_execve_wrapper_exe: config.main_execve_wrapper_exe.clone(),
|
||||
analytics_events_client: AnalyticsEventsClient::new(
|
||||
Arc::clone(&config),
|
||||
Arc::clone(&auth_manager),
|
||||
config.chatgpt_base_url.trim_end_matches('/').to_string(),
|
||||
config.analytics_enabled,
|
||||
),
|
||||
hooks,
|
||||
rollout: Mutex::new(rollout_recorder),
|
||||
@@ -1935,7 +2017,7 @@ impl Session {
|
||||
approval_policy: session_configuration.approval_policy.value(),
|
||||
approvals_reviewer: session_configuration.approvals_reviewer,
|
||||
sandbox_policy: session_configuration.sandbox_policy.get().clone(),
|
||||
cwd: session_configuration.cwd.clone(),
|
||||
cwd: session_configuration.cwd.to_path_buf(),
|
||||
reasoning_effort: session_configuration.collaboration_mode.reasoning_effort(),
|
||||
history_log_id,
|
||||
history_entry_count,
|
||||
@@ -1956,7 +2038,7 @@ impl Session {
|
||||
let sandbox_state = SandboxState {
|
||||
sandbox_policy: session_configuration.sandbox_policy.get().clone(),
|
||||
codex_linux_sandbox_exe: config.codex_linux_sandbox_exe.clone(),
|
||||
sandbox_cwd: session_configuration.cwd.clone(),
|
||||
sandbox_cwd: session_configuration.cwd.to_path_buf(),
|
||||
use_legacy_landlock: config.features.use_legacy_landlock(),
|
||||
};
|
||||
let mut required_mcp_servers: Vec<String> = mcp_servers
|
||||
@@ -2233,15 +2315,6 @@ impl Session {
|
||||
self.persist_rollout_items(&rollout_items).await;
|
||||
}
|
||||
|
||||
// Append the current session's initial context after the reconstructed history.
|
||||
let initial_context = self.build_initial_context(&turn_context).await;
|
||||
self.record_conversation_items(&turn_context, &initial_context)
|
||||
.await;
|
||||
{
|
||||
let mut state = self.state.lock().await;
|
||||
state.set_reference_context_item(Some(turn_context.to_turn_context_item()));
|
||||
}
|
||||
|
||||
// Forked threads should remain file-backed immediately after startup.
|
||||
self.ensure_rollout_materialized().await;
|
||||
|
||||
@@ -2434,7 +2507,7 @@ impl Session {
|
||||
let sandbox_state = SandboxState {
|
||||
sandbox_policy: per_turn_config.permissions.sandbox_policy.get().clone(),
|
||||
codex_linux_sandbox_exe: per_turn_config.codex_linux_sandbox_exe.clone(),
|
||||
sandbox_cwd: per_turn_config.cwd.clone(),
|
||||
sandbox_cwd: per_turn_config.cwd.to_path_buf(),
|
||||
use_legacy_landlock: per_turn_config.features.use_legacy_landlock(),
|
||||
};
|
||||
if let Err(e) = self
|
||||
@@ -2457,10 +2530,16 @@ impl Session {
|
||||
&per_turn_config,
|
||||
)
|
||||
.await;
|
||||
let plugin_outcome = self
|
||||
.services
|
||||
.plugins_manager
|
||||
.plugins_for_config(&per_turn_config);
|
||||
let effective_skill_roots = plugin_outcome.effective_skill_roots();
|
||||
let skills_input = skills_load_input_from_config(&per_turn_config, effective_skill_roots);
|
||||
let skills_outcome = Arc::new(
|
||||
self.services
|
||||
.skills_manager
|
||||
.skills_for_config(&per_turn_config),
|
||||
.skills_for_config(&skills_input),
|
||||
);
|
||||
let mut turn_context: TurnContext = Self::make_turn_context(
|
||||
self.conversation_id,
|
||||
@@ -3555,7 +3634,16 @@ impl Session {
|
||||
}
|
||||
}
|
||||
if turn_context.apps_enabled() {
|
||||
developer_sections.push(render_apps_section());
|
||||
let mcp_connection_manager = self.services.mcp_connection_manager.read().await;
|
||||
let accessible_and_enabled_connectors =
|
||||
connectors::list_accessible_and_enabled_connectors_from_manager(
|
||||
&mcp_connection_manager,
|
||||
&turn_context.config,
|
||||
)
|
||||
.await;
|
||||
if let Some(apps_section) = render_apps_section(&accessible_and_enabled_connectors) {
|
||||
developer_sections.push(apps_section);
|
||||
}
|
||||
}
|
||||
let implicit_skills = turn_context
|
||||
.turn_skills
|
||||
@@ -4156,7 +4244,7 @@ impl Session {
|
||||
let sandbox_state = SandboxState {
|
||||
sandbox_policy: turn_context.sandbox_policy.get().clone(),
|
||||
codex_linux_sandbox_exe: turn_context.codex_linux_sandbox_exe.clone(),
|
||||
sandbox_cwd: turn_context.cwd.clone(),
|
||||
sandbox_cwd: turn_context.cwd.to_path_buf(),
|
||||
use_legacy_landlock: turn_context.features.use_legacy_landlock(),
|
||||
};
|
||||
{
|
||||
@@ -4506,8 +4594,14 @@ mod handlers {
|
||||
use crate::codex::SessionSettingsUpdate;
|
||||
use crate::codex::SteerInputError;
|
||||
|
||||
use crate::SkillError;
|
||||
use crate::codex::spawn_review_thread;
|
||||
use crate::config::Config;
|
||||
use crate::config_loader::CloudRequirementsLoader;
|
||||
use crate::config_loader::LoaderOverrides;
|
||||
use crate::config_loader::load_config_layers_state;
|
||||
use codex_features::Feature;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
|
||||
use crate::mcp::auth::compute_auth_statuses;
|
||||
use crate::mcp::collect_mcp_snapshot_from_manager;
|
||||
@@ -4937,17 +5031,70 @@ mod handlers {
|
||||
) {
|
||||
let cwds = if cwds.is_empty() {
|
||||
let state = sess.state.lock().await;
|
||||
vec![state.session_configuration.cwd.clone()]
|
||||
vec![state.session_configuration.cwd.to_path_buf()]
|
||||
} else {
|
||||
cwds
|
||||
};
|
||||
|
||||
let skills_manager = &sess.services.skills_manager;
|
||||
let plugins_manager = &sess.services.plugins_manager;
|
||||
let config = sess.get_config().await;
|
||||
let codex_home = sess.codex_home().await;
|
||||
let mut skills = Vec::new();
|
||||
let empty_cli_overrides: &[(String, toml::Value)] = &[];
|
||||
for cwd in cwds {
|
||||
let cwd_abs = match AbsolutePathBuf::try_from(cwd.as_path()) {
|
||||
Ok(path) => path,
|
||||
Err(err) => {
|
||||
let message = err.to_string();
|
||||
let cwd_for_entry = cwd.clone();
|
||||
skills.push(SkillsListEntry {
|
||||
cwd: cwd_for_entry.clone(),
|
||||
skills: Vec::new(),
|
||||
errors: super::errors_to_info(&[SkillError {
|
||||
path: cwd_for_entry,
|
||||
message,
|
||||
}]),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let config_layer_stack = match load_config_layers_state(
|
||||
&codex_home,
|
||||
Some(cwd_abs),
|
||||
empty_cli_overrides,
|
||||
LoaderOverrides::default(),
|
||||
CloudRequirementsLoader::default(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(config_layer_stack) => config_layer_stack,
|
||||
Err(err) => {
|
||||
let message = err.to_string();
|
||||
let cwd_for_entry = cwd.clone();
|
||||
skills.push(SkillsListEntry {
|
||||
cwd: cwd_for_entry.clone(),
|
||||
skills: Vec::new(),
|
||||
errors: super::errors_to_info(&[SkillError {
|
||||
path: cwd_for_entry,
|
||||
message,
|
||||
}]),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let effective_skill_roots = plugins_manager.effective_skill_roots_for_layer_stack(
|
||||
&config_layer_stack,
|
||||
config.features.enabled(Feature::Plugins),
|
||||
);
|
||||
let skills_input = crate::SkillsLoadInput::new(
|
||||
cwd.clone(),
|
||||
effective_skill_roots,
|
||||
config_layer_stack,
|
||||
config.bundled_skills_enabled(),
|
||||
);
|
||||
let outcome = skills_manager
|
||||
.skills_for_cwd(&cwd, config.as_ref(), force_reload)
|
||||
.skills_for_cwd(&skills_input, force_reload)
|
||||
.await;
|
||||
let errors = super::errors_to_info(&outcome.errors);
|
||||
let skills_metadata = super::skills_to_info(&outcome.skills, &outcome.disabled_paths);
|
||||
@@ -5381,7 +5528,7 @@ async fn spawn_review_thread(
|
||||
let turn_metadata_state = Arc::new(TurnMetadataState::new(
|
||||
sess.conversation_id.to_string(),
|
||||
review_turn_id.clone(),
|
||||
parent_turn_context.cwd.clone(),
|
||||
parent_turn_context.cwd.to_path_buf(),
|
||||
parent_turn_context.sandbox_policy.get(),
|
||||
parent_turn_context.windows_sandbox_level,
|
||||
));
|
||||
@@ -5870,7 +6017,7 @@ pub(crate) async fn run_turn(
|
||||
let stop_request = codex_hooks::StopRequest {
|
||||
session_id: sess.conversation_id,
|
||||
turn_id: turn_context.sub_id.clone(),
|
||||
cwd: turn_context.cwd.clone(),
|
||||
cwd: turn_context.cwd.to_path_buf(),
|
||||
transcript_path: sess.hook_transcript_path().await,
|
||||
model: turn_context.model_info.slug.clone(),
|
||||
permission_mode: stop_hook_permission_mode,
|
||||
@@ -5920,7 +6067,7 @@ pub(crate) async fn run_turn(
|
||||
.hooks()
|
||||
.dispatch(HookPayload {
|
||||
session_id: sess.conversation_id,
|
||||
cwd: turn_context.cwd.clone(),
|
||||
cwd: turn_context.cwd.to_path_buf(),
|
||||
client: turn_context.app_server_client_name.clone(),
|
||||
triggered_at: chrono::Utc::now(),
|
||||
hook_event: HookEvent::AfterAgent {
|
||||
@@ -6505,10 +6652,7 @@ pub(crate) async fn built_tools(
|
||||
None
|
||||
};
|
||||
let auth = sess.services.auth_manager.auth().await;
|
||||
let discoverable_tools = if apps_enabled
|
||||
&& turn_context.tools_config.search_tool
|
||||
&& turn_context.tools_config.tool_suggest
|
||||
{
|
||||
let discoverable_tools = if apps_enabled && turn_context.tools_config.tool_suggest {
|
||||
if let Some(accessible_connectors) = accessible_connectors_with_enabled_state.as_ref() {
|
||||
match connectors::list_tool_suggest_discoverable_tools_with_auth(
|
||||
&turn_context.config,
|
||||
|
||||
@@ -62,7 +62,7 @@ async fn record_initial_history_resumed_bare_turn_context_does_not_hydrate_previ
|
||||
let previous_context_item = TurnContextItem {
|
||||
turn_id: Some(turn_context.sub_id.clone()),
|
||||
trace_id: turn_context.trace_id.clone(),
|
||||
cwd: turn_context.cwd.clone(),
|
||||
cwd: turn_context.cwd.to_path_buf(),
|
||||
current_date: turn_context.current_date.clone(),
|
||||
timezone: turn_context.timezone.clone(),
|
||||
approval_policy: turn_context.approval_policy.value(),
|
||||
@@ -77,7 +77,7 @@ async fn record_initial_history_resumed_bare_turn_context_does_not_hydrate_previ
|
||||
user_instructions: None,
|
||||
developer_instructions: None,
|
||||
final_output_json_schema: None,
|
||||
truncation_policy: Some(turn_context.truncation_policy.into()),
|
||||
truncation_policy: Some(turn_context.truncation_policy),
|
||||
};
|
||||
let rollout_items = vec![RolloutItem::TurnContext(previous_context_item)];
|
||||
|
||||
@@ -101,7 +101,7 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif
|
||||
let mut previous_context_item = TurnContextItem {
|
||||
turn_id: Some(turn_context.sub_id.clone()),
|
||||
trace_id: turn_context.trace_id.clone(),
|
||||
cwd: turn_context.cwd.clone(),
|
||||
cwd: turn_context.cwd.to_path_buf(),
|
||||
current_date: turn_context.current_date.clone(),
|
||||
timezone: turn_context.timezone.clone(),
|
||||
approval_policy: turn_context.approval_policy.value(),
|
||||
@@ -116,7 +116,7 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif
|
||||
user_instructions: None,
|
||||
developer_instructions: None,
|
||||
final_output_json_schema: None,
|
||||
truncation_policy: Some(turn_context.truncation_policy.into()),
|
||||
truncation_policy: Some(turn_context.truncation_policy),
|
||||
};
|
||||
let turn_id = previous_context_item
|
||||
.turn_id
|
||||
@@ -851,7 +851,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis
|
||||
let previous_context_item = TurnContextItem {
|
||||
turn_id: Some(turn_context.sub_id.clone()),
|
||||
trace_id: turn_context.trace_id.clone(),
|
||||
cwd: turn_context.cwd.clone(),
|
||||
cwd: turn_context.cwd.to_path_buf(),
|
||||
current_date: turn_context.current_date.clone(),
|
||||
timezone: turn_context.timezone.clone(),
|
||||
approval_policy: turn_context.approval_policy.value(),
|
||||
@@ -866,7 +866,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis
|
||||
user_instructions: None,
|
||||
developer_instructions: None,
|
||||
final_output_json_schema: None,
|
||||
truncation_policy: Some(turn_context.truncation_policy.into()),
|
||||
truncation_policy: Some(turn_context.truncation_policy),
|
||||
};
|
||||
let previous_turn_id = previous_context_item
|
||||
.turn_id
|
||||
@@ -923,7 +923,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis
|
||||
serde_json::to_value(Some(TurnContextItem {
|
||||
turn_id: Some(turn_context.sub_id.clone()),
|
||||
trace_id: turn_context.trace_id.clone(),
|
||||
cwd: turn_context.cwd.clone(),
|
||||
cwd: turn_context.cwd.to_path_buf(),
|
||||
current_date: turn_context.current_date.clone(),
|
||||
timezone: turn_context.timezone.clone(),
|
||||
approval_policy: turn_context.approval_policy.value(),
|
||||
@@ -938,7 +938,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis
|
||||
user_instructions: None,
|
||||
developer_instructions: None,
|
||||
final_output_json_schema: None,
|
||||
truncation_policy: Some(turn_context.truncation_policy.into()),
|
||||
truncation_policy: Some(turn_context.truncation_policy),
|
||||
}))
|
||||
.expect("serialize expected reference context item")
|
||||
);
|
||||
@@ -952,7 +952,7 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu
|
||||
let previous_context_item = TurnContextItem {
|
||||
turn_id: Some(turn_context.sub_id.clone()),
|
||||
trace_id: turn_context.trace_id.clone(),
|
||||
cwd: turn_context.cwd.clone(),
|
||||
cwd: turn_context.cwd.to_path_buf(),
|
||||
current_date: turn_context.current_date.clone(),
|
||||
timezone: turn_context.timezone.clone(),
|
||||
approval_policy: turn_context.approval_policy.value(),
|
||||
@@ -967,7 +967,7 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu
|
||||
user_instructions: None,
|
||||
developer_instructions: None,
|
||||
final_output_json_schema: None,
|
||||
truncation_policy: Some(turn_context.truncation_policy.into()),
|
||||
truncation_policy: Some(turn_context.truncation_policy),
|
||||
};
|
||||
let previous_turn_id = previous_context_item
|
||||
.turn_id
|
||||
@@ -1058,7 +1058,7 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo
|
||||
let current_context_item = TurnContextItem {
|
||||
turn_id: Some(current_turn_id.clone()),
|
||||
trace_id: turn_context.trace_id.clone(),
|
||||
cwd: turn_context.cwd.clone(),
|
||||
cwd: turn_context.cwd.to_path_buf(),
|
||||
current_date: turn_context.current_date.clone(),
|
||||
timezone: turn_context.timezone.clone(),
|
||||
approval_policy: turn_context.approval_policy.value(),
|
||||
@@ -1073,7 +1073,7 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo
|
||||
user_instructions: None,
|
||||
developer_instructions: None,
|
||||
final_output_json_schema: None,
|
||||
truncation_policy: Some(turn_context.truncation_policy.into()),
|
||||
truncation_policy: Some(turn_context.truncation_policy),
|
||||
};
|
||||
|
||||
let rollout_items = vec![
|
||||
@@ -1160,7 +1160,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea
|
||||
let previous_context_item = TurnContextItem {
|
||||
turn_id: Some(turn_context.sub_id.clone()),
|
||||
trace_id: turn_context.trace_id.clone(),
|
||||
cwd: turn_context.cwd.clone(),
|
||||
cwd: turn_context.cwd.to_path_buf(),
|
||||
current_date: turn_context.current_date.clone(),
|
||||
timezone: turn_context.timezone.clone(),
|
||||
approval_policy: turn_context.approval_policy.value(),
|
||||
@@ -1175,7 +1175,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea
|
||||
user_instructions: None,
|
||||
developer_instructions: None,
|
||||
final_output_json_schema: None,
|
||||
truncation_policy: Some(turn_context.truncation_policy.into()),
|
||||
truncation_policy: Some(turn_context.truncation_policy),
|
||||
};
|
||||
let previous_turn_id = previous_context_item
|
||||
.turn_id
|
||||
@@ -1304,7 +1304,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear
|
||||
let previous_context_item = TurnContextItem {
|
||||
turn_id: Some(turn_context.sub_id.clone()),
|
||||
trace_id: turn_context.trace_id.clone(),
|
||||
cwd: turn_context.cwd.clone(),
|
||||
cwd: turn_context.cwd.to_path_buf(),
|
||||
current_date: turn_context.current_date.clone(),
|
||||
timezone: turn_context.timezone.clone(),
|
||||
approval_policy: turn_context.approval_policy.value(),
|
||||
@@ -1319,7 +1319,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear
|
||||
user_instructions: None,
|
||||
developer_instructions: None,
|
||||
final_output_json_schema: None,
|
||||
truncation_policy: Some(turn_context.truncation_policy.into()),
|
||||
truncation_policy: Some(turn_context.truncation_policy),
|
||||
};
|
||||
let previous_turn_id = previous_context_item
|
||||
.turn_id
|
||||
|
||||
@@ -22,7 +22,6 @@ use codex_protocol::request_permissions::RequestPermissionsResponse;
|
||||
use codex_protocol::request_user_input::RequestUserInputArgs;
|
||||
use codex_protocol::request_user_input::RequestUserInputResponse;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use serde_json::Value;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::Mutex;
|
||||
@@ -518,7 +517,7 @@ async fn handle_patch_approval(
|
||||
let change_count = changes.len();
|
||||
let maybe_files = changes
|
||||
.keys()
|
||||
.map(|path| AbsolutePathBuf::from_absolute_path(parent_ctx.cwd.join(path)).ok())
|
||||
.map(|path| parent_ctx.cwd.join(path).ok())
|
||||
.collect::<Option<Vec<_>>>();
|
||||
if let Some(files) = maybe_files {
|
||||
let review_cancel = cancel_token.child_token();
|
||||
@@ -554,7 +553,7 @@ async fn handle_patch_approval(
|
||||
Arc::clone(parent_ctx),
|
||||
GuardianApprovalRequest::ApplyPatch {
|
||||
id: approval_id.clone(),
|
||||
cwd: parent_ctx.cwd.clone(),
|
||||
cwd: parent_ctx.cwd.to_path_buf(),
|
||||
files,
|
||||
change_count,
|
||||
patch,
|
||||
|
||||
@@ -80,6 +80,7 @@ use codex_protocol::protocol::ConversationAudioParams;
|
||||
use codex_protocol::protocol::RealtimeAudioFrame;
|
||||
use codex_protocol::protocol::Submission;
|
||||
use codex_protocol::protocol::W3cTraceContext;
|
||||
use core_test_support::PathBufExt;
|
||||
use core_test_support::context_snapshot;
|
||||
use core_test_support::context_snapshot::ContextSnapshotOptions;
|
||||
use core_test_support::context_snapshot::ContextSnapshotRenderMode;
|
||||
@@ -1115,18 +1116,12 @@ async fn recompute_token_usage_updates_model_context_window() {
|
||||
#[tokio::test]
|
||||
async fn record_initial_history_reconstructs_forked_transcript() {
|
||||
let (session, turn_context) = make_session_and_context().await;
|
||||
let (rollout_items, mut expected) = sample_rollout(&session, &turn_context).await;
|
||||
let (rollout_items, expected) = sample_rollout(&session, &turn_context).await;
|
||||
|
||||
session
|
||||
.record_initial_history(InitialHistory::Forked(rollout_items))
|
||||
.await;
|
||||
|
||||
let reconstruction_turn = session.new_default_turn().await;
|
||||
expected.extend(
|
||||
session
|
||||
.build_initial_context(reconstruction_turn.as_ref())
|
||||
.await,
|
||||
);
|
||||
let history = session.state.lock().await.clone_history();
|
||||
assert_eq!(expected, history.raw_items());
|
||||
}
|
||||
@@ -1244,7 +1239,7 @@ async fn fork_startup_context_then_first_turn_diff_snapshot() -> anyhow::Result<
|
||||
|
||||
let request = first_forked_request.single_request();
|
||||
let snapshot = context_snapshot::format_labeled_requests_snapshot(
|
||||
"First request after fork when fork startup changes approval policy and the first forked turn changes approval policy again and enters plan mode.",
|
||||
"First request after fork when startup preserves the parent baseline, the fork changes approval policy, and the first forked turn enters plan mode.",
|
||||
&[("First Forked Turn Request", &request)],
|
||||
&ContextSnapshotOptions::default()
|
||||
.render_mode(ContextSnapshotRenderMode::KindWithTextPrefix { max_chars: 96 })
|
||||
@@ -1272,7 +1267,7 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() {
|
||||
let previous_context_item = TurnContextItem {
|
||||
turn_id: Some(turn_context.sub_id.clone()),
|
||||
trace_id: turn_context.trace_id.clone(),
|
||||
cwd: turn_context.cwd.clone(),
|
||||
cwd: turn_context.cwd.to_path_buf(),
|
||||
current_date: turn_context.current_date.clone(),
|
||||
timezone: turn_context.timezone.clone(),
|
||||
approval_policy: turn_context.approval_policy.value(),
|
||||
@@ -1287,7 +1282,7 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() {
|
||||
user_instructions: None,
|
||||
developer_instructions: None,
|
||||
final_output_json_schema: None,
|
||||
truncation_policy: Some(turn_context.truncation_policy.into()),
|
||||
truncation_policy: Some(turn_context.truncation_policy),
|
||||
};
|
||||
let turn_id = previous_context_item
|
||||
.turn_id
|
||||
@@ -1309,7 +1304,7 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() {
|
||||
text_elements: Vec::new(),
|
||||
},
|
||||
)),
|
||||
RolloutItem::TurnContext(previous_context_item),
|
||||
RolloutItem::TurnContext(previous_context_item.clone()),
|
||||
RolloutItem::EventMsg(EventMsg::TurnComplete(
|
||||
codex_protocol::protocol::TurnCompleteEvent {
|
||||
turn_id,
|
||||
@@ -1322,6 +1317,7 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() {
|
||||
.record_initial_history(InitialHistory::Forked(rollout_items))
|
||||
.await;
|
||||
|
||||
let history = session.clone_history().await;
|
||||
assert_eq!(
|
||||
session.previous_turn_settings().await,
|
||||
Some(PreviousTurnSettings {
|
||||
@@ -1329,6 +1325,13 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() {
|
||||
realtime_active: Some(turn_context.realtime_active),
|
||||
})
|
||||
);
|
||||
assert_eq!(history.raw_items(), &[]);
|
||||
assert_eq!(
|
||||
serde_json::to_value(session.reference_context_item().await)
|
||||
.expect("serialize fork reference context item"),
|
||||
serde_json::to_value(Some(previous_context_item))
|
||||
.expect("serialize expected reference context item")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -2280,10 +2283,9 @@ async fn session_configuration_apply_preserves_split_file_system_policy_on_cwd_o
|
||||
let original_cwd = project_root.join("subdir");
|
||||
let docs_dir = original_cwd.join("docs");
|
||||
std::fs::create_dir_all(&docs_dir).expect("create docs dir");
|
||||
let docs_dir =
|
||||
codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path(&docs_dir).expect("docs");
|
||||
let docs_dir = docs_dir.abs();
|
||||
|
||||
session_configuration.cwd = original_cwd;
|
||||
session_configuration.cwd = original_cwd.abs();
|
||||
session_configuration.sandbox_policy =
|
||||
codex_config::Constrained::allow_any(SandboxPolicy::WorkspaceWrite {
|
||||
writable_roots: Vec::new(),
|
||||
@@ -2339,7 +2341,10 @@ async fn new_default_turn_uses_config_aware_skills_for_role_overrides() {
|
||||
let parent_outcome = session
|
||||
.services
|
||||
.skills_manager
|
||||
.skills_for_cwd(&parent_config.cwd, &parent_config, true)
|
||||
.skills_for_cwd(
|
||||
&crate::skills_load_input_from_config(&parent_config, Vec::new()),
|
||||
true,
|
||||
)
|
||||
.await;
|
||||
let parent_skill = parent_outcome
|
||||
.skills
|
||||
@@ -2405,10 +2410,9 @@ async fn session_configuration_apply_rederives_legacy_file_system_policy_on_cwd_
|
||||
let original_cwd = project_root.join("subdir");
|
||||
let docs_dir = original_cwd.join("docs");
|
||||
std::fs::create_dir_all(&docs_dir).expect("create docs dir");
|
||||
let docs_dir =
|
||||
codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path(&docs_dir).expect("docs");
|
||||
let docs_dir = docs_dir.abs();
|
||||
|
||||
session_configuration.cwd = original_cwd;
|
||||
session_configuration.cwd = original_cwd.abs();
|
||||
session_configuration.sandbox_policy =
|
||||
codex_config::Constrained::allow_any(SandboxPolicy::WorkspaceWrite {
|
||||
writable_roots: Vec::new(),
|
||||
@@ -2442,6 +2446,36 @@ async fn session_configuration_apply_rederives_legacy_file_system_policy_on_cwd_
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn session_update_settings_keeps_runtime_cwds_absolute() {
|
||||
let (session, turn_context) = make_session_and_context().await;
|
||||
let updated_cwd = turn_context
|
||||
.cwd
|
||||
.join("project")
|
||||
.expect("resolve project dir");
|
||||
std::fs::create_dir_all(updated_cwd.as_path()).expect("create project dir");
|
||||
|
||||
session
|
||||
.update_settings(SessionSettingsUpdate {
|
||||
cwd: Some(PathBuf::from("project")),
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.expect("cwd update should succeed");
|
||||
|
||||
let session_cwd = {
|
||||
let state = session.state.lock().await;
|
||||
state.session_configuration.cwd.clone()
|
||||
};
|
||||
let config = session.get_config().await;
|
||||
let next_turn = session.new_default_turn().await;
|
||||
|
||||
assert_eq!(session_cwd, updated_cwd);
|
||||
assert_eq!(config.cwd, turn_context.cwd);
|
||||
assert_eq!(next_turn.cwd, updated_cwd);
|
||||
assert_eq!(next_turn.config.cwd, updated_cwd);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() {
|
||||
let codex_home = tempfile::tempdir().expect("create temp dir");
|
||||
@@ -2506,11 +2540,7 @@ async fn session_new_fails_when_zsh_fork_enabled_without_zsh_path() {
|
||||
let (agent_status_tx, _agent_status_rx) = watch::channel(AgentStatus::PendingInit);
|
||||
let plugins_manager = Arc::new(PluginsManager::new(config.codex_home.clone()));
|
||||
let mcp_manager = Arc::new(McpManager::new(Arc::clone(&plugins_manager)));
|
||||
let skills_manager = Arc::new(SkillsManager::new(
|
||||
config.codex_home.clone(),
|
||||
Arc::clone(&plugins_manager),
|
||||
true,
|
||||
));
|
||||
let skills_manager = Arc::new(SkillsManager::new(config.codex_home.clone(), true));
|
||||
let result = Session::new(
|
||||
session_configuration,
|
||||
Arc::clone(&config),
|
||||
@@ -2611,11 +2641,7 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) {
|
||||
let state = SessionState::new(session_configuration.clone());
|
||||
let plugins_manager = Arc::new(PluginsManager::new(config.codex_home.clone()));
|
||||
let mcp_manager = Arc::new(McpManager::new(Arc::clone(&plugins_manager)));
|
||||
let skills_manager = Arc::new(SkillsManager::new(
|
||||
config.codex_home.clone(),
|
||||
Arc::clone(&plugins_manager),
|
||||
true,
|
||||
));
|
||||
let skills_manager = Arc::new(SkillsManager::new(config.codex_home.clone(), true));
|
||||
let network_approval = Arc::new(NetworkApprovalService::default());
|
||||
let environment = Arc::new(
|
||||
codex_exec_server::Environment::create(None)
|
||||
@@ -2637,8 +2663,9 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) {
|
||||
shell_zsh_path: None,
|
||||
main_execve_wrapper_exe: config.main_execve_wrapper_exe.clone(),
|
||||
analytics_events_client: AnalyticsEventsClient::new(
|
||||
Arc::clone(&config),
|
||||
Arc::clone(&auth_manager),
|
||||
config.chatgpt_base_url.trim_end_matches('/').to_string(),
|
||||
config.analytics_enabled,
|
||||
),
|
||||
hooks: Hooks::new(HooksConfig {
|
||||
legacy_notify_argv: config.notify.clone(),
|
||||
@@ -2682,7 +2709,13 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) {
|
||||
config.js_repl_node_module_dirs.clone(),
|
||||
));
|
||||
|
||||
let skills_outcome = Arc::new(services.skills_manager.skills_for_config(&per_turn_config));
|
||||
let plugin_outcome = services
|
||||
.plugins_manager
|
||||
.plugins_for_config(&per_turn_config);
|
||||
let effective_skill_roots = plugin_outcome.effective_skill_roots();
|
||||
let skills_input =
|
||||
crate::skills_load_input_from_config(&per_turn_config, effective_skill_roots);
|
||||
let skills_outcome = Arc::new(services.skills_manager.skills_for_config(&skills_input));
|
||||
let turn_context = Session::make_turn_context(
|
||||
conversation_id,
|
||||
Some(Arc::clone(&auth_manager)),
|
||||
@@ -3056,7 +3089,7 @@ async fn user_turn_updates_approvals_reviewer() {
|
||||
text: "hello".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}],
|
||||
cwd: config.cwd.clone(),
|
||||
cwd: config.cwd.to_path_buf(),
|
||||
approval_policy: config.permissions.approval_policy.value(),
|
||||
approvals_reviewer: Some(crate::config::types::ApprovalsReviewer::GuardianSubagent),
|
||||
sandbox_policy: config.permissions.sandbox_policy.get().clone(),
|
||||
@@ -3446,11 +3479,7 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx(
|
||||
let state = SessionState::new(session_configuration.clone());
|
||||
let plugins_manager = Arc::new(PluginsManager::new(config.codex_home.clone()));
|
||||
let mcp_manager = Arc::new(McpManager::new(Arc::clone(&plugins_manager)));
|
||||
let skills_manager = Arc::new(SkillsManager::new(
|
||||
config.codex_home.clone(),
|
||||
Arc::clone(&plugins_manager),
|
||||
true,
|
||||
));
|
||||
let skills_manager = Arc::new(SkillsManager::new(config.codex_home.clone(), true));
|
||||
let network_approval = Arc::new(NetworkApprovalService::default());
|
||||
let environment = Arc::new(
|
||||
codex_exec_server::Environment::create(None)
|
||||
@@ -3472,8 +3501,9 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx(
|
||||
shell_zsh_path: None,
|
||||
main_execve_wrapper_exe: config.main_execve_wrapper_exe.clone(),
|
||||
analytics_events_client: AnalyticsEventsClient::new(
|
||||
Arc::clone(&config),
|
||||
Arc::clone(&auth_manager),
|
||||
config.chatgpt_base_url.trim_end_matches('/').to_string(),
|
||||
config.analytics_enabled,
|
||||
),
|
||||
hooks: Hooks::new(HooksConfig {
|
||||
legacy_notify_argv: config.notify.clone(),
|
||||
@@ -3517,7 +3547,13 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx(
|
||||
config.js_repl_node_module_dirs.clone(),
|
||||
));
|
||||
|
||||
let skills_outcome = Arc::new(services.skills_manager.skills_for_config(&per_turn_config));
|
||||
let plugin_outcome = services
|
||||
.plugins_manager
|
||||
.plugins_for_config(&per_turn_config);
|
||||
let effective_skill_roots = plugin_outcome.effective_skill_roots();
|
||||
let skills_input =
|
||||
crate::skills_load_input_from_config(&per_turn_config, effective_skill_roots);
|
||||
let skills_outcome = Arc::new(services.skills_manager.skills_for_config(&skills_input));
|
||||
let turn_context = Arc::new(Session::make_turn_context(
|
||||
conversation_id,
|
||||
Some(Arc::clone(&auth_manager)),
|
||||
@@ -5058,7 +5094,7 @@ async fn rejects_escalated_permissions_when_policy_not_on_request() {
|
||||
"echo hi".to_string(),
|
||||
]
|
||||
},
|
||||
cwd: turn_context.cwd.clone(),
|
||||
cwd: turn_context.cwd.to_path_buf(),
|
||||
expiration: timeout_ms.into(),
|
||||
capture_policy: ExecCapturePolicy::ShellTool,
|
||||
env: HashMap::new(),
|
||||
|
||||
@@ -23,7 +23,8 @@ use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::models::function_call_output_content_items_to_text;
|
||||
use codex_protocol::permissions::FileSystemSandboxPolicy;
|
||||
use codex_protocol::permissions::NetworkSandboxPolicy;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use core_test_support::PathExt;
|
||||
use core_test_support::TempDirExt;
|
||||
use core_test_support::codex_linux_sandbox_exe_or_skip;
|
||||
use core_test_support::responses::ev_assistant_message;
|
||||
use core_test_support::responses::ev_completed;
|
||||
@@ -123,7 +124,7 @@ async fn guardian_allows_shell_additional_permissions_requests_past_policy_valid
|
||||
"echo hi".to_string(),
|
||||
]
|
||||
},
|
||||
cwd: turn_context.cwd.clone(),
|
||||
cwd: turn_context.cwd.to_path_buf(),
|
||||
expiration: expiration_ms.into(),
|
||||
capture_policy: ExecCapturePolicy::ShellTool,
|
||||
env: HashMap::new(),
|
||||
@@ -388,12 +389,11 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() {
|
||||
.expect("write policy file");
|
||||
|
||||
let mut config = build_test_config(codex_home.path()).await;
|
||||
config.cwd = project_dir.path().to_path_buf();
|
||||
config.cwd = project_dir.abs();
|
||||
config.config_layer_stack = ConfigLayerStack::new(
|
||||
vec![ConfigLayerEntry::new(
|
||||
ConfigLayerSource::Project {
|
||||
dot_codex_folder: AbsolutePathBuf::from_absolute_path(project_dir.path())
|
||||
.expect("absolute project path"),
|
||||
dot_codex_folder: project_dir.path().abs(),
|
||||
},
|
||||
toml::Value::Table(Default::default()),
|
||||
)],
|
||||
@@ -429,11 +429,7 @@ async fn guardian_subagent_does_not_inherit_parent_exec_policy_rules() {
|
||||
CollaborationModesConfig::default(),
|
||||
));
|
||||
let plugins_manager = Arc::new(PluginsManager::new(config.codex_home.clone()));
|
||||
let skills_manager = Arc::new(SkillsManager::new(
|
||||
config.codex_home.clone(),
|
||||
Arc::clone(&plugins_manager),
|
||||
true,
|
||||
));
|
||||
let skills_manager = Arc::new(SkillsManager::new(config.codex_home.clone(), true));
|
||||
let mcp_manager = Arc::new(McpManager::new(Arc::clone(&plugins_manager)));
|
||||
let skills_watcher = Arc::new(SkillsWatcher::noop());
|
||||
|
||||
|
||||
@@ -15,9 +15,6 @@ use crate::protocol::CompactedItem;
|
||||
use crate::protocol::EventMsg;
|
||||
use crate::protocol::TurnStartedEvent;
|
||||
use crate::protocol::WarningEvent;
|
||||
use crate::truncate::TruncationPolicy;
|
||||
use crate::truncate::approx_token_count;
|
||||
use crate::truncate::truncate_text;
|
||||
use crate::util::backoff;
|
||||
use codex_protocol::items::ContextCompactionItem;
|
||||
use codex_protocol::items::TurnItem;
|
||||
@@ -25,6 +22,9 @@ use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::ResponseInputItem;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use codex_utils_output_truncation::TruncationPolicy;
|
||||
use codex_utils_output_truncation::approx_token_count;
|
||||
use codex_utils_output_truncation::truncate_text;
|
||||
use futures::prelude::*;
|
||||
use tracing::error;
|
||||
|
||||
|
||||
@@ -27,6 +27,9 @@ use serde::Deserialize;
|
||||
use tempfile::tempdir;
|
||||
|
||||
use super::*;
|
||||
use core_test_support::PathBufExt;
|
||||
use core_test_support::PathExt;
|
||||
use core_test_support::TempDirExt;
|
||||
use core_test_support::test_absolute_path;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
@@ -77,6 +80,23 @@ fn http_mcp(url: &str) -> McpServerConfig {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_config_normalizes_relative_cwd_override() -> std::io::Result<()> {
|
||||
let expected_cwd = AbsolutePathBuf::relative_to_current_dir("nested")?;
|
||||
let codex_home = tempdir()?;
|
||||
let config = Config::load_from_base_config_with_overrides(
|
||||
ConfigToml::default(),
|
||||
ConfigOverrides {
|
||||
cwd: Some(PathBuf::from("nested")),
|
||||
..Default::default()
|
||||
},
|
||||
codex_home.abs().into_path_buf(),
|
||||
)?;
|
||||
|
||||
assert_eq!(config.cwd, expected_cwd);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_toml_parsing() {
|
||||
let history_with_persistence = r#"
|
||||
@@ -460,7 +480,7 @@ fn default_permissions_profile_populates_runtime_sandbox_policy() -> std::io::Re
|
||||
codex_home.path().to_path_buf(),
|
||||
)?;
|
||||
|
||||
let memories_root = AbsolutePathBuf::try_from(codex_home.path().join("memories")).unwrap();
|
||||
let memories_root = codex_home.path().join("memories").abs();
|
||||
assert_eq!(
|
||||
config.permissions.file_system_sandbox_policy,
|
||||
FileSystemSandboxPolicy::restricted(vec![
|
||||
@@ -496,9 +516,7 @@ fn default_permissions_profile_populates_runtime_sandbox_policy() -> std::io::Re
|
||||
writable_roots: vec![memories_root],
|
||||
read_only_access: ReadOnlyAccess::Restricted {
|
||||
include_platform_defaults: true,
|
||||
readable_roots: vec![
|
||||
AbsolutePathBuf::try_from(cwd.path().join("docs")).expect("absolute docs path"),
|
||||
],
|
||||
readable_roots: vec![cwd.path().join("docs").abs(),],
|
||||
},
|
||||
network_access: false,
|
||||
exclude_tmpdir_env_var: true,
|
||||
@@ -1277,7 +1295,7 @@ fn add_dir_override_extends_workspace_writable_roots() -> std::io::Result<()> {
|
||||
temp_dir.path().to_path_buf(),
|
||||
)?;
|
||||
|
||||
let expected_backend = AbsolutePathBuf::try_from(backend).unwrap();
|
||||
let expected_backend = backend.abs();
|
||||
if cfg!(target_os = "windows") {
|
||||
match config.permissions.sandbox_policy.get() {
|
||||
SandboxPolicy::ReadOnly { .. } => {}
|
||||
@@ -1327,7 +1345,7 @@ fn workspace_write_always_includes_memories_root_once() -> std::io::Result<()> {
|
||||
let config = Config::load_from_base_config_with_overrides(
|
||||
ConfigToml {
|
||||
sandbox_workspace_write: Some(SandboxWorkspaceWrite {
|
||||
writable_roots: vec![AbsolutePathBuf::from_absolute_path(&memories_root)?],
|
||||
writable_roots: vec![memories_root.abs()],
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
@@ -1350,7 +1368,7 @@ fn workspace_write_always_includes_memories_root_once() -> std::io::Result<()> {
|
||||
"expected memories root directory to exist at {}",
|
||||
memories_root.display()
|
||||
);
|
||||
let expected_memories_root = AbsolutePathBuf::from_absolute_path(&memories_root)?;
|
||||
let expected_memories_root = memories_root.abs();
|
||||
match config.permissions.sandbox_policy.get() {
|
||||
SandboxPolicy::WorkspaceWrite { writable_roots, .. } => {
|
||||
assert_eq!(
|
||||
@@ -1768,7 +1786,7 @@ async fn managed_config_overrides_oauth_store_mode() -> anyhow::Result<()> {
|
||||
macos_managed_config_requirements_base64: None,
|
||||
};
|
||||
|
||||
let cwd = AbsolutePathBuf::try_from(codex_home.path())?;
|
||||
let cwd = codex_home.path().abs();
|
||||
let config_layer_stack = load_config_layers_state(
|
||||
codex_home.path(),
|
||||
Some(cwd),
|
||||
@@ -1897,7 +1915,7 @@ async fn managed_config_wins_over_cli_overrides() -> anyhow::Result<()> {
|
||||
macos_managed_config_requirements_base64: None,
|
||||
};
|
||||
|
||||
let cwd = AbsolutePathBuf::try_from(codex_home.path())?;
|
||||
let cwd = codex_home.path().abs();
|
||||
let config_layer_stack = load_config_layers_state(
|
||||
codex_home.path(),
|
||||
Some(cwd),
|
||||
@@ -2933,7 +2951,11 @@ struct PrecedenceTestFixture {
|
||||
}
|
||||
|
||||
impl PrecedenceTestFixture {
|
||||
fn cwd(&self) -> PathBuf {
|
||||
fn cwd(&self) -> AbsolutePathBuf {
|
||||
self.cwd.abs()
|
||||
}
|
||||
|
||||
fn cwd_path(&self) -> PathBuf {
|
||||
self.cwd.path().to_path_buf()
|
||||
}
|
||||
|
||||
@@ -2974,7 +2996,7 @@ fn loads_compact_prompt_from_file() -> std::io::Result<()> {
|
||||
std::fs::write(&prompt_path, " summarize differently ")?;
|
||||
|
||||
let cfg = ConfigToml {
|
||||
experimental_compact_prompt_file: Some(AbsolutePathBuf::from_absolute_path(prompt_path)?),
|
||||
experimental_compact_prompt_file: Some(prompt_path.abs()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
@@ -3071,7 +3093,7 @@ fn load_config_rejects_missing_agent_role_config_file() -> std::io::Result<()> {
|
||||
"researcher".to_string(),
|
||||
AgentRoleToml {
|
||||
description: Some("Research role".to_string()),
|
||||
config_file: Some(AbsolutePathBuf::from_absolute_path(missing_path)?),
|
||||
config_file: Some(missing_path.abs()),
|
||||
nickname_candidates: None,
|
||||
},
|
||||
)]),
|
||||
@@ -4082,7 +4104,7 @@ fn model_catalog_json_loads_from_path() -> std::io::Result<()> {
|
||||
)?;
|
||||
|
||||
let cfg = ConfigToml {
|
||||
model_catalog_json: Some(AbsolutePathBuf::from_absolute_path(catalog_path)?),
|
||||
model_catalog_json: Some(catalog_path.abs()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
@@ -4103,7 +4125,7 @@ fn model_catalog_json_rejects_empty_catalog() -> std::io::Result<()> {
|
||||
std::fs::write(&catalog_path, r#"{"models":[]}"#)?;
|
||||
|
||||
let cfg = ConfigToml {
|
||||
model_catalog_json: Some(AbsolutePathBuf::from_absolute_path(catalog_path)?),
|
||||
model_catalog_json: Some(catalog_path.abs()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
@@ -4240,7 +4262,7 @@ fn test_precedence_fixture_with_o3_profile() -> std::io::Result<()> {
|
||||
|
||||
let o3_profile_overrides = ConfigOverrides {
|
||||
config_profile: Some("o3".to_string()),
|
||||
cwd: Some(fixture.cwd()),
|
||||
cwd: Some(fixture.cwd_path()),
|
||||
..Default::default()
|
||||
};
|
||||
let o3_profile_config: Config = Config::load_from_base_config_with_overrides(
|
||||
@@ -4368,7 +4390,7 @@ fn metrics_exporter_defaults_to_statsig_when_missing() -> std::io::Result<()> {
|
||||
let config = Config::load_from_base_config_with_overrides(
|
||||
fixture.cfg.clone(),
|
||||
ConfigOverrides {
|
||||
cwd: Some(fixture.cwd()),
|
||||
cwd: Some(fixture.cwd_path()),
|
||||
..Default::default()
|
||||
},
|
||||
fixture.codex_home(),
|
||||
@@ -4384,7 +4406,7 @@ fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> {
|
||||
|
||||
let gpt3_profile_overrides = ConfigOverrides {
|
||||
config_profile: Some("gpt3".to_string()),
|
||||
cwd: Some(fixture.cwd()),
|
||||
cwd: Some(fixture.cwd_path()),
|
||||
..Default::default()
|
||||
};
|
||||
let gpt3_profile_config = Config::load_from_base_config_with_overrides(
|
||||
@@ -4505,7 +4527,7 @@ fn test_precedence_fixture_with_gpt3_profile() -> std::io::Result<()> {
|
||||
// Verify that loading without specifying a profile in ConfigOverrides
|
||||
// uses the default profile from the config file (which is "gpt3").
|
||||
let default_profile_overrides = ConfigOverrides {
|
||||
cwd: Some(fixture.cwd()),
|
||||
cwd: Some(fixture.cwd_path()),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
@@ -4525,7 +4547,7 @@ fn test_precedence_fixture_with_zdr_profile() -> std::io::Result<()> {
|
||||
|
||||
let zdr_profile_overrides = ConfigOverrides {
|
||||
config_profile: Some("zdr".to_string()),
|
||||
cwd: Some(fixture.cwd()),
|
||||
cwd: Some(fixture.cwd_path()),
|
||||
..Default::default()
|
||||
};
|
||||
let zdr_profile_config = Config::load_from_base_config_with_overrides(
|
||||
@@ -4652,7 +4674,7 @@ fn test_precedence_fixture_with_gpt5_profile() -> std::io::Result<()> {
|
||||
|
||||
let gpt5_profile_overrides = ConfigOverrides {
|
||||
config_profile: Some("gpt5".to_string()),
|
||||
cwd: Some(fixture.cwd()),
|
||||
cwd: Some(fixture.cwd_path()),
|
||||
..Default::default()
|
||||
};
|
||||
let gpt5_profile_config = Config::load_from_base_config_with_overrides(
|
||||
@@ -4820,7 +4842,7 @@ fn test_requirements_web_search_mode_allowlist_does_not_warn_when_unset() -> any
|
||||
let config = Config::load_config_with_layer_stack(
|
||||
fixture.cfg.clone(),
|
||||
ConfigOverrides {
|
||||
cwd: Some(fixture.cwd()),
|
||||
cwd: Some(fixture.cwd_path()),
|
||||
..Default::default()
|
||||
},
|
||||
fixture.codex_home(),
|
||||
|
||||
@@ -395,10 +395,10 @@ pub struct Config {
|
||||
/// Syntax highlighting theme override (kebab-case name).
|
||||
pub tui_theme: Option<String>,
|
||||
|
||||
/// The directory that should be treated as the current working directory
|
||||
/// for the session. All relative paths inside the business-logic layer are
|
||||
/// resolved against this path.
|
||||
pub cwd: PathBuf,
|
||||
/// The absolute directory that should be treated as the current working
|
||||
/// directory for the session. All relative paths inside the business-logic
|
||||
/// layer are resolved against this path.
|
||||
pub cwd: AbsolutePathBuf,
|
||||
|
||||
/// Preferred store for CLI auth credentials.
|
||||
/// file (default): Use a file in the Codex home directory.
|
||||
@@ -683,7 +683,7 @@ impl ConfigBuilder {
|
||||
let loader_overrides = loader_overrides.unwrap_or_default();
|
||||
let cwd_override = harness_overrides.cwd.as_deref().or(fallback_cwd.as_deref());
|
||||
let cwd = match cwd_override {
|
||||
Some(path) => AbsolutePathBuf::try_from(path)?,
|
||||
Some(path) => AbsolutePathBuf::relative_to_current_dir(path)?,
|
||||
None => AbsolutePathBuf::current_dir()?,
|
||||
};
|
||||
harness_overrides.cwd = Some(cwd.to_path_buf());
|
||||
@@ -2104,7 +2104,7 @@ impl Config {
|
||||
let windows_sandbox_mode = resolve_windows_sandbox_mode(&cfg, &config_profile);
|
||||
let windows_sandbox_private_desktop =
|
||||
resolve_windows_sandbox_private_desktop(&cfg, &config_profile);
|
||||
let resolved_cwd = normalize_for_native_workdir({
|
||||
let resolved_cwd = AbsolutePathBuf::try_from(normalize_for_native_workdir({
|
||||
use std::env;
|
||||
|
||||
match cwd {
|
||||
@@ -2121,13 +2121,13 @@ impl Config {
|
||||
current
|
||||
}
|
||||
}
|
||||
});
|
||||
}))?;
|
||||
let mut additional_writable_roots: Vec<AbsolutePathBuf> = additional_writable_roots
|
||||
.into_iter()
|
||||
.map(|path| AbsolutePathBuf::resolve_path_against_base(path, &resolved_cwd))
|
||||
.map(|path| AbsolutePathBuf::resolve_path_against_base(path, resolved_cwd.as_path()))
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
let active_project = cfg
|
||||
.get_active_project(&resolved_cwd)
|
||||
.get_active_project(resolved_cwd.as_path())
|
||||
.unwrap_or(ProjectConfig { trust_level: None });
|
||||
let permission_config_syntax = resolve_permission_config_syntax(
|
||||
&config_layer_stack,
|
||||
@@ -2200,12 +2200,15 @@ impl Config {
|
||||
&mut startup_warnings,
|
||||
)?;
|
||||
let mut sandbox_policy = file_system_sandbox_policy
|
||||
.to_legacy_sandbox_policy(network_sandbox_policy, &resolved_cwd)?;
|
||||
.to_legacy_sandbox_policy(network_sandbox_policy, resolved_cwd.as_path())?;
|
||||
if matches!(sandbox_policy, SandboxPolicy::WorkspaceWrite { .. }) {
|
||||
file_system_sandbox_policy = file_system_sandbox_policy
|
||||
.with_additional_writable_roots(&resolved_cwd, &additional_writable_roots);
|
||||
.with_additional_writable_roots(
|
||||
resolved_cwd.as_path(),
|
||||
&additional_writable_roots,
|
||||
);
|
||||
sandbox_policy = file_system_sandbox_policy
|
||||
.to_legacy_sandbox_policy(network_sandbox_policy, &resolved_cwd)?;
|
||||
.to_legacy_sandbox_policy(network_sandbox_policy, resolved_cwd.as_path())?;
|
||||
}
|
||||
(
|
||||
configured_network_proxy_config,
|
||||
@@ -2219,7 +2222,7 @@ impl Config {
|
||||
sandbox_mode,
|
||||
config_profile.sandbox_mode,
|
||||
windows_sandbox_level,
|
||||
&resolved_cwd,
|
||||
resolved_cwd.as_path(),
|
||||
Some(&constrained_sandbox_policy),
|
||||
);
|
||||
if let SandboxPolicy::WorkspaceWrite { writable_roots, .. } = &mut sandbox_policy {
|
||||
@@ -2229,8 +2232,10 @@ impl Config {
|
||||
}
|
||||
}
|
||||
}
|
||||
let file_system_sandbox_policy =
|
||||
FileSystemSandboxPolicy::from_legacy_sandbox_policy(&sandbox_policy, &resolved_cwd);
|
||||
let file_system_sandbox_policy = FileSystemSandboxPolicy::from_legacy_sandbox_policy(
|
||||
&sandbox_policy,
|
||||
resolved_cwd.as_path(),
|
||||
);
|
||||
let network_sandbox_policy = NetworkSandboxPolicy::from(&sandbox_policy);
|
||||
(
|
||||
configured_network_proxy_config,
|
||||
@@ -2566,11 +2571,11 @@ impl Config {
|
||||
} else {
|
||||
FileSystemSandboxPolicy::from_legacy_sandbox_policy(
|
||||
&effective_sandbox_policy,
|
||||
&resolved_cwd,
|
||||
resolved_cwd.as_path(),
|
||||
)
|
||||
};
|
||||
let effective_file_system_sandbox_policy = effective_file_system_sandbox_policy
|
||||
.with_additional_readable_roots(&resolved_cwd, &helper_readable_roots);
|
||||
.with_additional_readable_roots(resolved_cwd.as_path(), &helper_readable_roots);
|
||||
let effective_network_sandbox_policy =
|
||||
if effective_sandbox_policy == original_sandbox_policy {
|
||||
network_sandbox_policy
|
||||
@@ -2844,7 +2849,7 @@ impl Config {
|
||||
}
|
||||
|
||||
pub fn bundled_skills_enabled(&self) -> bool {
|
||||
crate::skills::manager::bundled_skills_enabled_from_stack(&self.config_layer_stack)
|
||||
crate::manager::bundled_skills_enabled_from_stack(&self.config_layer_stack)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -237,6 +237,54 @@ async fn read_includes_origins_and_layers() {
|
||||
));
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[tokio::test]
|
||||
async fn write_value_succeeds_when_managed_preferences_expand_home_directory_paths() -> Result<()> {
|
||||
use base64::Engine;
|
||||
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
std::fs::write(tmp.path().join(CONFIG_TOML_FILE), "model = \"user\"\n")?;
|
||||
|
||||
let service = ConfigService::new(
|
||||
tmp.path().to_path_buf(),
|
||||
vec![],
|
||||
LoaderOverrides {
|
||||
managed_config_path: Some(tmp.path().join("managed_config.toml")),
|
||||
managed_preferences_base64: Some(
|
||||
base64::prelude::BASE64_STANDARD.encode(
|
||||
r#"
|
||||
sandbox_mode = "workspace-write"
|
||||
[sandbox_workspace_write]
|
||||
writable_roots = ["~/code"]
|
||||
"#
|
||||
.as_bytes(),
|
||||
),
|
||||
),
|
||||
macos_managed_config_requirements_base64: None,
|
||||
},
|
||||
CloudRequirementsLoader::default(),
|
||||
);
|
||||
|
||||
let response = service
|
||||
.write_value(ConfigValueWriteParams {
|
||||
file_path: Some(tmp.path().join(CONFIG_TOML_FILE).display().to_string()),
|
||||
key_path: "model".to_string(),
|
||||
value: serde_json::json!("updated"),
|
||||
merge_strategy: MergeStrategy::Replace,
|
||||
expected_version: None,
|
||||
})
|
||||
.await
|
||||
.expect("write succeeds");
|
||||
|
||||
assert_eq!(response.status, WriteStatus::Ok);
|
||||
assert_eq!(
|
||||
std::fs::read_to_string(tmp.path().join(CONFIG_TOML_FILE)).expect("read config"),
|
||||
"model = \"updated\"\n"
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn write_value_reports_override() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
|
||||
@@ -801,17 +801,9 @@ impl Notice {
|
||||
pub(crate) const TABLE_KEY: &'static str = "notice";
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct SkillConfig {
|
||||
/// Path-based selector.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub path: Option<AbsolutePathBuf>,
|
||||
/// Name-based selector.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub name: Option<String>,
|
||||
pub enabled: bool,
|
||||
}
|
||||
pub use codex_config::BundledSkillsConfig;
|
||||
pub use codex_config::SkillConfig;
|
||||
pub use codex_config::SkillsConfig;
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
@@ -820,29 +812,6 @@ pub struct PluginConfig {
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct SkillsConfig {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub bundled: Option<BundledSkillsConfig>,
|
||||
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub config: Vec<SkillConfig>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct BundledSkillsConfig {
|
||||
#[serde(default = "default_enabled")]
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
impl Default for BundledSkillsConfig {
|
||||
fn default() -> Self {
|
||||
Self { enabled: true }
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema)]
|
||||
#[schemars(deny_unknown_fields)]
|
||||
pub struct SandboxWorkspaceWrite {
|
||||
|
||||
@@ -52,10 +52,12 @@ pub use codex_config::TextRange;
|
||||
pub use codex_config::WebSearchModeRequirement;
|
||||
pub(crate) use codex_config::build_cli_overrides_layer;
|
||||
pub(crate) use codex_config::config_error_from_toml;
|
||||
pub use codex_config::default_project_root_markers;
|
||||
pub use codex_config::format_config_error;
|
||||
pub use codex_config::format_config_error_with_source;
|
||||
pub(crate) use codex_config::io_error_from_config_error;
|
||||
pub use codex_config::merge_toml_values;
|
||||
pub use codex_config::project_root_markers_from_config;
|
||||
#[cfg(test)]
|
||||
pub(crate) use codex_config::version_for_toml;
|
||||
|
||||
@@ -67,8 +69,6 @@ pub const SYSTEM_CONFIG_TOML_FILE_UNIX: &str = "/etc/codex/config.toml";
|
||||
#[cfg(windows)]
|
||||
const DEFAULT_PROGRAM_DATA_DIR_WINDOWS: &str = r"C:\ProgramData";
|
||||
|
||||
const DEFAULT_PROJECT_ROOT_MARKERS: &[&str] = &[".git"];
|
||||
|
||||
pub(crate) async fn first_layer_config_error(layers: &ConfigLayerStack) -> Option<ConfigError> {
|
||||
codex_config::first_layer_config_error::<ConfigToml>(layers, CONFIG_TOML_FILE).await
|
||||
}
|
||||
@@ -285,9 +285,16 @@ pub async fn load_config_layers_state(
|
||||
));
|
||||
}
|
||||
if let Some(config) = managed_config_from_mdm {
|
||||
// As a general rule, config from MDM should _not_ include relative
|
||||
// paths, starting with `./`, but a path starting with `~/` _is_ a
|
||||
// supported use case. Because resolve_relative_paths_in_config_toml()
|
||||
// relies on AbsolutePathBufGuard to resolve `~/`, we must supply a
|
||||
// value for base_dir, so codex_home is as good a value as any.
|
||||
let managed_config =
|
||||
resolve_relative_paths_in_config_toml(config.managed_config, codex_home)?;
|
||||
layers.push(ConfigLayerEntry::new_with_raw_toml(
|
||||
ConfigLayerSource::LegacyManagedConfigTomlFromMdm,
|
||||
config.managed_config,
|
||||
managed_config,
|
||||
config.raw_toml,
|
||||
));
|
||||
}
|
||||
@@ -522,55 +529,6 @@ async fn load_requirements_from_legacy_scheme(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Reads `project_root_markers` from the [toml::Value] produced by merging
|
||||
/// `config.toml` from the config layers in the stack preceding
|
||||
/// [ConfigLayerSource::Project].
|
||||
///
|
||||
/// Invariants:
|
||||
/// - If `project_root_markers` is not specified, returns `Ok(None)`.
|
||||
/// - If `project_root_markers` is specified, returns `Ok(Some(markers))` where
|
||||
/// `markers` is a `Vec<String>` (including `Ok(Some(Vec::new()))` for an
|
||||
/// empty array, which indicates that root detection should be disabled).
|
||||
/// - Returns an error if `project_root_markers` is specified but is not an
|
||||
/// array of strings.
|
||||
pub(crate) fn project_root_markers_from_config(
|
||||
config: &TomlValue,
|
||||
) -> io::Result<Option<Vec<String>>> {
|
||||
let Some(table) = config.as_table() else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(markers_value) = table.get("project_root_markers") else {
|
||||
return Ok(None);
|
||||
};
|
||||
let TomlValue::Array(entries) = markers_value else {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
"project_root_markers must be an array of strings",
|
||||
));
|
||||
};
|
||||
if entries.is_empty() {
|
||||
return Ok(Some(Vec::new()));
|
||||
}
|
||||
let mut markers = Vec::new();
|
||||
for entry in entries {
|
||||
let Some(marker) = entry.as_str() else {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidData,
|
||||
"project_root_markers must be an array of strings",
|
||||
));
|
||||
};
|
||||
markers.push(marker.to_string());
|
||||
}
|
||||
Ok(Some(markers))
|
||||
}
|
||||
|
||||
pub(crate) fn default_project_root_markers() -> Vec<String> {
|
||||
DEFAULT_PROJECT_ROOT_MARKERS
|
||||
.iter()
|
||||
.map(ToString::to_string)
|
||||
.collect()
|
||||
}
|
||||
|
||||
struct ProjectTrustContext {
|
||||
project_root: AbsolutePathBuf,
|
||||
project_root_key: String,
|
||||
|
||||
@@ -380,6 +380,54 @@ flag = false
|
||||
assert!(raw.contains("value = \"managed\""));
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[tokio::test]
|
||||
async fn managed_preferences_expand_home_directory_in_workspace_write_roots() -> anyhow::Result<()>
|
||||
{
|
||||
use base64::Engine;
|
||||
|
||||
let Some(home) = dirs::home_dir() else {
|
||||
return Ok(());
|
||||
};
|
||||
let tmp = tempdir()?;
|
||||
|
||||
let config = ConfigBuilder::default()
|
||||
.codex_home(tmp.path().to_path_buf())
|
||||
.fallback_cwd(Some(tmp.path().to_path_buf()))
|
||||
.loader_overrides(LoaderOverrides {
|
||||
managed_config_path: Some(tmp.path().join("managed_config.toml")),
|
||||
managed_preferences_base64: Some(
|
||||
base64::prelude::BASE64_STANDARD.encode(
|
||||
r#"
|
||||
sandbox_mode = "workspace-write"
|
||||
[sandbox_workspace_write]
|
||||
writable_roots = ["~/code"]
|
||||
"#
|
||||
.as_bytes(),
|
||||
),
|
||||
),
|
||||
macos_managed_config_requirements_base64: None,
|
||||
})
|
||||
.build()
|
||||
.await?;
|
||||
|
||||
let expected_root = AbsolutePathBuf::from_absolute_path(home.join("code"))?;
|
||||
match config.permissions.sandbox_policy.get() {
|
||||
SandboxPolicy::WorkspaceWrite { writable_roots, .. } => {
|
||||
assert_eq!(
|
||||
writable_roots
|
||||
.iter()
|
||||
.filter(|root| **root == expected_root)
|
||||
.count(),
|
||||
1,
|
||||
);
|
||||
}
|
||||
other => panic!("expected workspace-write policy, got {other:?}"),
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
#[tokio::test]
|
||||
async fn managed_preferences_requirements_are_applied() -> anyhow::Result<()> {
|
||||
|
||||
@@ -103,6 +103,19 @@ pub async fn list_accessible_connectors_from_mcp_tools(
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) async fn list_accessible_and_enabled_connectors_from_manager(
|
||||
mcp_connection_manager: &McpConnectionManager,
|
||||
config: &Config,
|
||||
) -> Vec<AppInfo> {
|
||||
with_app_enabled_state(
|
||||
accessible_connectors_from_mcp_tools(&mcp_connection_manager.list_all_tools().await),
|
||||
config,
|
||||
)
|
||||
.into_iter()
|
||||
.filter(|connector| connector.is_accessible && connector.is_enabled)
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) async fn list_tool_suggest_discoverable_tools_with_auth(
|
||||
config: &Config,
|
||||
auth: Option<&CodexAuth>,
|
||||
|
||||
@@ -3,12 +3,6 @@ use crate::context_manager::normalize;
|
||||
use crate::event_mapping::has_non_contextual_dev_message_content;
|
||||
use crate::event_mapping::is_contextual_dev_message_content;
|
||||
use crate::event_mapping::is_contextual_user_message_content;
|
||||
use crate::truncate::TruncationPolicy;
|
||||
use crate::truncate::approx_bytes_for_tokens;
|
||||
use crate::truncate::approx_token_count;
|
||||
use crate::truncate::approx_tokens_from_byte_count_i64;
|
||||
use crate::truncate::truncate_function_output_items_with_policy;
|
||||
use crate::truncate::truncate_text;
|
||||
use base64::Engine;
|
||||
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
|
||||
use codex_protocol::models::BaseInstructions;
|
||||
@@ -25,6 +19,12 @@ use codex_protocol::protocol::TokenUsageInfo;
|
||||
use codex_protocol::protocol::TurnContextItem;
|
||||
use codex_utils_cache::BlockingLruCache;
|
||||
use codex_utils_cache::sha1_digest;
|
||||
use codex_utils_output_truncation::TruncationPolicy;
|
||||
use codex_utils_output_truncation::approx_bytes_for_tokens;
|
||||
use codex_utils_output_truncation::approx_token_count;
|
||||
use codex_utils_output_truncation::approx_tokens_from_byte_count_i64;
|
||||
use codex_utils_output_truncation::truncate_function_output_items_with_policy;
|
||||
use codex_utils_output_truncation::truncate_text;
|
||||
use std::num::NonZeroUsize;
|
||||
use std::ops::Deref;
|
||||
use std::sync::LazyLock;
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
use super::*;
|
||||
use crate::truncate;
|
||||
use crate::truncate::TruncationPolicy;
|
||||
use base64::Engine;
|
||||
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
|
||||
use codex_git_utils::GhostCommit;
|
||||
@@ -23,6 +21,8 @@ use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::InterAgentCommunication;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use codex_protocol::protocol::TurnContextItem;
|
||||
use codex_utils_output_truncation::TruncationPolicy;
|
||||
use codex_utils_output_truncation::truncate_text;
|
||||
use image::ImageBuffer;
|
||||
use image::ImageFormat;
|
||||
use image::Rgba;
|
||||
@@ -179,7 +179,7 @@ fn reasoning_with_encrypted_content(len: usize) -> ResponseItem {
|
||||
}
|
||||
|
||||
fn truncate_exec_output(content: &str) -> String {
|
||||
truncate::truncate_text(content, TruncationPolicy::Tokens(EXEC_FORMAT_MAX_TOKENS))
|
||||
truncate_text(content, TruncationPolicy::Tokens(EXEC_FORMAT_MAX_TOKENS))
|
||||
}
|
||||
|
||||
fn approx_token_count_for_text(text: &str) -> i64 {
|
||||
|
||||
@@ -193,6 +193,10 @@ pub(crate) fn build_settings_update_items(
|
||||
exec_policy: &Policy,
|
||||
personality_feature_enabled: bool,
|
||||
) -> Vec<ResponseItem> {
|
||||
// TODO(ccunningham): build_settings_update_items still does not cover every
|
||||
// model-visible item emitted by build_initial_context. Persist the remaining
|
||||
// inputs or add explicit replay events so fork/resume can diff everything
|
||||
// deterministically.
|
||||
let contextual_user_message = build_environment_update_item(previous, next, shell);
|
||||
let developer_update_sections = [
|
||||
// Keep model-switch instructions first so model-specific guidance is read before
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
use codex_instructions::AGENTS_MD_FRAGMENT;
|
||||
use codex_instructions::ContextualUserFragmentDefinition;
|
||||
use codex_instructions::SKILL_FRAGMENT;
|
||||
use codex_protocol::items::HookPromptItem;
|
||||
use codex_protocol::items::parse_hook_prompt_fragment;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::protocol::ENVIRONMENT_CONTEXT_CLOSE_TAG;
|
||||
use codex_protocol::protocol::ENVIRONMENT_CONTEXT_OPEN_TAG;
|
||||
|
||||
pub(crate) const AGENTS_MD_START_MARKER: &str = "# AGENTS.md instructions for ";
|
||||
pub(crate) const AGENTS_MD_END_MARKER: &str = "</INSTRUCTIONS>";
|
||||
pub(crate) const SKILL_OPEN_TAG: &str = "<skill>";
|
||||
pub(crate) const SKILL_CLOSE_TAG: &str = "</skill>";
|
||||
pub(crate) const USER_SHELL_COMMAND_OPEN_TAG: &str = "<user_shell_command>";
|
||||
pub(crate) const USER_SHELL_COMMAND_CLOSE_TAG: &str = "</user_shell_command>";
|
||||
pub(crate) const TURN_ABORTED_OPEN_TAG: &str = "<turn_aborted>";
|
||||
@@ -16,64 +14,11 @@ pub(crate) const TURN_ABORTED_CLOSE_TAG: &str = "</turn_aborted>";
|
||||
pub(crate) const SUBAGENT_NOTIFICATION_OPEN_TAG: &str = "<subagent_notification>";
|
||||
pub(crate) const SUBAGENT_NOTIFICATION_CLOSE_TAG: &str = "</subagent_notification>";
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) struct ContextualUserFragmentDefinition {
|
||||
start_marker: &'static str,
|
||||
end_marker: &'static str,
|
||||
}
|
||||
|
||||
impl ContextualUserFragmentDefinition {
|
||||
pub(crate) const fn new(start_marker: &'static str, end_marker: &'static str) -> Self {
|
||||
Self {
|
||||
start_marker,
|
||||
end_marker,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn matches_text(&self, text: &str) -> bool {
|
||||
let trimmed = text.trim_start();
|
||||
let starts_with_marker = trimmed
|
||||
.get(..self.start_marker.len())
|
||||
.is_some_and(|candidate| candidate.eq_ignore_ascii_case(self.start_marker));
|
||||
let trimmed = trimmed.trim_end();
|
||||
let ends_with_marker = trimmed
|
||||
.get(trimmed.len().saturating_sub(self.end_marker.len())..)
|
||||
.is_some_and(|candidate| candidate.eq_ignore_ascii_case(self.end_marker));
|
||||
starts_with_marker && ends_with_marker
|
||||
}
|
||||
|
||||
pub(crate) const fn start_marker(&self) -> &'static str {
|
||||
self.start_marker
|
||||
}
|
||||
|
||||
pub(crate) const fn end_marker(&self) -> &'static str {
|
||||
self.end_marker
|
||||
}
|
||||
|
||||
pub(crate) fn wrap(&self, body: String) -> String {
|
||||
format!("{}\n{}\n{}", self.start_marker, body, self.end_marker)
|
||||
}
|
||||
|
||||
pub(crate) fn into_message(self, text: String) -> ResponseItem {
|
||||
ResponseItem::Message {
|
||||
id: None,
|
||||
role: "user".to_string(),
|
||||
content: vec![ContentItem::InputText { text }],
|
||||
end_turn: None,
|
||||
phase: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) const AGENTS_MD_FRAGMENT: ContextualUserFragmentDefinition =
|
||||
ContextualUserFragmentDefinition::new(AGENTS_MD_START_MARKER, AGENTS_MD_END_MARKER);
|
||||
pub(crate) const ENVIRONMENT_CONTEXT_FRAGMENT: ContextualUserFragmentDefinition =
|
||||
ContextualUserFragmentDefinition::new(
|
||||
ENVIRONMENT_CONTEXT_OPEN_TAG,
|
||||
ENVIRONMENT_CONTEXT_CLOSE_TAG,
|
||||
);
|
||||
pub(crate) const SKILL_FRAGMENT: ContextualUserFragmentDefinition =
|
||||
ContextualUserFragmentDefinition::new(SKILL_OPEN_TAG, SKILL_CLOSE_TAG);
|
||||
pub(crate) const USER_SHELL_COMMAND_FRAGMENT: ContextualUserFragmentDefinition =
|
||||
ContextualUserFragmentDefinition::new(
|
||||
USER_SHELL_COMMAND_OPEN_TAG,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use super::*;
|
||||
use codex_protocol::items::HookPromptFragment;
|
||||
use codex_protocol::items::build_hook_prompt_message;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
|
||||
#[test]
|
||||
fn detects_environment_context_fragment() {
|
||||
|
||||
@@ -1,46 +0,0 @@
|
||||
//! Functions for environment detection that need to be shared across crates.
|
||||
|
||||
fn env_var_set(key: &str) -> bool {
|
||||
std::env::var(key).is_ok_and(|v| !v.trim().is_empty())
|
||||
}
|
||||
|
||||
/// Returns true if the current process is running under Windows Subsystem for Linux.
|
||||
pub fn is_wsl() -> bool {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
if std::env::var_os("WSL_DISTRO_NAME").is_some() {
|
||||
return true;
|
||||
}
|
||||
match std::fs::read_to_string("/proc/version") {
|
||||
Ok(version) => version.to_lowercase().contains("microsoft"),
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns true when Codex is likely running in an environment without a usable GUI.
|
||||
///
|
||||
/// This is intentionally conservative and is used by frontends to avoid flows that would try to
|
||||
/// open a browser (e.g. device-code auth fallback).
|
||||
pub fn is_headless_environment() -> bool {
|
||||
if env_var_set("CI")
|
||||
|| env_var_set("SSH_CONNECTION")
|
||||
|| env_var_set("SSH_CLIENT")
|
||||
|| env_var_set("SSH_TTY")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
if !env_var_set("DISPLAY") && !env_var_set("WAYLAND_DISPLAY") {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
@@ -70,8 +70,8 @@ impl EnvironmentContext {
|
||||
) -> Self {
|
||||
let before_network = Self::network_from_turn_context_item(before);
|
||||
let after_network = Self::network_from_turn_context(after);
|
||||
let cwd = if before.cwd != after.cwd {
|
||||
Some(after.cwd.clone())
|
||||
let cwd = if before.cwd.as_path() != after.cwd.as_path() {
|
||||
Some(after.cwd.to_path_buf())
|
||||
} else {
|
||||
None
|
||||
};
|
||||
@@ -94,7 +94,7 @@ impl EnvironmentContext {
|
||||
|
||||
pub fn from_turn_context(turn_context: &TurnContext, shell: &Shell) -> Self {
|
||||
Self::new(
|
||||
Some(turn_context.cwd.clone()),
|
||||
Some(turn_context.cwd.to_path_buf()),
|
||||
shell.clone(),
|
||||
turn_context.current_date.clone(),
|
||||
turn_context.timezone.clone(),
|
||||
|
||||
@@ -2,8 +2,6 @@ use crate::exec::ExecToolCallOutput;
|
||||
use crate::network_policy_decision::NetworkPolicyDecisionPayload;
|
||||
use crate::token_data::KnownPlan;
|
||||
use crate::token_data::PlanType;
|
||||
use crate::truncate::TruncationPolicy;
|
||||
use crate::truncate::truncate_text;
|
||||
use chrono::DateTime;
|
||||
use chrono::Datelike;
|
||||
use chrono::Local;
|
||||
@@ -15,6 +13,8 @@ use codex_protocol::ThreadId;
|
||||
use codex_protocol::protocol::CodexErrorInfo;
|
||||
use codex_protocol::protocol::ErrorEvent;
|
||||
use codex_protocol::protocol::RateLimitSnapshot;
|
||||
use codex_utils_output_truncation::TruncationPolicy;
|
||||
use codex_utils_output_truncation::truncate_text;
|
||||
use reqwest::StatusCode;
|
||||
use serde_json;
|
||||
use std::io;
|
||||
|
||||
+214
-66
@@ -1,6 +1,7 @@
|
||||
#[cfg(unix)]
|
||||
use std::os::unix::process::ExitStatusExt;
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
use std::collections::HashMap;
|
||||
use std::io;
|
||||
use std::path::Path;
|
||||
@@ -32,7 +33,7 @@ use crate::spawn::StdioPolicy;
|
||||
use crate::spawn::spawn_child_async;
|
||||
use crate::text_encoding::bytes_to_string_smart;
|
||||
use codex_network_proxy::NetworkProxy;
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
use codex_protocol::config_types::WindowsSandboxLevel;
|
||||
use codex_protocol::permissions::FileSystemSandboxKind;
|
||||
use codex_protocol::permissions::FileSystemSandboxPolicy;
|
||||
use codex_protocol::permissions::NetworkSandboxPolicy;
|
||||
@@ -41,6 +42,7 @@ use codex_sandboxing::SandboxManager;
|
||||
use codex_sandboxing::SandboxTransformRequest;
|
||||
use codex_sandboxing::SandboxType;
|
||||
use codex_sandboxing::SandboxablePreference;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use codex_utils_pty::DEFAULT_OUTPUT_BYTES_CAP;
|
||||
use codex_utils_pty::process_group::kill_child_process_group;
|
||||
|
||||
@@ -91,6 +93,17 @@ pub struct ExecParams {
|
||||
pub arg0: Option<String>,
|
||||
}
|
||||
|
||||
/// Extra filesystem deny-write carveouts for the non-elevated Windows
|
||||
/// restricted-token backend.
|
||||
///
|
||||
/// These are applied on top of the legacy `WorkspaceWrite` allow set, so we
|
||||
/// can support a narrow split-policy subset without changing legacy Windows
|
||||
/// sandbox semantics.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct WindowsRestrictedTokenFilesystemOverlay {
|
||||
pub(crate) additional_deny_write_paths: Vec<AbsolutePathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
|
||||
pub enum ExecCapturePolicy {
|
||||
/// Shell-like execs keep the historical output cap and timeout behavior.
|
||||
@@ -241,10 +254,10 @@ pub fn build_exec_request(
|
||||
expiration,
|
||||
capture_policy,
|
||||
network,
|
||||
sandbox_permissions,
|
||||
sandbox_permissions: _,
|
||||
windows_sandbox_level,
|
||||
windows_sandbox_private_desktop,
|
||||
justification,
|
||||
justification: _,
|
||||
arg0: _,
|
||||
} = params;
|
||||
if let Some(network) = network.as_ref() {
|
||||
@@ -268,10 +281,8 @@ pub fn build_exec_request(
|
||||
let options = ExecOptions {
|
||||
expiration,
|
||||
capture_policy,
|
||||
sandbox_permissions,
|
||||
justification,
|
||||
};
|
||||
let exec_req = manager
|
||||
let mut exec_req = manager
|
||||
.transform(SandboxTransformRequest {
|
||||
command,
|
||||
policy: sandbox_policy,
|
||||
@@ -290,6 +301,16 @@ pub fn build_exec_request(
|
||||
})
|
||||
.map(|request| ExecRequest::from_sandbox_exec_request(request, options))
|
||||
.map_err(CodexErr::from)?;
|
||||
exec_req.windows_restricted_token_filesystem_overlay =
|
||||
resolve_windows_restricted_token_filesystem_overlay(
|
||||
exec_req.sandbox,
|
||||
&exec_req.sandbox_policy,
|
||||
&exec_req.file_system_sandbox_policy,
|
||||
exec_req.network_sandbox_policy,
|
||||
sandbox_cwd,
|
||||
exec_req.windows_sandbox_level,
|
||||
)
|
||||
.map_err(CodexErr::UnsupportedOperation)?;
|
||||
Ok(exec_req)
|
||||
}
|
||||
|
||||
@@ -309,11 +330,10 @@ pub(crate) async fn execute_exec_request(
|
||||
sandbox,
|
||||
windows_sandbox_level,
|
||||
windows_sandbox_private_desktop,
|
||||
sandbox_permissions,
|
||||
sandbox_policy: _sandbox_policy_from_env,
|
||||
file_system_sandbox_policy,
|
||||
network_sandbox_policy,
|
||||
justification,
|
||||
windows_restricted_token_filesystem_overlay,
|
||||
arg0,
|
||||
} = exec_request;
|
||||
let _ = _sandbox_policy_from_env;
|
||||
@@ -325,10 +345,10 @@ pub(crate) async fn execute_exec_request(
|
||||
capture_policy,
|
||||
env,
|
||||
network: network.clone(),
|
||||
sandbox_permissions,
|
||||
sandbox_permissions: SandboxPermissions::UseDefault,
|
||||
windows_sandbox_level,
|
||||
windows_sandbox_private_desktop,
|
||||
justification,
|
||||
justification: None,
|
||||
arg0,
|
||||
};
|
||||
|
||||
@@ -338,6 +358,7 @@ pub(crate) async fn execute_exec_request(
|
||||
sandbox,
|
||||
sandbox_policy,
|
||||
&file_system_sandbox_policy,
|
||||
windows_restricted_token_filesystem_overlay.as_ref(),
|
||||
network_sandbox_policy,
|
||||
stdout_stream,
|
||||
after_spawn,
|
||||
@@ -417,11 +438,11 @@ fn record_windows_sandbox_spawn_failure(
|
||||
async fn exec_windows_sandbox(
|
||||
params: ExecParams,
|
||||
sandbox_policy: &SandboxPolicy,
|
||||
windows_restricted_token_filesystem_overlay: Option<&WindowsRestrictedTokenFilesystemOverlay>,
|
||||
) -> Result<RawExecToolCallOutput> {
|
||||
use crate::config::find_codex_home;
|
||||
use codex_protocol::config_types::WindowsSandboxLevel;
|
||||
use codex_windows_sandbox::run_windows_sandbox_capture;
|
||||
use codex_windows_sandbox::run_windows_sandbox_capture_elevated;
|
||||
use codex_windows_sandbox::run_windows_sandbox_capture_with_extra_deny_write_paths;
|
||||
|
||||
let ExecParams {
|
||||
command,
|
||||
@@ -460,6 +481,15 @@ async fn exec_windows_sandbox(
|
||||
let command_path = command.first().cloned();
|
||||
let sandbox_level = windows_sandbox_level;
|
||||
let use_elevated = matches!(sandbox_level, WindowsSandboxLevel::Elevated);
|
||||
let additional_deny_write_paths = windows_restricted_token_filesystem_overlay
|
||||
.map(|overlay| {
|
||||
overlay
|
||||
.additional_deny_write_paths
|
||||
.iter()
|
||||
.map(AbsolutePathBuf::to_path_buf)
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let spawn_res = tokio::task::spawn_blocking(move || {
|
||||
if use_elevated {
|
||||
run_windows_sandbox_capture_elevated(
|
||||
@@ -473,7 +503,7 @@ async fn exec_windows_sandbox(
|
||||
windows_sandbox_private_desktop,
|
||||
)
|
||||
} else {
|
||||
run_windows_sandbox_capture(
|
||||
run_windows_sandbox_capture_with_extra_deny_write_paths(
|
||||
policy_str.as_str(),
|
||||
&sandbox_cwd,
|
||||
codex_home.as_ref(),
|
||||
@@ -481,6 +511,7 @@ async fn exec_windows_sandbox(
|
||||
&cwd,
|
||||
env,
|
||||
timeout_ms,
|
||||
&additional_deny_write_paths,
|
||||
windows_sandbox_private_desktop,
|
||||
)
|
||||
}
|
||||
@@ -789,29 +820,25 @@ impl Default for ExecToolCallOutput {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg_attr(not(target_os = "windows"), allow(unused_variables))]
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn exec(
|
||||
params: ExecParams,
|
||||
sandbox: SandboxType,
|
||||
sandbox_policy: &SandboxPolicy,
|
||||
file_system_sandbox_policy: &FileSystemSandboxPolicy,
|
||||
_sandbox: SandboxType,
|
||||
_sandbox_policy: &SandboxPolicy,
|
||||
_file_system_sandbox_policy: &FileSystemSandboxPolicy,
|
||||
_windows_restricted_token_filesystem_overlay: Option<&WindowsRestrictedTokenFilesystemOverlay>,
|
||||
network_sandbox_policy: NetworkSandboxPolicy,
|
||||
stdout_stream: Option<StdoutStream>,
|
||||
after_spawn: Option<Box<dyn FnOnce() + Send>>,
|
||||
) -> Result<RawExecToolCallOutput> {
|
||||
#[cfg(target_os = "windows")]
|
||||
if sandbox == SandboxType::WindowsRestrictedToken {
|
||||
let support = windows_restricted_token_sandbox_support(
|
||||
sandbox,
|
||||
params.windows_sandbox_level,
|
||||
sandbox_policy,
|
||||
file_system_sandbox_policy,
|
||||
network_sandbox_policy,
|
||||
);
|
||||
if let Some(reason) = support.unsupported_reason {
|
||||
return Err(CodexErr::Io(io::Error::other(reason)));
|
||||
}
|
||||
return exec_windows_sandbox(params, sandbox_policy).await;
|
||||
if _sandbox == SandboxType::WindowsRestrictedToken {
|
||||
return exec_windows_sandbox(
|
||||
params,
|
||||
_sandbox_policy,
|
||||
_windows_restricted_token_filesystem_overlay,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
let ExecParams {
|
||||
command,
|
||||
@@ -856,54 +883,175 @@ async fn exec(
|
||||
}
|
||||
|
||||
#[cfg_attr(not(target_os = "windows"), allow(dead_code))]
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
struct WindowsRestrictedTokenSandboxSupport {
|
||||
should_use: bool,
|
||||
unsupported_reason: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(any(target_os = "windows", test))]
|
||||
fn windows_restricted_token_sandbox_support(
|
||||
fn should_use_windows_restricted_token_sandbox(
|
||||
sandbox: SandboxType,
|
||||
windows_sandbox_level: codex_protocol::config_types::WindowsSandboxLevel,
|
||||
sandbox_policy: &SandboxPolicy,
|
||||
file_system_sandbox_policy: &FileSystemSandboxPolicy,
|
||||
network_sandbox_policy: NetworkSandboxPolicy,
|
||||
) -> WindowsRestrictedTokenSandboxSupport {
|
||||
if sandbox != SandboxType::WindowsRestrictedToken {
|
||||
return WindowsRestrictedTokenSandboxSupport {
|
||||
should_use: false,
|
||||
unsupported_reason: None,
|
||||
};
|
||||
}
|
||||
|
||||
// Windows currently reuses SandboxType::WindowsRestrictedToken for both
|
||||
// the legacy restricted-token backend and the elevated setup/runner path.
|
||||
// The sandbox level decides whether restricted read-only policies are
|
||||
// supported.
|
||||
let should_use = file_system_sandbox_policy.kind == FileSystemSandboxKind::Restricted
|
||||
) -> bool {
|
||||
sandbox == SandboxType::WindowsRestrictedToken
|
||||
&& file_system_sandbox_policy.kind == FileSystemSandboxKind::Restricted
|
||||
&& !matches!(
|
||||
sandbox_policy,
|
||||
SandboxPolicy::DangerFullAccess | SandboxPolicy::ExternalSandbox { .. }
|
||||
)
|
||||
&& (matches!(
|
||||
windows_sandbox_level,
|
||||
codex_protocol::config_types::WindowsSandboxLevel::Elevated
|
||||
) || sandbox_policy.has_full_disk_read_access());
|
||||
}
|
||||
|
||||
let unsupported_reason = if should_use {
|
||||
None
|
||||
} else {
|
||||
Some(format!(
|
||||
#[cfg_attr(not(test), allow(dead_code))]
|
||||
pub(crate) fn unsupported_windows_restricted_token_sandbox_reason(
|
||||
sandbox: SandboxType,
|
||||
sandbox_policy: &SandboxPolicy,
|
||||
file_system_sandbox_policy: &FileSystemSandboxPolicy,
|
||||
network_sandbox_policy: NetworkSandboxPolicy,
|
||||
sandbox_policy_cwd: &Path,
|
||||
windows_sandbox_level: WindowsSandboxLevel,
|
||||
) -> Option<String> {
|
||||
resolve_windows_restricted_token_filesystem_overlay(
|
||||
sandbox,
|
||||
sandbox_policy,
|
||||
file_system_sandbox_policy,
|
||||
network_sandbox_policy,
|
||||
sandbox_policy_cwd,
|
||||
windows_sandbox_level,
|
||||
)
|
||||
.err()
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_windows_restricted_token_filesystem_overlay(
|
||||
sandbox: SandboxType,
|
||||
sandbox_policy: &SandboxPolicy,
|
||||
file_system_sandbox_policy: &FileSystemSandboxPolicy,
|
||||
network_sandbox_policy: NetworkSandboxPolicy,
|
||||
sandbox_policy_cwd: &Path,
|
||||
windows_sandbox_level: WindowsSandboxLevel,
|
||||
) -> std::result::Result<Option<WindowsRestrictedTokenFilesystemOverlay>, String> {
|
||||
if sandbox != SandboxType::WindowsRestrictedToken {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let needs_direct_runtime_enforcement = file_system_sandbox_policy
|
||||
.needs_direct_runtime_enforcement(network_sandbox_policy, sandbox_policy_cwd);
|
||||
|
||||
if should_use_windows_restricted_token_sandbox(
|
||||
sandbox,
|
||||
sandbox_policy,
|
||||
file_system_sandbox_policy,
|
||||
) && !needs_direct_runtime_enforcement
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
if !should_use_windows_restricted_token_sandbox(
|
||||
sandbox,
|
||||
sandbox_policy,
|
||||
file_system_sandbox_policy,
|
||||
) {
|
||||
return Err(format!(
|
||||
"windows sandbox backend cannot enforce file_system={:?}, network={network_sandbox_policy:?}, legacy_policy={sandbox_policy:?}; refusing to run unsandboxed",
|
||||
file_system_sandbox_policy.kind,
|
||||
))
|
||||
};
|
||||
|
||||
WindowsRestrictedTokenSandboxSupport {
|
||||
should_use,
|
||||
unsupported_reason,
|
||||
));
|
||||
}
|
||||
|
||||
if windows_sandbox_level != WindowsSandboxLevel::RestrictedToken {
|
||||
return Err(
|
||||
"windows elevated sandbox backend cannot enforce split filesystem permissions directly; refusing to run unsandboxed"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
if !file_system_sandbox_policy.has_full_disk_read_access() {
|
||||
return Err(
|
||||
"windows unelevated restricted-token sandbox cannot enforce split filesystem read restrictions directly; refusing to run unsandboxed"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
if !file_system_sandbox_policy
|
||||
.get_unreadable_roots_with_cwd(sandbox_policy_cwd)
|
||||
.is_empty()
|
||||
{
|
||||
return Err(
|
||||
"windows unelevated restricted-token sandbox cannot enforce unreadable split filesystem carveouts directly; refusing to run unsandboxed"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
let legacy_writable_roots = sandbox_policy.get_writable_roots_with_cwd(sandbox_policy_cwd);
|
||||
let split_writable_roots =
|
||||
file_system_sandbox_policy.get_writable_roots_with_cwd(sandbox_policy_cwd);
|
||||
let legacy_root_paths: BTreeSet<PathBuf> = legacy_writable_roots
|
||||
.iter()
|
||||
.map(|root| normalize_windows_overlay_path(root.root.as_path()))
|
||||
.collect::<std::result::Result<_, _>>()?;
|
||||
let split_root_paths: BTreeSet<PathBuf> = split_writable_roots
|
||||
.iter()
|
||||
.map(|root| normalize_windows_overlay_path(root.root.as_path()))
|
||||
.collect::<std::result::Result<_, _>>()?;
|
||||
|
||||
if legacy_root_paths != split_root_paths {
|
||||
return Err(
|
||||
"windows unelevated restricted-token sandbox cannot enforce split writable root sets directly; refusing to run unsandboxed"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
for writable_root in &split_writable_roots {
|
||||
for read_only_subpath in &writable_root.read_only_subpaths {
|
||||
if split_writable_roots.iter().any(|candidate| {
|
||||
candidate.root.as_path() != writable_root.root.as_path()
|
||||
&& candidate
|
||||
.root
|
||||
.as_path()
|
||||
.starts_with(read_only_subpath.as_path())
|
||||
}) {
|
||||
return Err(
|
||||
"windows unelevated restricted-token sandbox cannot reopen writable descendants under read-only carveouts directly; refusing to run unsandboxed"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut additional_deny_write_paths = BTreeSet::new();
|
||||
for split_root in &split_writable_roots {
|
||||
let split_root_path = normalize_windows_overlay_path(split_root.root.as_path())?;
|
||||
let Some(legacy_root) = legacy_writable_roots.iter().find(|candidate| {
|
||||
normalize_windows_overlay_path(candidate.root.as_path())
|
||||
.is_ok_and(|candidate_path| candidate_path == split_root_path)
|
||||
}) else {
|
||||
return Err(
|
||||
"windows unelevated restricted-token sandbox cannot enforce split writable root sets directly; refusing to run unsandboxed"
|
||||
.to_string(),
|
||||
);
|
||||
};
|
||||
|
||||
for read_only_subpath in &split_root.read_only_subpaths {
|
||||
if !legacy_root
|
||||
.read_only_subpaths
|
||||
.iter()
|
||||
.any(|candidate| candidate == read_only_subpath)
|
||||
{
|
||||
additional_deny_write_paths
|
||||
.insert(normalize_windows_overlay_path(read_only_subpath.as_path())?);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if additional_deny_write_paths.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
Ok(Some(WindowsRestrictedTokenFilesystemOverlay {
|
||||
additional_deny_write_paths: additional_deny_write_paths
|
||||
.into_iter()
|
||||
.map(|path| AbsolutePathBuf::from_absolute_path(path).map_err(|err| err.to_string()))
|
||||
.collect::<std::result::Result<_, _>>()?,
|
||||
}))
|
||||
}
|
||||
|
||||
fn normalize_windows_overlay_path(path: &Path) -> std::result::Result<PathBuf, String> {
|
||||
AbsolutePathBuf::from_absolute_path(dunce::simplified(path))
|
||||
.map(AbsolutePathBuf::into_path_buf)
|
||||
.map_err(|err| err.to_string())
|
||||
}
|
||||
|
||||
/// Consumes the output of a child process according to the configured capture
|
||||
|
||||
+215
-91
@@ -269,6 +269,7 @@ async fn exec_full_buffer_capture_ignores_expiration() -> Result<()> {
|
||||
SandboxType::None,
|
||||
&SandboxPolicy::DangerFullAccess,
|
||||
&FileSystemSandboxPolicy::unrestricted(),
|
||||
/*windows_restricted_token_filesystem_overlay*/ None,
|
||||
NetworkSandboxPolicy::Enabled,
|
||||
/*stdout_stream*/ None,
|
||||
/*after_spawn*/ None,
|
||||
@@ -308,6 +309,7 @@ async fn exec_full_buffer_capture_keeps_io_drain_timeout_when_descendant_holds_p
|
||||
SandboxType::None,
|
||||
&SandboxPolicy::DangerFullAccess,
|
||||
&FileSystemSandboxPolicy::unrestricted(),
|
||||
/*windows_restricted_token_filesystem_overlay*/ None,
|
||||
NetworkSandboxPolicy::Enabled,
|
||||
/*stdout_stream*/ None,
|
||||
/*after_spawn*/ None,
|
||||
@@ -376,42 +378,30 @@ fn windows_restricted_token_skips_external_sandbox_policies() {
|
||||
let policy = SandboxPolicy::ExternalSandbox {
|
||||
network_access: codex_protocol::protocol::NetworkAccess::Restricted,
|
||||
};
|
||||
let file_system_policy = FileSystemSandboxPolicy::restricted(vec![]);
|
||||
let file_system_policy = FileSystemSandboxPolicy::from(&policy);
|
||||
|
||||
assert_eq!(
|
||||
windows_restricted_token_sandbox_support(
|
||||
should_use_windows_restricted_token_sandbox(
|
||||
SandboxType::WindowsRestrictedToken,
|
||||
WindowsSandboxLevel::Disabled,
|
||||
&policy,
|
||||
&file_system_policy,
|
||||
NetworkSandboxPolicy::Restricted,
|
||||
),
|
||||
WindowsRestrictedTokenSandboxSupport {
|
||||
should_use: false,
|
||||
unsupported_reason: Some(
|
||||
"windows sandbox backend cannot enforce file_system=Restricted, network=Restricted, legacy_policy=ExternalSandbox { network_access: Restricted }; refusing to run unsandboxed".to_string()
|
||||
),
|
||||
}
|
||||
false
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_restricted_token_runs_for_legacy_restricted_policies() {
|
||||
let policy = SandboxPolicy::new_read_only_policy();
|
||||
let file_system_policy = FileSystemSandboxPolicy::restricted(vec![]);
|
||||
let file_system_policy = FileSystemSandboxPolicy::from(&policy);
|
||||
|
||||
assert_eq!(
|
||||
windows_restricted_token_sandbox_support(
|
||||
should_use_windows_restricted_token_sandbox(
|
||||
SandboxType::WindowsRestrictedToken,
|
||||
WindowsSandboxLevel::Disabled,
|
||||
&policy,
|
||||
&file_system_policy,
|
||||
NetworkSandboxPolicy::Restricted,
|
||||
),
|
||||
WindowsRestrictedTokenSandboxSupport {
|
||||
should_use: true,
|
||||
unsupported_reason: None,
|
||||
}
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
@@ -421,70 +411,39 @@ fn windows_restricted_token_rejects_network_only_restrictions() {
|
||||
network_access: codex_protocol::protocol::NetworkAccess::Restricted,
|
||||
};
|
||||
let file_system_policy = FileSystemSandboxPolicy::unrestricted();
|
||||
let sandbox_policy_cwd = std::env::current_dir().expect("cwd");
|
||||
|
||||
assert_eq!(
|
||||
windows_restricted_token_sandbox_support(
|
||||
SandboxType::WindowsRestrictedToken,
|
||||
WindowsSandboxLevel::Disabled,
|
||||
&policy,
|
||||
&file_system_policy,
|
||||
NetworkSandboxPolicy::Restricted,
|
||||
),
|
||||
WindowsRestrictedTokenSandboxSupport {
|
||||
should_use: false,
|
||||
unsupported_reason: Some(
|
||||
"windows sandbox backend cannot enforce file_system=Unrestricted, network=Restricted, legacy_policy=ExternalSandbox { network_access: Restricted }; refusing to run unsandboxed".to_string()
|
||||
unsupported_windows_restricted_token_sandbox_reason(
|
||||
SandboxType::WindowsRestrictedToken,
|
||||
&policy,
|
||||
&file_system_policy,
|
||||
NetworkSandboxPolicy::Restricted,
|
||||
&sandbox_policy_cwd,
|
||||
WindowsSandboxLevel::RestrictedToken,
|
||||
),
|
||||
}
|
||||
);
|
||||
Some(
|
||||
"windows sandbox backend cannot enforce file_system=Unrestricted, network=Restricted, legacy_policy=ExternalSandbox { network_access: Restricted }; refusing to run unsandboxed".to_string()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_restricted_token_allows_legacy_restricted_policies() {
|
||||
let policy = SandboxPolicy::new_read_only_policy();
|
||||
let file_system_policy = FileSystemSandboxPolicy::restricted(vec![]);
|
||||
|
||||
assert_eq!(
|
||||
windows_restricted_token_sandbox_support(
|
||||
SandboxType::WindowsRestrictedToken,
|
||||
WindowsSandboxLevel::Disabled,
|
||||
&policy,
|
||||
&file_system_policy,
|
||||
NetworkSandboxPolicy::Restricted,
|
||||
),
|
||||
WindowsRestrictedTokenSandboxSupport {
|
||||
should_use: true,
|
||||
unsupported_reason: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_restricted_token_rejects_restricted_read_only_policies() {
|
||||
let policy = SandboxPolicy::ReadOnly {
|
||||
access: codex_protocol::protocol::ReadOnlyAccess::Restricted {
|
||||
include_platform_defaults: true,
|
||||
readable_roots: vec![],
|
||||
},
|
||||
network_access: false,
|
||||
};
|
||||
let file_system_policy = FileSystemSandboxPolicy::from(&policy);
|
||||
let sandbox_policy_cwd = std::env::current_dir().expect("cwd");
|
||||
|
||||
assert_eq!(
|
||||
windows_restricted_token_sandbox_support(
|
||||
unsupported_windows_restricted_token_sandbox_reason(
|
||||
SandboxType::WindowsRestrictedToken,
|
||||
WindowsSandboxLevel::Disabled,
|
||||
&policy,
|
||||
&file_system_policy,
|
||||
NetworkSandboxPolicy::Restricted,
|
||||
&sandbox_policy_cwd,
|
||||
WindowsSandboxLevel::RestrictedToken,
|
||||
),
|
||||
WindowsRestrictedTokenSandboxSupport {
|
||||
should_use: false,
|
||||
unsupported_reason: Some(
|
||||
"windows sandbox backend cannot enforce file_system=Restricted, network=Restricted, legacy_policy=ReadOnly { access: Restricted { include_platform_defaults: true, readable_roots: [] }, network_access: false }; refusing to run unsandboxed".to_string()
|
||||
),
|
||||
},
|
||||
"restricted-token should fail closed for restricted read-only policies"
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
@@ -494,50 +453,213 @@ fn windows_restricted_token_allows_legacy_workspace_write_policies() {
|
||||
writable_roots: vec![],
|
||||
read_only_access: codex_protocol::protocol::ReadOnlyAccess::FullAccess,
|
||||
network_access: false,
|
||||
exclude_tmpdir_env_var: false,
|
||||
exclude_slash_tmp: false,
|
||||
exclude_tmpdir_env_var: true,
|
||||
exclude_slash_tmp: true,
|
||||
};
|
||||
let file_system_policy = FileSystemSandboxPolicy::from(&policy);
|
||||
let sandbox_policy_cwd = std::env::current_dir().expect("cwd");
|
||||
|
||||
assert_eq!(
|
||||
windows_restricted_token_sandbox_support(
|
||||
unsupported_windows_restricted_token_sandbox_reason(
|
||||
SandboxType::WindowsRestrictedToken,
|
||||
WindowsSandboxLevel::Disabled,
|
||||
&policy,
|
||||
&file_system_policy,
|
||||
NetworkSandboxPolicy::Restricted,
|
||||
&sandbox_policy_cwd,
|
||||
WindowsSandboxLevel::RestrictedToken,
|
||||
),
|
||||
WindowsRestrictedTokenSandboxSupport {
|
||||
should_use: true,
|
||||
unsupported_reason: None,
|
||||
}
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_elevated_sandbox_allows_restricted_read_only_policies() {
|
||||
let policy = SandboxPolicy::ReadOnly {
|
||||
access: codex_protocol::protocol::ReadOnlyAccess::Restricted {
|
||||
include_platform_defaults: true,
|
||||
readable_roots: vec![],
|
||||
},
|
||||
fn windows_restricted_token_rejects_split_only_filesystem_policies() {
|
||||
let temp_dir = tempfile::TempDir::new().expect("tempdir");
|
||||
let docs = temp_dir.path().join("docs");
|
||||
std::fs::create_dir_all(&docs).expect("create docs");
|
||||
let policy = SandboxPolicy::WorkspaceWrite {
|
||||
writable_roots: vec![],
|
||||
read_only_access: codex_protocol::protocol::ReadOnlyAccess::FullAccess,
|
||||
network_access: false,
|
||||
exclude_tmpdir_env_var: true,
|
||||
exclude_slash_tmp: true,
|
||||
};
|
||||
let file_system_policy = FileSystemSandboxPolicy::from(&policy);
|
||||
let file_system_policy = FileSystemSandboxPolicy::restricted(vec![
|
||||
codex_protocol::permissions::FileSystemSandboxEntry {
|
||||
path: codex_protocol::permissions::FileSystemPath::Special {
|
||||
value: codex_protocol::permissions::FileSystemSpecialPath::CurrentWorkingDirectory,
|
||||
},
|
||||
access: codex_protocol::permissions::FileSystemAccessMode::Write,
|
||||
},
|
||||
codex_protocol::permissions::FileSystemSandboxEntry {
|
||||
path: codex_protocol::permissions::FileSystemPath::Path {
|
||||
path: codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path(&docs)
|
||||
.expect("absolute docs"),
|
||||
},
|
||||
access: codex_protocol::permissions::FileSystemAccessMode::Read,
|
||||
},
|
||||
]);
|
||||
|
||||
assert_eq!(
|
||||
windows_restricted_token_sandbox_support(
|
||||
unsupported_windows_restricted_token_sandbox_reason(
|
||||
SandboxType::WindowsRestrictedToken,
|
||||
WindowsSandboxLevel::Elevated,
|
||||
&policy,
|
||||
&file_system_policy,
|
||||
NetworkSandboxPolicy::Restricted,
|
||||
temp_dir.path(),
|
||||
WindowsSandboxLevel::RestrictedToken,
|
||||
),
|
||||
WindowsRestrictedTokenSandboxSupport {
|
||||
should_use: true,
|
||||
unsupported_reason: None,
|
||||
Some(
|
||||
"windows unelevated restricted-token sandbox cannot enforce split filesystem read restrictions directly; refusing to run unsandboxed"
|
||||
.to_string()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_restricted_token_rejects_root_write_read_only_carveouts() {
|
||||
let temp_dir = tempfile::TempDir::new().expect("tempdir");
|
||||
let docs = temp_dir.path().join("docs");
|
||||
std::fs::create_dir_all(&docs).expect("create docs");
|
||||
let policy = SandboxPolicy::WorkspaceWrite {
|
||||
writable_roots: vec![],
|
||||
read_only_access: codex_protocol::protocol::ReadOnlyAccess::FullAccess,
|
||||
network_access: false,
|
||||
exclude_tmpdir_env_var: true,
|
||||
exclude_slash_tmp: true,
|
||||
};
|
||||
let file_system_policy = FileSystemSandboxPolicy::restricted(vec![
|
||||
codex_protocol::permissions::FileSystemSandboxEntry {
|
||||
path: codex_protocol::permissions::FileSystemPath::Special {
|
||||
value: codex_protocol::permissions::FileSystemSpecialPath::Root,
|
||||
},
|
||||
access: codex_protocol::permissions::FileSystemAccessMode::Write,
|
||||
},
|
||||
"elevated Windows sandbox should keep restricted read-only support enabled"
|
||||
codex_protocol::permissions::FileSystemSandboxEntry {
|
||||
path: codex_protocol::permissions::FileSystemPath::Path {
|
||||
path: codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path(&docs)
|
||||
.expect("absolute docs"),
|
||||
},
|
||||
access: codex_protocol::permissions::FileSystemAccessMode::Read,
|
||||
},
|
||||
]);
|
||||
|
||||
assert_eq!(
|
||||
unsupported_windows_restricted_token_sandbox_reason(
|
||||
SandboxType::WindowsRestrictedToken,
|
||||
&policy,
|
||||
&file_system_policy,
|
||||
NetworkSandboxPolicy::Restricted,
|
||||
temp_dir.path(),
|
||||
WindowsSandboxLevel::RestrictedToken,
|
||||
),
|
||||
Some(
|
||||
"windows unelevated restricted-token sandbox cannot enforce split writable root sets directly; refusing to run unsandboxed"
|
||||
.to_string()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_restricted_token_supports_full_read_split_write_read_carveouts() {
|
||||
let temp_dir = tempfile::TempDir::new().expect("tempdir");
|
||||
let cwd = dunce::canonicalize(temp_dir.path()).expect("canonicalize temp dir");
|
||||
let docs = cwd.join("docs");
|
||||
std::fs::create_dir_all(&docs).expect("create docs");
|
||||
let policy = SandboxPolicy::WorkspaceWrite {
|
||||
writable_roots: vec![],
|
||||
read_only_access: codex_protocol::protocol::ReadOnlyAccess::FullAccess,
|
||||
network_access: false,
|
||||
exclude_tmpdir_env_var: true,
|
||||
exclude_slash_tmp: true,
|
||||
};
|
||||
let file_system_policy = FileSystemSandboxPolicy::restricted(vec![
|
||||
codex_protocol::permissions::FileSystemSandboxEntry {
|
||||
path: codex_protocol::permissions::FileSystemPath::Special {
|
||||
value: codex_protocol::permissions::FileSystemSpecialPath::Root,
|
||||
},
|
||||
access: codex_protocol::permissions::FileSystemAccessMode::Read,
|
||||
},
|
||||
codex_protocol::permissions::FileSystemSandboxEntry {
|
||||
path: codex_protocol::permissions::FileSystemPath::Special {
|
||||
value: codex_protocol::permissions::FileSystemSpecialPath::CurrentWorkingDirectory,
|
||||
},
|
||||
access: codex_protocol::permissions::FileSystemAccessMode::Write,
|
||||
},
|
||||
codex_protocol::permissions::FileSystemSandboxEntry {
|
||||
path: codex_protocol::permissions::FileSystemPath::Path {
|
||||
path: codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path(&docs)
|
||||
.expect("absolute docs"),
|
||||
},
|
||||
access: codex_protocol::permissions::FileSystemAccessMode::Read,
|
||||
},
|
||||
]);
|
||||
|
||||
assert_eq!(
|
||||
resolve_windows_restricted_token_filesystem_overlay(
|
||||
SandboxType::WindowsRestrictedToken,
|
||||
&policy,
|
||||
&file_system_policy,
|
||||
NetworkSandboxPolicy::Restricted,
|
||||
&cwd,
|
||||
WindowsSandboxLevel::RestrictedToken,
|
||||
),
|
||||
Ok(Some(WindowsRestrictedTokenFilesystemOverlay {
|
||||
additional_deny_write_paths: vec![
|
||||
codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path(&docs)
|
||||
.expect("absolute docs"),
|
||||
],
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_elevated_rejects_split_write_read_carveouts() {
|
||||
let temp_dir = tempfile::TempDir::new().expect("tempdir");
|
||||
let docs = temp_dir.path().join("docs");
|
||||
std::fs::create_dir_all(&docs).expect("create docs");
|
||||
let policy = SandboxPolicy::WorkspaceWrite {
|
||||
writable_roots: vec![],
|
||||
read_only_access: codex_protocol::protocol::ReadOnlyAccess::FullAccess,
|
||||
network_access: false,
|
||||
exclude_tmpdir_env_var: false,
|
||||
exclude_slash_tmp: false,
|
||||
};
|
||||
let file_system_policy = FileSystemSandboxPolicy::restricted(vec![
|
||||
codex_protocol::permissions::FileSystemSandboxEntry {
|
||||
path: codex_protocol::permissions::FileSystemPath::Special {
|
||||
value: codex_protocol::permissions::FileSystemSpecialPath::Root,
|
||||
},
|
||||
access: codex_protocol::permissions::FileSystemAccessMode::Read,
|
||||
},
|
||||
codex_protocol::permissions::FileSystemSandboxEntry {
|
||||
path: codex_protocol::permissions::FileSystemPath::Special {
|
||||
value: codex_protocol::permissions::FileSystemSpecialPath::CurrentWorkingDirectory,
|
||||
},
|
||||
access: codex_protocol::permissions::FileSystemAccessMode::Write,
|
||||
},
|
||||
codex_protocol::permissions::FileSystemSandboxEntry {
|
||||
path: codex_protocol::permissions::FileSystemPath::Path {
|
||||
path: codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path(&docs)
|
||||
.expect("absolute docs"),
|
||||
},
|
||||
access: codex_protocol::permissions::FileSystemAccessMode::Read,
|
||||
},
|
||||
]);
|
||||
|
||||
assert_eq!(
|
||||
unsupported_windows_restricted_token_sandbox_reason(
|
||||
SandboxType::WindowsRestrictedToken,
|
||||
&policy,
|
||||
&file_system_policy,
|
||||
NetworkSandboxPolicy::Restricted,
|
||||
temp_dir.path(),
|
||||
WindowsSandboxLevel::Elevated,
|
||||
),
|
||||
Some(
|
||||
"windows elevated sandbox backend cannot enforce split filesystem permissions directly; refusing to run unsandboxed"
|
||||
.to_string()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -549,7 +671,7 @@ fn process_exec_tool_call_uses_platform_sandbox_for_network_only_restrictions()
|
||||
select_process_exec_tool_sandbox_type(
|
||||
&FileSystemSandboxPolicy::unrestricted(),
|
||||
NetworkSandboxPolicy::Restricted,
|
||||
WindowsSandboxLevel::Disabled,
|
||||
codex_protocol::config_types::WindowsSandboxLevel::Disabled,
|
||||
false,
|
||||
),
|
||||
expected
|
||||
@@ -581,16 +703,17 @@ async fn kill_child_process_group_kills_grandchildren_on_timeout() -> Result<()>
|
||||
"-c".to_string(),
|
||||
"sleep 60 & echo $!; sleep 60".to_string(),
|
||||
];
|
||||
let cwd = std::env::current_dir()?;
|
||||
let env: HashMap<String, String> = std::env::vars().collect();
|
||||
let params = ExecParams {
|
||||
command,
|
||||
cwd: std::env::current_dir()?,
|
||||
cwd,
|
||||
expiration: 500.into(),
|
||||
capture_policy: ExecCapturePolicy::ShellTool,
|
||||
env,
|
||||
network: None,
|
||||
sandbox_permissions: SandboxPermissions::UseDefault,
|
||||
windows_sandbox_level: WindowsSandboxLevel::Disabled,
|
||||
windows_sandbox_level: codex_protocol::config_types::WindowsSandboxLevel::Disabled,
|
||||
windows_sandbox_private_desktop: false,
|
||||
justification: None,
|
||||
arg0: None,
|
||||
@@ -601,6 +724,7 @@ async fn kill_child_process_group_kills_grandchildren_on_timeout() -> Result<()>
|
||||
SandboxType::None,
|
||||
&SandboxPolicy::new_read_only_policy(),
|
||||
&FileSystemSandboxPolicy::from(&SandboxPolicy::new_read_only_policy()),
|
||||
None,
|
||||
NetworkSandboxPolicy::Restricted,
|
||||
None,
|
||||
None,
|
||||
@@ -648,7 +772,7 @@ async fn process_exec_tool_call_respects_cancellation_token() -> Result<()> {
|
||||
env,
|
||||
network: None,
|
||||
sandbox_permissions: SandboxPermissions::UseDefault,
|
||||
windows_sandbox_level: WindowsSandboxLevel::Disabled,
|
||||
windows_sandbox_level: codex_protocol::config_types::WindowsSandboxLevel::Disabled,
|
||||
windows_sandbox_private_desktop: false,
|
||||
justification: None,
|
||||
arg0: None,
|
||||
|
||||
@@ -7,9 +7,9 @@ use serde_json::Value;
|
||||
use crate::codex::Session;
|
||||
use crate::compact::content_items_to_text;
|
||||
use crate::event_mapping::is_contextual_user_message_content;
|
||||
use crate::truncate::approx_bytes_for_tokens;
|
||||
use crate::truncate::approx_token_count;
|
||||
use crate::truncate::approx_tokens_from_byte_count;
|
||||
use codex_utils_output_truncation::approx_bytes_for_tokens;
|
||||
use codex_utils_output_truncation::approx_token_count;
|
||||
use codex_utils_output_truncation::approx_tokens_from_byte_count;
|
||||
|
||||
use super::GUARDIAN_MAX_MESSAGE_ENTRY_TOKENS;
|
||||
use super::GUARDIAN_MAX_MESSAGE_TRANSCRIPT_TOKENS;
|
||||
|
||||
@@ -140,7 +140,7 @@ impl GuardianReviewSessionReuseKey {
|
||||
base_instructions: spawn_config.base_instructions.clone(),
|
||||
user_instructions: spawn_config.user_instructions.clone(),
|
||||
compact_prompt: spawn_config.compact_prompt.clone(),
|
||||
cwd: spawn_config.cwd.clone(),
|
||||
cwd: spawn_config.cwd.to_path_buf(),
|
||||
mcp_servers: spawn_config.mcp_servers.clone(),
|
||||
codex_linux_sandbox_exe: spawn_config.codex_linux_sandbox_exe.clone(),
|
||||
main_execve_wrapper_exe: spawn_config.main_execve_wrapper_exe.clone(),
|
||||
@@ -512,7 +512,7 @@ async fn run_review_on_session(
|
||||
.codex
|
||||
.submit(Op::UserTurn {
|
||||
items: params.prompt_items.clone(),
|
||||
cwd: params.parent_turn.cwd.clone(),
|
||||
cwd: params.parent_turn.cwd.to_path_buf(),
|
||||
approval_policy: AskForApproval::Never,
|
||||
approvals_reviewer: None,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
|
||||
@@ -25,7 +25,8 @@ use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::GuardianAssessmentStatus;
|
||||
use codex_protocol::protocol::GuardianRiskLevel;
|
||||
use codex_protocol::protocol::ReviewDecision;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use core_test_support::PathBufExt;
|
||||
use core_test_support::TempDirExt;
|
||||
use core_test_support::context_snapshot;
|
||||
use core_test_support::context_snapshot::ContextSnapshotOptions;
|
||||
use core_test_support::responses::ev_assistant_message;
|
||||
@@ -322,7 +323,7 @@ fn guardian_assessment_action_value_redacts_apply_patch_patch_text() {
|
||||
("/tmp", "/tmp/guardian.txt")
|
||||
};
|
||||
let cwd = PathBuf::from(cwd);
|
||||
let file = AbsolutePathBuf::try_from(file).expect("absolute path");
|
||||
let file = PathBuf::from(file).abs();
|
||||
let action = GuardianApprovalRequest::ApplyPatch {
|
||||
id: "patch-1".to_string(),
|
||||
cwd: cwd.clone(),
|
||||
@@ -356,7 +357,7 @@ fn guardian_request_turn_id_prefers_network_access_owner_turn() {
|
||||
let apply_patch = GuardianApprovalRequest::ApplyPatch {
|
||||
id: "patch-1".to_string(),
|
||||
cwd: PathBuf::from("/tmp"),
|
||||
files: vec![AbsolutePathBuf::try_from("/tmp/guardian.txt").expect("absolute path")],
|
||||
files: vec![PathBuf::from("/tmp/guardian.txt").abs()],
|
||||
change_count: 1usize,
|
||||
patch: "*** Begin Patch\n*** Update File: guardian.txt\n@@\n+hello\n*** End Patch"
|
||||
.to_string(),
|
||||
@@ -384,7 +385,7 @@ async fn cancelled_guardian_review_emits_terminal_abort_without_warning() {
|
||||
GuardianApprovalRequest::ApplyPatch {
|
||||
id: "patch-1".to_string(),
|
||||
cwd: PathBuf::from("/tmp"),
|
||||
files: vec![AbsolutePathBuf::try_from("/tmp/guardian.txt").expect("absolute path")],
|
||||
files: vec![PathBuf::from("/tmp/guardian.txt").abs()],
|
||||
change_count: 1usize,
|
||||
patch: "*** Begin Patch\n*** Update File: guardian.txt\n@@\n+hello\n*** End Patch"
|
||||
.to_string(),
|
||||
@@ -512,7 +513,7 @@ async fn guardian_review_request_layout_matches_model_visible_request_snapshot()
|
||||
let (mut session, mut turn) = crate::codex::make_session_and_context().await;
|
||||
let temp_cwd = TempDir::new()?;
|
||||
let mut config = (*turn.config).clone();
|
||||
config.cwd = temp_cwd.path().to_path_buf();
|
||||
config.cwd = temp_cwd.abs();
|
||||
config.model_provider.base_url = Some(format!("{}/v1", server.uri()));
|
||||
let config = Arc::new(config);
|
||||
let models_manager = Arc::new(test_support::models_manager_with_provider(
|
||||
|
||||
@@ -92,7 +92,7 @@ pub(crate) async fn run_pending_session_start_hooks(
|
||||
|
||||
let request = codex_hooks::SessionStartRequest {
|
||||
session_id: sess.conversation_id,
|
||||
cwd: turn_context.cwd.clone(),
|
||||
cwd: turn_context.cwd.to_path_buf(),
|
||||
transcript_path: sess.hook_transcript_path().await,
|
||||
model: turn_context.model_info.slug.clone(),
|
||||
permission_mode: hook_permission_mode(turn_context),
|
||||
@@ -120,7 +120,7 @@ pub(crate) async fn run_pre_tool_use_hooks(
|
||||
let request = PreToolUseRequest {
|
||||
session_id: sess.conversation_id,
|
||||
turn_id: turn_context.sub_id.clone(),
|
||||
cwd: turn_context.cwd.clone(),
|
||||
cwd: turn_context.cwd.to_path_buf(),
|
||||
transcript_path: sess.hook_transcript_path().await,
|
||||
model: turn_context.model_info.slug.clone(),
|
||||
permission_mode: hook_permission_mode(turn_context),
|
||||
@@ -149,7 +149,7 @@ pub(crate) async fn run_user_prompt_submit_hooks(
|
||||
let request = UserPromptSubmitRequest {
|
||||
session_id: sess.conversation_id,
|
||||
turn_id: turn_context.sub_id.clone(),
|
||||
cwd: turn_context.cwd.clone(),
|
||||
cwd: turn_context.cwd.to_path_buf(),
|
||||
transcript_path: sess.hook_transcript_path().await,
|
||||
model: turn_context.model_info.slug.clone(),
|
||||
permission_mode: hook_permission_mode(turn_context),
|
||||
|
||||
@@ -1,5 +1,2 @@
|
||||
mod user_instructions;
|
||||
|
||||
pub(crate) use user_instructions::SkillInstructions;
|
||||
pub use user_instructions::USER_INSTRUCTIONS_PREFIX;
|
||||
pub(crate) use user_instructions::UserInstructions;
|
||||
pub use codex_instructions::USER_INSTRUCTIONS_PREFIX;
|
||||
pub(crate) use codex_instructions::UserInstructions;
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
|
||||
use codex_protocol::models::ResponseItem;
|
||||
|
||||
use crate::contextual_user_message::AGENTS_MD_FRAGMENT;
|
||||
use crate::contextual_user_message::SKILL_FRAGMENT;
|
||||
|
||||
pub const USER_INSTRUCTIONS_PREFIX: &str = "# AGENTS.md instructions for ";
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename = "user_instructions", rename_all = "snake_case")]
|
||||
pub(crate) struct UserInstructions {
|
||||
pub directory: String,
|
||||
pub text: String,
|
||||
}
|
||||
|
||||
impl UserInstructions {
|
||||
pub(crate) fn serialize_to_text(&self) -> String {
|
||||
format!(
|
||||
"{prefix}{directory}\n\n<INSTRUCTIONS>\n{contents}\n{suffix}",
|
||||
prefix = AGENTS_MD_FRAGMENT.start_marker(),
|
||||
directory = self.directory,
|
||||
contents = self.text,
|
||||
suffix = AGENTS_MD_FRAGMENT.end_marker(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<UserInstructions> for ResponseItem {
|
||||
fn from(ui: UserInstructions) -> Self {
|
||||
AGENTS_MD_FRAGMENT.into_message(ui.serialize_to_text())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
#[serde(rename = "skill_instructions", rename_all = "snake_case")]
|
||||
pub(crate) struct SkillInstructions {
|
||||
pub name: String,
|
||||
pub path: String,
|
||||
pub contents: String,
|
||||
}
|
||||
|
||||
impl SkillInstructions {}
|
||||
|
||||
impl From<SkillInstructions> for ResponseItem {
|
||||
fn from(si: SkillInstructions) -> Self {
|
||||
SKILL_FRAGMENT.into_message(SKILL_FRAGMENT.wrap(format!(
|
||||
"<name>{}</name>\n<path>{}</path>\n{}",
|
||||
si.name, si.path, si.contents
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "user_instructions_tests.rs"]
|
||||
mod tests;
|
||||
@@ -1,68 +0,0 @@
|
||||
use super::*;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn test_user_instructions() {
|
||||
let user_instructions = UserInstructions {
|
||||
directory: "test_directory".to_string(),
|
||||
text: "test_text".to_string(),
|
||||
};
|
||||
let response_item: ResponseItem = user_instructions.into();
|
||||
|
||||
let ResponseItem::Message { role, content, .. } = response_item else {
|
||||
panic!("expected ResponseItem::Message");
|
||||
};
|
||||
|
||||
assert_eq!(role, "user");
|
||||
|
||||
let [ContentItem::InputText { text }] = content.as_slice() else {
|
||||
panic!("expected one InputText content item");
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
text,
|
||||
"# AGENTS.md instructions for test_directory\n\n<INSTRUCTIONS>\ntest_text\n</INSTRUCTIONS>",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_user_instructions() {
|
||||
assert!(AGENTS_MD_FRAGMENT.matches_text(
|
||||
"# AGENTS.md instructions for test_directory\n\n<INSTRUCTIONS>\ntest_text\n</INSTRUCTIONS>"
|
||||
));
|
||||
assert!(!AGENTS_MD_FRAGMENT.matches_text("test_text"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_skill_instructions() {
|
||||
let skill_instructions = SkillInstructions {
|
||||
name: "demo-skill".to_string(),
|
||||
path: "skills/demo/SKILL.md".to_string(),
|
||||
contents: "body".to_string(),
|
||||
};
|
||||
let response_item: ResponseItem = skill_instructions.into();
|
||||
|
||||
let ResponseItem::Message { role, content, .. } = response_item else {
|
||||
panic!("expected ResponseItem::Message");
|
||||
};
|
||||
|
||||
assert_eq!(role, "user");
|
||||
|
||||
let [ContentItem::InputText { text }] = content.as_slice() else {
|
||||
panic!("expected one InputText content item");
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
text,
|
||||
"<skill>\n<name>demo-skill</name>\n<path>skills/demo/SKILL.md</path>\nbody\n</skill>",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_is_skill_instructions() {
|
||||
assert!(SKILL_FRAGMENT.matches_text(
|
||||
"<skill>\n<name>demo-skill</name>\n<path>skills/demo/SKILL.md</path>\nbody\n</skill>"
|
||||
));
|
||||
assert!(!SKILL_FRAGMENT.matches_text("regular text"));
|
||||
}
|
||||
@@ -5,7 +5,6 @@
|
||||
// the TUI or the tracing stack).
|
||||
#![deny(clippy::print_stdout, clippy::print_stderr)]
|
||||
|
||||
mod analytics_client;
|
||||
pub mod api_bridge;
|
||||
mod apply_patch;
|
||||
mod apps;
|
||||
@@ -32,7 +31,7 @@ pub mod connectors;
|
||||
mod context_manager;
|
||||
mod contextual_user_message;
|
||||
pub mod custom_prompts;
|
||||
pub mod env;
|
||||
pub use codex_utils_path::env;
|
||||
mod environment_context;
|
||||
pub mod error;
|
||||
pub mod exec;
|
||||
@@ -62,23 +61,49 @@ pub use text_encoding::bytes_to_string_smart;
|
||||
mod mcp_tool_call;
|
||||
mod memories;
|
||||
pub mod mention_syntax;
|
||||
mod mentions;
|
||||
pub mod message_history;
|
||||
mod model_provider_info;
|
||||
pub mod path_utils;
|
||||
pub mod utils;
|
||||
pub use utils::path_utils;
|
||||
pub mod personality_migration;
|
||||
pub mod plugins;
|
||||
pub(crate) mod mentions {
|
||||
pub(crate) use crate::plugins::build_connector_slug_counts;
|
||||
pub(crate) use crate::plugins::build_skill_name_counts;
|
||||
pub(crate) use crate::plugins::collect_explicit_app_ids;
|
||||
pub(crate) use crate::plugins::collect_explicit_plugin_mentions;
|
||||
pub(crate) use crate::plugins::collect_tool_mentions_from_messages;
|
||||
}
|
||||
mod sandbox_tags;
|
||||
pub mod sandboxing;
|
||||
mod session_prefix;
|
||||
mod session_startup_prewarm;
|
||||
mod shell_detect;
|
||||
pub mod skills;
|
||||
pub(crate) use skills::SkillError;
|
||||
pub(crate) use skills::SkillInjections;
|
||||
pub(crate) use skills::SkillLoadOutcome;
|
||||
pub(crate) use skills::SkillMetadata;
|
||||
pub(crate) use skills::SkillsLoadInput;
|
||||
pub(crate) use skills::SkillsManager;
|
||||
pub(crate) use skills::build_skill_injections;
|
||||
pub(crate) use skills::build_skill_name_counts;
|
||||
pub(crate) use skills::collect_env_var_dependencies;
|
||||
pub(crate) use skills::collect_explicit_skill_mentions;
|
||||
pub(crate) use skills::config_rules;
|
||||
pub(crate) use skills::injection;
|
||||
pub(crate) use skills::loader;
|
||||
pub(crate) use skills::manager;
|
||||
pub(crate) use skills::maybe_emit_implicit_skill_invocation;
|
||||
pub(crate) use skills::model;
|
||||
pub(crate) use skills::render_skills_section;
|
||||
pub(crate) use skills::resolve_skill_dependencies_for_turn;
|
||||
pub(crate) use skills::skills_load_input_from_config;
|
||||
mod skills_watcher;
|
||||
mod stream_events_utils;
|
||||
pub mod test_support;
|
||||
mod text_encoding;
|
||||
pub use codex_login::token_data;
|
||||
mod truncate;
|
||||
mod unified_exec;
|
||||
pub mod windows_sandbox;
|
||||
pub use client::X_RESPONSESAPI_INCLUDE_TIMING_METRICS_HEADER;
|
||||
@@ -108,9 +133,9 @@ pub type NewConversation = NewThread;
|
||||
#[deprecated(note = "use CodexThread")]
|
||||
pub type CodexConversation = CodexThread;
|
||||
// Re-export common auth types for workspace consumers
|
||||
pub use analytics_client::AnalyticsEventsClient;
|
||||
pub use auth::AuthManager;
|
||||
pub use auth::CodexAuth;
|
||||
pub use codex_analytics::AnalyticsEventsClient;
|
||||
mod default_client_forwarding;
|
||||
|
||||
/// Default Codex HTTP client headers and reqwest construction.
|
||||
@@ -124,11 +149,13 @@ pub mod project_doc;
|
||||
mod rollout;
|
||||
pub(crate) mod safety;
|
||||
pub mod seatbelt;
|
||||
mod session_rollout_init_error;
|
||||
pub mod shell;
|
||||
pub mod shell_snapshot;
|
||||
pub mod skills;
|
||||
pub mod spawn;
|
||||
pub mod state_db;
|
||||
pub mod state_db_bridge;
|
||||
pub use codex_rollout::state_db;
|
||||
mod thread_rollout_truncation;
|
||||
mod tools;
|
||||
pub mod turn_diff_tracker;
|
||||
mod turn_metadata;
|
||||
|
||||
@@ -15,6 +15,7 @@ use super::auth::McpOAuthLoginSupport;
|
||||
use super::auth::oauth_login_support;
|
||||
use super::auth::resolve_oauth_scopes;
|
||||
use super::auth::should_retry_without_scopes;
|
||||
use crate::SkillMetadata;
|
||||
use crate::codex::Session;
|
||||
use crate::codex::TurnContext;
|
||||
use crate::config::Config;
|
||||
@@ -24,8 +25,7 @@ use crate::config::types::McpServerConfig;
|
||||
use crate::config::types::McpServerTransportConfig;
|
||||
use crate::default_client::is_first_party_originator;
|
||||
use crate::default_client::originator;
|
||||
use crate::skills::SkillMetadata;
|
||||
use crate::skills::model::SkillToolDependency;
|
||||
use crate::model::SkillToolDependency;
|
||||
use codex_features::Feature;
|
||||
|
||||
const SKILL_MCP_DEPENDENCY_PROMPT_ID: &str = "skill_mcp_dependency_install";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use super::*;
|
||||
use crate::skills::model::SkillDependencies;
|
||||
use crate::model::SkillDependencies;
|
||||
use codex_protocol::protocol::SkillScope;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::path::PathBuf;
|
||||
|
||||
@@ -1440,7 +1440,12 @@ async fn make_rmcp_client(
|
||||
} => {
|
||||
let command_os: OsString = command.into();
|
||||
let args_os: Vec<OsString> = args.into_iter().map(Into::into).collect();
|
||||
RmcpClient::new_stdio_client(command_os, args_os, env, &env_vars, cwd)
|
||||
let env_os = env.map(|env| {
|
||||
env.into_iter()
|
||||
.map(|(key, value)| (key.into(), value.into()))
|
||||
.collect::<HashMap<_, _>>()
|
||||
});
|
||||
RmcpClient::new_stdio_client(command_os, args_os, env_os, &env_vars, cwd)
|
||||
.await
|
||||
.map_err(|err| StartupOutcomeError::from(anyhow!(err)))
|
||||
}
|
||||
|
||||
@@ -8,9 +8,6 @@ use codex_app_server_protocol::McpServerElicitationRequest;
|
||||
use codex_app_server_protocol::McpServerElicitationRequestParams;
|
||||
use tracing::error;
|
||||
|
||||
use crate::analytics_client::AppInvocation;
|
||||
use crate::analytics_client::InvocationType;
|
||||
use crate::analytics_client::build_track_events_context;
|
||||
use crate::arc_monitor::ArcMonitorOutcome;
|
||||
use crate::arc_monitor::monitor_action;
|
||||
use crate::codex::Session;
|
||||
@@ -32,6 +29,9 @@ use crate::protocol::McpInvocation;
|
||||
use crate::protocol::McpToolCallBeginEvent;
|
||||
use crate::protocol::McpToolCallEndEvent;
|
||||
use crate::state_db;
|
||||
use codex_analytics::AppInvocation;
|
||||
use codex_analytics::InvocationType;
|
||||
use codex_analytics::build_track_events_context;
|
||||
use codex_features::Feature;
|
||||
use codex_protocol::mcp::CallToolResult;
|
||||
use codex_protocol::openai_models::InputModality;
|
||||
@@ -495,7 +495,7 @@ async fn maybe_request_mcp_tool_approval(
|
||||
approval_mode: AppToolApproval,
|
||||
) -> Option<McpToolApprovalDecision> {
|
||||
let annotations = metadata.and_then(|metadata| metadata.annotations.as_ref());
|
||||
let approval_required = annotations.is_some_and(requires_mcp_tool_approval);
|
||||
let approval_required = requires_mcp_tool_approval(annotations);
|
||||
let mut monitor_reason = None;
|
||||
let auto_approved_by_policy = approval_mode == AppToolApproval::Approve
|
||||
|| (approval_mode == AppToolApproval::Auto && is_full_access_mode(turn_context));
|
||||
@@ -1299,12 +1299,23 @@ async fn persist_codex_app_tool_approval(
|
||||
.await
|
||||
}
|
||||
|
||||
fn requires_mcp_tool_approval(annotations: &ToolAnnotations) -> bool {
|
||||
if annotations.destructive_hint == Some(true) {
|
||||
fn requires_mcp_tool_approval(annotations: Option<&ToolAnnotations>) -> bool {
|
||||
let destructive_hint = annotations.and_then(|annotations| annotations.destructive_hint);
|
||||
if destructive_hint == Some(true) {
|
||||
return true;
|
||||
}
|
||||
|
||||
annotations.read_only_hint == Some(false) && annotations.open_world_hint == Some(true)
|
||||
let read_only_hint = annotations
|
||||
.and_then(|annotations| annotations.read_only_hint)
|
||||
.unwrap_or(false);
|
||||
if read_only_hint {
|
||||
return false;
|
||||
}
|
||||
|
||||
destructive_hint.unwrap_or(true)
|
||||
|| annotations
|
||||
.and_then(|annotations| annotations.open_world_hint)
|
||||
.unwrap_or(true)
|
||||
}
|
||||
|
||||
async fn notify_mcp_tool_call_skip(
|
||||
|
||||
@@ -64,19 +64,30 @@ fn prompt_options(
|
||||
#[test]
|
||||
fn approval_required_when_read_only_false_and_destructive() {
|
||||
let annotations = annotations(Some(false), Some(true), None);
|
||||
assert_eq!(requires_mcp_tool_approval(&annotations), true);
|
||||
assert_eq!(requires_mcp_tool_approval(Some(&annotations)), true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn approval_required_when_read_only_false_and_open_world() {
|
||||
let annotations = annotations(Some(false), None, Some(true));
|
||||
assert_eq!(requires_mcp_tool_approval(&annotations), true);
|
||||
assert_eq!(requires_mcp_tool_approval(Some(&annotations)), true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn approval_required_when_destructive_even_if_read_only_true() {
|
||||
let annotations = annotations(Some(true), Some(true), Some(true));
|
||||
assert_eq!(requires_mcp_tool_approval(&annotations), true);
|
||||
assert_eq!(requires_mcp_tool_approval(Some(&annotations)), true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn approval_required_when_annotations_are_absent() {
|
||||
assert_eq!(requires_mcp_tool_approval(None), true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn approval_not_required_when_read_only_and_other_hints_are_absent() {
|
||||
let annotations = annotations(Some(true), None, None);
|
||||
assert_eq!(requires_mcp_tool_approval(Some(&annotations)), false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1067,6 +1078,75 @@ async fn approve_mode_blocks_when_arc_returns_interrupt_for_model() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn approve_mode_blocks_when_arc_returns_interrupt_without_annotations() {
|
||||
use wiremock::Mock;
|
||||
use wiremock::MockServer;
|
||||
use wiremock::ResponseTemplate;
|
||||
use wiremock::matchers::method;
|
||||
use wiremock::matchers::path;
|
||||
|
||||
let server = MockServer::start().await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/codex/safety/arc"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"outcome": "steer-model",
|
||||
"short_reason": "needs approval",
|
||||
"rationale": "high-risk action",
|
||||
"risk_score": 96,
|
||||
"risk_level": "critical",
|
||||
"evidence": [{
|
||||
"message": "dangerous_tool",
|
||||
"why": "high-risk action",
|
||||
}],
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let (session, mut turn_context) = make_session_and_context().await;
|
||||
turn_context.auth_manager = Some(crate::test_support::auth_manager_from_auth(
|
||||
crate::CodexAuth::create_dummy_chatgpt_auth_for_testing(),
|
||||
));
|
||||
let mut config = (*turn_context.config).clone();
|
||||
config.chatgpt_base_url = server.uri();
|
||||
turn_context.config = Arc::new(config);
|
||||
|
||||
let session = Arc::new(session);
|
||||
let turn_context = Arc::new(turn_context);
|
||||
let invocation = McpInvocation {
|
||||
server: CODEX_APPS_MCP_SERVER_NAME.to_string(),
|
||||
tool: "dangerous_tool".to_string(),
|
||||
arguments: Some(serde_json::json!({ "id": 1 })),
|
||||
};
|
||||
let metadata = McpToolApprovalMetadata {
|
||||
annotations: None,
|
||||
connector_id: Some("calendar".to_string()),
|
||||
connector_name: Some("Calendar".to_string()),
|
||||
connector_description: Some("Manage events".to_string()),
|
||||
tool_title: Some("Dangerous Tool".to_string()),
|
||||
tool_description: Some("Performs a risky action.".to_string()),
|
||||
codex_apps_meta: None,
|
||||
};
|
||||
|
||||
let decision = maybe_request_mcp_tool_approval(
|
||||
&session,
|
||||
&turn_context,
|
||||
"call-3",
|
||||
&invocation,
|
||||
Some(&metadata),
|
||||
AppToolApproval::Approve,
|
||||
)
|
||||
.await;
|
||||
|
||||
assert_eq!(
|
||||
decision,
|
||||
Some(McpToolApprovalDecision::BlockedBySafetyMonitor(
|
||||
"Tool call was cancelled because of safety risks: high-risk action".to_string(),
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn full_access_auto_mode_blocks_when_arc_returns_interrupt_for_model() {
|
||||
use wiremock::Mock;
|
||||
|
||||
@@ -266,7 +266,16 @@ mod agent {
|
||||
let root = memory_root(&config.codex_home);
|
||||
let mut agent_config = config.as_ref().clone();
|
||||
|
||||
agent_config.cwd = root;
|
||||
match AbsolutePathBuf::from_absolute_path(root) {
|
||||
Ok(root) => agent_config.cwd = root,
|
||||
Err(err) => {
|
||||
warn!(
|
||||
"memory phase-2 consolidation could not set cwd from codex_home {}: {err}",
|
||||
agent_config.codex_home.display()
|
||||
);
|
||||
return None;
|
||||
}
|
||||
}
|
||||
// Consolidation threads must never feed back into phase-1 memory generation.
|
||||
agent_config.memories.generate_memories = false;
|
||||
// Approval policy
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
use crate::memories::memory_root;
|
||||
use crate::memories::phase_one;
|
||||
use crate::memories::storage::rollout_summary_file_stem_from_parts;
|
||||
use crate::truncate::TruncationPolicy;
|
||||
use crate::truncate::truncate_text;
|
||||
use askama::Template;
|
||||
use codex_protocol::openai_models::ModelInfo;
|
||||
use codex_state::Phase2InputSelection;
|
||||
use codex_state::Stage1Output;
|
||||
use codex_state::Stage1OutputRef;
|
||||
use codex_utils_output_truncation::TruncationPolicy;
|
||||
use codex_utils_output_truncation::truncate_text;
|
||||
use std::path::Path;
|
||||
use tokio::fs;
|
||||
use tracing::warn;
|
||||
|
||||
@@ -435,6 +435,7 @@ mod phase2 {
|
||||
use codex_state::Phase2JobClaimOutcome;
|
||||
use codex_state::Stage1Output;
|
||||
use codex_state::ThreadMetadataBuilder;
|
||||
use core_test_support::PathBufExt;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
@@ -469,7 +470,7 @@ mod phase2 {
|
||||
let codex_home = tempfile::tempdir().expect("create temp codex home");
|
||||
let mut config = test_config();
|
||||
config.codex_home = codex_home.path().to_path_buf();
|
||||
config.cwd = config.codex_home.clone();
|
||||
config.cwd = config.codex_home.abs();
|
||||
let config = Arc::new(config);
|
||||
|
||||
let state_db = codex_state::StateRuntime::init(
|
||||
@@ -507,7 +508,7 @@ mod phase2 {
|
||||
Utc::now(),
|
||||
SessionSource::Cli,
|
||||
);
|
||||
metadata_builder.cwd = self.config.cwd.clone();
|
||||
metadata_builder.cwd = self.config.cwd.to_path_buf();
|
||||
metadata_builder.model_provider = Some(self.config.model_provider_id.clone());
|
||||
let metadata = metadata_builder.build(&self.config.model_provider_id);
|
||||
|
||||
@@ -882,7 +883,7 @@ mod phase2 {
|
||||
let codex_home = tempfile::tempdir().expect("create temp codex home");
|
||||
let mut config = test_config();
|
||||
config.codex_home = codex_home.path().to_path_buf();
|
||||
config.cwd = config.codex_home.clone();
|
||||
config.cwd = config.codex_home.abs();
|
||||
let config = Arc::new(config);
|
||||
|
||||
let state_db = codex_state::StateRuntime::init(
|
||||
@@ -904,7 +905,7 @@ mod phase2 {
|
||||
Utc::now(),
|
||||
SessionSource::Cli,
|
||||
);
|
||||
metadata_builder.cwd = config.cwd.clone();
|
||||
metadata_builder.cwd = config.cwd.to_path_buf();
|
||||
metadata_builder.model_provider = Some(config.model_provider_id.clone());
|
||||
let metadata = metadata_builder.build(&config.model_provider_id);
|
||||
state_db
|
||||
|
||||
@@ -1,4 +1,2 @@
|
||||
// Default plaintext sigil for tools.
|
||||
pub const TOOL_MENTION_SIGIL: char = '$';
|
||||
// Plugins use `@` in linked plaintext outside TUI.
|
||||
pub const PLUGIN_TEXT_MENTION_SIGIL: char = '@';
|
||||
pub use codex_utils_plugins::mention_syntax::PLUGIN_TEXT_MENTION_SIGIL;
|
||||
pub use codex_utils_plugins::mention_syntax::TOOL_MENTION_SIGIL;
|
||||
|
||||
@@ -10,8 +10,8 @@ use codex_protocol::openai_models::WebSearchToolType;
|
||||
use codex_protocol::openai_models::default_input_modalities;
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::truncate::approx_bytes_for_tokens;
|
||||
use codex_features::Feature;
|
||||
use codex_utils_output_truncation::approx_bytes_for_tokens;
|
||||
use tracing::warn;
|
||||
|
||||
pub const BASE_INSTRUCTIONS: &str = include_str!("../../prompt.md");
|
||||
|
||||
@@ -1,203 +0,0 @@
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use std::collections::HashSet;
|
||||
use std::io;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use tempfile::NamedTempFile;
|
||||
|
||||
use crate::env;
|
||||
|
||||
pub fn normalize_for_path_comparison(path: impl AsRef<Path>) -> std::io::Result<PathBuf> {
|
||||
let canonical = path.as_ref().canonicalize()?;
|
||||
Ok(normalize_for_wsl(canonical))
|
||||
}
|
||||
|
||||
pub fn normalize_for_native_workdir(path: impl AsRef<Path>) -> PathBuf {
|
||||
normalize_for_native_workdir_with_flag(path.as_ref().to_path_buf(), cfg!(windows))
|
||||
}
|
||||
|
||||
pub struct SymlinkWritePaths {
|
||||
pub read_path: Option<PathBuf>,
|
||||
pub write_path: PathBuf,
|
||||
}
|
||||
|
||||
/// Resolve the final filesystem target for `path` while retaining a safe write path.
|
||||
///
|
||||
/// This follows symlink chains (including relative symlink targets) until it reaches a
|
||||
/// non-symlink path. If the chain cycles or any metadata/link resolution fails, it
|
||||
/// returns `read_path: None` and uses the original absolute path as `write_path`.
|
||||
/// There is no fixed max-resolution count; cycles are detected via a visited set.
|
||||
pub fn resolve_symlink_write_paths(path: &Path) -> io::Result<SymlinkWritePaths> {
|
||||
let root = AbsolutePathBuf::from_absolute_path(path)
|
||||
.map(AbsolutePathBuf::into_path_buf)
|
||||
.unwrap_or_else(|_| path.to_path_buf());
|
||||
let mut current = root.clone();
|
||||
let mut visited = HashSet::new();
|
||||
|
||||
// Follow symlink chains while guarding against cycles.
|
||||
loop {
|
||||
let meta = match std::fs::symlink_metadata(¤t) {
|
||||
Ok(meta) => meta,
|
||||
Err(err) if err.kind() == io::ErrorKind::NotFound => {
|
||||
return Ok(SymlinkWritePaths {
|
||||
read_path: Some(current.clone()),
|
||||
write_path: current,
|
||||
});
|
||||
}
|
||||
Err(_) => {
|
||||
return Ok(SymlinkWritePaths {
|
||||
read_path: None,
|
||||
write_path: root,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
if !meta.file_type().is_symlink() {
|
||||
return Ok(SymlinkWritePaths {
|
||||
read_path: Some(current.clone()),
|
||||
write_path: current,
|
||||
});
|
||||
}
|
||||
|
||||
// If we've already seen this path, the chain cycles.
|
||||
if !visited.insert(current.clone()) {
|
||||
return Ok(SymlinkWritePaths {
|
||||
read_path: None,
|
||||
write_path: root,
|
||||
});
|
||||
}
|
||||
|
||||
let target = match std::fs::read_link(¤t) {
|
||||
Ok(target) => target,
|
||||
Err(_) => {
|
||||
return Ok(SymlinkWritePaths {
|
||||
read_path: None,
|
||||
write_path: root,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
let next = if target.is_absolute() {
|
||||
AbsolutePathBuf::from_absolute_path(&target)
|
||||
} else if let Some(parent) = current.parent() {
|
||||
AbsolutePathBuf::resolve_path_against_base(&target, parent)
|
||||
} else {
|
||||
return Ok(SymlinkWritePaths {
|
||||
read_path: None,
|
||||
write_path: root,
|
||||
});
|
||||
};
|
||||
|
||||
let next = match next {
|
||||
Ok(path) => path.into_path_buf(),
|
||||
Err(_) => {
|
||||
return Ok(SymlinkWritePaths {
|
||||
read_path: None,
|
||||
write_path: root,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
current = next;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn write_atomically(write_path: &Path, contents: &str) -> io::Result<()> {
|
||||
let parent = write_path.parent().ok_or_else(|| {
|
||||
io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
format!("path {} has no parent directory", write_path.display()),
|
||||
)
|
||||
})?;
|
||||
std::fs::create_dir_all(parent)?;
|
||||
let tmp = NamedTempFile::new_in(parent)?;
|
||||
std::fs::write(tmp.path(), contents)?;
|
||||
tmp.persist(write_path)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn normalize_for_wsl(path: PathBuf) -> PathBuf {
|
||||
normalize_for_wsl_with_flag(path, env::is_wsl())
|
||||
}
|
||||
|
||||
fn normalize_for_native_workdir_with_flag(path: PathBuf, is_windows: bool) -> PathBuf {
|
||||
if is_windows {
|
||||
dunce::simplified(&path).to_path_buf()
|
||||
} else {
|
||||
path
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_for_wsl_with_flag(path: PathBuf, is_wsl: bool) -> PathBuf {
|
||||
if !is_wsl {
|
||||
return path;
|
||||
}
|
||||
|
||||
if !is_wsl_case_insensitive_path(&path) {
|
||||
return path;
|
||||
}
|
||||
|
||||
lower_ascii_path(path)
|
||||
}
|
||||
|
||||
fn is_wsl_case_insensitive_path(path: &Path) -> bool {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
use std::path::Component;
|
||||
|
||||
let mut components = path.components();
|
||||
let Some(Component::RootDir) = components.next() else {
|
||||
return false;
|
||||
};
|
||||
let Some(Component::Normal(mnt)) = components.next() else {
|
||||
return false;
|
||||
};
|
||||
if !ascii_eq_ignore_case(mnt.as_bytes(), b"mnt") {
|
||||
return false;
|
||||
}
|
||||
let Some(Component::Normal(drive)) = components.next() else {
|
||||
return false;
|
||||
};
|
||||
let drive_bytes = drive.as_bytes();
|
||||
drive_bytes.len() == 1 && drive_bytes[0].is_ascii_alphabetic()
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
let _ = path;
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn ascii_eq_ignore_case(left: &[u8], right: &[u8]) -> bool {
|
||||
left.len() == right.len()
|
||||
&& left
|
||||
.iter()
|
||||
.zip(right)
|
||||
.all(|(lhs, rhs)| lhs.to_ascii_lowercase() == *rhs)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn lower_ascii_path(path: PathBuf) -> PathBuf {
|
||||
use std::ffi::OsString;
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
use std::os::unix::ffi::OsStringExt;
|
||||
|
||||
// WSL mounts Windows drives under /mnt/<drive>, which are case-insensitive.
|
||||
let bytes = path.as_os_str().as_bytes();
|
||||
let mut lowered = Vec::with_capacity(bytes.len());
|
||||
for byte in bytes {
|
||||
lowered.push(byte.to_ascii_lowercase());
|
||||
}
|
||||
PathBuf::from(OsString::from_vec(lowered))
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
fn lower_ascii_path(path: PathBuf) -> PathBuf {
|
||||
path
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "path_utils_tests.rs"]
|
||||
mod tests;
|
||||
@@ -1,78 +0,0 @@
|
||||
#[cfg(unix)]
|
||||
mod symlinks {
|
||||
use super::super::resolve_symlink_write_paths;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::os::unix::fs::symlink;
|
||||
|
||||
#[test]
|
||||
fn symlink_cycles_fall_back_to_root_write_path() -> std::io::Result<()> {
|
||||
let dir = tempfile::tempdir()?;
|
||||
let a = dir.path().join("a");
|
||||
let b = dir.path().join("b");
|
||||
|
||||
symlink(&b, &a)?;
|
||||
symlink(&a, &b)?;
|
||||
|
||||
let resolved = resolve_symlink_write_paths(&a)?;
|
||||
|
||||
assert_eq!(resolved.read_path, None);
|
||||
assert_eq!(resolved.write_path, a);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
mod wsl {
|
||||
use super::super::normalize_for_wsl_with_flag;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[test]
|
||||
fn wsl_mnt_drive_paths_lowercase() {
|
||||
let normalized = normalize_for_wsl_with_flag(PathBuf::from("/mnt/C/Users/Dev"), true);
|
||||
|
||||
assert_eq!(normalized, PathBuf::from("/mnt/c/users/dev"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wsl_non_drive_paths_unchanged() {
|
||||
let path = PathBuf::from("/mnt/cc/Users/Dev");
|
||||
let normalized = normalize_for_wsl_with_flag(path.clone(), true);
|
||||
|
||||
assert_eq!(normalized, path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wsl_non_mnt_paths_unchanged() {
|
||||
let path = PathBuf::from("/home/Dev");
|
||||
let normalized = normalize_for_wsl_with_flag(path.clone(), true);
|
||||
|
||||
assert_eq!(normalized, path);
|
||||
}
|
||||
}
|
||||
|
||||
mod native_workdir {
|
||||
use super::super::normalize_for_native_workdir_with_flag;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
#[test]
|
||||
fn windows_verbatim_paths_are_simplified() {
|
||||
let path = PathBuf::from(r"\\?\D:\c\x\worktrees\2508\swift-base");
|
||||
let normalized = normalize_for_native_workdir_with_flag(path, true);
|
||||
|
||||
assert_eq!(
|
||||
normalized,
|
||||
PathBuf::from(r"D:\c\x\worktrees\2508\swift-base")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_windows_paths_are_unchanged() {
|
||||
let path = PathBuf::from(r"\\?\D:\c\x\worktrees\2508\swift-base");
|
||||
let normalized = normalize_for_native_workdir_with_flag(path.clone(), false);
|
||||
|
||||
assert_eq!(normalized, path);
|
||||
}
|
||||
}
|
||||
@@ -44,6 +44,12 @@ async fn list_tool_suggest_discoverable_plugins_returns_empty_when_plugins_featu
|
||||
let codex_home = tempdir().expect("tempdir should succeed");
|
||||
let curated_root = crate::plugins::curated_plugins_repo_path(codex_home.path());
|
||||
write_openai_curated_marketplace(&curated_root, &["slack"]);
|
||||
write_file(
|
||||
&codex_home.path().join(crate::config::CONFIG_TOML_FILE),
|
||||
r#"[features]
|
||||
plugins = false
|
||||
"#,
|
||||
);
|
||||
|
||||
let config = load_plugins_config(codex_home.path()).await;
|
||||
let discoverable_plugins = list_tool_suggest_discoverable_plugins(&config)
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use super::LoadedPlugin;
|
||||
use super::PluginLoadOutcome;
|
||||
use super::PluginManifestPaths;
|
||||
use super::curated_plugins_repo_path;
|
||||
use super::load_plugin_manifest;
|
||||
@@ -20,14 +22,12 @@ use super::remote::fetch_remote_featured_plugin_ids;
|
||||
use super::remote::fetch_remote_plugin_status;
|
||||
use super::remote::uninstall_remote_plugin;
|
||||
use super::startup_sync::start_startup_remote_plugin_sync_once;
|
||||
use super::store::PluginId;
|
||||
use super::store::PluginIdError;
|
||||
use super::store::PluginInstallResult as StorePluginInstallResult;
|
||||
use super::store::PluginStore;
|
||||
use super::store::PluginStoreError;
|
||||
use super::sync_openai_plugins_repo;
|
||||
use crate::AuthManager;
|
||||
use crate::analytics_client::AnalyticsEventsClient;
|
||||
use crate::SkillMetadata;
|
||||
use crate::auth::CodexAuth;
|
||||
use crate::config::Config;
|
||||
use crate::config::ConfigService;
|
||||
@@ -37,15 +37,21 @@ use crate::config::edit::ConfigEditsBuilder;
|
||||
use crate::config::types::McpServerConfig;
|
||||
use crate::config::types::PluginConfig;
|
||||
use crate::config_loader::ConfigLayerStack;
|
||||
use crate::skills::SkillMetadata;
|
||||
use crate::skills::config_rules::SkillConfigRules;
|
||||
use crate::skills::config_rules::resolve_disabled_skill_paths;
|
||||
use crate::skills::config_rules::skill_config_rules_from_stack;
|
||||
use crate::skills::loader::SkillRoot;
|
||||
use crate::skills::loader::load_skills_from_roots;
|
||||
use crate::config_rules::SkillConfigRules;
|
||||
use crate::config_rules::resolve_disabled_skill_paths;
|
||||
use crate::config_rules::skill_config_rules_from_stack;
|
||||
use crate::loader::SkillRoot;
|
||||
use crate::loader::load_skills_from_roots;
|
||||
use codex_analytics::AnalyticsEventsClient;
|
||||
use codex_app_server_protocol::ConfigValueWriteParams;
|
||||
use codex_app_server_protocol::MergeStrategy;
|
||||
use codex_features::Feature;
|
||||
use codex_plugin::AppConnectorId;
|
||||
use codex_plugin::PluginCapabilitySummary;
|
||||
use codex_plugin::PluginId;
|
||||
use codex_plugin::PluginIdError;
|
||||
use codex_plugin::PluginTelemetryMetadata;
|
||||
use codex_plugin::prompt_safe_plugin_description;
|
||||
use codex_protocol::protocol::Product;
|
||||
use codex_protocol::protocol::SkillScope;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
@@ -73,7 +79,6 @@ const DEFAULT_MCP_CONFIG_FILE: &str = ".mcp.json";
|
||||
const DEFAULT_APP_CONFIG_FILE: &str = ".app.json";
|
||||
pub const OPENAI_CURATED_MARKETPLACE_NAME: &str = "openai-curated";
|
||||
static CURATED_REPO_SYNC_STARTED: AtomicBool = AtomicBool::new(false);
|
||||
const MAX_CAPABILITY_SUMMARY_DESCRIPTION_LEN: usize = 1024;
|
||||
const FEATURED_PLUGIN_IDS_CACHE_TTL: std::time::Duration =
|
||||
std::time::Duration::from_secs(60 * 60 * 3);
|
||||
|
||||
@@ -114,9 +119,6 @@ fn featured_plugin_ids_cache_key(
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub struct AppConnectorId(pub String);
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PluginInstallRequest {
|
||||
pub plugin_name: String,
|
||||
@@ -185,89 +187,6 @@ pub struct ConfiguredMarketplaceListOutcome {
|
||||
pub errors: Vec<MarketplaceListError>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct LoadedPlugin {
|
||||
pub config_name: String,
|
||||
pub manifest_name: Option<String>,
|
||||
pub manifest_description: Option<String>,
|
||||
pub root: AbsolutePathBuf,
|
||||
pub enabled: bool,
|
||||
pub skill_roots: Vec<PathBuf>,
|
||||
pub disabled_skill_paths: HashSet<PathBuf>,
|
||||
pub has_enabled_skills: bool,
|
||||
pub mcp_servers: HashMap<String, McpServerConfig>,
|
||||
pub apps: Vec<AppConnectorId>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
impl LoadedPlugin {
|
||||
fn is_active(&self) -> bool {
|
||||
self.enabled && self.error.is_none()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct PluginCapabilitySummary {
|
||||
pub config_name: String,
|
||||
pub display_name: String,
|
||||
pub description: Option<String>,
|
||||
pub has_skills: bool,
|
||||
pub mcp_server_names: Vec<String>,
|
||||
pub app_connector_ids: Vec<AppConnectorId>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PluginTelemetryMetadata {
|
||||
pub plugin_id: PluginId,
|
||||
pub capability_summary: Option<PluginCapabilitySummary>,
|
||||
}
|
||||
|
||||
impl PluginTelemetryMetadata {
|
||||
pub fn from_plugin_id(plugin_id: &PluginId) -> Self {
|
||||
Self {
|
||||
plugin_id: plugin_id.clone(),
|
||||
capability_summary: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl PluginCapabilitySummary {
|
||||
fn from_plugin(plugin: &LoadedPlugin) -> Option<Self> {
|
||||
if !plugin.is_active() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut mcp_server_names: Vec<String> = plugin.mcp_servers.keys().cloned().collect();
|
||||
mcp_server_names.sort_unstable();
|
||||
|
||||
let summary = Self {
|
||||
config_name: plugin.config_name.clone(),
|
||||
display_name: plugin
|
||||
.manifest_name
|
||||
.clone()
|
||||
.unwrap_or_else(|| plugin.config_name.clone()),
|
||||
description: prompt_safe_plugin_description(plugin.manifest_description.as_deref()),
|
||||
has_skills: plugin.has_enabled_skills,
|
||||
mcp_server_names,
|
||||
app_connector_ids: plugin.apps.clone(),
|
||||
};
|
||||
|
||||
(summary.has_skills
|
||||
|| !summary.mcp_server_names.is_empty()
|
||||
|| !summary.app_connector_ids.is_empty())
|
||||
.then_some(summary)
|
||||
}
|
||||
|
||||
pub fn telemetry_metadata(&self) -> Option<PluginTelemetryMetadata> {
|
||||
PluginId::parse(&self.config_name)
|
||||
.ok()
|
||||
.map(|plugin_id| PluginTelemetryMetadata {
|
||||
plugin_id,
|
||||
capability_summary: Some(self.clone()),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl From<PluginDetail> for PluginCapabilitySummary {
|
||||
fn from(value: PluginDetail) -> Self {
|
||||
let has_skills = value.skills.iter().any(|skill| {
|
||||
@@ -286,95 +205,6 @@ impl From<PluginDetail> for PluginCapabilitySummary {
|
||||
}
|
||||
}
|
||||
|
||||
fn prompt_safe_plugin_description(description: Option<&str>) -> Option<String> {
|
||||
let description = description?
|
||||
.split_whitespace()
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
if description.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(
|
||||
description
|
||||
.chars()
|
||||
.take(MAX_CAPABILITY_SUMMARY_DESCRIPTION_LEN)
|
||||
.collect(),
|
||||
)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct PluginLoadOutcome {
|
||||
plugins: Vec<LoadedPlugin>,
|
||||
capability_summaries: Vec<PluginCapabilitySummary>,
|
||||
}
|
||||
|
||||
impl Default for PluginLoadOutcome {
|
||||
fn default() -> Self {
|
||||
Self::from_plugins(Vec::new())
|
||||
}
|
||||
}
|
||||
|
||||
impl PluginLoadOutcome {
|
||||
fn from_plugins(plugins: Vec<LoadedPlugin>) -> Self {
|
||||
let capability_summaries = plugins
|
||||
.iter()
|
||||
.filter_map(PluginCapabilitySummary::from_plugin)
|
||||
.collect::<Vec<_>>();
|
||||
Self {
|
||||
plugins,
|
||||
capability_summaries,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn effective_skill_roots(&self) -> Vec<PathBuf> {
|
||||
let mut skill_roots: Vec<PathBuf> = self
|
||||
.plugins
|
||||
.iter()
|
||||
.filter(|plugin| plugin.is_active())
|
||||
.flat_map(|plugin| plugin.skill_roots.iter().cloned())
|
||||
.collect();
|
||||
skill_roots.sort_unstable();
|
||||
skill_roots.dedup();
|
||||
skill_roots
|
||||
}
|
||||
|
||||
pub fn effective_mcp_servers(&self) -> HashMap<String, McpServerConfig> {
|
||||
let mut mcp_servers = HashMap::new();
|
||||
for plugin in self.plugins.iter().filter(|plugin| plugin.is_active()) {
|
||||
for (name, config) in &plugin.mcp_servers {
|
||||
mcp_servers
|
||||
.entry(name.clone())
|
||||
.or_insert_with(|| config.clone());
|
||||
}
|
||||
}
|
||||
mcp_servers
|
||||
}
|
||||
|
||||
pub fn effective_apps(&self) -> Vec<AppConnectorId> {
|
||||
let mut apps = Vec::new();
|
||||
let mut seen_connector_ids = std::collections::HashSet::new();
|
||||
|
||||
for plugin in self.plugins.iter().filter(|plugin| plugin.is_active()) {
|
||||
for connector_id in &plugin.apps {
|
||||
if seen_connector_ids.insert(connector_id.clone()) {
|
||||
apps.push(connector_id.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
apps
|
||||
}
|
||||
|
||||
pub fn capability_summaries(&self) -> &[PluginCapabilitySummary] {
|
||||
&self.capability_summaries
|
||||
}
|
||||
|
||||
pub fn plugins(&self) -> &[LoadedPlugin] {
|
||||
&self.plugins
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct RemotePluginSyncResult {
|
||||
/// Plugin ids newly installed into the local plugin cache.
|
||||
@@ -575,6 +405,19 @@ impl PluginsManager {
|
||||
*cached_enabled_outcome = None;
|
||||
}
|
||||
|
||||
/// Resolve plugin skill roots for a config layer stack without touching the plugins cache.
|
||||
pub fn effective_skill_roots_for_layer_stack(
|
||||
&self,
|
||||
config_layer_stack: &ConfigLayerStack,
|
||||
plugins_feature_enabled: bool,
|
||||
) -> Vec<PathBuf> {
|
||||
if !plugins_feature_enabled {
|
||||
return Vec::new();
|
||||
}
|
||||
load_plugins_from_layer_stack(config_layer_stack, &self.store, self.restriction_product)
|
||||
.effective_skill_roots()
|
||||
}
|
||||
|
||||
fn cached_enabled_outcome(&self) -> Option<PluginLoadOutcome> {
|
||||
match self.cached_enabled_outcome.read() {
|
||||
Ok(cache) => cache.clone(),
|
||||
@@ -1174,7 +1017,7 @@ impl PluginsManager {
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
configured_curated_plugin_ids.sort_unstable_by_key(super::store::PluginId::as_key);
|
||||
configured_curated_plugin_ids.sort_unstable_by_key(PluginId::as_key);
|
||||
self.start_curated_repo_sync(configured_curated_plugin_ids);
|
||||
start_startup_remote_plugin_sync_once(
|
||||
Arc::clone(self),
|
||||
@@ -1341,7 +1184,7 @@ impl PluginUninstallError {
|
||||
|
||||
fn log_plugin_load_errors(outcome: &PluginLoadOutcome) {
|
||||
for plugin in outcome
|
||||
.plugins
|
||||
.plugins()
|
||||
.iter()
|
||||
.filter(|plugin| plugin.error.is_some())
|
||||
{
|
||||
@@ -1413,16 +1256,6 @@ pub(crate) fn load_plugins_from_layer_stack(
|
||||
PluginLoadOutcome::from_plugins(plugins)
|
||||
}
|
||||
|
||||
pub(crate) fn plugin_namespace_for_skill_path(path: &Path) -> Option<String> {
|
||||
for ancestor in path.ancestors() {
|
||||
if let Some(manifest) = load_plugin_manifest(ancestor) {
|
||||
return Some(manifest.name);
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn refresh_curated_plugin_cache(
|
||||
codex_home: &Path,
|
||||
plugin_version: &str,
|
||||
|
||||
@@ -7,7 +7,9 @@ use crate::config_loader::ConfigLayerEntry;
|
||||
use crate::config_loader::ConfigLayerStack;
|
||||
use crate::config_loader::ConfigRequirements;
|
||||
use crate::config_loader::ConfigRequirementsToml;
|
||||
use crate::plugins::LoadedPlugin;
|
||||
use crate::plugins::MarketplacePluginInstallPolicy;
|
||||
use crate::plugins::PluginLoadOutcome;
|
||||
use crate::plugins::test_support::TEST_CURATED_PLUGIN_SHA;
|
||||
use crate::plugins::test_support::write_curated_plugin_sha_with as write_curated_plugin_sha;
|
||||
use crate::plugins::test_support::write_file;
|
||||
@@ -26,6 +28,8 @@ use wiremock::matchers::method;
|
||||
use wiremock::matchers::path;
|
||||
use wiremock::matchers::query_param;
|
||||
|
||||
const MAX_CAPABILITY_SUMMARY_DESCRIPTION_LEN: usize = 1024;
|
||||
|
||||
fn write_plugin(root: &Path, dir_name: &str, manifest_name: &str) {
|
||||
let plugin_root = root.join(dir_name);
|
||||
fs::create_dir_all(plugin_root.join(".codex-plugin")).unwrap();
|
||||
@@ -130,7 +134,7 @@ fn load_plugins_loads_default_skills_and_mcp_servers() {
|
||||
let outcome = load_plugins_from_config(&plugin_config_toml(true, true), codex_home.path());
|
||||
|
||||
assert_eq!(
|
||||
outcome.plugins,
|
||||
outcome.plugins(),
|
||||
vec![LoadedPlugin {
|
||||
config_name: "sample@test".to_string(),
|
||||
manifest_name: Some("sample".to_string()),
|
||||
@@ -220,10 +224,10 @@ enabled = true
|
||||
let skill_path = dunce::canonicalize(skill_path).expect("skill path should canonicalize");
|
||||
|
||||
assert_eq!(
|
||||
outcome.plugins[0].disabled_skill_paths,
|
||||
outcome.plugins()[0].disabled_skill_paths,
|
||||
HashSet::from([skill_path])
|
||||
);
|
||||
assert!(!outcome.plugins[0].has_enabled_skills);
|
||||
assert!(!outcome.plugins()[0].has_enabled_skills);
|
||||
assert!(outcome.capability_summaries().is_empty());
|
||||
}
|
||||
|
||||
@@ -256,8 +260,8 @@ enabled = true
|
||||
"#;
|
||||
let outcome = load_plugins_from_config(config_toml, codex_home.path());
|
||||
|
||||
assert!(outcome.plugins[0].disabled_skill_paths.is_empty());
|
||||
assert!(outcome.plugins[0].has_enabled_skills);
|
||||
assert!(outcome.plugins()[0].disabled_skill_paths.is_empty());
|
||||
assert!(outcome.plugins()[0].has_enabled_skills);
|
||||
assert_eq!(
|
||||
outcome.capability_summaries(),
|
||||
&[PluginCapabilitySummary {
|
||||
@@ -338,7 +342,7 @@ fn capability_summary_sanitizes_plugin_descriptions_to_one_line() {
|
||||
let outcome = load_plugins_from_config(&plugin_config_toml(true, true), codex_home.path());
|
||||
|
||||
assert_eq!(
|
||||
outcome.plugins[0].manifest_description.as_deref(),
|
||||
outcome.plugins()[0].manifest_description.as_deref(),
|
||||
Some("Plugin that\n includes the sample\tserver")
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -373,7 +377,7 @@ fn capability_summary_truncates_overlong_plugin_descriptions() {
|
||||
let outcome = load_plugins_from_config(&plugin_config_toml(true, true), codex_home.path());
|
||||
|
||||
assert_eq!(
|
||||
outcome.plugins[0].manifest_description.as_deref(),
|
||||
outcome.plugins()[0].manifest_description.as_deref(),
|
||||
Some(too_long.as_str())
|
||||
);
|
||||
assert_eq!(
|
||||
@@ -453,14 +457,14 @@ fn load_plugins_uses_manifest_configured_component_paths() {
|
||||
let outcome = load_plugins_from_config(&plugin_config_toml(true, true), codex_home.path());
|
||||
|
||||
assert_eq!(
|
||||
outcome.plugins[0].skill_roots,
|
||||
outcome.plugins()[0].skill_roots,
|
||||
vec![
|
||||
plugin_root.join("custom-skills"),
|
||||
plugin_root.join("skills")
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
outcome.plugins[0].mcp_servers,
|
||||
outcome.plugins()[0].mcp_servers,
|
||||
HashMap::from([(
|
||||
"custom".to_string(),
|
||||
McpServerConfig {
|
||||
@@ -483,7 +487,7 @@ fn load_plugins_uses_manifest_configured_component_paths() {
|
||||
)])
|
||||
);
|
||||
assert_eq!(
|
||||
outcome.plugins[0].apps,
|
||||
outcome.plugins()[0].apps,
|
||||
vec![AppConnectorId("connector_custom".to_string())]
|
||||
);
|
||||
}
|
||||
@@ -559,11 +563,11 @@ fn load_plugins_ignores_manifest_component_paths_without_dot_slash() {
|
||||
let outcome = load_plugins_from_config(&plugin_config_toml(true, true), codex_home.path());
|
||||
|
||||
assert_eq!(
|
||||
outcome.plugins[0].skill_roots,
|
||||
outcome.plugins()[0].skill_roots,
|
||||
vec![plugin_root.join("skills")]
|
||||
);
|
||||
assert_eq!(
|
||||
outcome.plugins[0].mcp_servers,
|
||||
outcome.plugins()[0].mcp_servers,
|
||||
HashMap::from([(
|
||||
"default".to_string(),
|
||||
McpServerConfig {
|
||||
@@ -586,7 +590,7 @@ fn load_plugins_ignores_manifest_component_paths_without_dot_slash() {
|
||||
)])
|
||||
);
|
||||
assert_eq!(
|
||||
outcome.plugins[0].apps,
|
||||
outcome.plugins()[0].apps,
|
||||
vec![AppConnectorId("connector_default".to_string())]
|
||||
);
|
||||
}
|
||||
@@ -618,7 +622,7 @@ fn load_plugins_preserves_disabled_plugins_without_effective_contributions() {
|
||||
let outcome = load_plugins_from_config(&plugin_config_toml(false, true), codex_home.path());
|
||||
|
||||
assert_eq!(
|
||||
outcome.plugins,
|
||||
outcome.plugins(),
|
||||
vec![LoadedPlugin {
|
||||
config_name: "sample@test".to_string(),
|
||||
manifest_name: None,
|
||||
@@ -805,24 +809,6 @@ fn capability_index_filters_inactive_and_zero_capability_plugins() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_namespace_for_skill_path_uses_manifest_name() {
|
||||
let codex_home = TempDir::new().unwrap();
|
||||
let plugin_root = codex_home.path().join("plugins/sample");
|
||||
let skill_path = plugin_root.join("skills/search/SKILL.md");
|
||||
|
||||
write_file(
|
||||
&plugin_root.join(".codex-plugin/plugin.json"),
|
||||
r#"{"name":"sample"}"#,
|
||||
);
|
||||
write_file(&skill_path, "---\ndescription: search\n---\n");
|
||||
|
||||
assert_eq!(
|
||||
plugin_namespace_for_skill_path(&skill_path),
|
||||
Some("sample".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_plugins_returns_empty_when_feature_disabled() {
|
||||
let codex_home = TempDir::new().unwrap();
|
||||
@@ -880,9 +866,9 @@ fn load_plugins_rejects_invalid_plugin_keys() {
|
||||
codex_home.path(),
|
||||
);
|
||||
|
||||
assert_eq!(outcome.plugins.len(), 1);
|
||||
assert_eq!(outcome.plugins().len(), 1);
|
||||
assert_eq!(
|
||||
outcome.plugins[0].error.as_deref(),
|
||||
outcome.plugins()[0].error.as_deref(),
|
||||
Some("invalid plugin key `sample`; expected <plugin>@<marketplace>")
|
||||
);
|
||||
assert!(outcome.effective_skill_roots().is_empty());
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use codex_utils_plugins::PLUGIN_MANIFEST_PATH;
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value as JsonValue;
|
||||
use std::fs;
|
||||
use std::path::Component;
|
||||
use std::path::Path;
|
||||
|
||||
pub(crate) const PLUGIN_MANIFEST_PATH: &str = ".codex-plugin/plugin.json";
|
||||
const MAX_DEFAULT_PROMPT_COUNT: usize = 3;
|
||||
const MAX_DEFAULT_PROMPT_LEN: usize = 128;
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
use super::PluginManifestInterface;
|
||||
use super::load_plugin_manifest;
|
||||
use super::store::PluginId;
|
||||
use super::store::PluginIdError;
|
||||
use codex_app_server_protocol::PluginAuthPolicy;
|
||||
use codex_app_server_protocol::PluginInstallPolicy;
|
||||
use codex_git_utils::get_git_repo_root;
|
||||
use codex_plugin::PluginId;
|
||||
use codex_plugin::PluginIdError;
|
||||
use codex_protocol::protocol::Product;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use dirs::home_dir;
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use codex_protocol::user_input::UserInput;
|
||||
|
||||
use crate::connectors;
|
||||
use crate::injection::ToolMentionKind;
|
||||
use crate::injection::app_id_from_path;
|
||||
use crate::injection::extract_tool_mentions_with_sigil;
|
||||
use crate::injection::plugin_config_name_from_path;
|
||||
use crate::injection::tool_kind_for_path;
|
||||
use crate::mention_syntax::PLUGIN_TEXT_MENTION_SIGIL;
|
||||
use crate::mention_syntax::TOOL_MENTION_SIGIL;
|
||||
use crate::plugins::PluginCapabilitySummary;
|
||||
use crate::skills::SkillMetadata;
|
||||
use crate::skills::injection::ToolMentionKind;
|
||||
use crate::skills::injection::app_id_from_path;
|
||||
use crate::skills::injection::extract_tool_mentions_with_sigil;
|
||||
use crate::skills::injection::plugin_config_name_from_path;
|
||||
use crate::skills::injection::tool_kind_for_path;
|
||||
|
||||
use super::PluginCapabilitySummary;
|
||||
|
||||
pub(crate) struct CollectedToolMentions {
|
||||
pub(crate) plain_names: HashSet<String>,
|
||||
@@ -102,23 +101,7 @@ pub(crate) fn collect_explicit_plugin_mentions(
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn build_skill_name_counts(
|
||||
skills: &[SkillMetadata],
|
||||
disabled_paths: &HashSet<PathBuf>,
|
||||
) -> (HashMap<String, usize>, HashMap<String, usize>) {
|
||||
let mut exact_counts: HashMap<String, usize> = HashMap::new();
|
||||
let mut lower_counts: HashMap<String, usize> = HashMap::new();
|
||||
for skill in skills {
|
||||
if disabled_paths.contains(&skill.path_to_skills_md) {
|
||||
continue;
|
||||
}
|
||||
*exact_counts.entry(skill.name.clone()).or_insert(0) += 1;
|
||||
*lower_counts
|
||||
.entry(skill.name.to_ascii_lowercase())
|
||||
.or_insert(0) += 1;
|
||||
}
|
||||
(exact_counts, lower_counts)
|
||||
}
|
||||
pub(crate) use crate::build_skill_name_counts;
|
||||
|
||||
pub(crate) fn build_connector_slug_counts(
|
||||
connectors: &[connectors::AppInfo],
|
||||
@@ -1,8 +1,11 @@
|
||||
use crate::config::types::McpServerConfig;
|
||||
|
||||
mod discoverable;
|
||||
mod injection;
|
||||
mod manager;
|
||||
mod manifest;
|
||||
mod marketplace;
|
||||
mod mentions;
|
||||
mod remote;
|
||||
mod render;
|
||||
mod startup_sync;
|
||||
@@ -11,31 +14,35 @@ mod store;
|
||||
pub(crate) mod test_support;
|
||||
mod toggles;
|
||||
|
||||
pub use codex_plugin::AppConnectorId;
|
||||
pub use codex_plugin::EffectiveSkillRoots;
|
||||
pub use codex_plugin::PluginCapabilitySummary;
|
||||
pub use codex_plugin::PluginId;
|
||||
pub use codex_plugin::PluginIdError;
|
||||
pub use codex_plugin::PluginTelemetryMetadata;
|
||||
|
||||
pub type LoadedPlugin = codex_plugin::LoadedPlugin<McpServerConfig>;
|
||||
pub type PluginLoadOutcome = codex_plugin::PluginLoadOutcome<McpServerConfig>;
|
||||
|
||||
pub(crate) use discoverable::list_tool_suggest_discoverable_plugins;
|
||||
pub(crate) use injection::build_plugin_injections;
|
||||
pub use manager::AppConnectorId;
|
||||
pub use manager::ConfiguredMarketplace;
|
||||
pub use manager::ConfiguredMarketplaceListOutcome;
|
||||
pub use manager::ConfiguredMarketplacePlugin;
|
||||
pub use manager::LoadedPlugin;
|
||||
pub use manager::OPENAI_CURATED_MARKETPLACE_NAME;
|
||||
pub use manager::PluginCapabilitySummary;
|
||||
pub use manager::PluginDetail;
|
||||
pub use manager::PluginInstallError;
|
||||
pub use manager::PluginInstallOutcome;
|
||||
pub use manager::PluginInstallRequest;
|
||||
pub use manager::PluginLoadOutcome;
|
||||
pub use manager::PluginReadOutcome;
|
||||
pub use manager::PluginReadRequest;
|
||||
pub use manager::PluginRemoteSyncError;
|
||||
pub use manager::PluginTelemetryMetadata;
|
||||
pub use manager::PluginUninstallError;
|
||||
pub use manager::PluginsManager;
|
||||
pub use manager::RemotePluginSyncResult;
|
||||
pub use manager::installed_plugin_telemetry_metadata;
|
||||
pub use manager::load_plugin_apps;
|
||||
pub use manager::load_plugin_mcp_servers;
|
||||
pub(crate) use manager::plugin_namespace_for_skill_path;
|
||||
pub use manager::plugin_telemetry_metadata_from_root;
|
||||
pub use manifest::PluginManifestInterface;
|
||||
pub(crate) use manifest::PluginManifestPaths;
|
||||
@@ -53,5 +60,10 @@ pub(crate) use render::render_plugins_section;
|
||||
pub(crate) use startup_sync::curated_plugins_repo_path;
|
||||
pub(crate) use startup_sync::read_curated_plugins_sha;
|
||||
pub(crate) use startup_sync::sync_openai_plugins_repo;
|
||||
pub use store::PluginId;
|
||||
pub use toggles::collect_plugin_enabled_candidates;
|
||||
|
||||
pub(crate) use mentions::build_connector_slug_counts;
|
||||
pub(crate) use mentions::build_skill_name_counts;
|
||||
pub(crate) use mentions::collect_explicit_app_ids;
|
||||
pub(crate) use mentions::collect_explicit_plugin_mentions;
|
||||
pub(crate) use mentions::collect_tool_mentions_from_messages;
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
use super::load_plugin_manifest;
|
||||
use super::manifest::PLUGIN_MANIFEST_PATH;
|
||||
use codex_plugin::PluginId;
|
||||
use codex_plugin::validate_plugin_segment;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use codex_utils_plugins::PLUGIN_MANIFEST_PATH;
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::path::Path;
|
||||
@@ -9,53 +11,6 @@ use std::path::PathBuf;
|
||||
pub(crate) const DEFAULT_PLUGIN_VERSION: &str = "local";
|
||||
pub(crate) const PLUGINS_CACHE_DIR: &str = "plugins/cache";
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum PluginIdError {
|
||||
#[error("{0}")]
|
||||
Invalid(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PluginId {
|
||||
pub plugin_name: String,
|
||||
pub marketplace_name: String,
|
||||
}
|
||||
|
||||
impl PluginId {
|
||||
pub fn new(plugin_name: String, marketplace_name: String) -> Result<Self, PluginIdError> {
|
||||
validate_plugin_segment(&plugin_name, "plugin name").map_err(PluginIdError::Invalid)?;
|
||||
validate_plugin_segment(&marketplace_name, "marketplace name")
|
||||
.map_err(PluginIdError::Invalid)?;
|
||||
Ok(Self {
|
||||
plugin_name,
|
||||
marketplace_name,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn parse(plugin_key: &str) -> Result<Self, PluginIdError> {
|
||||
let Some((plugin_name, marketplace_name)) = plugin_key.rsplit_once('@') else {
|
||||
return Err(PluginIdError::Invalid(format!(
|
||||
"invalid plugin key `{plugin_key}`; expected <plugin>@<marketplace>"
|
||||
)));
|
||||
};
|
||||
if plugin_name.is_empty() || marketplace_name.is_empty() {
|
||||
return Err(PluginIdError::Invalid(format!(
|
||||
"invalid plugin key `{plugin_key}`; expected <plugin>@<marketplace>"
|
||||
)));
|
||||
}
|
||||
|
||||
Self::new(plugin_name.to_string(), marketplace_name.to_string()).map_err(|err| match err {
|
||||
PluginIdError::Invalid(message) => {
|
||||
PluginIdError::Invalid(format!("{message} in `{plugin_key}`"))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub fn as_key(&self) -> String {
|
||||
format!("{}@{}", self.plugin_name, self.marketplace_name)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PluginInstallResult {
|
||||
pub plugin_id: PluginId,
|
||||
@@ -221,21 +176,6 @@ fn plugin_name_for_source(source_path: &Path) -> Result<String, PluginStoreError
|
||||
.map(|_| plugin_name)
|
||||
}
|
||||
|
||||
fn validate_plugin_segment(segment: &str, kind: &str) -> Result<(), String> {
|
||||
if segment.is_empty() {
|
||||
return Err(format!("invalid {kind}: must not be empty"));
|
||||
}
|
||||
if !segment
|
||||
.chars()
|
||||
.all(|ch| ch.is_ascii_alphanumeric() || ch == '-' || ch == '_')
|
||||
{
|
||||
return Err(format!(
|
||||
"invalid {kind}: only ASCII letters, digits, `_`, and `-` are allowed"
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn remove_existing_target(path: &Path) -> Result<(), PluginStoreError> {
|
||||
if !path.exists() {
|
||||
return Ok(());
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use super::*;
|
||||
use codex_plugin::PluginId;
|
||||
use pretty_assertions::assert_eq;
|
||||
use tempfile::tempdir;
|
||||
|
||||
|
||||
@@ -184,7 +184,7 @@ pub async fn read_project_docs(config: &Config) -> std::io::Result<Option<String
|
||||
/// directory (inclusive). Symlinks are allowed. When `project_doc_max_bytes`
|
||||
/// is zero, returns an empty list.
|
||||
pub fn discover_project_doc_paths(config: &Config) -> std::io::Result<Vec<PathBuf>> {
|
||||
let mut dir = config.cwd.clone();
|
||||
let mut dir = config.cwd.to_path_buf();
|
||||
if let Ok(canon) = normalize_path(&dir) {
|
||||
dir = canon;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
use super::*;
|
||||
use crate::config::ConfigBuilder;
|
||||
use codex_features::Feature;
|
||||
use core_test_support::PathBufExt;
|
||||
use core_test_support::TempDirExt;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use tempfile::TempDir;
|
||||
@@ -18,7 +20,7 @@ async fn make_config(root: &TempDir, limit: usize, instructions: Option<&str>) -
|
||||
.await
|
||||
.expect("defaults for test should always succeed");
|
||||
|
||||
config.cwd = root.path().to_path_buf();
|
||||
config.cwd = root.abs();
|
||||
config.project_doc_max_bytes = limit;
|
||||
|
||||
config.user_instructions = instructions.map(ToOwned::to_owned);
|
||||
@@ -62,7 +64,7 @@ async fn make_config_with_project_root_markers(
|
||||
.await
|
||||
.expect("defaults for test should always succeed");
|
||||
|
||||
config.cwd = root.path().to_path_buf();
|
||||
config.cwd = root.abs();
|
||||
config.project_doc_max_bytes = limit;
|
||||
config.user_instructions = instructions.map(ToOwned::to_owned);
|
||||
config
|
||||
@@ -136,7 +138,7 @@ async fn finds_doc_in_repo_root() {
|
||||
|
||||
// Build config pointing at the nested dir.
|
||||
let mut cfg = make_config(&repo, 4096, None).await;
|
||||
cfg.cwd = nested;
|
||||
cfg.cwd = nested.abs();
|
||||
|
||||
let res = get_user_instructions(&cfg).await.expect("doc expected");
|
||||
assert_eq!(res, "root level doc");
|
||||
@@ -261,7 +263,7 @@ async fn concatenates_root_and_cwd_docs() {
|
||||
fs::write(nested.join("AGENTS.md"), "crate doc").unwrap();
|
||||
|
||||
let mut cfg = make_config(&repo, 4096, None).await;
|
||||
cfg.cwd = nested;
|
||||
cfg.cwd = nested.abs();
|
||||
|
||||
let res = get_user_instructions(&cfg).await.expect("doc expected");
|
||||
assert_eq!(res, "root doc\n\ncrate doc");
|
||||
@@ -278,13 +280,13 @@ async fn project_root_markers_are_honored_for_agents_discovery() {
|
||||
fs::write(nested.join("AGENTS.md"), "child doc").unwrap();
|
||||
|
||||
let mut cfg = make_config_with_project_root_markers(&root, 4096, None, &[".codex-root"]).await;
|
||||
cfg.cwd = nested;
|
||||
cfg.cwd = nested.abs();
|
||||
|
||||
let discovery = discover_project_doc_paths(&cfg).expect("discover paths");
|
||||
let expected_parent =
|
||||
dunce::canonicalize(root.path().join("AGENTS.md")).expect("canonical parent doc path");
|
||||
let expected_child =
|
||||
dunce::canonicalize(cfg.cwd.join("AGENTS.md")).expect("canonical child doc path");
|
||||
dunce::canonicalize(cfg.cwd.as_path().join("AGENTS.md")).expect("canonical child doc path");
|
||||
assert_eq!(discovery.len(), 2);
|
||||
assert_eq!(discovery[0], expected_parent);
|
||||
assert_eq!(discovery[1], expected_child);
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
use crate::codex::Session;
|
||||
use crate::compact::content_items_to_text;
|
||||
use crate::event_mapping::is_contextual_user_message_content;
|
||||
use crate::truncate::TruncationPolicy;
|
||||
use crate::truncate::truncate_text;
|
||||
use chrono::Utc;
|
||||
use codex_git_utils::resolve_root_git_project_for_trust;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_state::SortKey;
|
||||
use codex_state::ThreadMetadata;
|
||||
use codex_utils_output_truncation::TruncationPolicy;
|
||||
use codex_utils_output_truncation::truncate_text;
|
||||
use dirs::home_dir;
|
||||
use std::cmp::Reverse;
|
||||
use std::collections::HashMap;
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
use crate::config::Config;
|
||||
pub use codex_rollout::ARCHIVED_SESSIONS_SUBDIR;
|
||||
pub use codex_rollout::INTERACTIVE_SESSION_SOURCES;
|
||||
pub use codex_rollout::RolloutRecorder;
|
||||
pub use codex_rollout::RolloutRecorderParams;
|
||||
pub use codex_rollout::SESSIONS_SUBDIR;
|
||||
pub use codex_rollout::SessionMeta;
|
||||
pub use codex_rollout::append_thread_name;
|
||||
pub use codex_rollout::find_archived_thread_path_by_id_str;
|
||||
#[deprecated(note = "use find_thread_path_by_id_str")]
|
||||
pub use codex_rollout::find_conversation_path_by_id_str;
|
||||
pub use codex_rollout::find_thread_name_by_id;
|
||||
pub use codex_rollout::find_thread_path_by_id_str;
|
||||
pub use codex_rollout::find_thread_path_by_name_str;
|
||||
pub use codex_rollout::rollout_date_parts;
|
||||
|
||||
impl codex_rollout::RolloutConfigView for Config {
|
||||
fn codex_home(&self) -> &std::path::Path {
|
||||
self.codex_home.as_path()
|
||||
}
|
||||
|
||||
fn sqlite_home(&self) -> &std::path::Path {
|
||||
self.sqlite_home.as_path()
|
||||
}
|
||||
|
||||
fn cwd(&self) -> &std::path::Path {
|
||||
self.cwd.as_path()
|
||||
}
|
||||
|
||||
fn model_provider_id(&self) -> &str {
|
||||
self.model_provider_id.as_str()
|
||||
}
|
||||
|
||||
fn generate_memories(&self) -> bool {
|
||||
self.memories.generate_memories
|
||||
}
|
||||
}
|
||||
|
||||
pub mod list {
|
||||
pub use codex_rollout::list::*;
|
||||
}
|
||||
|
||||
pub(crate) mod metadata {
|
||||
pub(crate) use codex_rollout::metadata::builder_from_items;
|
||||
}
|
||||
|
||||
pub mod policy {
|
||||
pub use codex_rollout::policy::*;
|
||||
}
|
||||
|
||||
pub mod recorder {
|
||||
pub use codex_rollout::recorder::*;
|
||||
}
|
||||
|
||||
pub mod session_index {
|
||||
pub use codex_rollout::session_index::*;
|
||||
}
|
||||
|
||||
pub(crate) use crate::session_rollout_init_error::map_session_init_error;
|
||||
|
||||
pub(crate) mod truncation {
|
||||
pub(crate) use crate::thread_rollout_truncation::*;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,442 +0,0 @@
|
||||
use crate::config::Config;
|
||||
use crate::rollout;
|
||||
use crate::rollout::list::parse_timestamp_uuid_from_filename;
|
||||
use crate::rollout::recorder::RolloutRecorder;
|
||||
use crate::state_db::normalize_cwd_for_state_db;
|
||||
use chrono::DateTime;
|
||||
use chrono::NaiveDateTime;
|
||||
use chrono::Timelike;
|
||||
use chrono::Utc;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::RolloutItem;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use codex_protocol::protocol::SessionMetaLine;
|
||||
use codex_protocol::protocol::SessionSource;
|
||||
use codex_state::BackfillState;
|
||||
use codex_state::BackfillStats;
|
||||
use codex_state::BackfillStatus;
|
||||
use codex_state::DB_ERROR_METRIC;
|
||||
use codex_state::DB_METRIC_BACKFILL;
|
||||
use codex_state::DB_METRIC_BACKFILL_DURATION_MS;
|
||||
use codex_state::ExtractionOutcome;
|
||||
use codex_state::ThreadMetadataBuilder;
|
||||
use codex_state::apply_rollout_item;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use tracing::info;
|
||||
use tracing::warn;
|
||||
|
||||
const ROLLOUT_PREFIX: &str = "rollout-";
|
||||
const ROLLOUT_SUFFIX: &str = ".jsonl";
|
||||
const BACKFILL_BATCH_SIZE: usize = 200;
|
||||
#[cfg(not(test))]
|
||||
const BACKFILL_LEASE_SECONDS: i64 = 900;
|
||||
#[cfg(test)]
|
||||
const BACKFILL_LEASE_SECONDS: i64 = 1;
|
||||
|
||||
pub(crate) fn builder_from_session_meta(
|
||||
session_meta: &SessionMetaLine,
|
||||
rollout_path: &Path,
|
||||
) -> Option<ThreadMetadataBuilder> {
|
||||
let created_at = parse_timestamp_to_utc(session_meta.meta.timestamp.as_str())?;
|
||||
let mut builder = ThreadMetadataBuilder::new(
|
||||
session_meta.meta.id,
|
||||
rollout_path.to_path_buf(),
|
||||
created_at,
|
||||
session_meta.meta.source.clone(),
|
||||
);
|
||||
builder.model_provider = session_meta.meta.model_provider.clone();
|
||||
builder.agent_nickname = session_meta.meta.agent_nickname.clone();
|
||||
builder.agent_role = session_meta.meta.agent_role.clone();
|
||||
builder.agent_path = session_meta.meta.agent_path.clone();
|
||||
builder.cwd = session_meta.meta.cwd.clone();
|
||||
builder.cli_version = Some(session_meta.meta.cli_version.clone());
|
||||
builder.sandbox_policy = SandboxPolicy::new_read_only_policy();
|
||||
builder.approval_mode = AskForApproval::OnRequest;
|
||||
if let Some(git) = session_meta.git.as_ref() {
|
||||
builder.git_sha = git.commit_hash.as_ref().map(|sha| sha.0.clone());
|
||||
builder.git_branch = git.branch.clone();
|
||||
builder.git_origin_url = git.repository_url.clone();
|
||||
}
|
||||
Some(builder)
|
||||
}
|
||||
|
||||
pub(crate) fn builder_from_items(
|
||||
items: &[RolloutItem],
|
||||
rollout_path: &Path,
|
||||
) -> Option<ThreadMetadataBuilder> {
|
||||
if let Some(session_meta) = items.iter().find_map(|item| match item {
|
||||
RolloutItem::SessionMeta(meta_line) => Some(meta_line),
|
||||
RolloutItem::ResponseItem(_)
|
||||
| RolloutItem::Compacted(_)
|
||||
| RolloutItem::TurnContext(_)
|
||||
| RolloutItem::EventMsg(_) => None,
|
||||
}) && let Some(builder) = builder_from_session_meta(session_meta, rollout_path)
|
||||
{
|
||||
return Some(builder);
|
||||
}
|
||||
|
||||
let file_name = rollout_path.file_name()?.to_str()?;
|
||||
if !file_name.starts_with(ROLLOUT_PREFIX) || !file_name.ends_with(ROLLOUT_SUFFIX) {
|
||||
return None;
|
||||
}
|
||||
let (created_ts, uuid) = parse_timestamp_uuid_from_filename(file_name)?;
|
||||
let created_at =
|
||||
DateTime::<Utc>::from_timestamp(created_ts.unix_timestamp(), 0)?.with_nanosecond(0)?;
|
||||
let id = ThreadId::from_string(&uuid.to_string()).ok()?;
|
||||
Some(ThreadMetadataBuilder::new(
|
||||
id,
|
||||
rollout_path.to_path_buf(),
|
||||
created_at,
|
||||
SessionSource::default(),
|
||||
))
|
||||
}
|
||||
|
||||
pub(crate) async fn extract_metadata_from_rollout(
|
||||
rollout_path: &Path,
|
||||
default_provider: &str,
|
||||
) -> anyhow::Result<ExtractionOutcome> {
|
||||
let (items, _thread_id, parse_errors) =
|
||||
RolloutRecorder::load_rollout_items(rollout_path).await?;
|
||||
if items.is_empty() {
|
||||
return Err(anyhow::anyhow!(
|
||||
"empty session file: {}",
|
||||
rollout_path.display()
|
||||
));
|
||||
}
|
||||
let builder = builder_from_items(items.as_slice(), rollout_path).ok_or_else(|| {
|
||||
anyhow::anyhow!(
|
||||
"rollout missing metadata builder: {}",
|
||||
rollout_path.display()
|
||||
)
|
||||
})?;
|
||||
let mut metadata = builder.build(default_provider);
|
||||
for item in &items {
|
||||
apply_rollout_item(&mut metadata, item, default_provider);
|
||||
}
|
||||
if let Some(updated_at) = file_modified_time_utc(rollout_path).await {
|
||||
metadata.updated_at = updated_at;
|
||||
}
|
||||
Ok(ExtractionOutcome {
|
||||
metadata,
|
||||
memory_mode: items.iter().rev().find_map(|item| match item {
|
||||
RolloutItem::SessionMeta(meta_line) => meta_line.meta.memory_mode.clone(),
|
||||
RolloutItem::ResponseItem(_)
|
||||
| RolloutItem::Compacted(_)
|
||||
| RolloutItem::TurnContext(_)
|
||||
| RolloutItem::EventMsg(_) => None,
|
||||
}),
|
||||
parse_errors,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) async fn backfill_sessions(runtime: &codex_state::StateRuntime, config: &Config) {
|
||||
let metric_client = codex_otel::metrics::global();
|
||||
let timer = metric_client
|
||||
.as_ref()
|
||||
.and_then(|otel| otel.start_timer(DB_METRIC_BACKFILL_DURATION_MS, &[]).ok());
|
||||
let backfill_state = match runtime.get_backfill_state().await {
|
||||
Ok(state) => state,
|
||||
Err(err) => {
|
||||
warn!(
|
||||
"failed to read backfill state at {}: {err}",
|
||||
config.codex_home.display()
|
||||
);
|
||||
BackfillState::default()
|
||||
}
|
||||
};
|
||||
if backfill_state.status == BackfillStatus::Complete {
|
||||
return;
|
||||
}
|
||||
let claimed = match runtime.try_claim_backfill(BACKFILL_LEASE_SECONDS).await {
|
||||
Ok(claimed) => claimed,
|
||||
Err(err) => {
|
||||
warn!(
|
||||
"failed to claim backfill worker at {}: {err}",
|
||||
config.codex_home.display()
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
if !claimed {
|
||||
info!(
|
||||
"state db backfill already running at {}; skipping duplicate worker",
|
||||
config.codex_home.display()
|
||||
);
|
||||
return;
|
||||
}
|
||||
let mut backfill_state = match runtime.get_backfill_state().await {
|
||||
Ok(state) => state,
|
||||
Err(err) => {
|
||||
warn!(
|
||||
"failed to read claimed backfill state at {}: {err}",
|
||||
config.codex_home.display()
|
||||
);
|
||||
BackfillState {
|
||||
status: BackfillStatus::Running,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
};
|
||||
if backfill_state.status != BackfillStatus::Running {
|
||||
if let Err(err) = runtime.mark_backfill_running().await {
|
||||
warn!(
|
||||
"failed to mark backfill running at {}: {err}",
|
||||
config.codex_home.display()
|
||||
);
|
||||
} else {
|
||||
backfill_state.status = BackfillStatus::Running;
|
||||
}
|
||||
}
|
||||
|
||||
let sessions_root = config.codex_home.join(rollout::SESSIONS_SUBDIR);
|
||||
let archived_root = config.codex_home.join(rollout::ARCHIVED_SESSIONS_SUBDIR);
|
||||
let mut rollout_paths: Vec<BackfillRolloutPath> = Vec::new();
|
||||
for (root, archived) in [(sessions_root, false), (archived_root, true)] {
|
||||
if !tokio::fs::try_exists(&root).await.unwrap_or(false) {
|
||||
continue;
|
||||
}
|
||||
match collect_rollout_paths(&root).await {
|
||||
Ok(paths) => {
|
||||
rollout_paths.extend(paths.into_iter().map(|path| BackfillRolloutPath {
|
||||
watermark: backfill_watermark_for_path(config.codex_home.as_path(), &path),
|
||||
path,
|
||||
archived,
|
||||
}));
|
||||
}
|
||||
Err(err) => {
|
||||
warn!(
|
||||
"failed to collect rollout paths under {}: {err}",
|
||||
root.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
rollout_paths.sort_by(|a, b| a.watermark.cmp(&b.watermark));
|
||||
if let Some(last_watermark) = backfill_state.last_watermark.as_deref() {
|
||||
rollout_paths.retain(|entry| entry.watermark.as_str() > last_watermark);
|
||||
}
|
||||
|
||||
let mut stats = BackfillStats {
|
||||
scanned: 0,
|
||||
upserted: 0,
|
||||
failed: 0,
|
||||
};
|
||||
let mut last_watermark = backfill_state.last_watermark.clone();
|
||||
for batch in rollout_paths.chunks(BACKFILL_BATCH_SIZE) {
|
||||
for rollout in batch {
|
||||
stats.scanned = stats.scanned.saturating_add(1);
|
||||
match extract_metadata_from_rollout(&rollout.path, config.model_provider_id.as_str())
|
||||
.await
|
||||
{
|
||||
Ok(outcome) => {
|
||||
if outcome.parse_errors > 0
|
||||
&& let Some(ref metric_client) = metric_client
|
||||
{
|
||||
let _ = metric_client.counter(
|
||||
DB_ERROR_METRIC,
|
||||
outcome.parse_errors as i64,
|
||||
&[("stage", "backfill_sessions")],
|
||||
);
|
||||
}
|
||||
let mut metadata = outcome.metadata;
|
||||
metadata.cwd = normalize_cwd_for_state_db(&metadata.cwd);
|
||||
let memory_mode = outcome.memory_mode.unwrap_or_else(|| "enabled".to_string());
|
||||
if let Ok(Some(existing_metadata)) = runtime.get_thread(metadata.id).await {
|
||||
metadata.prefer_existing_git_info(&existing_metadata);
|
||||
}
|
||||
if rollout.archived && metadata.archived_at.is_none() {
|
||||
let fallback_archived_at = metadata.updated_at;
|
||||
metadata.archived_at = file_modified_time_utc(&rollout.path)
|
||||
.await
|
||||
.or(Some(fallback_archived_at));
|
||||
}
|
||||
if let Err(err) = runtime.upsert_thread(&metadata).await {
|
||||
stats.failed = stats.failed.saturating_add(1);
|
||||
warn!("failed to upsert rollout {}: {err}", rollout.path.display());
|
||||
} else {
|
||||
if let Err(err) = runtime
|
||||
.set_thread_memory_mode(metadata.id, memory_mode.as_str())
|
||||
.await
|
||||
{
|
||||
stats.failed = stats.failed.saturating_add(1);
|
||||
warn!(
|
||||
"failed to restore memory mode for {}: {err}",
|
||||
rollout.path.display()
|
||||
);
|
||||
continue;
|
||||
}
|
||||
stats.upserted = stats.upserted.saturating_add(1);
|
||||
if let Ok(meta_line) =
|
||||
rollout::list::read_session_meta_line(&rollout.path).await
|
||||
{
|
||||
if let Err(err) = runtime
|
||||
.persist_dynamic_tools(
|
||||
meta_line.meta.id,
|
||||
meta_line.meta.dynamic_tools.as_deref(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
warn!(
|
||||
"failed to backfill dynamic tools {}: {err}",
|
||||
rollout.path.display()
|
||||
);
|
||||
}
|
||||
} else {
|
||||
warn!(
|
||||
"failed to read session meta for dynamic tools {}",
|
||||
rollout.path.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
stats.failed = stats.failed.saturating_add(1);
|
||||
warn!(
|
||||
"failed to extract rollout {}: {err}",
|
||||
rollout.path.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(last_entry) = batch.last() {
|
||||
if let Err(err) = runtime
|
||||
.checkpoint_backfill(last_entry.watermark.as_str())
|
||||
.await
|
||||
{
|
||||
warn!(
|
||||
"failed to checkpoint backfill at {}: {err}",
|
||||
config.codex_home.display()
|
||||
);
|
||||
} else {
|
||||
last_watermark = Some(last_entry.watermark.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Err(err) = runtime
|
||||
.mark_backfill_complete(last_watermark.as_deref())
|
||||
.await
|
||||
{
|
||||
warn!(
|
||||
"failed to mark backfill complete at {}: {err}",
|
||||
config.codex_home.display()
|
||||
);
|
||||
}
|
||||
|
||||
info!(
|
||||
"state db backfill scanned={}, upserted={}, failed={}",
|
||||
stats.scanned, stats.upserted, stats.failed
|
||||
);
|
||||
if let Some(metric_client) = metric_client {
|
||||
let _ = metric_client.counter(
|
||||
DB_METRIC_BACKFILL,
|
||||
stats.upserted as i64,
|
||||
&[("status", "upserted")],
|
||||
);
|
||||
let _ = metric_client.counter(
|
||||
DB_METRIC_BACKFILL,
|
||||
stats.failed as i64,
|
||||
&[("status", "failed")],
|
||||
);
|
||||
}
|
||||
if let Some(timer) = timer.as_ref() {
|
||||
let status = if stats.failed == 0 {
|
||||
"success"
|
||||
} else if stats.upserted == 0 {
|
||||
"failed"
|
||||
} else {
|
||||
"partial_failure"
|
||||
};
|
||||
let _ = timer.record(&[("status", status)]);
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct BackfillRolloutPath {
|
||||
watermark: String,
|
||||
path: PathBuf,
|
||||
archived: bool,
|
||||
}
|
||||
|
||||
fn backfill_watermark_for_path(codex_home: &Path, path: &Path) -> String {
|
||||
path.strip_prefix(codex_home)
|
||||
.unwrap_or(path)
|
||||
.to_string_lossy()
|
||||
.replace('\\', "/")
|
||||
}
|
||||
|
||||
async fn file_modified_time_utc(path: &Path) -> Option<DateTime<Utc>> {
|
||||
let modified = tokio::fs::metadata(path).await.ok()?.modified().ok()?;
|
||||
let updated_at: DateTime<Utc> = modified.into();
|
||||
updated_at.with_nanosecond(0)
|
||||
}
|
||||
|
||||
fn parse_timestamp_to_utc(ts: &str) -> Option<DateTime<Utc>> {
|
||||
const FILENAME_TS_FORMAT: &str = "%Y-%m-%dT%H-%M-%S";
|
||||
if let Ok(naive) = NaiveDateTime::parse_from_str(ts, FILENAME_TS_FORMAT) {
|
||||
let dt = DateTime::<Utc>::from_naive_utc_and_offset(naive, Utc);
|
||||
return dt.with_nanosecond(0);
|
||||
}
|
||||
if let Ok(dt) = DateTime::parse_from_rfc3339(ts) {
|
||||
return dt.with_timezone(&Utc).with_nanosecond(0);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
async fn collect_rollout_paths(root: &Path) -> std::io::Result<Vec<PathBuf>> {
|
||||
let mut stack = vec![root.to_path_buf()];
|
||||
let mut paths = Vec::new();
|
||||
while let Some(dir) = stack.pop() {
|
||||
let mut read_dir = match tokio::fs::read_dir(&dir).await {
|
||||
Ok(read_dir) => read_dir,
|
||||
Err(err) => {
|
||||
warn!("failed to read directory {}: {err}", dir.display());
|
||||
continue;
|
||||
}
|
||||
};
|
||||
loop {
|
||||
let next_entry = match read_dir.next_entry().await {
|
||||
Ok(next_entry) => next_entry,
|
||||
Err(err) => {
|
||||
warn!(
|
||||
"failed to read directory entry under {}: {err}",
|
||||
dir.display()
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let Some(entry) = next_entry else {
|
||||
break;
|
||||
};
|
||||
let path = entry.path();
|
||||
let file_type = match entry.file_type().await {
|
||||
Ok(file_type) => file_type,
|
||||
Err(err) => {
|
||||
warn!("failed to read file type for {}: {err}", path.display());
|
||||
continue;
|
||||
}
|
||||
};
|
||||
if file_type.is_dir() {
|
||||
stack.push(path);
|
||||
continue;
|
||||
}
|
||||
if !file_type.is_file() {
|
||||
continue;
|
||||
}
|
||||
let file_name = entry.file_name();
|
||||
let Some(name) = file_name.to_str() else {
|
||||
continue;
|
||||
};
|
||||
if name.starts_with(ROLLOUT_PREFIX) && name.ends_with(ROLLOUT_SUFFIX) {
|
||||
paths.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(paths)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "metadata_tests.rs"]
|
||||
mod tests;
|
||||
@@ -1,380 +0,0 @@
|
||||
use super::*;
|
||||
use chrono::DateTime;
|
||||
use chrono::NaiveDateTime;
|
||||
use chrono::Timelike;
|
||||
use chrono::Utc;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::protocol::CompactedItem;
|
||||
use codex_protocol::protocol::GitInfo;
|
||||
use codex_protocol::protocol::RolloutItem;
|
||||
use codex_protocol::protocol::RolloutLine;
|
||||
use codex_protocol::protocol::SessionMeta;
|
||||
use codex_protocol::protocol::SessionMetaLine;
|
||||
use codex_protocol::protocol::SessionSource;
|
||||
use codex_state::BackfillStatus;
|
||||
use codex_state::ThreadMetadataBuilder;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::fs::File;
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use tempfile::tempdir;
|
||||
use uuid::Uuid;
|
||||
|
||||
#[tokio::test]
|
||||
async fn extract_metadata_from_rollout_uses_session_meta() {
|
||||
let dir = tempdir().expect("tempdir");
|
||||
let uuid = Uuid::new_v4();
|
||||
let id = ThreadId::from_string(&uuid.to_string()).expect("thread id");
|
||||
let path = dir
|
||||
.path()
|
||||
.join(format!("rollout-2026-01-27T12-34-56-{uuid}.jsonl"));
|
||||
|
||||
let session_meta = SessionMeta {
|
||||
id,
|
||||
forked_from_id: None,
|
||||
timestamp: "2026-01-27T12:34:56Z".to_string(),
|
||||
cwd: dir.path().to_path_buf(),
|
||||
originator: "cli".to_string(),
|
||||
cli_version: "0.0.0".to_string(),
|
||||
source: SessionSource::default(),
|
||||
agent_path: None,
|
||||
agent_nickname: None,
|
||||
agent_role: None,
|
||||
model_provider: Some("openai".to_string()),
|
||||
base_instructions: None,
|
||||
dynamic_tools: None,
|
||||
memory_mode: None,
|
||||
};
|
||||
let session_meta_line = SessionMetaLine {
|
||||
meta: session_meta,
|
||||
git: None,
|
||||
};
|
||||
let rollout_line = RolloutLine {
|
||||
timestamp: "2026-01-27T12:34:56Z".to_string(),
|
||||
item: RolloutItem::SessionMeta(session_meta_line.clone()),
|
||||
};
|
||||
let json = serde_json::to_string(&rollout_line).expect("rollout json");
|
||||
let mut file = File::create(&path).expect("create rollout");
|
||||
writeln!(file, "{json}").expect("write rollout");
|
||||
|
||||
let outcome = extract_metadata_from_rollout(&path, "openai")
|
||||
.await
|
||||
.expect("extract");
|
||||
|
||||
let builder = builder_from_session_meta(&session_meta_line, path.as_path()).expect("builder");
|
||||
let mut expected = builder.build("openai");
|
||||
apply_rollout_item(&mut expected, &rollout_line.item, "openai");
|
||||
expected.updated_at = file_modified_time_utc(&path).await.expect("mtime");
|
||||
|
||||
assert_eq!(outcome.metadata, expected);
|
||||
assert_eq!(outcome.memory_mode, None);
|
||||
assert_eq!(outcome.parse_errors, 0);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn extract_metadata_from_rollout_returns_latest_memory_mode() {
|
||||
let dir = tempdir().expect("tempdir");
|
||||
let uuid = Uuid::new_v4();
|
||||
let id = ThreadId::from_string(&uuid.to_string()).expect("thread id");
|
||||
let path = dir
|
||||
.path()
|
||||
.join(format!("rollout-2026-01-27T12-34-56-{uuid}.jsonl"));
|
||||
|
||||
let session_meta = SessionMeta {
|
||||
id,
|
||||
forked_from_id: None,
|
||||
timestamp: "2026-01-27T12:34:56Z".to_string(),
|
||||
cwd: dir.path().to_path_buf(),
|
||||
originator: "cli".to_string(),
|
||||
cli_version: "0.0.0".to_string(),
|
||||
source: SessionSource::default(),
|
||||
agent_path: None,
|
||||
agent_nickname: None,
|
||||
agent_role: None,
|
||||
model_provider: Some("openai".to_string()),
|
||||
base_instructions: None,
|
||||
dynamic_tools: None,
|
||||
memory_mode: None,
|
||||
};
|
||||
let polluted_meta = SessionMeta {
|
||||
memory_mode: Some("polluted".to_string()),
|
||||
..session_meta.clone()
|
||||
};
|
||||
let lines = vec![
|
||||
RolloutLine {
|
||||
timestamp: "2026-01-27T12:34:56Z".to_string(),
|
||||
item: RolloutItem::SessionMeta(SessionMetaLine {
|
||||
meta: session_meta,
|
||||
git: None,
|
||||
}),
|
||||
},
|
||||
RolloutLine {
|
||||
timestamp: "2026-01-27T12:35:00Z".to_string(),
|
||||
item: RolloutItem::SessionMeta(SessionMetaLine {
|
||||
meta: polluted_meta,
|
||||
git: None,
|
||||
}),
|
||||
},
|
||||
];
|
||||
let mut file = File::create(&path).expect("create rollout");
|
||||
for line in lines {
|
||||
writeln!(
|
||||
file,
|
||||
"{}",
|
||||
serde_json::to_string(&line).expect("serialize rollout line")
|
||||
)
|
||||
.expect("write rollout line");
|
||||
}
|
||||
|
||||
let outcome = extract_metadata_from_rollout(&path, "openai")
|
||||
.await
|
||||
.expect("extract");
|
||||
|
||||
assert_eq!(outcome.memory_mode.as_deref(), Some("polluted"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn builder_from_items_falls_back_to_filename() {
|
||||
let dir = tempdir().expect("tempdir");
|
||||
let uuid = Uuid::new_v4();
|
||||
let path = dir
|
||||
.path()
|
||||
.join(format!("rollout-2026-01-27T12-34-56-{uuid}.jsonl"));
|
||||
let items = vec![RolloutItem::Compacted(CompactedItem {
|
||||
message: "noop".to_string(),
|
||||
replacement_history: None,
|
||||
})];
|
||||
|
||||
let builder = builder_from_items(items.as_slice(), path.as_path()).expect("builder");
|
||||
let naive = NaiveDateTime::parse_from_str("2026-01-27T12-34-56", "%Y-%m-%dT%H-%M-%S")
|
||||
.expect("timestamp");
|
||||
let created_at = DateTime::<Utc>::from_naive_utc_and_offset(naive, Utc)
|
||||
.with_nanosecond(0)
|
||||
.expect("nanosecond");
|
||||
let expected = ThreadMetadataBuilder::new(
|
||||
ThreadId::from_string(&uuid.to_string()).expect("thread id"),
|
||||
path,
|
||||
created_at,
|
||||
SessionSource::default(),
|
||||
);
|
||||
|
||||
assert_eq!(builder, expected);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn backfill_sessions_resumes_from_watermark_and_marks_complete() {
|
||||
let dir = tempdir().expect("tempdir");
|
||||
let codex_home = dir.path().to_path_buf();
|
||||
let first_uuid = Uuid::new_v4();
|
||||
let second_uuid = Uuid::new_v4();
|
||||
let first_path = write_rollout_in_sessions(
|
||||
codex_home.as_path(),
|
||||
"2026-01-27T12-34-56",
|
||||
"2026-01-27T12:34:56Z",
|
||||
first_uuid,
|
||||
None,
|
||||
);
|
||||
let second_path = write_rollout_in_sessions(
|
||||
codex_home.as_path(),
|
||||
"2026-01-27T12-35-56",
|
||||
"2026-01-27T12:35:56Z",
|
||||
second_uuid,
|
||||
None,
|
||||
);
|
||||
|
||||
let runtime = codex_state::StateRuntime::init(codex_home.clone(), "test-provider".to_string())
|
||||
.await
|
||||
.expect("initialize runtime");
|
||||
let first_watermark = backfill_watermark_for_path(codex_home.as_path(), first_path.as_path());
|
||||
runtime.mark_backfill_running().await.expect("mark running");
|
||||
runtime
|
||||
.checkpoint_backfill(first_watermark.as_str())
|
||||
.await
|
||||
.expect("checkpoint first watermark");
|
||||
tokio::time::sleep(std::time::Duration::from_secs(
|
||||
(BACKFILL_LEASE_SECONDS + 1) as u64,
|
||||
))
|
||||
.await;
|
||||
|
||||
let mut config = crate::config::test_config();
|
||||
config.codex_home = codex_home.clone();
|
||||
config.model_provider_id = "test-provider".to_string();
|
||||
backfill_sessions(runtime.as_ref(), &config).await;
|
||||
|
||||
let first_id = ThreadId::from_string(&first_uuid.to_string()).expect("first thread id");
|
||||
let second_id = ThreadId::from_string(&second_uuid.to_string()).expect("second thread id");
|
||||
assert_eq!(
|
||||
runtime
|
||||
.get_thread(first_id)
|
||||
.await
|
||||
.expect("get first thread"),
|
||||
None
|
||||
);
|
||||
assert!(
|
||||
runtime
|
||||
.get_thread(second_id)
|
||||
.await
|
||||
.expect("get second thread")
|
||||
.is_some()
|
||||
);
|
||||
|
||||
let state = runtime
|
||||
.get_backfill_state()
|
||||
.await
|
||||
.expect("get backfill state");
|
||||
assert_eq!(state.status, BackfillStatus::Complete);
|
||||
assert_eq!(
|
||||
state.last_watermark,
|
||||
Some(backfill_watermark_for_path(
|
||||
codex_home.as_path(),
|
||||
second_path.as_path()
|
||||
))
|
||||
);
|
||||
assert!(state.last_success_at.is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn backfill_sessions_preserves_existing_git_branch_and_fills_missing_git_fields() {
|
||||
let dir = tempdir().expect("tempdir");
|
||||
let codex_home = dir.path().to_path_buf();
|
||||
let thread_uuid = Uuid::new_v4();
|
||||
let rollout_path = write_rollout_in_sessions(
|
||||
codex_home.as_path(),
|
||||
"2026-01-27T12-34-56",
|
||||
"2026-01-27T12:34:56Z",
|
||||
thread_uuid,
|
||||
Some(GitInfo {
|
||||
commit_hash: Some(codex_git_utils::GitSha::new("rollout-sha")),
|
||||
branch: Some("rollout-branch".to_string()),
|
||||
repository_url: Some("git@example.com:openai/codex.git".to_string()),
|
||||
}),
|
||||
);
|
||||
|
||||
let runtime = codex_state::StateRuntime::init(codex_home.clone(), "test-provider".to_string())
|
||||
.await
|
||||
.expect("initialize runtime");
|
||||
let thread_id = ThreadId::from_string(&thread_uuid.to_string()).expect("thread id");
|
||||
let mut existing = extract_metadata_from_rollout(&rollout_path, "test-provider")
|
||||
.await
|
||||
.expect("extract")
|
||||
.metadata;
|
||||
existing.git_sha = None;
|
||||
existing.git_branch = Some("sqlite-branch".to_string());
|
||||
existing.git_origin_url = None;
|
||||
runtime
|
||||
.upsert_thread(&existing)
|
||||
.await
|
||||
.expect("existing metadata upsert");
|
||||
|
||||
let mut config = crate::config::test_config();
|
||||
config.codex_home = codex_home.clone();
|
||||
config.model_provider_id = "test-provider".to_string();
|
||||
backfill_sessions(runtime.as_ref(), &config).await;
|
||||
|
||||
let persisted = runtime
|
||||
.get_thread(thread_id)
|
||||
.await
|
||||
.expect("get thread")
|
||||
.expect("thread exists");
|
||||
assert_eq!(persisted.git_sha.as_deref(), Some("rollout-sha"));
|
||||
assert_eq!(persisted.git_branch.as_deref(), Some("sqlite-branch"));
|
||||
assert_eq!(
|
||||
persisted.git_origin_url.as_deref(),
|
||||
Some("git@example.com:openai/codex.git")
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn backfill_sessions_normalizes_cwd_before_upsert() {
|
||||
let dir = tempdir().expect("tempdir");
|
||||
let codex_home = dir.path().to_path_buf();
|
||||
let thread_uuid = Uuid::new_v4();
|
||||
let session_cwd = codex_home.join(".");
|
||||
let rollout_path = write_rollout_in_sessions_with_cwd(
|
||||
codex_home.as_path(),
|
||||
"2026-01-27T12-34-56",
|
||||
"2026-01-27T12:34:56Z",
|
||||
thread_uuid,
|
||||
session_cwd.clone(),
|
||||
None,
|
||||
);
|
||||
|
||||
let runtime = codex_state::StateRuntime::init(codex_home.clone(), "test-provider".to_string())
|
||||
.await
|
||||
.expect("initialize runtime");
|
||||
|
||||
let mut config = crate::config::test_config();
|
||||
config.codex_home = codex_home.clone();
|
||||
config.model_provider_id = "test-provider".to_string();
|
||||
backfill_sessions(runtime.as_ref(), &config).await;
|
||||
|
||||
let thread_id = ThreadId::from_string(&thread_uuid.to_string()).expect("thread id");
|
||||
let stored = runtime
|
||||
.get_thread(thread_id)
|
||||
.await
|
||||
.expect("get thread")
|
||||
.expect("thread should be backfilled");
|
||||
|
||||
assert_eq!(stored.rollout_path, rollout_path);
|
||||
assert_eq!(stored.cwd, normalize_cwd_for_state_db(&session_cwd));
|
||||
}
|
||||
|
||||
fn write_rollout_in_sessions(
|
||||
codex_home: &Path,
|
||||
filename_ts: &str,
|
||||
event_ts: &str,
|
||||
thread_uuid: Uuid,
|
||||
git: Option<GitInfo>,
|
||||
) -> PathBuf {
|
||||
write_rollout_in_sessions_with_cwd(
|
||||
codex_home,
|
||||
filename_ts,
|
||||
event_ts,
|
||||
thread_uuid,
|
||||
codex_home.to_path_buf(),
|
||||
git,
|
||||
)
|
||||
}
|
||||
|
||||
fn write_rollout_in_sessions_with_cwd(
|
||||
codex_home: &Path,
|
||||
filename_ts: &str,
|
||||
event_ts: &str,
|
||||
thread_uuid: Uuid,
|
||||
cwd: PathBuf,
|
||||
git: Option<GitInfo>,
|
||||
) -> PathBuf {
|
||||
let id = ThreadId::from_string(&thread_uuid.to_string()).expect("thread id");
|
||||
let sessions_dir = codex_home.join("sessions");
|
||||
std::fs::create_dir_all(sessions_dir.as_path()).expect("create sessions dir");
|
||||
let path = sessions_dir.join(format!("rollout-{filename_ts}-{thread_uuid}.jsonl"));
|
||||
let session_meta = SessionMeta {
|
||||
id,
|
||||
forked_from_id: None,
|
||||
timestamp: event_ts.to_string(),
|
||||
cwd,
|
||||
originator: "cli".to_string(),
|
||||
cli_version: "0.0.0".to_string(),
|
||||
source: SessionSource::default(),
|
||||
agent_path: None,
|
||||
agent_nickname: None,
|
||||
agent_role: None,
|
||||
model_provider: Some("test-provider".to_string()),
|
||||
base_instructions: None,
|
||||
dynamic_tools: None,
|
||||
memory_mode: None,
|
||||
};
|
||||
let session_meta_line = SessionMetaLine {
|
||||
meta: session_meta,
|
||||
git,
|
||||
};
|
||||
let rollout_line = RolloutLine {
|
||||
timestamp: event_ts.to_string(),
|
||||
item: RolloutItem::SessionMeta(session_meta_line),
|
||||
};
|
||||
let json = serde_json::to_string(&rollout_line).expect("serialize rollout");
|
||||
let mut file = File::create(&path).expect("create rollout");
|
||||
writeln!(file, "{json}").expect("write rollout");
|
||||
path
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
//! Rollout module: persistence and discovery of session rollout files.
|
||||
|
||||
use std::sync::LazyLock;
|
||||
|
||||
use codex_protocol::protocol::SessionSource;
|
||||
|
||||
pub const SESSIONS_SUBDIR: &str = "sessions";
|
||||
pub const ARCHIVED_SESSIONS_SUBDIR: &str = "archived_sessions";
|
||||
pub static INTERACTIVE_SESSION_SOURCES: LazyLock<Vec<SessionSource>> = LazyLock::new(|| {
|
||||
vec![
|
||||
SessionSource::Cli,
|
||||
SessionSource::VSCode,
|
||||
SessionSource::Custom("atlas".to_string()),
|
||||
SessionSource::Custom("chatgpt".to_string()),
|
||||
]
|
||||
});
|
||||
|
||||
pub(crate) mod error;
|
||||
pub mod list;
|
||||
pub(crate) mod metadata;
|
||||
pub(crate) mod policy;
|
||||
pub mod recorder;
|
||||
pub(crate) mod session_index;
|
||||
pub(crate) mod truncation;
|
||||
|
||||
pub use codex_protocol::protocol::SessionMeta;
|
||||
pub(crate) use error::map_session_init_error;
|
||||
pub use list::find_archived_thread_path_by_id_str;
|
||||
pub use list::find_thread_path_by_id_str;
|
||||
#[deprecated(note = "use find_thread_path_by_id_str")]
|
||||
pub use list::find_thread_path_by_id_str as find_conversation_path_by_id_str;
|
||||
pub use list::rollout_date_parts;
|
||||
pub use recorder::RolloutRecorder;
|
||||
pub use recorder::RolloutRecorderParams;
|
||||
pub use session_index::append_thread_name;
|
||||
pub use session_index::find_thread_name_by_id;
|
||||
pub use session_index::find_thread_path_by_name_str;
|
||||
|
||||
#[cfg(test)]
|
||||
pub mod tests;
|
||||
@@ -1,209 +0,0 @@
|
||||
use crate::protocol::EventMsg;
|
||||
use crate::protocol::RolloutItem;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
|
||||
pub enum EventPersistenceMode {
|
||||
#[default]
|
||||
Limited,
|
||||
Extended,
|
||||
}
|
||||
|
||||
/// Whether a rollout `item` should be persisted in rollout files for the
|
||||
/// provided persistence `mode`.
|
||||
#[inline]
|
||||
pub(crate) fn is_persisted_response_item(item: &RolloutItem, mode: EventPersistenceMode) -> bool {
|
||||
match item {
|
||||
RolloutItem::ResponseItem(item) => should_persist_response_item(item),
|
||||
RolloutItem::EventMsg(ev) => should_persist_event_msg(ev, mode),
|
||||
// Persist Codex executive markers so we can analyze flows (e.g., compaction, API turns).
|
||||
RolloutItem::Compacted(_) | RolloutItem::TurnContext(_) | RolloutItem::SessionMeta(_) => {
|
||||
true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a `ResponseItem` should be persisted in rollout files.
|
||||
#[inline]
|
||||
pub(crate) fn should_persist_response_item(item: &ResponseItem) -> bool {
|
||||
match item {
|
||||
ResponseItem::Message { .. }
|
||||
| ResponseItem::Reasoning { .. }
|
||||
| ResponseItem::LocalShellCall { .. }
|
||||
| ResponseItem::FunctionCall { .. }
|
||||
| ResponseItem::ToolSearchCall { .. }
|
||||
| ResponseItem::FunctionCallOutput { .. }
|
||||
| ResponseItem::ToolSearchOutput { .. }
|
||||
| ResponseItem::CustomToolCall { .. }
|
||||
| ResponseItem::CustomToolCallOutput { .. }
|
||||
| ResponseItem::WebSearchCall { .. }
|
||||
| ResponseItem::ImageGenerationCall { .. }
|
||||
| ResponseItem::GhostSnapshot { .. }
|
||||
| ResponseItem::Compaction { .. } => true,
|
||||
ResponseItem::Other => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a `ResponseItem` should be persisted for the memories.
|
||||
#[inline]
|
||||
pub(crate) fn should_persist_response_item_for_memories(item: &ResponseItem) -> bool {
|
||||
match item {
|
||||
ResponseItem::Message { role, .. } => role != "developer",
|
||||
ResponseItem::LocalShellCall { .. }
|
||||
| ResponseItem::FunctionCall { .. }
|
||||
| ResponseItem::ToolSearchCall { .. }
|
||||
| ResponseItem::FunctionCallOutput { .. }
|
||||
| ResponseItem::ToolSearchOutput { .. }
|
||||
| ResponseItem::CustomToolCall { .. }
|
||||
| ResponseItem::CustomToolCallOutput { .. }
|
||||
| ResponseItem::WebSearchCall { .. } => true,
|
||||
ResponseItem::Reasoning { .. }
|
||||
| ResponseItem::ImageGenerationCall { .. }
|
||||
| ResponseItem::GhostSnapshot { .. }
|
||||
| ResponseItem::Compaction { .. }
|
||||
| ResponseItem::Other => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether an `EventMsg` should be persisted in rollout files for the
|
||||
/// provided persistence `mode`.
|
||||
#[inline]
|
||||
pub(crate) fn should_persist_event_msg(ev: &EventMsg, mode: EventPersistenceMode) -> bool {
|
||||
match mode {
|
||||
EventPersistenceMode::Limited => should_persist_event_msg_limited(ev),
|
||||
EventPersistenceMode::Extended => should_persist_event_msg_extended(ev),
|
||||
}
|
||||
}
|
||||
|
||||
fn should_persist_event_msg_limited(ev: &EventMsg) -> bool {
|
||||
matches!(
|
||||
event_msg_persistence_mode(ev),
|
||||
Some(EventPersistenceMode::Limited)
|
||||
)
|
||||
}
|
||||
|
||||
fn should_persist_event_msg_extended(ev: &EventMsg) -> bool {
|
||||
matches!(
|
||||
event_msg_persistence_mode(ev),
|
||||
Some(EventPersistenceMode::Limited) | Some(EventPersistenceMode::Extended)
|
||||
)
|
||||
}
|
||||
|
||||
/// Returns the minimum persistence mode that includes this event.
|
||||
/// `None` means the event should never be persisted.
|
||||
fn event_msg_persistence_mode(ev: &EventMsg) -> Option<EventPersistenceMode> {
|
||||
match ev {
|
||||
EventMsg::UserMessage(_)
|
||||
| EventMsg::AgentMessage(_)
|
||||
| EventMsg::AgentReasoning(_)
|
||||
| EventMsg::AgentReasoningRawContent(_)
|
||||
| EventMsg::TokenCount(_)
|
||||
| EventMsg::ContextCompacted(_)
|
||||
| EventMsg::EnteredReviewMode(_)
|
||||
| EventMsg::ExitedReviewMode(_)
|
||||
| EventMsg::ThreadRolledBack(_)
|
||||
| EventMsg::UndoCompleted(_)
|
||||
| EventMsg::TurnAborted(_)
|
||||
| EventMsg::TurnStarted(_)
|
||||
| EventMsg::TurnComplete(_)
|
||||
| EventMsg::ImageGenerationEnd(_) => Some(EventPersistenceMode::Limited),
|
||||
EventMsg::ItemCompleted(event) => {
|
||||
// Plan items are derived from streaming tags and are not part of the
|
||||
// raw ResponseItem history, so we persist their completion to replay
|
||||
// them on resume without bloating rollouts with every item lifecycle.
|
||||
if matches!(event.item, codex_protocol::items::TurnItem::Plan(_)) {
|
||||
Some(EventPersistenceMode::Limited)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
EventMsg::Error(_)
|
||||
| EventMsg::GuardianAssessment(_)
|
||||
| EventMsg::WebSearchEnd(_)
|
||||
| EventMsg::ExecCommandEnd(_)
|
||||
| EventMsg::PatchApplyEnd(_)
|
||||
| EventMsg::McpToolCallEnd(_)
|
||||
| EventMsg::ViewImageToolCall(_)
|
||||
| EventMsg::CollabAgentSpawnEnd(_)
|
||||
| EventMsg::CollabAgentInteractionEnd(_)
|
||||
| EventMsg::CollabWaitingEnd(_)
|
||||
| EventMsg::CollabCloseEnd(_)
|
||||
| EventMsg::CollabResumeEnd(_)
|
||||
| EventMsg::DynamicToolCallRequest(_)
|
||||
| EventMsg::DynamicToolCallResponse(_) => Some(EventPersistenceMode::Extended),
|
||||
EventMsg::Warning(_)
|
||||
| EventMsg::RealtimeConversationStarted(_)
|
||||
| EventMsg::RealtimeConversationRealtime(_)
|
||||
| EventMsg::RealtimeConversationClosed(_)
|
||||
| EventMsg::ModelReroute(_)
|
||||
| EventMsg::AgentMessageDelta(_)
|
||||
| EventMsg::AgentReasoningDelta(_)
|
||||
| EventMsg::AgentReasoningRawContentDelta(_)
|
||||
| EventMsg::AgentReasoningSectionBreak(_)
|
||||
| EventMsg::RawResponseItem(_)
|
||||
| EventMsg::SessionConfigured(_)
|
||||
| EventMsg::ThreadNameUpdated(_)
|
||||
| EventMsg::McpToolCallBegin(_)
|
||||
| EventMsg::WebSearchBegin(_)
|
||||
| EventMsg::ExecCommandBegin(_)
|
||||
| EventMsg::TerminalInteraction(_)
|
||||
| EventMsg::ExecCommandOutputDelta(_)
|
||||
| EventMsg::ExecApprovalRequest(_)
|
||||
| EventMsg::RequestPermissions(_)
|
||||
| EventMsg::RequestUserInput(_)
|
||||
| EventMsg::ElicitationRequest(_)
|
||||
| EventMsg::ApplyPatchApprovalRequest(_)
|
||||
| EventMsg::BackgroundEvent(_)
|
||||
| EventMsg::StreamError(_)
|
||||
| EventMsg::PatchApplyBegin(_)
|
||||
| EventMsg::TurnDiff(_)
|
||||
| EventMsg::GetHistoryEntryResponse(_)
|
||||
| EventMsg::UndoStarted(_)
|
||||
| EventMsg::McpListToolsResponse(_)
|
||||
| EventMsg::McpStartupUpdate(_)
|
||||
| EventMsg::McpStartupComplete(_)
|
||||
| EventMsg::ListCustomPromptsResponse(_)
|
||||
| EventMsg::ListSkillsResponse(_)
|
||||
| EventMsg::PlanUpdate(_)
|
||||
| EventMsg::ShutdownComplete
|
||||
| EventMsg::DeprecationNotice(_)
|
||||
| EventMsg::ItemStarted(_)
|
||||
| EventMsg::HookStarted(_)
|
||||
| EventMsg::HookCompleted(_)
|
||||
| EventMsg::AgentMessageContentDelta(_)
|
||||
| EventMsg::PlanDelta(_)
|
||||
| EventMsg::ReasoningContentDelta(_)
|
||||
| EventMsg::ReasoningRawContentDelta(_)
|
||||
| EventMsg::SkillsUpdateAvailable
|
||||
| EventMsg::CollabAgentSpawnBegin(_)
|
||||
| EventMsg::CollabAgentInteractionBegin(_)
|
||||
| EventMsg::CollabWaitingBegin(_)
|
||||
| EventMsg::CollabCloseBegin(_)
|
||||
| EventMsg::CollabResumeBegin(_)
|
||||
| EventMsg::ImageGenerationBegin(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::EventPersistenceMode;
|
||||
use super::should_persist_event_msg;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::ImageGenerationEndEvent;
|
||||
|
||||
#[test]
|
||||
fn persists_image_generation_end_events_in_limited_mode() {
|
||||
let event = EventMsg::ImageGenerationEnd(ImageGenerationEndEvent {
|
||||
call_id: "ig_123".into(),
|
||||
status: "completed".into(),
|
||||
revised_prompt: Some("final prompt".into()),
|
||||
result: "Zm9v".into(),
|
||||
saved_path: None,
|
||||
});
|
||||
|
||||
assert!(should_persist_event_msg(
|
||||
&event,
|
||||
EventPersistenceMode::Limited
|
||||
));
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,512 +0,0 @@
|
||||
use super::*;
|
||||
use crate::config::ConfigBuilder;
|
||||
use chrono::TimeZone;
|
||||
use codex_features::Feature;
|
||||
use codex_protocol::config_types::ReasoningSummary as ReasoningSummaryConfig;
|
||||
use codex_protocol::protocol::AgentMessageEvent;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::EventMsg;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use codex_protocol::protocol::TurnContextItem;
|
||||
use codex_protocol::protocol::UserMessageEvent;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::fs::File;
|
||||
use std::fs::{self};
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
use tempfile::TempDir;
|
||||
use uuid::Uuid;
|
||||
|
||||
fn write_session_file(root: &Path, ts: &str, uuid: Uuid) -> std::io::Result<PathBuf> {
|
||||
let day_dir = root.join("sessions/2025/01/03");
|
||||
fs::create_dir_all(&day_dir)?;
|
||||
let path = day_dir.join(format!("rollout-{ts}-{uuid}.jsonl"));
|
||||
let mut file = File::create(&path)?;
|
||||
let meta = serde_json::json!({
|
||||
"timestamp": ts,
|
||||
"type": "session_meta",
|
||||
"payload": {
|
||||
"id": uuid,
|
||||
"timestamp": ts,
|
||||
"cwd": ".",
|
||||
"originator": "test_originator",
|
||||
"cli_version": "test_version",
|
||||
"source": "cli",
|
||||
"model_provider": "test-provider",
|
||||
},
|
||||
});
|
||||
writeln!(file, "{meta}")?;
|
||||
let user_event = serde_json::json!({
|
||||
"timestamp": ts,
|
||||
"type": "event_msg",
|
||||
"payload": {
|
||||
"type": "user_message",
|
||||
"message": "Hello from user",
|
||||
"kind": "plain",
|
||||
},
|
||||
});
|
||||
writeln!(file, "{user_event}")?;
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn recorder_materializes_only_after_explicit_persist() -> std::io::Result<()> {
|
||||
let home = TempDir::new().expect("temp dir");
|
||||
let config = ConfigBuilder::default()
|
||||
.codex_home(home.path().to_path_buf())
|
||||
.build()
|
||||
.await?;
|
||||
let thread_id = ThreadId::new();
|
||||
let recorder = RolloutRecorder::new(
|
||||
&config,
|
||||
RolloutRecorderParams::new(
|
||||
thread_id,
|
||||
None,
|
||||
SessionSource::Exec,
|
||||
BaseInstructions::default(),
|
||||
Vec::new(),
|
||||
EventPersistenceMode::Limited,
|
||||
),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let rollout_path = recorder.rollout_path().to_path_buf();
|
||||
assert!(
|
||||
!rollout_path.exists(),
|
||||
"rollout file should not exist before first user message"
|
||||
);
|
||||
|
||||
recorder
|
||||
.record_items(&[RolloutItem::EventMsg(EventMsg::AgentMessage(
|
||||
AgentMessageEvent {
|
||||
message: "buffered-event".to_string(),
|
||||
phase: None,
|
||||
memory_citation: None,
|
||||
},
|
||||
))])
|
||||
.await?;
|
||||
recorder.flush().await?;
|
||||
assert!(
|
||||
!rollout_path.exists(),
|
||||
"rollout file should remain deferred before first user message"
|
||||
);
|
||||
|
||||
recorder
|
||||
.record_items(&[RolloutItem::EventMsg(EventMsg::UserMessage(
|
||||
UserMessageEvent {
|
||||
message: "first-user-message".to_string(),
|
||||
images: None,
|
||||
local_images: Vec::new(),
|
||||
text_elements: Vec::new(),
|
||||
},
|
||||
))])
|
||||
.await?;
|
||||
recorder.flush().await?;
|
||||
assert!(
|
||||
!rollout_path.exists(),
|
||||
"user-message-like items should not materialize without explicit persist"
|
||||
);
|
||||
|
||||
recorder.persist().await?;
|
||||
// Second call verifies `persist()` is idempotent after materialization.
|
||||
recorder.persist().await?;
|
||||
assert!(rollout_path.exists(), "rollout file should be materialized");
|
||||
|
||||
let text = std::fs::read_to_string(&rollout_path)?;
|
||||
assert!(
|
||||
text.contains("\"type\":\"session_meta\""),
|
||||
"expected session metadata in rollout"
|
||||
);
|
||||
let buffered_idx = text
|
||||
.find("buffered-event")
|
||||
.expect("buffered event in rollout");
|
||||
let user_idx = text
|
||||
.find("first-user-message")
|
||||
.expect("first user message in rollout");
|
||||
assert!(
|
||||
buffered_idx < user_idx,
|
||||
"buffered items should preserve ordering"
|
||||
);
|
||||
let text_after_second_persist = std::fs::read_to_string(&rollout_path)?;
|
||||
assert_eq!(text_after_second_persist, text);
|
||||
|
||||
recorder.shutdown().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn metadata_irrelevant_events_touch_state_db_updated_at() -> std::io::Result<()> {
|
||||
let home = TempDir::new().expect("temp dir");
|
||||
let mut config = ConfigBuilder::default()
|
||||
.codex_home(home.path().to_path_buf())
|
||||
.build()
|
||||
.await?;
|
||||
config
|
||||
.features
|
||||
.enable(Feature::Sqlite)
|
||||
.expect("test config should allow sqlite");
|
||||
|
||||
let state_db = StateRuntime::init(home.path().to_path_buf(), config.model_provider_id.clone())
|
||||
.await
|
||||
.expect("state db should initialize");
|
||||
state_db
|
||||
.mark_backfill_complete(None)
|
||||
.await
|
||||
.expect("backfill should be complete");
|
||||
|
||||
let thread_id = ThreadId::new();
|
||||
let recorder = RolloutRecorder::new(
|
||||
&config,
|
||||
RolloutRecorderParams::new(
|
||||
thread_id,
|
||||
None,
|
||||
SessionSource::Cli,
|
||||
BaseInstructions::default(),
|
||||
Vec::new(),
|
||||
EventPersistenceMode::Limited,
|
||||
),
|
||||
Some(state_db.clone()),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
recorder
|
||||
.record_items(&[RolloutItem::EventMsg(EventMsg::UserMessage(
|
||||
UserMessageEvent {
|
||||
message: "first-user-message".to_string(),
|
||||
images: None,
|
||||
local_images: Vec::new(),
|
||||
text_elements: Vec::new(),
|
||||
},
|
||||
))])
|
||||
.await?;
|
||||
recorder.persist().await?;
|
||||
recorder.flush().await?;
|
||||
let initial_thread = state_db
|
||||
.get_thread(thread_id)
|
||||
.await
|
||||
.expect("thread should load")
|
||||
.expect("thread should exist");
|
||||
let initial_updated_at = initial_thread.updated_at;
|
||||
let initial_title = initial_thread.title.clone();
|
||||
let initial_first_user_message = initial_thread.first_user_message.clone();
|
||||
|
||||
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||
|
||||
recorder
|
||||
.record_items(&[RolloutItem::EventMsg(EventMsg::AgentMessage(
|
||||
AgentMessageEvent {
|
||||
message: "assistant text".to_string(),
|
||||
phase: None,
|
||||
memory_citation: None,
|
||||
},
|
||||
))])
|
||||
.await?;
|
||||
recorder.flush().await?;
|
||||
|
||||
let updated_thread = state_db
|
||||
.get_thread(thread_id)
|
||||
.await
|
||||
.expect("thread should load after agent message")
|
||||
.expect("thread should still exist");
|
||||
|
||||
assert!(updated_thread.updated_at > initial_updated_at);
|
||||
assert_eq!(updated_thread.title, initial_title);
|
||||
assert_eq!(
|
||||
updated_thread.first_user_message,
|
||||
initial_first_user_message
|
||||
);
|
||||
|
||||
recorder.shutdown().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn metadata_irrelevant_events_fall_back_to_upsert_when_thread_missing() -> std::io::Result<()>
|
||||
{
|
||||
let home = TempDir::new().expect("temp dir");
|
||||
let mut config = ConfigBuilder::default()
|
||||
.codex_home(home.path().to_path_buf())
|
||||
.build()
|
||||
.await?;
|
||||
config
|
||||
.features
|
||||
.enable(Feature::Sqlite)
|
||||
.expect("test config should allow sqlite");
|
||||
|
||||
let state_db = StateRuntime::init(home.path().to_path_buf(), config.model_provider_id.clone())
|
||||
.await
|
||||
.expect("state db should initialize");
|
||||
let thread_id = ThreadId::new();
|
||||
let rollout_path = home.path().join("rollout.jsonl");
|
||||
let builder = ThreadMetadataBuilder::new(
|
||||
thread_id,
|
||||
rollout_path.clone(),
|
||||
Utc::now(),
|
||||
SessionSource::Cli,
|
||||
);
|
||||
let items = vec![RolloutItem::EventMsg(EventMsg::AgentMessage(
|
||||
AgentMessageEvent {
|
||||
message: "assistant text".to_string(),
|
||||
phase: None,
|
||||
memory_citation: None,
|
||||
},
|
||||
))];
|
||||
|
||||
sync_thread_state_after_write(
|
||||
Some(state_db.as_ref()),
|
||||
rollout_path.as_path(),
|
||||
Some(&builder),
|
||||
items.as_slice(),
|
||||
config.model_provider_id.as_str(),
|
||||
None,
|
||||
)
|
||||
.await;
|
||||
|
||||
let thread = state_db
|
||||
.get_thread(thread_id)
|
||||
.await
|
||||
.expect("thread should load after fallback")
|
||||
.expect("thread should be inserted after fallback");
|
||||
assert_eq!(thread.id, thread_id);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_threads_db_disabled_does_not_skip_paginated_items() -> std::io::Result<()> {
|
||||
let home = TempDir::new().expect("temp dir");
|
||||
let mut config = ConfigBuilder::default()
|
||||
.codex_home(home.path().to_path_buf())
|
||||
.build()
|
||||
.await?;
|
||||
config
|
||||
.features
|
||||
.disable(Feature::Sqlite)
|
||||
.expect("test config should allow sqlite to be disabled");
|
||||
|
||||
let newest = write_session_file(home.path(), "2025-01-03T12-00-00", Uuid::from_u128(9001))?;
|
||||
let middle = write_session_file(home.path(), "2025-01-02T12-00-00", Uuid::from_u128(9002))?;
|
||||
let _oldest = write_session_file(home.path(), "2025-01-01T12-00-00", Uuid::from_u128(9003))?;
|
||||
|
||||
let default_provider = config.model_provider_id.clone();
|
||||
let page1 = RolloutRecorder::list_threads(
|
||||
&config,
|
||||
1,
|
||||
None,
|
||||
ThreadSortKey::CreatedAt,
|
||||
&[],
|
||||
None,
|
||||
default_provider.as_str(),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(page1.items.len(), 1);
|
||||
assert_eq!(page1.items[0].path, newest);
|
||||
let cursor = page1.next_cursor.clone().expect("cursor should be present");
|
||||
|
||||
let page2 = RolloutRecorder::list_threads(
|
||||
&config,
|
||||
1,
|
||||
Some(&cursor),
|
||||
ThreadSortKey::CreatedAt,
|
||||
&[],
|
||||
None,
|
||||
default_provider.as_str(),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(page2.items.len(), 1);
|
||||
assert_eq!(page2.items[0].path, middle);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_threads_db_enabled_drops_missing_rollout_paths() -> std::io::Result<()> {
|
||||
let home = TempDir::new().expect("temp dir");
|
||||
let mut config = ConfigBuilder::default()
|
||||
.codex_home(home.path().to_path_buf())
|
||||
.build()
|
||||
.await?;
|
||||
config
|
||||
.features
|
||||
.enable(Feature::Sqlite)
|
||||
.expect("test config should allow sqlite");
|
||||
|
||||
let uuid = Uuid::from_u128(9010);
|
||||
let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id");
|
||||
let stale_path = home.path().join(format!(
|
||||
"sessions/2099/01/01/rollout-2099-01-01T00-00-00-{uuid}.jsonl"
|
||||
));
|
||||
|
||||
let runtime = codex_state::StateRuntime::init(
|
||||
home.path().to_path_buf(),
|
||||
config.model_provider_id.clone(),
|
||||
)
|
||||
.await
|
||||
.expect("state db should initialize");
|
||||
runtime
|
||||
.mark_backfill_complete(None)
|
||||
.await
|
||||
.expect("backfill should be complete");
|
||||
let created_at = chrono::Utc
|
||||
.with_ymd_and_hms(2025, 1, 3, 13, 0, 0)
|
||||
.single()
|
||||
.expect("valid datetime");
|
||||
let mut builder = codex_state::ThreadMetadataBuilder::new(
|
||||
thread_id,
|
||||
stale_path,
|
||||
created_at,
|
||||
SessionSource::Cli,
|
||||
);
|
||||
builder.model_provider = Some(config.model_provider_id.clone());
|
||||
builder.cwd = home.path().to_path_buf();
|
||||
let mut metadata = builder.build(config.model_provider_id.as_str());
|
||||
metadata.first_user_message = Some("Hello from user".to_string());
|
||||
runtime
|
||||
.upsert_thread(&metadata)
|
||||
.await
|
||||
.expect("state db upsert should succeed");
|
||||
|
||||
let default_provider = config.model_provider_id.clone();
|
||||
let page = RolloutRecorder::list_threads(
|
||||
&config,
|
||||
10,
|
||||
None,
|
||||
ThreadSortKey::CreatedAt,
|
||||
&[],
|
||||
None,
|
||||
default_provider.as_str(),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(page.items.len(), 0);
|
||||
let stored_path = runtime
|
||||
.find_rollout_path_by_id(thread_id, Some(false))
|
||||
.await
|
||||
.expect("state db lookup should succeed");
|
||||
assert_eq!(stored_path, None);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn list_threads_db_enabled_repairs_stale_rollout_paths() -> std::io::Result<()> {
|
||||
let home = TempDir::new().expect("temp dir");
|
||||
let mut config = ConfigBuilder::default()
|
||||
.codex_home(home.path().to_path_buf())
|
||||
.build()
|
||||
.await?;
|
||||
config
|
||||
.features
|
||||
.enable(Feature::Sqlite)
|
||||
.expect("test config should allow sqlite");
|
||||
|
||||
let uuid = Uuid::from_u128(9011);
|
||||
let thread_id = ThreadId::from_string(&uuid.to_string()).expect("valid thread id");
|
||||
let real_path = write_session_file(home.path(), "2025-01-03T13-00-00", uuid)?;
|
||||
let stale_path = home.path().join(format!(
|
||||
"sessions/2099/01/01/rollout-2099-01-01T00-00-00-{uuid}.jsonl"
|
||||
));
|
||||
|
||||
let runtime = codex_state::StateRuntime::init(
|
||||
home.path().to_path_buf(),
|
||||
config.model_provider_id.clone(),
|
||||
)
|
||||
.await
|
||||
.expect("state db should initialize");
|
||||
runtime
|
||||
.mark_backfill_complete(None)
|
||||
.await
|
||||
.expect("backfill should be complete");
|
||||
let created_at = chrono::Utc
|
||||
.with_ymd_and_hms(2025, 1, 3, 13, 0, 0)
|
||||
.single()
|
||||
.expect("valid datetime");
|
||||
let mut builder = codex_state::ThreadMetadataBuilder::new(
|
||||
thread_id,
|
||||
stale_path,
|
||||
created_at,
|
||||
SessionSource::Cli,
|
||||
);
|
||||
builder.model_provider = Some(config.model_provider_id.clone());
|
||||
builder.cwd = home.path().to_path_buf();
|
||||
let mut metadata = builder.build(config.model_provider_id.as_str());
|
||||
metadata.first_user_message = Some("Hello from user".to_string());
|
||||
runtime
|
||||
.upsert_thread(&metadata)
|
||||
.await
|
||||
.expect("state db upsert should succeed");
|
||||
|
||||
let default_provider = config.model_provider_id.clone();
|
||||
let page = RolloutRecorder::list_threads(
|
||||
&config,
|
||||
1,
|
||||
None,
|
||||
ThreadSortKey::CreatedAt,
|
||||
&[],
|
||||
None,
|
||||
default_provider.as_str(),
|
||||
None,
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(page.items.len(), 1);
|
||||
assert_eq!(page.items[0].path, real_path);
|
||||
|
||||
let repaired_path = runtime
|
||||
.find_rollout_path_by_id(thread_id, Some(false))
|
||||
.await
|
||||
.expect("state db lookup should succeed");
|
||||
assert_eq!(repaired_path, Some(real_path));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resume_candidate_matches_cwd_reads_latest_turn_context() -> std::io::Result<()> {
|
||||
let home = TempDir::new().expect("temp dir");
|
||||
let stale_cwd = home.path().join("stale");
|
||||
let latest_cwd = home.path().join("latest");
|
||||
fs::create_dir_all(&stale_cwd)?;
|
||||
fs::create_dir_all(&latest_cwd)?;
|
||||
|
||||
let path = write_session_file(home.path(), "2025-01-03T13-00-00", Uuid::from_u128(9012))?;
|
||||
let mut file = std::fs::OpenOptions::new().append(true).open(&path)?;
|
||||
let turn_context = RolloutLine {
|
||||
timestamp: "2025-01-03T13:00:01Z".to_string(),
|
||||
item: RolloutItem::TurnContext(TurnContextItem {
|
||||
turn_id: Some("turn-1".to_string()),
|
||||
trace_id: None,
|
||||
cwd: latest_cwd.clone(),
|
||||
current_date: None,
|
||||
timezone: None,
|
||||
approval_policy: AskForApproval::Never,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
network: None,
|
||||
model: "test-model".to_string(),
|
||||
personality: None,
|
||||
collaboration_mode: None,
|
||||
realtime_active: None,
|
||||
effort: None,
|
||||
summary: ReasoningSummaryConfig::Auto,
|
||||
user_instructions: None,
|
||||
developer_instructions: None,
|
||||
final_output_json_schema: None,
|
||||
truncation_policy: None,
|
||||
}),
|
||||
};
|
||||
writeln!(file, "{}", serde_json::to_string(&turn_context)?)?;
|
||||
|
||||
assert!(
|
||||
resume_candidate_matches_cwd(
|
||||
path.as_path(),
|
||||
Some(stale_cwd.as_path()),
|
||||
latest_cwd.as_path(),
|
||||
"test-provider",
|
||||
)
|
||||
.await
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,233 +0,0 @@
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
use std::fs::File;
|
||||
use std::io::Read;
|
||||
use std::io::Seek;
|
||||
use std::io::SeekFrom;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use codex_protocol::ThreadId;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use tokio::io::AsyncBufReadExt;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
|
||||
const SESSION_INDEX_FILE: &str = "session_index.jsonl";
|
||||
const READ_CHUNK_SIZE: usize = 8192;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct SessionIndexEntry {
|
||||
pub id: ThreadId,
|
||||
pub thread_name: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
/// Append a thread name update to the session index.
|
||||
/// The index is append-only; the most recent entry wins when resolving names or ids.
|
||||
pub async fn append_thread_name(
|
||||
codex_home: &Path,
|
||||
thread_id: ThreadId,
|
||||
name: &str,
|
||||
) -> std::io::Result<()> {
|
||||
use time::OffsetDateTime;
|
||||
use time::format_description::well_known::Rfc3339;
|
||||
|
||||
let updated_at = OffsetDateTime::now_utc()
|
||||
.format(&Rfc3339)
|
||||
.unwrap_or_else(|_| "unknown".to_string());
|
||||
let entry = SessionIndexEntry {
|
||||
id: thread_id,
|
||||
thread_name: name.to_string(),
|
||||
updated_at,
|
||||
};
|
||||
append_session_index_entry(codex_home, &entry).await
|
||||
}
|
||||
|
||||
/// Append a raw session index entry to `session_index.jsonl`.
|
||||
/// The file is append-only; consumers scan from the end to find the newest match.
|
||||
pub async fn append_session_index_entry(
|
||||
codex_home: &Path,
|
||||
entry: &SessionIndexEntry,
|
||||
) -> std::io::Result<()> {
|
||||
let path = session_index_path(codex_home);
|
||||
let mut file = tokio::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&path)
|
||||
.await?;
|
||||
let mut line = serde_json::to_string(entry).map_err(std::io::Error::other)?;
|
||||
line.push('\n');
|
||||
file.write_all(line.as_bytes()).await?;
|
||||
file.flush().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Find the latest thread name for a thread id, if any.
|
||||
pub async fn find_thread_name_by_id(
|
||||
codex_home: &Path,
|
||||
thread_id: &ThreadId,
|
||||
) -> std::io::Result<Option<String>> {
|
||||
let path = session_index_path(codex_home);
|
||||
if !path.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
let id = *thread_id;
|
||||
let entry = tokio::task::spawn_blocking(move || scan_index_from_end_by_id(&path, &id))
|
||||
.await
|
||||
.map_err(std::io::Error::other)??;
|
||||
Ok(entry.map(|entry| entry.thread_name))
|
||||
}
|
||||
|
||||
/// Find the latest thread names for a batch of thread ids.
|
||||
pub async fn find_thread_names_by_ids(
|
||||
codex_home: &Path,
|
||||
thread_ids: &HashSet<ThreadId>,
|
||||
) -> std::io::Result<HashMap<ThreadId, String>> {
|
||||
let path = session_index_path(codex_home);
|
||||
if thread_ids.is_empty() || !path.exists() {
|
||||
return Ok(HashMap::new());
|
||||
}
|
||||
|
||||
let file = tokio::fs::File::open(&path).await?;
|
||||
let reader = tokio::io::BufReader::new(file);
|
||||
let mut lines = reader.lines();
|
||||
let mut names = HashMap::with_capacity(thread_ids.len());
|
||||
|
||||
while let Some(line) = lines.next_line().await? {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let Ok(entry) = serde_json::from_str::<SessionIndexEntry>(trimmed) else {
|
||||
continue;
|
||||
};
|
||||
let name = entry.thread_name.trim();
|
||||
if !name.is_empty() && thread_ids.contains(&entry.id) {
|
||||
names.insert(entry.id, name.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
Ok(names)
|
||||
}
|
||||
|
||||
/// Find the most recently updated thread id for a thread name, if any.
|
||||
pub async fn find_thread_id_by_name(
|
||||
codex_home: &Path,
|
||||
name: &str,
|
||||
) -> std::io::Result<Option<ThreadId>> {
|
||||
if name.trim().is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let path = session_index_path(codex_home);
|
||||
if !path.exists() {
|
||||
return Ok(None);
|
||||
}
|
||||
let name = name.to_string();
|
||||
let entry = tokio::task::spawn_blocking(move || scan_index_from_end_by_name(&path, &name))
|
||||
.await
|
||||
.map_err(std::io::Error::other)??;
|
||||
Ok(entry.map(|entry| entry.id))
|
||||
}
|
||||
|
||||
/// Locate a recorded thread rollout file by thread name using newest-first ordering.
|
||||
/// Returns `Ok(Some(path))` if found, `Ok(None)` if not present.
|
||||
pub async fn find_thread_path_by_name_str(
|
||||
codex_home: &Path,
|
||||
name: &str,
|
||||
) -> std::io::Result<Option<PathBuf>> {
|
||||
let Some(thread_id) = find_thread_id_by_name(codex_home, name).await? else {
|
||||
return Ok(None);
|
||||
};
|
||||
super::list::find_thread_path_by_id_str(codex_home, &thread_id.to_string()).await
|
||||
}
|
||||
|
||||
fn session_index_path(codex_home: &Path) -> PathBuf {
|
||||
codex_home.join(SESSION_INDEX_FILE)
|
||||
}
|
||||
|
||||
fn scan_index_from_end_by_id(
|
||||
path: &Path,
|
||||
thread_id: &ThreadId,
|
||||
) -> std::io::Result<Option<SessionIndexEntry>> {
|
||||
scan_index_from_end(path, |entry| entry.id == *thread_id)
|
||||
}
|
||||
|
||||
fn scan_index_from_end_by_name(
|
||||
path: &Path,
|
||||
name: &str,
|
||||
) -> std::io::Result<Option<SessionIndexEntry>> {
|
||||
scan_index_from_end(path, |entry| entry.thread_name == name)
|
||||
}
|
||||
|
||||
fn scan_index_from_end<F>(
|
||||
path: &Path,
|
||||
mut predicate: F,
|
||||
) -> std::io::Result<Option<SessionIndexEntry>>
|
||||
where
|
||||
F: FnMut(&SessionIndexEntry) -> bool,
|
||||
{
|
||||
let mut file = File::open(path)?;
|
||||
let mut remaining = file.metadata()?.len();
|
||||
let mut line_rev: Vec<u8> = Vec::new();
|
||||
let mut buf = vec![0u8; READ_CHUNK_SIZE];
|
||||
|
||||
while remaining > 0 {
|
||||
let read_size = usize::try_from(remaining.min(READ_CHUNK_SIZE as u64))
|
||||
.map_err(std::io::Error::other)?;
|
||||
remaining -= read_size as u64;
|
||||
file.seek(SeekFrom::Start(remaining))?;
|
||||
file.read_exact(&mut buf[..read_size])?;
|
||||
|
||||
for &byte in buf[..read_size].iter().rev() {
|
||||
if byte == b'\n' {
|
||||
if let Some(entry) = parse_line_from_rev(&mut line_rev, &mut predicate)? {
|
||||
return Ok(Some(entry));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
line_rev.push(byte);
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(entry) = parse_line_from_rev(&mut line_rev, &mut predicate)? {
|
||||
return Ok(Some(entry));
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
fn parse_line_from_rev<F>(
|
||||
line_rev: &mut Vec<u8>,
|
||||
predicate: &mut F,
|
||||
) -> std::io::Result<Option<SessionIndexEntry>>
|
||||
where
|
||||
F: FnMut(&SessionIndexEntry) -> bool,
|
||||
{
|
||||
if line_rev.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
line_rev.reverse();
|
||||
let line = std::mem::take(line_rev);
|
||||
let Ok(mut line) = String::from_utf8(line) else {
|
||||
return Ok(None);
|
||||
};
|
||||
if line.ends_with('\r') {
|
||||
line.pop();
|
||||
}
|
||||
let trimmed = line.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let Ok(entry) = serde_json::from_str::<SessionIndexEntry>(trimmed) else {
|
||||
return Ok(None);
|
||||
};
|
||||
if predicate(&entry) {
|
||||
return Ok(Some(entry));
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "session_index_tests.rs"]
|
||||
mod tests;
|
||||
@@ -1,167 +0,0 @@
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
use tempfile::TempDir;
|
||||
fn write_index(path: &Path, lines: &[SessionIndexEntry]) -> std::io::Result<()> {
|
||||
let mut out = String::new();
|
||||
for entry in lines {
|
||||
out.push_str(&serde_json::to_string(entry).unwrap());
|
||||
out.push('\n');
|
||||
}
|
||||
std::fs::write(path, out)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_thread_id_by_name_prefers_latest_entry() -> std::io::Result<()> {
|
||||
let temp = TempDir::new()?;
|
||||
let path = session_index_path(temp.path());
|
||||
let id1 = ThreadId::new();
|
||||
let id2 = ThreadId::new();
|
||||
let lines = vec![
|
||||
SessionIndexEntry {
|
||||
id: id1,
|
||||
thread_name: "same".to_string(),
|
||||
updated_at: "2024-01-01T00:00:00Z".to_string(),
|
||||
},
|
||||
SessionIndexEntry {
|
||||
id: id2,
|
||||
thread_name: "same".to_string(),
|
||||
updated_at: "2024-01-02T00:00:00Z".to_string(),
|
||||
},
|
||||
];
|
||||
write_index(&path, &lines)?;
|
||||
|
||||
let found = scan_index_from_end_by_name(&path, "same")?;
|
||||
assert_eq!(found.map(|entry| entry.id), Some(id2));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_thread_name_by_id_prefers_latest_entry() -> std::io::Result<()> {
|
||||
let temp = TempDir::new()?;
|
||||
let path = session_index_path(temp.path());
|
||||
let id = ThreadId::new();
|
||||
let lines = vec![
|
||||
SessionIndexEntry {
|
||||
id,
|
||||
thread_name: "first".to_string(),
|
||||
updated_at: "2024-01-01T00:00:00Z".to_string(),
|
||||
},
|
||||
SessionIndexEntry {
|
||||
id,
|
||||
thread_name: "second".to_string(),
|
||||
updated_at: "2024-01-02T00:00:00Z".to_string(),
|
||||
},
|
||||
];
|
||||
write_index(&path, &lines)?;
|
||||
|
||||
let found = scan_index_from_end_by_id(&path, &id)?;
|
||||
assert_eq!(
|
||||
found.map(|entry| entry.thread_name),
|
||||
Some("second".to_string())
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_index_returns_none_when_entry_missing() -> std::io::Result<()> {
|
||||
let temp = TempDir::new()?;
|
||||
let path = session_index_path(temp.path());
|
||||
let id = ThreadId::new();
|
||||
let lines = vec![SessionIndexEntry {
|
||||
id,
|
||||
thread_name: "present".to_string(),
|
||||
updated_at: "2024-01-01T00:00:00Z".to_string(),
|
||||
}];
|
||||
write_index(&path, &lines)?;
|
||||
|
||||
let missing_name = scan_index_from_end_by_name(&path, "missing")?;
|
||||
assert_eq!(missing_name, None);
|
||||
|
||||
let missing_id = scan_index_from_end_by_id(&path, &ThreadId::new())?;
|
||||
assert_eq!(missing_id, None);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn find_thread_names_by_ids_prefers_latest_entry() -> std::io::Result<()> {
|
||||
let temp = TempDir::new()?;
|
||||
let path = session_index_path(temp.path());
|
||||
let id1 = ThreadId::new();
|
||||
let id2 = ThreadId::new();
|
||||
let lines = vec![
|
||||
SessionIndexEntry {
|
||||
id: id1,
|
||||
thread_name: "first".to_string(),
|
||||
updated_at: "2024-01-01T00:00:00Z".to_string(),
|
||||
},
|
||||
SessionIndexEntry {
|
||||
id: id2,
|
||||
thread_name: "other".to_string(),
|
||||
updated_at: "2024-01-01T00:00:00Z".to_string(),
|
||||
},
|
||||
SessionIndexEntry {
|
||||
id: id1,
|
||||
thread_name: "latest".to_string(),
|
||||
updated_at: "2024-01-02T00:00:00Z".to_string(),
|
||||
},
|
||||
];
|
||||
write_index(&path, &lines)?;
|
||||
|
||||
let mut ids = HashSet::new();
|
||||
ids.insert(id1);
|
||||
ids.insert(id2);
|
||||
|
||||
let mut expected = HashMap::new();
|
||||
expected.insert(id1, "latest".to_string());
|
||||
expected.insert(id2, "other".to_string());
|
||||
|
||||
let found = find_thread_names_by_ids(temp.path(), &ids).await?;
|
||||
assert_eq!(found, expected);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn scan_index_finds_latest_match_among_mixed_entries() -> std::io::Result<()> {
|
||||
let temp = TempDir::new()?;
|
||||
let path = session_index_path(temp.path());
|
||||
let id_target = ThreadId::new();
|
||||
let id_other = ThreadId::new();
|
||||
let expected = SessionIndexEntry {
|
||||
id: id_target,
|
||||
thread_name: "target".to_string(),
|
||||
updated_at: "2024-01-03T00:00:00Z".to_string(),
|
||||
};
|
||||
let expected_other = SessionIndexEntry {
|
||||
id: id_other,
|
||||
thread_name: "target".to_string(),
|
||||
updated_at: "2024-01-02T00:00:00Z".to_string(),
|
||||
};
|
||||
// Resolution is based on append order (scan from end), not updated_at.
|
||||
let lines = vec![
|
||||
SessionIndexEntry {
|
||||
id: id_target,
|
||||
thread_name: "target".to_string(),
|
||||
updated_at: "2024-01-01T00:00:00Z".to_string(),
|
||||
},
|
||||
expected_other.clone(),
|
||||
expected.clone(),
|
||||
SessionIndexEntry {
|
||||
id: ThreadId::new(),
|
||||
thread_name: "another".to_string(),
|
||||
updated_at: "2024-01-04T00:00:00Z".to_string(),
|
||||
},
|
||||
];
|
||||
write_index(&path, &lines)?;
|
||||
|
||||
let found_by_name = scan_index_from_end_by_name(&path, "target")?;
|
||||
assert_eq!(found_by_name, Some(expected.clone()));
|
||||
|
||||
let found_by_id = scan_index_from_end_by_id(&path, &id_target)?;
|
||||
assert_eq!(found_by_id, Some(expected));
|
||||
|
||||
let found_other_by_id = scan_index_from_end_by_id(&path, &id_other)?;
|
||||
assert_eq!(found_other_by_id, Some(expected_other));
|
||||
Ok(())
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -11,6 +11,7 @@ use crate::exec::ExecCapturePolicy;
|
||||
use crate::exec::ExecExpiration;
|
||||
use crate::exec::ExecToolCallOutput;
|
||||
use crate::exec::StdoutStream;
|
||||
use crate::exec::WindowsRestrictedTokenFilesystemOverlay;
|
||||
use crate::exec::execute_exec_request;
|
||||
#[cfg(target_os = "macos")]
|
||||
use crate::spawn::CODEX_SANDBOX_ENV_VAR;
|
||||
@@ -30,8 +31,6 @@ use std::path::PathBuf;
|
||||
pub(crate) struct ExecOptions {
|
||||
pub(crate) expiration: ExecExpiration,
|
||||
pub(crate) capture_policy: ExecCapturePolicy,
|
||||
pub(crate) sandbox_permissions: SandboxPermissions,
|
||||
pub(crate) justification: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -45,15 +44,49 @@ pub struct ExecRequest {
|
||||
pub sandbox: SandboxType,
|
||||
pub windows_sandbox_level: WindowsSandboxLevel,
|
||||
pub windows_sandbox_private_desktop: bool,
|
||||
pub sandbox_permissions: SandboxPermissions,
|
||||
pub sandbox_policy: SandboxPolicy,
|
||||
pub file_system_sandbox_policy: FileSystemSandboxPolicy,
|
||||
pub network_sandbox_policy: NetworkSandboxPolicy,
|
||||
pub justification: Option<String>,
|
||||
pub(crate) windows_restricted_token_filesystem_overlay:
|
||||
Option<WindowsRestrictedTokenFilesystemOverlay>,
|
||||
pub arg0: Option<String>,
|
||||
}
|
||||
|
||||
impl ExecRequest {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn new(
|
||||
command: Vec<String>,
|
||||
cwd: PathBuf,
|
||||
env: HashMap<String, String>,
|
||||
network: Option<NetworkProxy>,
|
||||
expiration: ExecExpiration,
|
||||
capture_policy: ExecCapturePolicy,
|
||||
sandbox: SandboxType,
|
||||
windows_sandbox_level: WindowsSandboxLevel,
|
||||
windows_sandbox_private_desktop: bool,
|
||||
sandbox_policy: SandboxPolicy,
|
||||
file_system_sandbox_policy: FileSystemSandboxPolicy,
|
||||
network_sandbox_policy: NetworkSandboxPolicy,
|
||||
arg0: Option<String>,
|
||||
) -> Self {
|
||||
Self {
|
||||
command,
|
||||
cwd,
|
||||
env,
|
||||
network,
|
||||
expiration,
|
||||
capture_policy,
|
||||
sandbox,
|
||||
windows_sandbox_level,
|
||||
windows_sandbox_private_desktop,
|
||||
sandbox_policy,
|
||||
file_system_sandbox_policy,
|
||||
network_sandbox_policy,
|
||||
windows_restricted_token_filesystem_overlay: None,
|
||||
arg0,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn from_sandbox_exec_request(
|
||||
request: SandboxExecRequest,
|
||||
options: ExecOptions,
|
||||
@@ -74,8 +107,6 @@ impl ExecRequest {
|
||||
let ExecOptions {
|
||||
expiration,
|
||||
capture_policy,
|
||||
sandbox_permissions,
|
||||
justification,
|
||||
} = options;
|
||||
if !network_sandbox_policy.is_enabled() {
|
||||
env.insert(
|
||||
@@ -97,11 +128,10 @@ impl ExecRequest {
|
||||
sandbox,
|
||||
windows_sandbox_level,
|
||||
windows_sandbox_private_desktop,
|
||||
sandbox_permissions,
|
||||
sandbox_policy,
|
||||
file_system_sandbox_policy,
|
||||
network_sandbox_policy,
|
||||
justification,
|
||||
windows_restricted_token_filesystem_overlay: None,
|
||||
arg0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
use std::env;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::codex::Session;
|
||||
use crate::codex::TurnContext;
|
||||
use crate::config::Config;
|
||||
use codex_analytics::InvocationType;
|
||||
use codex_analytics::SkillInvocation;
|
||||
use codex_analytics::build_track_events_context;
|
||||
use codex_protocol::protocol::SkillScope;
|
||||
use codex_protocol::request_user_input::RequestUserInputArgs;
|
||||
use codex_protocol::request_user_input::RequestUserInputQuestion;
|
||||
use codex_protocol::request_user_input::RequestUserInputResponse;
|
||||
use tracing::warn;
|
||||
|
||||
pub use codex_core_skills::SkillDependencyInfo;
|
||||
pub use codex_core_skills::SkillError;
|
||||
pub use codex_core_skills::SkillLoadOutcome;
|
||||
pub use codex_core_skills::SkillMetadata;
|
||||
pub use codex_core_skills::SkillPolicy;
|
||||
pub use codex_core_skills::SkillsLoadInput;
|
||||
pub use codex_core_skills::SkillsManager;
|
||||
pub use codex_core_skills::build_skill_name_counts;
|
||||
pub use codex_core_skills::collect_env_var_dependencies;
|
||||
pub use codex_core_skills::config_rules;
|
||||
pub use codex_core_skills::detect_implicit_skill_invocation_for_command;
|
||||
pub use codex_core_skills::filter_skill_load_outcome_for_product;
|
||||
pub use codex_core_skills::injection;
|
||||
pub use codex_core_skills::injection::SkillInjections;
|
||||
pub use codex_core_skills::injection::build_skill_injections;
|
||||
pub use codex_core_skills::injection::collect_explicit_skill_mentions;
|
||||
pub use codex_core_skills::loader;
|
||||
pub use codex_core_skills::manager;
|
||||
pub use codex_core_skills::model;
|
||||
pub use codex_core_skills::remote;
|
||||
pub use codex_core_skills::render;
|
||||
pub use codex_core_skills::render_skills_section;
|
||||
pub use codex_core_skills::system;
|
||||
|
||||
pub(crate) fn skills_load_input_from_config(
|
||||
config: &Config,
|
||||
effective_skill_roots: Vec<PathBuf>,
|
||||
) -> SkillsLoadInput {
|
||||
SkillsLoadInput::new(
|
||||
config.cwd.clone().to_path_buf(),
|
||||
effective_skill_roots,
|
||||
config.config_layer_stack.clone(),
|
||||
config.bundled_skills_enabled(),
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) async fn resolve_skill_dependencies_for_turn(
|
||||
sess: &Arc<Session>,
|
||||
turn_context: &Arc<TurnContext>,
|
||||
dependencies: &[SkillDependencyInfo],
|
||||
) {
|
||||
if dependencies.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let existing_env = sess.dependency_env().await;
|
||||
let mut loaded_values = HashMap::new();
|
||||
let mut missing = Vec::new();
|
||||
let mut seen_names = HashSet::new();
|
||||
|
||||
for dependency in dependencies {
|
||||
let name = dependency.name.clone();
|
||||
if !seen_names.insert(name.clone()) || existing_env.contains_key(&name) {
|
||||
continue;
|
||||
}
|
||||
match env::var(&name) {
|
||||
Ok(value) => {
|
||||
loaded_values.insert(name.clone(), value);
|
||||
}
|
||||
Err(env::VarError::NotPresent) => {
|
||||
missing.push(dependency.clone());
|
||||
}
|
||||
Err(err) => {
|
||||
warn!("failed to read env var {name}: {err}");
|
||||
missing.push(dependency.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !loaded_values.is_empty() {
|
||||
sess.set_dependency_env(loaded_values).await;
|
||||
}
|
||||
|
||||
if !missing.is_empty() {
|
||||
request_skill_dependencies(sess, turn_context, &missing).await;
|
||||
}
|
||||
}
|
||||
|
||||
async fn request_skill_dependencies(
|
||||
sess: &Arc<Session>,
|
||||
turn_context: &Arc<TurnContext>,
|
||||
dependencies: &[SkillDependencyInfo],
|
||||
) {
|
||||
let questions = dependencies
|
||||
.iter()
|
||||
.map(|dependency| {
|
||||
let requirement = dependency.description.as_ref().map_or_else(
|
||||
|| {
|
||||
format!(
|
||||
"The skill \"{}\" requires \"{}\" to be set.",
|
||||
dependency.skill_name, dependency.name
|
||||
)
|
||||
},
|
||||
|description| {
|
||||
format!(
|
||||
"The skill \"{}\" requires \"{}\" to be set ({}).",
|
||||
dependency.skill_name, dependency.name, description
|
||||
)
|
||||
},
|
||||
);
|
||||
RequestUserInputQuestion {
|
||||
id: dependency.name.clone(),
|
||||
header: "Skill requires environment variable".to_string(),
|
||||
question: format!(
|
||||
"{requirement} This is an experimental internal feature. The value is stored in memory for this session only."
|
||||
),
|
||||
is_other: false,
|
||||
is_secret: true,
|
||||
options: None,
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
if questions.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let response = sess
|
||||
.request_user_input(
|
||||
turn_context,
|
||||
format!("skill-deps-{}", turn_context.sub_id),
|
||||
RequestUserInputArgs { questions },
|
||||
)
|
||||
.await
|
||||
.unwrap_or_else(|| RequestUserInputResponse {
|
||||
answers: HashMap::new(),
|
||||
});
|
||||
if response.answers.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut values = HashMap::new();
|
||||
for (name, answer) in response.answers {
|
||||
let mut user_note = None;
|
||||
for entry in &answer.answers {
|
||||
if let Some(note) = entry.strip_prefix("user_note: ")
|
||||
&& !note.trim().is_empty()
|
||||
{
|
||||
user_note = Some(note.trim().to_string());
|
||||
}
|
||||
}
|
||||
if let Some(value) = user_note {
|
||||
values.insert(name, value);
|
||||
}
|
||||
}
|
||||
if values.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
sess.set_dependency_env(values).await;
|
||||
}
|
||||
|
||||
pub(crate) async fn maybe_emit_implicit_skill_invocation(
|
||||
sess: &Session,
|
||||
turn_context: &TurnContext,
|
||||
command: &str,
|
||||
workdir: &Path,
|
||||
) {
|
||||
let Some(candidate) = detect_implicit_skill_invocation_for_command(
|
||||
turn_context.turn_skills.outcome.as_ref(),
|
||||
command,
|
||||
workdir,
|
||||
) else {
|
||||
return;
|
||||
};
|
||||
let invocation = SkillInvocation {
|
||||
skill_name: candidate.name,
|
||||
skill_scope: candidate.scope,
|
||||
skill_path: candidate.path_to_skills_md,
|
||||
invocation_type: InvocationType::Implicit,
|
||||
};
|
||||
let skill_scope = match invocation.skill_scope {
|
||||
SkillScope::User => "user",
|
||||
SkillScope::Repo => "repo",
|
||||
SkillScope::System => "system",
|
||||
SkillScope::Admin => "admin",
|
||||
};
|
||||
let skill_path = invocation.skill_path.to_string_lossy();
|
||||
let skill_name = invocation.skill_name.clone();
|
||||
let seen_key = format!("{skill_scope}:{skill_path}:{skill_name}");
|
||||
let inserted = {
|
||||
let mut seen_skills = turn_context
|
||||
.turn_skills
|
||||
.implicit_invocation_seen_skills
|
||||
.lock()
|
||||
.await;
|
||||
seen_skills.insert(seen_key)
|
||||
};
|
||||
if !inserted {
|
||||
return;
|
||||
}
|
||||
|
||||
turn_context.session_telemetry.counter(
|
||||
"codex.skill.injected",
|
||||
/*inc*/ 1,
|
||||
&[
|
||||
("status", "ok"),
|
||||
("skill", skill_name.as_str()),
|
||||
("invoke_type", "implicit"),
|
||||
],
|
||||
);
|
||||
sess.services
|
||||
.analytics_events_client
|
||||
.track_skill_invocations(
|
||||
build_track_events_context(
|
||||
turn_context.model_info.slug.clone(),
|
||||
sess.conversation_id.to_string(),
|
||||
turn_context.sub_id.clone(),
|
||||
),
|
||||
vec![invocation],
|
||||
);
|
||||
}
|
||||
@@ -1,135 +0,0 @@
|
||||
use std::collections::HashSet;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use codex_app_server_protocol::ConfigLayerSource;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::config::types::SkillConfig;
|
||||
use crate::config::types::SkillsConfig;
|
||||
use crate::config_loader::ConfigLayerStack;
|
||||
use crate::config_loader::ConfigLayerStackOrdering;
|
||||
use crate::skills::SkillMetadata;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
|
||||
pub(crate) enum SkillConfigRuleSelector {
|
||||
Name(String),
|
||||
Path(PathBuf),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub(crate) struct SkillConfigRule {
|
||||
pub selector: SkillConfigRuleSelector,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Hash)]
|
||||
pub(crate) struct SkillConfigRules {
|
||||
pub entries: Vec<SkillConfigRule>,
|
||||
}
|
||||
|
||||
pub(crate) fn skill_config_rules_from_stack(
|
||||
config_layer_stack: &ConfigLayerStack,
|
||||
) -> SkillConfigRules {
|
||||
let mut entries = Vec::new();
|
||||
for layer in config_layer_stack.get_layers(
|
||||
ConfigLayerStackOrdering::LowestPrecedenceFirst,
|
||||
/*include_disabled*/ true,
|
||||
) {
|
||||
if !matches!(
|
||||
layer.name,
|
||||
ConfigLayerSource::User { .. } | ConfigLayerSource::SessionFlags
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let Some(skills_value) = layer.config.get("skills") else {
|
||||
continue;
|
||||
};
|
||||
let skills: SkillsConfig = match skills_value.clone().try_into() {
|
||||
Ok(skills) => skills,
|
||||
Err(err) => {
|
||||
warn!("invalid skills config: {err}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
for entry in skills.config {
|
||||
let Some(selector) = skill_config_rule_selector(&entry) else {
|
||||
continue;
|
||||
};
|
||||
// Preserve layer order so a later name selector can override an earlier path selector
|
||||
// for the same loaded skill.
|
||||
entries.retain(|entry: &SkillConfigRule| entry.selector != selector);
|
||||
entries.push(SkillConfigRule {
|
||||
selector,
|
||||
enabled: entry.enabled,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
SkillConfigRules { entries }
|
||||
}
|
||||
|
||||
pub(crate) fn resolve_disabled_skill_paths(
|
||||
skills: &[SkillMetadata],
|
||||
rules: &SkillConfigRules,
|
||||
) -> HashSet<PathBuf> {
|
||||
let mut disabled_paths = HashSet::new();
|
||||
|
||||
for entry in &rules.entries {
|
||||
match &entry.selector {
|
||||
SkillConfigRuleSelector::Path(path) => {
|
||||
if entry.enabled {
|
||||
disabled_paths.remove(path);
|
||||
} else {
|
||||
disabled_paths.insert(path.clone());
|
||||
}
|
||||
}
|
||||
SkillConfigRuleSelector::Name(name) => {
|
||||
for path in skills
|
||||
.iter()
|
||||
.filter(|skill| skill.name == *name)
|
||||
.map(|skill| skill.path_to_skills_md.clone())
|
||||
{
|
||||
if entry.enabled {
|
||||
disabled_paths.remove(&path);
|
||||
} else {
|
||||
disabled_paths.insert(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
disabled_paths
|
||||
}
|
||||
|
||||
fn skill_config_rule_selector(entry: &SkillConfig) -> Option<SkillConfigRuleSelector> {
|
||||
match (entry.path.as_ref(), entry.name.as_deref()) {
|
||||
(Some(path), None) => Some(SkillConfigRuleSelector::Path(normalize_rule_path(
|
||||
path.as_path(),
|
||||
))),
|
||||
(None, Some(name)) => {
|
||||
let name = name.trim();
|
||||
if name.is_empty() {
|
||||
warn!("ignoring empty skills.config name override");
|
||||
None
|
||||
} else {
|
||||
Some(SkillConfigRuleSelector::Name(name.to_string()))
|
||||
}
|
||||
}
|
||||
(Some(_), Some(_)) => {
|
||||
warn!("ignoring skills.config entry with both path and name selectors");
|
||||
None
|
||||
}
|
||||
(None, None) => {
|
||||
warn!("ignoring skills.config entry without a path or name selector");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_rule_path(path: &Path) -> PathBuf {
|
||||
dunce::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
|
||||
}
|
||||
@@ -1,162 +0,0 @@
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
use std::env;
|
||||
use std::sync::Arc;
|
||||
|
||||
use codex_protocol::request_user_input::RequestUserInputArgs;
|
||||
use codex_protocol::request_user_input::RequestUserInputQuestion;
|
||||
use codex_protocol::request_user_input::RequestUserInputResponse;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::codex::Session;
|
||||
use crate::codex::TurnContext;
|
||||
use crate::skills::SkillMetadata;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct SkillDependencyInfo {
|
||||
pub(crate) skill_name: String,
|
||||
pub(crate) name: String,
|
||||
pub(crate) description: Option<String>,
|
||||
}
|
||||
|
||||
/// Resolve required dependency values (session cache, then env vars),
|
||||
/// and prompt the UI for any missing ones.
|
||||
pub(crate) async fn resolve_skill_dependencies_for_turn(
|
||||
sess: &Arc<Session>,
|
||||
turn_context: &Arc<TurnContext>,
|
||||
dependencies: &[SkillDependencyInfo],
|
||||
) {
|
||||
if dependencies.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let existing_env = sess.dependency_env().await;
|
||||
let mut loaded_values = HashMap::new();
|
||||
let mut missing = Vec::new();
|
||||
let mut seen_names = HashSet::new();
|
||||
|
||||
for dependency in dependencies {
|
||||
let name = dependency.name.clone();
|
||||
if !seen_names.insert(name.clone()) {
|
||||
continue;
|
||||
}
|
||||
if existing_env.contains_key(&name) {
|
||||
continue;
|
||||
}
|
||||
match env::var(&name) {
|
||||
Ok(value) => {
|
||||
loaded_values.insert(name.clone(), value);
|
||||
continue;
|
||||
}
|
||||
Err(env::VarError::NotPresent) => {}
|
||||
Err(err) => {
|
||||
warn!("failed to read env var {name}: {err}");
|
||||
}
|
||||
}
|
||||
missing.push(dependency.clone());
|
||||
}
|
||||
|
||||
if !loaded_values.is_empty() {
|
||||
sess.set_dependency_env(loaded_values).await;
|
||||
}
|
||||
|
||||
if !missing.is_empty() {
|
||||
request_skill_dependencies(sess, turn_context, &missing).await;
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn collect_env_var_dependencies(
|
||||
mentioned_skills: &[SkillMetadata],
|
||||
) -> Vec<SkillDependencyInfo> {
|
||||
let mut dependencies = Vec::new();
|
||||
for skill in mentioned_skills {
|
||||
let Some(skill_dependencies) = &skill.dependencies else {
|
||||
continue;
|
||||
};
|
||||
for tool in &skill_dependencies.tools {
|
||||
if tool.r#type != "env_var" {
|
||||
continue;
|
||||
}
|
||||
if tool.value.is_empty() {
|
||||
continue;
|
||||
}
|
||||
dependencies.push(SkillDependencyInfo {
|
||||
skill_name: skill.name.clone(),
|
||||
name: tool.value.clone(),
|
||||
description: tool.description.clone(),
|
||||
});
|
||||
}
|
||||
}
|
||||
dependencies
|
||||
}
|
||||
|
||||
/// Prompt via request_user_input to gather missing env vars.
|
||||
pub(crate) async fn request_skill_dependencies(
|
||||
sess: &Arc<Session>,
|
||||
turn_context: &Arc<TurnContext>,
|
||||
dependencies: &[SkillDependencyInfo],
|
||||
) {
|
||||
let questions = dependencies
|
||||
.iter()
|
||||
.map(|dep| {
|
||||
let requirement = dep.description.as_ref().map_or_else(
|
||||
|| format!("The skill \"{}\" requires \"{}\" to be set.", dep.skill_name, dep.name),
|
||||
|description| {
|
||||
format!(
|
||||
"The skill \"{}\" requires \"{}\" to be set ({}).",
|
||||
dep.skill_name, dep.name, description
|
||||
)
|
||||
},
|
||||
);
|
||||
let question = format!(
|
||||
"{requirement} This is an experimental internal feature. The value is stored in memory for this session only.",
|
||||
);
|
||||
RequestUserInputQuestion {
|
||||
id: dep.name.clone(),
|
||||
header: "Skill requires environment variable".to_string(),
|
||||
question,
|
||||
is_other: false,
|
||||
is_secret: true,
|
||||
options: None,
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if questions.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let args = RequestUserInputArgs { questions };
|
||||
let call_id = format!("skill-deps-{}", turn_context.sub_id);
|
||||
let response = sess
|
||||
.request_user_input(turn_context, call_id, args)
|
||||
.await
|
||||
.unwrap_or_else(|| RequestUserInputResponse {
|
||||
answers: HashMap::new(),
|
||||
});
|
||||
|
||||
if response.answers.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut values = HashMap::new();
|
||||
for (name, answer) in response.answers {
|
||||
let mut user_note = None;
|
||||
for entry in &answer.answers {
|
||||
if let Some(note) = entry.strip_prefix("user_note: ")
|
||||
&& !note.trim().is_empty()
|
||||
{
|
||||
user_note = Some(note.trim().to_string());
|
||||
}
|
||||
}
|
||||
if let Some(value) = user_note {
|
||||
values.insert(name, value);
|
||||
}
|
||||
}
|
||||
|
||||
if values.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
sess.set_dependency_env(values).await;
|
||||
}
|
||||
@@ -1,493 +0,0 @@
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::analytics_client::AnalyticsEventsClient;
|
||||
use crate::analytics_client::InvocationType;
|
||||
use crate::analytics_client::SkillInvocation;
|
||||
use crate::analytics_client::TrackEventsContext;
|
||||
use crate::instructions::SkillInstructions;
|
||||
use crate::mention_syntax::TOOL_MENTION_SIGIL;
|
||||
use crate::mentions::build_skill_name_counts;
|
||||
use crate::skills::SkillMetadata;
|
||||
use codex_otel::SessionTelemetry;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
use tokio::fs;
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub(crate) struct SkillInjections {
|
||||
pub(crate) items: Vec<ResponseItem>,
|
||||
pub(crate) warnings: Vec<String>,
|
||||
}
|
||||
|
||||
pub(crate) async fn build_skill_injections(
|
||||
mentioned_skills: &[SkillMetadata],
|
||||
otel: Option<&SessionTelemetry>,
|
||||
analytics_client: &AnalyticsEventsClient,
|
||||
tracking: TrackEventsContext,
|
||||
) -> SkillInjections {
|
||||
if mentioned_skills.is_empty() {
|
||||
return SkillInjections::default();
|
||||
}
|
||||
|
||||
let mut result = SkillInjections {
|
||||
items: Vec::with_capacity(mentioned_skills.len()),
|
||||
warnings: Vec::new(),
|
||||
};
|
||||
let mut invocations = Vec::new();
|
||||
|
||||
for skill in mentioned_skills {
|
||||
match fs::read_to_string(&skill.path_to_skills_md).await {
|
||||
Ok(contents) => {
|
||||
emit_skill_injected_metric(otel, skill, "ok");
|
||||
invocations.push(SkillInvocation {
|
||||
skill_name: skill.name.clone(),
|
||||
skill_scope: skill.scope,
|
||||
skill_path: skill.path_to_skills_md.clone(),
|
||||
invocation_type: InvocationType::Explicit,
|
||||
});
|
||||
result.items.push(ResponseItem::from(SkillInstructions {
|
||||
name: skill.name.clone(),
|
||||
path: skill.path_to_skills_md.to_string_lossy().into_owned(),
|
||||
contents,
|
||||
}));
|
||||
}
|
||||
Err(err) => {
|
||||
emit_skill_injected_metric(otel, skill, "error");
|
||||
let message = format!(
|
||||
"Failed to load skill {name} at {path}: {err:#}",
|
||||
name = skill.name,
|
||||
path = skill.path_to_skills_md.display()
|
||||
);
|
||||
result.warnings.push(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
analytics_client.track_skill_invocations(tracking, invocations);
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
fn emit_skill_injected_metric(
|
||||
otel: Option<&SessionTelemetry>,
|
||||
skill: &SkillMetadata,
|
||||
status: &str,
|
||||
) {
|
||||
let Some(otel) = otel else {
|
||||
return;
|
||||
};
|
||||
|
||||
otel.counter(
|
||||
"codex.skill.injected",
|
||||
/*inc*/ 1,
|
||||
&[("status", status), ("skill", skill.name.as_str())],
|
||||
);
|
||||
}
|
||||
|
||||
/// Collect explicitly mentioned skills from structured and text mentions.
|
||||
///
|
||||
/// Structured `UserInput::Skill` selections are resolved first by path against
|
||||
/// enabled skills. Text inputs are then scanned to extract `$skill-name` tokens, and we
|
||||
/// iterate `skills` in their existing order to preserve prior ordering semantics.
|
||||
/// Explicit links are resolved by path and plain names are only used when the match
|
||||
/// is unambiguous.
|
||||
///
|
||||
/// Complexity: `O(T + (N_s + N_t) * S)` time, `O(S + M)` space, where:
|
||||
/// `S` = number of skills, `T` = total text length, `N_s` = number of structured skill inputs,
|
||||
/// `N_t` = number of text inputs, `M` = max mentions parsed from a single text input.
|
||||
pub(crate) fn collect_explicit_skill_mentions(
|
||||
inputs: &[UserInput],
|
||||
skills: &[SkillMetadata],
|
||||
disabled_paths: &HashSet<PathBuf>,
|
||||
connector_slug_counts: &HashMap<String, usize>,
|
||||
) -> Vec<SkillMetadata> {
|
||||
let skill_name_counts = build_skill_name_counts(skills, disabled_paths).0;
|
||||
|
||||
let selection_context = SkillSelectionContext {
|
||||
skills,
|
||||
disabled_paths,
|
||||
skill_name_counts: &skill_name_counts,
|
||||
connector_slug_counts,
|
||||
};
|
||||
let mut selected: Vec<SkillMetadata> = Vec::new();
|
||||
let mut seen_names: HashSet<String> = HashSet::new();
|
||||
let mut seen_paths: HashSet<PathBuf> = HashSet::new();
|
||||
let mut blocked_plain_names: HashSet<String> = HashSet::new();
|
||||
|
||||
for input in inputs {
|
||||
if let UserInput::Skill { name, path } = input {
|
||||
blocked_plain_names.insert(name.clone());
|
||||
if selection_context.disabled_paths.contains(path) || seen_paths.contains(path) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(skill) = selection_context
|
||||
.skills
|
||||
.iter()
|
||||
.find(|skill| skill.path_to_skills_md.as_path() == path.as_path())
|
||||
{
|
||||
seen_paths.insert(skill.path_to_skills_md.clone());
|
||||
seen_names.insert(skill.name.clone());
|
||||
selected.push(skill.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for input in inputs {
|
||||
if let UserInput::Text { text, .. } = input {
|
||||
let mentioned_names = extract_tool_mentions(text);
|
||||
select_skills_from_mentions(
|
||||
&selection_context,
|
||||
&blocked_plain_names,
|
||||
&mentioned_names,
|
||||
&mut seen_names,
|
||||
&mut seen_paths,
|
||||
&mut selected,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
selected
|
||||
}
|
||||
|
||||
struct SkillSelectionContext<'a> {
|
||||
skills: &'a [SkillMetadata],
|
||||
disabled_paths: &'a HashSet<PathBuf>,
|
||||
skill_name_counts: &'a HashMap<String, usize>,
|
||||
connector_slug_counts: &'a HashMap<String, usize>,
|
||||
}
|
||||
|
||||
pub(crate) struct ToolMentions<'a> {
|
||||
names: HashSet<&'a str>,
|
||||
paths: HashSet<&'a str>,
|
||||
plain_names: HashSet<&'a str>,
|
||||
}
|
||||
|
||||
impl<'a> ToolMentions<'a> {
|
||||
fn is_empty(&self) -> bool {
|
||||
self.names.is_empty() && self.paths.is_empty()
|
||||
}
|
||||
|
||||
pub(crate) fn plain_names(&self) -> impl Iterator<Item = &'a str> + '_ {
|
||||
self.plain_names.iter().copied()
|
||||
}
|
||||
|
||||
pub(crate) fn paths(&self) -> impl Iterator<Item = &'a str> + '_ {
|
||||
self.paths.iter().copied()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) enum ToolMentionKind {
|
||||
App,
|
||||
Mcp,
|
||||
Plugin,
|
||||
Skill,
|
||||
Other,
|
||||
}
|
||||
|
||||
const APP_PATH_PREFIX: &str = "app://";
|
||||
const MCP_PATH_PREFIX: &str = "mcp://";
|
||||
const PLUGIN_PATH_PREFIX: &str = "plugin://";
|
||||
const SKILL_PATH_PREFIX: &str = "skill://";
|
||||
const SKILL_FILENAME: &str = "SKILL.md";
|
||||
|
||||
pub(crate) fn tool_kind_for_path(path: &str) -> ToolMentionKind {
|
||||
if path.starts_with(APP_PATH_PREFIX) {
|
||||
ToolMentionKind::App
|
||||
} else if path.starts_with(MCP_PATH_PREFIX) {
|
||||
ToolMentionKind::Mcp
|
||||
} else if path.starts_with(PLUGIN_PATH_PREFIX) {
|
||||
ToolMentionKind::Plugin
|
||||
} else if path.starts_with(SKILL_PATH_PREFIX) || is_skill_filename(path) {
|
||||
ToolMentionKind::Skill
|
||||
} else {
|
||||
ToolMentionKind::Other
|
||||
}
|
||||
}
|
||||
|
||||
fn is_skill_filename(path: &str) -> bool {
|
||||
let file_name = path.rsplit(['/', '\\']).next().unwrap_or(path);
|
||||
file_name.eq_ignore_ascii_case(SKILL_FILENAME)
|
||||
}
|
||||
|
||||
pub(crate) fn app_id_from_path(path: &str) -> Option<&str> {
|
||||
path.strip_prefix(APP_PATH_PREFIX)
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
pub(crate) fn plugin_config_name_from_path(path: &str) -> Option<&str> {
|
||||
path.strip_prefix(PLUGIN_PATH_PREFIX)
|
||||
.filter(|value| !value.is_empty())
|
||||
}
|
||||
|
||||
pub(crate) fn normalize_skill_path(path: &str) -> &str {
|
||||
path.strip_prefix(SKILL_PATH_PREFIX).unwrap_or(path)
|
||||
}
|
||||
|
||||
/// Extract `$tool-name` mentions from a single text input.
|
||||
///
|
||||
/// Supports explicit resource links in the form `[$tool-name](resource path)`. When a
|
||||
/// resource path is present, it is captured for exact path matching while also tracking
|
||||
/// the name for fallback matching.
|
||||
pub(crate) fn extract_tool_mentions(text: &str) -> ToolMentions<'_> {
|
||||
extract_tool_mentions_with_sigil(text, TOOL_MENTION_SIGIL)
|
||||
}
|
||||
|
||||
pub(crate) fn extract_tool_mentions_with_sigil(text: &str, sigil: char) -> ToolMentions<'_> {
|
||||
let text_bytes = text.as_bytes();
|
||||
let mut mentioned_names: HashSet<&str> = HashSet::new();
|
||||
let mut mentioned_paths: HashSet<&str> = HashSet::new();
|
||||
let mut plain_names: HashSet<&str> = HashSet::new();
|
||||
|
||||
let mut index = 0;
|
||||
while index < text_bytes.len() {
|
||||
let byte = text_bytes[index];
|
||||
if byte == b'['
|
||||
&& let Some((name, path, end_index)) =
|
||||
parse_linked_tool_mention(text, text_bytes, index, sigil)
|
||||
{
|
||||
if !is_common_env_var(name) {
|
||||
if !matches!(
|
||||
tool_kind_for_path(path),
|
||||
ToolMentionKind::App | ToolMentionKind::Mcp | ToolMentionKind::Plugin
|
||||
) {
|
||||
mentioned_names.insert(name);
|
||||
}
|
||||
mentioned_paths.insert(path);
|
||||
}
|
||||
index = end_index;
|
||||
continue;
|
||||
}
|
||||
|
||||
if byte != sigil as u8 {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
let name_start = index + 1;
|
||||
let Some(first_name_byte) = text_bytes.get(name_start) else {
|
||||
index += 1;
|
||||
continue;
|
||||
};
|
||||
if !is_mention_name_char(*first_name_byte) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut name_end = name_start + 1;
|
||||
while let Some(next_byte) = text_bytes.get(name_end)
|
||||
&& is_mention_name_char(*next_byte)
|
||||
{
|
||||
name_end += 1;
|
||||
}
|
||||
|
||||
let name = &text[name_start..name_end];
|
||||
if !is_common_env_var(name) {
|
||||
mentioned_names.insert(name);
|
||||
plain_names.insert(name);
|
||||
}
|
||||
index = name_end;
|
||||
}
|
||||
|
||||
ToolMentions {
|
||||
names: mentioned_names,
|
||||
paths: mentioned_paths,
|
||||
plain_names,
|
||||
}
|
||||
}
|
||||
|
||||
/// Select mentioned skills while preserving the order of `skills`.
|
||||
fn select_skills_from_mentions(
|
||||
selection_context: &SkillSelectionContext<'_>,
|
||||
blocked_plain_names: &HashSet<String>,
|
||||
mentions: &ToolMentions<'_>,
|
||||
seen_names: &mut HashSet<String>,
|
||||
seen_paths: &mut HashSet<PathBuf>,
|
||||
selected: &mut Vec<SkillMetadata>,
|
||||
) {
|
||||
if mentions.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let mention_skill_paths: HashSet<&str> = mentions
|
||||
.paths()
|
||||
.filter(|path| {
|
||||
!matches!(
|
||||
tool_kind_for_path(path),
|
||||
ToolMentionKind::App | ToolMentionKind::Mcp | ToolMentionKind::Plugin
|
||||
)
|
||||
})
|
||||
.map(normalize_skill_path)
|
||||
.collect();
|
||||
|
||||
for skill in selection_context.skills {
|
||||
if selection_context
|
||||
.disabled_paths
|
||||
.contains(&skill.path_to_skills_md)
|
||||
|| seen_paths.contains(&skill.path_to_skills_md)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
let path_str = skill.path_to_skills_md.to_string_lossy();
|
||||
if mention_skill_paths.contains(path_str.as_ref()) {
|
||||
seen_paths.insert(skill.path_to_skills_md.clone());
|
||||
seen_names.insert(skill.name.clone());
|
||||
selected.push(skill.clone());
|
||||
}
|
||||
}
|
||||
|
||||
for skill in selection_context.skills {
|
||||
if selection_context
|
||||
.disabled_paths
|
||||
.contains(&skill.path_to_skills_md)
|
||||
|| seen_paths.contains(&skill.path_to_skills_md)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if blocked_plain_names.contains(skill.name.as_str()) {
|
||||
continue;
|
||||
}
|
||||
if !mentions.plain_names.contains(skill.name.as_str()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let skill_count = selection_context
|
||||
.skill_name_counts
|
||||
.get(skill.name.as_str())
|
||||
.copied()
|
||||
.unwrap_or(0);
|
||||
let connector_count = selection_context
|
||||
.connector_slug_counts
|
||||
.get(&skill.name.to_ascii_lowercase())
|
||||
.copied()
|
||||
.unwrap_or(0);
|
||||
if skill_count != 1 || connector_count != 0 {
|
||||
continue;
|
||||
}
|
||||
|
||||
if seen_names.insert(skill.name.clone()) {
|
||||
seen_paths.insert(skill.path_to_skills_md.clone());
|
||||
selected.push(skill.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_linked_tool_mention<'a>(
|
||||
text: &'a str,
|
||||
text_bytes: &[u8],
|
||||
start: usize,
|
||||
sigil: char,
|
||||
) -> Option<(&'a str, &'a str, usize)> {
|
||||
let sigil_index = start + 1;
|
||||
if text_bytes.get(sigil_index) != Some(&(sigil as u8)) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let name_start = sigil_index + 1;
|
||||
let first_name_byte = text_bytes.get(name_start)?;
|
||||
if !is_mention_name_char(*first_name_byte) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut name_end = name_start + 1;
|
||||
while let Some(next_byte) = text_bytes.get(name_end)
|
||||
&& is_mention_name_char(*next_byte)
|
||||
{
|
||||
name_end += 1;
|
||||
}
|
||||
|
||||
if text_bytes.get(name_end) != Some(&b']') {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut path_start = name_end + 1;
|
||||
while let Some(next_byte) = text_bytes.get(path_start)
|
||||
&& next_byte.is_ascii_whitespace()
|
||||
{
|
||||
path_start += 1;
|
||||
}
|
||||
if text_bytes.get(path_start) != Some(&b'(') {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut path_end = path_start + 1;
|
||||
while let Some(next_byte) = text_bytes.get(path_end)
|
||||
&& *next_byte != b')'
|
||||
{
|
||||
path_end += 1;
|
||||
}
|
||||
if text_bytes.get(path_end) != Some(&b')') {
|
||||
return None;
|
||||
}
|
||||
|
||||
let path = text[path_start + 1..path_end].trim();
|
||||
if path.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let name = &text[name_start..name_end];
|
||||
Some((name, path, path_end + 1))
|
||||
}
|
||||
|
||||
fn is_common_env_var(name: &str) -> bool {
|
||||
let upper = name.to_ascii_uppercase();
|
||||
matches!(
|
||||
upper.as_str(),
|
||||
"PATH"
|
||||
| "HOME"
|
||||
| "USER"
|
||||
| "SHELL"
|
||||
| "PWD"
|
||||
| "TMPDIR"
|
||||
| "TEMP"
|
||||
| "TMP"
|
||||
| "LANG"
|
||||
| "TERM"
|
||||
| "XDG_CONFIG_HOME"
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn text_mentions_skill(text: &str, skill_name: &str) -> bool {
|
||||
if skill_name.is_empty() {
|
||||
return false;
|
||||
}
|
||||
|
||||
let text_bytes = text.as_bytes();
|
||||
let skill_bytes = skill_name.as_bytes();
|
||||
|
||||
for (index, byte) in text_bytes.iter().copied().enumerate() {
|
||||
if byte != b'$' {
|
||||
continue;
|
||||
}
|
||||
|
||||
let name_start = index + 1;
|
||||
let Some(rest) = text_bytes.get(name_start..) else {
|
||||
continue;
|
||||
};
|
||||
if !rest.starts_with(skill_bytes) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let after_index = name_start + skill_bytes.len();
|
||||
let after = text_bytes.get(after_index).copied();
|
||||
if after.is_none_or(|b| !is_mention_name_char(b)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
false
|
||||
}
|
||||
|
||||
fn is_mention_name_char(byte: u8) -> bool {
|
||||
matches!(byte, b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'_' | b'-' | b':')
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "injection_tests.rs"]
|
||||
mod tests;
|
||||
@@ -1,348 +0,0 @@
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
|
||||
fn make_skill(name: &str, path: &str) -> SkillMetadata {
|
||||
SkillMetadata {
|
||||
name: name.to_string(),
|
||||
description: format!("{name} skill"),
|
||||
short_description: None,
|
||||
interface: None,
|
||||
dependencies: None,
|
||||
policy: None,
|
||||
permission_profile: None,
|
||||
managed_network_override: None,
|
||||
path_to_skills_md: PathBuf::from(path),
|
||||
scope: codex_protocol::protocol::SkillScope::User,
|
||||
}
|
||||
}
|
||||
|
||||
fn set<'a>(items: &'a [&'a str]) -> HashSet<&'a str> {
|
||||
items.iter().copied().collect()
|
||||
}
|
||||
|
||||
fn assert_mentions(text: &str, expected_names: &[&str], expected_paths: &[&str]) {
|
||||
let mentions = extract_tool_mentions(text);
|
||||
assert_eq!(mentions.names, set(expected_names));
|
||||
assert_eq!(mentions.paths, set(expected_paths));
|
||||
}
|
||||
|
||||
fn collect_mentions(
|
||||
inputs: &[UserInput],
|
||||
skills: &[SkillMetadata],
|
||||
disabled_paths: &HashSet<PathBuf>,
|
||||
connector_slug_counts: &HashMap<String, usize>,
|
||||
) -> Vec<SkillMetadata> {
|
||||
collect_explicit_skill_mentions(inputs, skills, disabled_paths, connector_slug_counts)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_mentions_skill_requires_exact_boundary() {
|
||||
assert_eq!(
|
||||
true,
|
||||
text_mentions_skill("use $notion-research-doc please", "notion-research-doc")
|
||||
);
|
||||
assert_eq!(
|
||||
true,
|
||||
text_mentions_skill("($notion-research-doc)", "notion-research-doc")
|
||||
);
|
||||
assert_eq!(
|
||||
true,
|
||||
text_mentions_skill("$notion-research-doc.", "notion-research-doc")
|
||||
);
|
||||
assert_eq!(
|
||||
false,
|
||||
text_mentions_skill("$notion-research-docs", "notion-research-doc")
|
||||
);
|
||||
assert_eq!(
|
||||
false,
|
||||
text_mentions_skill("$notion-research-doc_extra", "notion-research-doc")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_mentions_skill_handles_end_boundary_and_near_misses() {
|
||||
assert_eq!(true, text_mentions_skill("$alpha-skill", "alpha-skill"));
|
||||
assert_eq!(false, text_mentions_skill("$alpha-skillx", "alpha-skill"));
|
||||
assert_eq!(
|
||||
true,
|
||||
text_mentions_skill("$alpha-skillx and later $alpha-skill ", "alpha-skill")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_mentions_skill_handles_many_dollars_without_looping() {
|
||||
let prefix = "$".repeat(256);
|
||||
let text = format!("{prefix} not-a-mention");
|
||||
assert_eq!(false, text_mentions_skill(&text, "alpha-skill"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_tool_mentions_handles_plain_and_linked_mentions() {
|
||||
assert_mentions(
|
||||
"use $alpha and [$beta](/tmp/beta)",
|
||||
&["alpha", "beta"],
|
||||
&["/tmp/beta"],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_tool_mentions_skips_common_env_vars() {
|
||||
assert_mentions("use $PATH and $alpha", &["alpha"], &[]);
|
||||
assert_mentions("use [$HOME](/tmp/skill)", &[], &[]);
|
||||
assert_mentions("use $XDG_CONFIG_HOME and $beta", &["beta"], &[]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_tool_mentions_requires_link_syntax() {
|
||||
assert_mentions("[beta](/tmp/beta)", &[], &[]);
|
||||
assert_mentions("[$beta] /tmp/beta", &["beta"], &[]);
|
||||
assert_mentions("[$beta]()", &["beta"], &[]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_tool_mentions_trims_linked_paths_and_allows_spacing() {
|
||||
assert_mentions("use [$beta] ( /tmp/beta )", &["beta"], &["/tmp/beta"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_tool_mentions_stops_at_non_name_chars() {
|
||||
assert_mentions(
|
||||
"use $alpha.skill and $beta_extra",
|
||||
&["alpha", "beta_extra"],
|
||||
&[],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extract_tool_mentions_keeps_plugin_skill_namespaces() {
|
||||
assert_mentions(
|
||||
"use $slack:search and $alpha",
|
||||
&["alpha", "slack:search"],
|
||||
&[],
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_explicit_skill_mentions_text_respects_skill_order() {
|
||||
let alpha = make_skill("alpha-skill", "/tmp/alpha");
|
||||
let beta = make_skill("beta-skill", "/tmp/beta");
|
||||
let skills = vec![beta.clone(), alpha.clone()];
|
||||
let inputs = vec![UserInput::Text {
|
||||
text: "first $alpha-skill then $beta-skill".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}];
|
||||
let connector_counts = HashMap::new();
|
||||
|
||||
let selected = collect_mentions(&inputs, &skills, &HashSet::new(), &connector_counts);
|
||||
|
||||
// Text scanning should not change the previous selection ordering semantics.
|
||||
assert_eq!(selected, vec![beta, alpha]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_explicit_skill_mentions_prioritizes_structured_inputs() {
|
||||
let alpha = make_skill("alpha-skill", "/tmp/alpha");
|
||||
let beta = make_skill("beta-skill", "/tmp/beta");
|
||||
let skills = vec![alpha.clone(), beta.clone()];
|
||||
let inputs = vec![
|
||||
UserInput::Text {
|
||||
text: "please run $alpha-skill".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
},
|
||||
UserInput::Skill {
|
||||
name: "beta-skill".to_string(),
|
||||
path: PathBuf::from("/tmp/beta"),
|
||||
},
|
||||
];
|
||||
let connector_counts = HashMap::new();
|
||||
|
||||
let selected = collect_mentions(&inputs, &skills, &HashSet::new(), &connector_counts);
|
||||
|
||||
assert_eq!(selected, vec![beta, alpha]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_explicit_skill_mentions_skips_invalid_structured_and_blocks_plain_fallback() {
|
||||
let alpha = make_skill("alpha-skill", "/tmp/alpha");
|
||||
let skills = vec![alpha];
|
||||
let inputs = vec![
|
||||
UserInput::Text {
|
||||
text: "please run $alpha-skill".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
},
|
||||
UserInput::Skill {
|
||||
name: "alpha-skill".to_string(),
|
||||
path: PathBuf::from("/tmp/missing"),
|
||||
},
|
||||
];
|
||||
let connector_counts = HashMap::new();
|
||||
|
||||
let selected = collect_mentions(&inputs, &skills, &HashSet::new(), &connector_counts);
|
||||
|
||||
assert_eq!(selected, Vec::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_explicit_skill_mentions_skips_disabled_structured_and_blocks_plain_fallback() {
|
||||
let alpha = make_skill("alpha-skill", "/tmp/alpha");
|
||||
let skills = vec![alpha];
|
||||
let inputs = vec![
|
||||
UserInput::Text {
|
||||
text: "please run $alpha-skill".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
},
|
||||
UserInput::Skill {
|
||||
name: "alpha-skill".to_string(),
|
||||
path: PathBuf::from("/tmp/alpha"),
|
||||
},
|
||||
];
|
||||
let disabled = HashSet::from([PathBuf::from("/tmp/alpha")]);
|
||||
let connector_counts = HashMap::new();
|
||||
|
||||
let selected = collect_mentions(&inputs, &skills, &disabled, &connector_counts);
|
||||
|
||||
assert_eq!(selected, Vec::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_explicit_skill_mentions_dedupes_by_path() {
|
||||
let alpha = make_skill("alpha-skill", "/tmp/alpha");
|
||||
let skills = vec![alpha.clone()];
|
||||
let inputs = vec and [$alpha-skill](/tmp/alpha)".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}];
|
||||
let connector_counts = HashMap::new();
|
||||
|
||||
let selected = collect_mentions(&inputs, &skills, &HashSet::new(), &connector_counts);
|
||||
|
||||
assert_eq!(selected, vec![alpha]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_explicit_skill_mentions_skips_ambiguous_name() {
|
||||
let alpha = make_skill("demo-skill", "/tmp/alpha");
|
||||
let beta = make_skill("demo-skill", "/tmp/beta");
|
||||
let skills = vec![alpha, beta];
|
||||
let inputs = vec![UserInput::Text {
|
||||
text: "use $demo-skill and again $demo-skill".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}];
|
||||
let connector_counts = HashMap::new();
|
||||
|
||||
let selected = collect_mentions(&inputs, &skills, &HashSet::new(), &connector_counts);
|
||||
|
||||
assert_eq!(selected, Vec::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_explicit_skill_mentions_prefers_linked_path_over_name() {
|
||||
let alpha = make_skill("demo-skill", "/tmp/alpha");
|
||||
let beta = make_skill("demo-skill", "/tmp/beta");
|
||||
let skills = vec![alpha, beta.clone()];
|
||||
let inputs = vec".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}];
|
||||
let connector_counts = HashMap::new();
|
||||
|
||||
let selected = collect_mentions(&inputs, &skills, &HashSet::new(), &connector_counts);
|
||||
|
||||
assert_eq!(selected, vec![beta]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_explicit_skill_mentions_skips_plain_name_when_connector_matches() {
|
||||
let alpha = make_skill("alpha-skill", "/tmp/alpha");
|
||||
let skills = vec![alpha];
|
||||
let inputs = vec![UserInput::Text {
|
||||
text: "use $alpha-skill".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}];
|
||||
let connector_counts = HashMap::from([("alpha-skill".to_string(), 1)]);
|
||||
|
||||
let selected = collect_mentions(&inputs, &skills, &HashSet::new(), &connector_counts);
|
||||
|
||||
assert_eq!(selected, Vec::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_explicit_skill_mentions_allows_explicit_path_with_connector_conflict() {
|
||||
let alpha = make_skill("alpha-skill", "/tmp/alpha");
|
||||
let skills = vec![alpha.clone()];
|
||||
let inputs = vec".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}];
|
||||
let connector_counts = HashMap::from([("alpha-skill".to_string(), 1)]);
|
||||
|
||||
let selected = collect_mentions(&inputs, &skills, &HashSet::new(), &connector_counts);
|
||||
|
||||
assert_eq!(selected, vec![alpha]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_explicit_skill_mentions_skips_when_linked_path_disabled() {
|
||||
let alpha = make_skill("demo-skill", "/tmp/alpha");
|
||||
let beta = make_skill("demo-skill", "/tmp/beta");
|
||||
let skills = vec![alpha, beta];
|
||||
let inputs = vec".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}];
|
||||
let disabled = HashSet::from([PathBuf::from("/tmp/alpha")]);
|
||||
let connector_counts = HashMap::new();
|
||||
|
||||
let selected = collect_mentions(&inputs, &skills, &disabled, &connector_counts);
|
||||
|
||||
assert_eq!(selected, Vec::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_explicit_skill_mentions_prefers_resource_path() {
|
||||
let alpha = make_skill("demo-skill", "/tmp/alpha");
|
||||
let beta = make_skill("demo-skill", "/tmp/beta");
|
||||
let skills = vec![alpha, beta.clone()];
|
||||
let inputs = vec".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}];
|
||||
let connector_counts = HashMap::new();
|
||||
|
||||
let selected = collect_mentions(&inputs, &skills, &HashSet::new(), &connector_counts);
|
||||
|
||||
assert_eq!(selected, vec![beta]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_explicit_skill_mentions_skips_missing_path_with_no_fallback() {
|
||||
let alpha = make_skill("demo-skill", "/tmp/alpha");
|
||||
let beta = make_skill("demo-skill", "/tmp/beta");
|
||||
let skills = vec![alpha, beta];
|
||||
let inputs = vec".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}];
|
||||
let connector_counts = HashMap::new();
|
||||
|
||||
let selected = collect_mentions(&inputs, &skills, &HashSet::new(), &connector_counts);
|
||||
|
||||
assert_eq!(selected, Vec::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_explicit_skill_mentions_skips_missing_path_without_fallback() {
|
||||
let alpha = make_skill("demo-skill", "/tmp/alpha");
|
||||
let skills = vec![alpha];
|
||||
let inputs = vec".to_string(),
|
||||
text_elements: Vec::new(),
|
||||
}];
|
||||
let connector_counts = HashMap::new();
|
||||
|
||||
let selected = collect_mentions(&inputs, &skills, &HashSet::new(), &connector_counts);
|
||||
|
||||
assert_eq!(selected, Vec::new());
|
||||
}
|
||||
@@ -1,235 +0,0 @@
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use crate::analytics_client::InvocationType;
|
||||
use crate::analytics_client::SkillInvocation;
|
||||
use crate::analytics_client::build_track_events_context;
|
||||
use crate::codex::Session;
|
||||
use crate::codex::TurnContext;
|
||||
use crate::skills::SkillLoadOutcome;
|
||||
use crate::skills::SkillMetadata;
|
||||
|
||||
pub(crate) fn build_implicit_skill_path_indexes(
|
||||
skills: Vec<SkillMetadata>,
|
||||
) -> (
|
||||
HashMap<PathBuf, SkillMetadata>,
|
||||
HashMap<PathBuf, SkillMetadata>,
|
||||
) {
|
||||
let mut by_scripts_dir = HashMap::new();
|
||||
let mut by_skill_doc_path = HashMap::new();
|
||||
for skill in skills {
|
||||
let skill_doc_path = normalize_path(skill.path_to_skills_md.as_path());
|
||||
by_skill_doc_path.insert(skill_doc_path, skill.clone());
|
||||
|
||||
if let Some(skill_dir) = skill.path_to_skills_md.parent() {
|
||||
let scripts_dir = normalize_path(&skill_dir.join("scripts"));
|
||||
by_scripts_dir.insert(scripts_dir, skill);
|
||||
}
|
||||
}
|
||||
|
||||
(by_scripts_dir, by_skill_doc_path)
|
||||
}
|
||||
|
||||
fn detect_implicit_skill_invocation_for_command(
|
||||
outcome: &SkillLoadOutcome,
|
||||
turn_context: &TurnContext,
|
||||
command: &str,
|
||||
workdir: Option<&str>,
|
||||
) -> Option<SkillMetadata> {
|
||||
let workdir = turn_context.resolve_path(workdir.map(str::to_owned));
|
||||
let workdir = normalize_path(workdir.as_path());
|
||||
let tokens = tokenize_command(command);
|
||||
|
||||
if let Some(candidate) = detect_skill_script_run(outcome, tokens.as_slice(), workdir.as_path())
|
||||
{
|
||||
return Some(candidate);
|
||||
}
|
||||
|
||||
if let Some(candidate) = detect_skill_doc_read(outcome, tokens.as_slice(), workdir.as_path()) {
|
||||
return Some(candidate);
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
pub(crate) async fn maybe_emit_implicit_skill_invocation(
|
||||
sess: &Session,
|
||||
turn_context: &TurnContext,
|
||||
command: &str,
|
||||
workdir: Option<&str>,
|
||||
) {
|
||||
let Some(candidate) = detect_implicit_skill_invocation_for_command(
|
||||
&turn_context.turn_skills.outcome,
|
||||
turn_context,
|
||||
command,
|
||||
workdir,
|
||||
) else {
|
||||
return;
|
||||
};
|
||||
let invocation = SkillInvocation {
|
||||
skill_name: candidate.name,
|
||||
skill_scope: candidate.scope,
|
||||
skill_path: candidate.path_to_skills_md,
|
||||
invocation_type: InvocationType::Implicit,
|
||||
};
|
||||
let skill_scope = match invocation.skill_scope {
|
||||
codex_protocol::protocol::SkillScope::User => "user",
|
||||
codex_protocol::protocol::SkillScope::Repo => "repo",
|
||||
codex_protocol::protocol::SkillScope::System => "system",
|
||||
codex_protocol::protocol::SkillScope::Admin => "admin",
|
||||
};
|
||||
let skill_path = invocation.skill_path.to_string_lossy();
|
||||
let skill_name = invocation.skill_name.clone();
|
||||
let seen_key = format!("{skill_scope}:{skill_path}:{skill_name}");
|
||||
let inserted = {
|
||||
let mut seen_skills = turn_context
|
||||
.turn_skills
|
||||
.implicit_invocation_seen_skills
|
||||
.lock()
|
||||
.await;
|
||||
seen_skills.insert(seen_key)
|
||||
};
|
||||
if !inserted {
|
||||
return;
|
||||
}
|
||||
|
||||
turn_context.session_telemetry.counter(
|
||||
"codex.skill.injected",
|
||||
/*inc*/ 1,
|
||||
&[
|
||||
("status", "ok"),
|
||||
("skill", skill_name.as_str()),
|
||||
("invoke_type", "implicit"),
|
||||
],
|
||||
);
|
||||
sess.services
|
||||
.analytics_events_client
|
||||
.track_skill_invocations(
|
||||
build_track_events_context(
|
||||
turn_context.model_info.slug.clone(),
|
||||
sess.conversation_id.to_string(),
|
||||
turn_context.sub_id.clone(),
|
||||
),
|
||||
vec![invocation],
|
||||
);
|
||||
}
|
||||
|
||||
fn tokenize_command(command: &str) -> Vec<String> {
|
||||
shlex::split(command).unwrap_or_else(|| {
|
||||
command
|
||||
.split_whitespace()
|
||||
.map(std::string::ToString::to_string)
|
||||
.collect()
|
||||
})
|
||||
}
|
||||
|
||||
fn script_run_token(tokens: &[String]) -> Option<&str> {
|
||||
const RUNNERS: [&str; 10] = [
|
||||
"python", "python3", "bash", "zsh", "sh", "node", "deno", "ruby", "perl", "pwsh",
|
||||
];
|
||||
const SCRIPT_EXTENSIONS: [&str; 7] = [".py", ".sh", ".js", ".ts", ".rb", ".pl", ".ps1"];
|
||||
|
||||
let runner_token = tokens.first()?;
|
||||
let runner = command_basename(runner_token).to_ascii_lowercase();
|
||||
let runner = runner.strip_suffix(".exe").unwrap_or(&runner);
|
||||
if !RUNNERS.contains(&runner) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut script_token: Option<&str> = None;
|
||||
for token in tokens.iter().skip(1) {
|
||||
if token == "--" {
|
||||
continue;
|
||||
}
|
||||
if token.starts_with('-') {
|
||||
continue;
|
||||
}
|
||||
script_token = Some(token.as_str());
|
||||
break;
|
||||
}
|
||||
let script_token = script_token?;
|
||||
if SCRIPT_EXTENSIONS
|
||||
.iter()
|
||||
.any(|extension| script_token.to_ascii_lowercase().ends_with(extension))
|
||||
{
|
||||
return Some(script_token);
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn detect_skill_script_run(
|
||||
outcome: &SkillLoadOutcome,
|
||||
tokens: &[String],
|
||||
workdir: &Path,
|
||||
) -> Option<SkillMetadata> {
|
||||
let script_token = script_run_token(tokens)?;
|
||||
let script_path = Path::new(script_token);
|
||||
let script_path = if script_path.is_absolute() {
|
||||
script_path.to_path_buf()
|
||||
} else {
|
||||
workdir.join(script_path)
|
||||
};
|
||||
let script_path = normalize_path(script_path.as_path());
|
||||
|
||||
for ancestor in script_path.ancestors() {
|
||||
if let Some(candidate) = outcome.implicit_skills_by_scripts_dir.get(ancestor) {
|
||||
return Some(candidate.clone());
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn detect_skill_doc_read(
|
||||
outcome: &SkillLoadOutcome,
|
||||
tokens: &[String],
|
||||
workdir: &Path,
|
||||
) -> Option<SkillMetadata> {
|
||||
if !command_reads_file(tokens) {
|
||||
return None;
|
||||
}
|
||||
|
||||
for token in tokens.iter().skip(1) {
|
||||
if token.starts_with('-') {
|
||||
continue;
|
||||
}
|
||||
let path = Path::new(token);
|
||||
let candidate_path = if path.is_absolute() {
|
||||
normalize_path(path)
|
||||
} else {
|
||||
normalize_path(&workdir.join(path))
|
||||
};
|
||||
if let Some(candidate) = outcome.implicit_skills_by_doc_path.get(&candidate_path) {
|
||||
return Some(candidate.clone());
|
||||
}
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn command_reads_file(tokens: &[String]) -> bool {
|
||||
const READERS: [&str; 8] = ["cat", "sed", "head", "tail", "less", "more", "bat", "awk"];
|
||||
let Some(program) = tokens.first() else {
|
||||
return false;
|
||||
};
|
||||
let program = command_basename(program).to_ascii_lowercase();
|
||||
READERS.contains(&program.as_str())
|
||||
}
|
||||
|
||||
fn command_basename(command: &str) -> String {
|
||||
Path::new(command)
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.unwrap_or(command)
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn normalize_path(path: &Path) -> PathBuf {
|
||||
std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "invocation_utils_tests.rs"]
|
||||
mod tests;
|
||||
@@ -1,119 +0,0 @@
|
||||
use super::SkillLoadOutcome;
|
||||
use super::SkillMetadata;
|
||||
use super::detect_skill_doc_read;
|
||||
use super::detect_skill_script_run;
|
||||
use super::normalize_path;
|
||||
use super::script_run_token;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::collections::HashMap;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
fn test_skill_metadata(skill_doc_path: PathBuf) -> SkillMetadata {
|
||||
SkillMetadata {
|
||||
name: "test-skill".to_string(),
|
||||
description: "test".to_string(),
|
||||
short_description: None,
|
||||
interface: None,
|
||||
dependencies: None,
|
||||
policy: None,
|
||||
permission_profile: None,
|
||||
managed_network_override: None,
|
||||
path_to_skills_md: skill_doc_path,
|
||||
scope: codex_protocol::protocol::SkillScope::User,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn script_run_detection_matches_runner_plus_extension() {
|
||||
let tokens = vec![
|
||||
"python3".to_string(),
|
||||
"-u".to_string(),
|
||||
"scripts/fetch_comments.py".to_string(),
|
||||
];
|
||||
|
||||
assert_eq!(script_run_token(&tokens).is_some(), true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn script_run_detection_excludes_python_c() {
|
||||
let tokens = vec![
|
||||
"python3".to_string(),
|
||||
"-c".to_string(),
|
||||
"print(1)".to_string(),
|
||||
];
|
||||
|
||||
assert_eq!(script_run_token(&tokens).is_some(), false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skill_doc_read_detection_matches_absolute_path() {
|
||||
let skill_doc_path = PathBuf::from("/tmp/skill-test/SKILL.md");
|
||||
let normalized_skill_doc_path = normalize_path(skill_doc_path.as_path());
|
||||
let skill = test_skill_metadata(skill_doc_path);
|
||||
let outcome = SkillLoadOutcome {
|
||||
implicit_skills_by_scripts_dir: Arc::new(HashMap::new()),
|
||||
implicit_skills_by_doc_path: Arc::new(HashMap::from([(normalized_skill_doc_path, skill)])),
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let tokens = vec![
|
||||
"cat".to_string(),
|
||||
"/tmp/skill-test/SKILL.md".to_string(),
|
||||
"|".to_string(),
|
||||
"head".to_string(),
|
||||
];
|
||||
let found = detect_skill_doc_read(&outcome, &tokens, Path::new("/tmp"));
|
||||
|
||||
assert_eq!(
|
||||
found.map(|value| value.name),
|
||||
Some("test-skill".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skill_script_run_detection_matches_relative_path_from_skill_root() {
|
||||
let skill_doc_path = PathBuf::from("/tmp/skill-test/SKILL.md");
|
||||
let scripts_dir = normalize_path(Path::new("/tmp/skill-test/scripts"));
|
||||
let skill = test_skill_metadata(skill_doc_path);
|
||||
let outcome = SkillLoadOutcome {
|
||||
implicit_skills_by_scripts_dir: Arc::new(HashMap::from([(scripts_dir, skill)])),
|
||||
implicit_skills_by_doc_path: Arc::new(HashMap::new()),
|
||||
..Default::default()
|
||||
};
|
||||
let tokens = vec![
|
||||
"python3".to_string(),
|
||||
"scripts/fetch_comments.py".to_string(),
|
||||
];
|
||||
|
||||
let found = detect_skill_script_run(&outcome, &tokens, Path::new("/tmp/skill-test"));
|
||||
|
||||
assert_eq!(
|
||||
found.map(|value| value.name),
|
||||
Some("test-skill".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skill_script_run_detection_matches_absolute_path_from_any_workdir() {
|
||||
let skill_doc_path = PathBuf::from("/tmp/skill-test/SKILL.md");
|
||||
let scripts_dir = normalize_path(Path::new("/tmp/skill-test/scripts"));
|
||||
let skill = test_skill_metadata(skill_doc_path);
|
||||
let outcome = SkillLoadOutcome {
|
||||
implicit_skills_by_scripts_dir: Arc::new(HashMap::from([(scripts_dir, skill)])),
|
||||
implicit_skills_by_doc_path: Arc::new(HashMap::new()),
|
||||
..Default::default()
|
||||
};
|
||||
let tokens = vec![
|
||||
"python3".to_string(),
|
||||
"/tmp/skill-test/scripts/fetch_comments.py".to_string(),
|
||||
];
|
||||
|
||||
let found = detect_skill_script_run(&outcome, &tokens, Path::new("/tmp/other"));
|
||||
|
||||
assert_eq!(
|
||||
found.map(|value| value.name),
|
||||
Some("test-skill".to_string())
|
||||
);
|
||||
}
|
||||
@@ -1,926 +0,0 @@
|
||||
use crate::config_loader::ConfigLayerStack;
|
||||
use crate::config_loader::ConfigLayerStackOrdering;
|
||||
use crate::config_loader::default_project_root_markers;
|
||||
use crate::config_loader::merge_toml_values;
|
||||
use crate::config_loader::project_root_markers_from_config;
|
||||
use crate::plugins::plugin_namespace_for_skill_path;
|
||||
use crate::skills::model::SkillDependencies;
|
||||
use crate::skills::model::SkillError;
|
||||
use crate::skills::model::SkillInterface;
|
||||
use crate::skills::model::SkillLoadOutcome;
|
||||
use crate::skills::model::SkillManagedNetworkOverride;
|
||||
use crate::skills::model::SkillMetadata;
|
||||
use crate::skills::model::SkillPolicy;
|
||||
use crate::skills::model::SkillToolDependency;
|
||||
use crate::skills::system::system_cache_root_dir;
|
||||
use codex_app_server_protocol::ConfigLayerSource;
|
||||
use codex_protocol::models::FileSystemPermissions;
|
||||
use codex_protocol::models::MacOsSeatbeltProfileExtensions;
|
||||
use codex_protocol::models::NetworkPermissions;
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
use codex_protocol::protocol::Product;
|
||||
use codex_protocol::protocol::SkillScope;
|
||||
use codex_utils_absolute_path::AbsolutePathBufGuard;
|
||||
use dirs::home_dir;
|
||||
use dunce::canonicalize as canonicalize_path;
|
||||
use serde::Deserialize;
|
||||
use std::collections::HashSet;
|
||||
use std::collections::VecDeque;
|
||||
use std::error::Error;
|
||||
use std::fmt;
|
||||
use std::fs;
|
||||
use std::path::Component;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use toml::Value as TomlValue;
|
||||
use tracing::error;
|
||||
|
||||
#[cfg(test)]
|
||||
use crate::config::Config;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct SkillFrontmatter {
|
||||
#[serde(default)]
|
||||
name: Option<String>,
|
||||
#[serde(default)]
|
||||
description: Option<String>,
|
||||
#[serde(default)]
|
||||
metadata: SkillFrontmatterMetadata,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
struct SkillFrontmatterMetadata {
|
||||
#[serde(default, rename = "short-description")]
|
||||
short_description: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
struct SkillMetadataFile {
|
||||
#[serde(default)]
|
||||
interface: Option<Interface>,
|
||||
#[serde(default)]
|
||||
dependencies: Option<Dependencies>,
|
||||
#[serde(default)]
|
||||
policy: Option<Policy>,
|
||||
#[serde(default)]
|
||||
permissions: Option<SkillPermissionProfile>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct LoadedSkillMetadata {
|
||||
interface: Option<SkillInterface>,
|
||||
dependencies: Option<SkillDependencies>,
|
||||
policy: Option<SkillPolicy>,
|
||||
permission_profile: Option<PermissionProfile>,
|
||||
managed_network_override: Option<SkillManagedNetworkOverride>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize, PartialEq, Eq)]
|
||||
struct SkillPermissionProfile {
|
||||
#[serde(default)]
|
||||
network: Option<SkillNetworkPermissions>,
|
||||
#[serde(default)]
|
||||
file_system: Option<FileSystemPermissions>,
|
||||
#[serde(default)]
|
||||
macos: Option<MacOsSeatbeltProfileExtensions>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize, PartialEq, Eq)]
|
||||
struct SkillNetworkPermissions {
|
||||
#[serde(default)]
|
||||
enabled: Option<bool>,
|
||||
#[serde(default)]
|
||||
allowed_domains: Option<Vec<String>>,
|
||||
#[serde(default)]
|
||||
denied_domains: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
struct Interface {
|
||||
display_name: Option<String>,
|
||||
short_description: Option<String>,
|
||||
icon_small: Option<PathBuf>,
|
||||
icon_large: Option<PathBuf>,
|
||||
brand_color: Option<String>,
|
||||
default_prompt: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
struct Dependencies {
|
||||
#[serde(default)]
|
||||
tools: Vec<DependencyTool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct Policy {
|
||||
#[serde(default)]
|
||||
allow_implicit_invocation: Option<bool>,
|
||||
#[serde(default)]
|
||||
products: Vec<Product>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
struct DependencyTool {
|
||||
#[serde(rename = "type")]
|
||||
kind: Option<String>,
|
||||
value: Option<String>,
|
||||
description: Option<String>,
|
||||
transport: Option<String>,
|
||||
command: Option<String>,
|
||||
url: Option<String>,
|
||||
}
|
||||
|
||||
const SKILLS_FILENAME: &str = "SKILL.md";
|
||||
const AGENTS_DIR_NAME: &str = ".agents";
|
||||
const SKILLS_METADATA_DIR: &str = "agents";
|
||||
const SKILLS_METADATA_FILENAME: &str = "openai.yaml";
|
||||
const SKILLS_DIR_NAME: &str = "skills";
|
||||
const MAX_NAME_LEN: usize = 64;
|
||||
const MAX_DESCRIPTION_LEN: usize = 1024;
|
||||
const MAX_SHORT_DESCRIPTION_LEN: usize = MAX_DESCRIPTION_LEN;
|
||||
const MAX_DEFAULT_PROMPT_LEN: usize = MAX_DESCRIPTION_LEN;
|
||||
const MAX_DEPENDENCY_TYPE_LEN: usize = MAX_NAME_LEN;
|
||||
const MAX_DEPENDENCY_TRANSPORT_LEN: usize = MAX_NAME_LEN;
|
||||
const MAX_DEPENDENCY_VALUE_LEN: usize = MAX_DESCRIPTION_LEN;
|
||||
const MAX_DEPENDENCY_DESCRIPTION_LEN: usize = MAX_DESCRIPTION_LEN;
|
||||
const MAX_DEPENDENCY_COMMAND_LEN: usize = MAX_DESCRIPTION_LEN;
|
||||
const MAX_DEPENDENCY_URL_LEN: usize = MAX_DESCRIPTION_LEN;
|
||||
// Traversal depth from the skills root.
|
||||
const MAX_SCAN_DEPTH: usize = 6;
|
||||
const MAX_SKILLS_DIRS_PER_ROOT: usize = 2000;
|
||||
|
||||
#[derive(Debug)]
|
||||
enum SkillParseError {
|
||||
Read(std::io::Error),
|
||||
MissingFrontmatter,
|
||||
InvalidYaml(serde_yaml::Error),
|
||||
MissingField(&'static str),
|
||||
InvalidField { field: &'static str, reason: String },
|
||||
}
|
||||
|
||||
impl fmt::Display for SkillParseError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
SkillParseError::Read(e) => write!(f, "failed to read file: {e}"),
|
||||
SkillParseError::MissingFrontmatter => {
|
||||
write!(f, "missing YAML frontmatter delimited by ---")
|
||||
}
|
||||
SkillParseError::InvalidYaml(e) => write!(f, "invalid YAML: {e}"),
|
||||
SkillParseError::MissingField(field) => write!(f, "missing field `{field}`"),
|
||||
SkillParseError::InvalidField { field, reason } => {
|
||||
write!(f, "invalid {field}: {reason}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Error for SkillParseError {}
|
||||
|
||||
pub(crate) struct SkillRoot {
|
||||
pub(crate) path: PathBuf,
|
||||
pub(crate) scope: SkillScope,
|
||||
}
|
||||
|
||||
pub(crate) fn load_skills_from_roots<I>(roots: I) -> SkillLoadOutcome
|
||||
where
|
||||
I: IntoIterator<Item = SkillRoot>,
|
||||
{
|
||||
let mut outcome = SkillLoadOutcome::default();
|
||||
for root in roots {
|
||||
discover_skills_under_root(&root.path, root.scope, &mut outcome);
|
||||
}
|
||||
|
||||
let mut seen: HashSet<PathBuf> = HashSet::new();
|
||||
outcome
|
||||
.skills
|
||||
.retain(|skill| seen.insert(skill.path_to_skills_md.clone()));
|
||||
|
||||
fn scope_rank(scope: SkillScope) -> u8 {
|
||||
// Higher-priority scopes first (matches root scan order for dedupe).
|
||||
match scope {
|
||||
SkillScope::Repo => 0,
|
||||
SkillScope::User => 1,
|
||||
SkillScope::System => 2,
|
||||
SkillScope::Admin => 3,
|
||||
}
|
||||
}
|
||||
|
||||
outcome.skills.sort_by(|a, b| {
|
||||
scope_rank(a.scope)
|
||||
.cmp(&scope_rank(b.scope))
|
||||
.then_with(|| a.name.cmp(&b.name))
|
||||
.then_with(|| a.path_to_skills_md.cmp(&b.path_to_skills_md))
|
||||
});
|
||||
|
||||
outcome
|
||||
}
|
||||
|
||||
pub(crate) fn skill_roots(
|
||||
config_layer_stack: &ConfigLayerStack,
|
||||
cwd: &Path,
|
||||
plugin_skill_roots: Vec<PathBuf>,
|
||||
) -> Vec<SkillRoot> {
|
||||
skill_roots_with_home_dir(
|
||||
config_layer_stack,
|
||||
cwd,
|
||||
home_dir().as_deref(),
|
||||
plugin_skill_roots,
|
||||
)
|
||||
}
|
||||
|
||||
fn skill_roots_with_home_dir(
|
||||
config_layer_stack: &ConfigLayerStack,
|
||||
cwd: &Path,
|
||||
home_dir: Option<&Path>,
|
||||
plugin_skill_roots: Vec<PathBuf>,
|
||||
) -> Vec<SkillRoot> {
|
||||
let mut roots = skill_roots_from_layer_stack_inner(config_layer_stack, home_dir);
|
||||
roots.extend(plugin_skill_roots.into_iter().map(|path| SkillRoot {
|
||||
path,
|
||||
scope: SkillScope::User,
|
||||
}));
|
||||
roots.extend(repo_agents_skill_roots(config_layer_stack, cwd));
|
||||
dedupe_skill_roots_by_path(&mut roots);
|
||||
roots
|
||||
}
|
||||
|
||||
fn skill_roots_from_layer_stack_inner(
|
||||
config_layer_stack: &ConfigLayerStack,
|
||||
home_dir: Option<&Path>,
|
||||
) -> Vec<SkillRoot> {
|
||||
let mut roots = Vec::new();
|
||||
|
||||
for layer in config_layer_stack.get_layers(
|
||||
ConfigLayerStackOrdering::HighestPrecedenceFirst,
|
||||
/*include_disabled*/ true,
|
||||
) {
|
||||
let Some(config_folder) = layer.config_folder() else {
|
||||
continue;
|
||||
};
|
||||
|
||||
match &layer.name {
|
||||
ConfigLayerSource::Project { .. } => {
|
||||
roots.push(SkillRoot {
|
||||
path: config_folder.as_path().join(SKILLS_DIR_NAME),
|
||||
scope: SkillScope::Repo,
|
||||
});
|
||||
}
|
||||
ConfigLayerSource::User { .. } => {
|
||||
// Deprecated user skills location (`$CODEX_HOME/skills`), kept for backward
|
||||
// compatibility.
|
||||
roots.push(SkillRoot {
|
||||
path: config_folder.as_path().join(SKILLS_DIR_NAME),
|
||||
scope: SkillScope::User,
|
||||
});
|
||||
|
||||
// `$HOME/.agents/skills` (user-installed skills).
|
||||
if let Some(home_dir) = home_dir {
|
||||
roots.push(SkillRoot {
|
||||
path: home_dir.join(AGENTS_DIR_NAME).join(SKILLS_DIR_NAME),
|
||||
scope: SkillScope::User,
|
||||
});
|
||||
}
|
||||
|
||||
// Embedded system skills are cached under `$CODEX_HOME/skills/.system` and are a
|
||||
// special case (not a config layer).
|
||||
roots.push(SkillRoot {
|
||||
path: system_cache_root_dir(config_folder.as_path()),
|
||||
scope: SkillScope::System,
|
||||
});
|
||||
}
|
||||
ConfigLayerSource::System { .. } => {
|
||||
// The system config layer lives under `/etc/codex/` on Unix, so treat
|
||||
// `/etc/codex/skills` as admin-scoped skills.
|
||||
roots.push(SkillRoot {
|
||||
path: config_folder.as_path().join(SKILLS_DIR_NAME),
|
||||
scope: SkillScope::Admin,
|
||||
});
|
||||
}
|
||||
ConfigLayerSource::Mdm { .. }
|
||||
| ConfigLayerSource::SessionFlags
|
||||
| ConfigLayerSource::LegacyManagedConfigTomlFromFile { .. }
|
||||
| ConfigLayerSource::LegacyManagedConfigTomlFromMdm => {}
|
||||
}
|
||||
}
|
||||
|
||||
roots
|
||||
}
|
||||
|
||||
fn repo_agents_skill_roots(config_layer_stack: &ConfigLayerStack, cwd: &Path) -> Vec<SkillRoot> {
|
||||
let project_root_markers = project_root_markers_from_stack(config_layer_stack);
|
||||
let project_root = find_project_root(cwd, &project_root_markers);
|
||||
let dirs = dirs_between_project_root_and_cwd(cwd, &project_root);
|
||||
let mut roots = Vec::new();
|
||||
for dir in dirs {
|
||||
let agents_skills = dir.join(AGENTS_DIR_NAME).join(SKILLS_DIR_NAME);
|
||||
if agents_skills.is_dir() {
|
||||
roots.push(SkillRoot {
|
||||
path: agents_skills,
|
||||
scope: SkillScope::Repo,
|
||||
});
|
||||
}
|
||||
}
|
||||
roots
|
||||
}
|
||||
|
||||
fn project_root_markers_from_stack(config_layer_stack: &ConfigLayerStack) -> Vec<String> {
|
||||
let mut merged = TomlValue::Table(toml::map::Map::new());
|
||||
for layer in config_layer_stack.get_layers(
|
||||
ConfigLayerStackOrdering::LowestPrecedenceFirst,
|
||||
/*include_disabled*/ false,
|
||||
) {
|
||||
if matches!(layer.name, ConfigLayerSource::Project { .. }) {
|
||||
continue;
|
||||
}
|
||||
merge_toml_values(&mut merged, &layer.config);
|
||||
}
|
||||
|
||||
match project_root_markers_from_config(&merged) {
|
||||
Ok(Some(markers)) => markers,
|
||||
Ok(None) => default_project_root_markers(),
|
||||
Err(err) => {
|
||||
tracing::warn!("invalid project_root_markers: {err}");
|
||||
default_project_root_markers()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn find_project_root(cwd: &Path, project_root_markers: &[String]) -> PathBuf {
|
||||
if project_root_markers.is_empty() {
|
||||
return cwd.to_path_buf();
|
||||
}
|
||||
|
||||
for ancestor in cwd.ancestors() {
|
||||
for marker in project_root_markers {
|
||||
let marker_path = ancestor.join(marker);
|
||||
if marker_path.exists() {
|
||||
return ancestor.to_path_buf();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cwd.to_path_buf()
|
||||
}
|
||||
|
||||
fn dirs_between_project_root_and_cwd(cwd: &Path, project_root: &Path) -> Vec<PathBuf> {
|
||||
let mut dirs = cwd
|
||||
.ancestors()
|
||||
.scan(false, |done, a| {
|
||||
if *done {
|
||||
None
|
||||
} else {
|
||||
if a == project_root {
|
||||
*done = true;
|
||||
}
|
||||
Some(a.to_path_buf())
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
dirs.reverse();
|
||||
dirs
|
||||
}
|
||||
|
||||
fn dedupe_skill_roots_by_path(roots: &mut Vec<SkillRoot>) {
|
||||
let mut seen: HashSet<PathBuf> = HashSet::new();
|
||||
roots.retain(|root| seen.insert(root.path.clone()));
|
||||
}
|
||||
|
||||
fn discover_skills_under_root(root: &Path, scope: SkillScope, outcome: &mut SkillLoadOutcome) {
|
||||
let Ok(root) = canonicalize_path(root) else {
|
||||
return;
|
||||
};
|
||||
|
||||
if !root.is_dir() {
|
||||
return;
|
||||
}
|
||||
|
||||
fn enqueue_dir(
|
||||
queue: &mut VecDeque<(PathBuf, usize)>,
|
||||
visited_dirs: &mut HashSet<PathBuf>,
|
||||
truncated_by_dir_limit: &mut bool,
|
||||
path: PathBuf,
|
||||
depth: usize,
|
||||
) {
|
||||
if depth > MAX_SCAN_DEPTH {
|
||||
return;
|
||||
}
|
||||
if visited_dirs.len() >= MAX_SKILLS_DIRS_PER_ROOT {
|
||||
*truncated_by_dir_limit = true;
|
||||
return;
|
||||
}
|
||||
if visited_dirs.insert(path.clone()) {
|
||||
queue.push_back((path, depth));
|
||||
}
|
||||
}
|
||||
|
||||
// Follow symlinked directories for user, admin, and repo skills. System skills are written by Codex itself.
|
||||
let follow_symlinks = matches!(
|
||||
scope,
|
||||
SkillScope::Repo | SkillScope::User | SkillScope::Admin
|
||||
);
|
||||
|
||||
let mut visited_dirs: HashSet<PathBuf> = HashSet::new();
|
||||
visited_dirs.insert(root.clone());
|
||||
|
||||
let mut queue: VecDeque<(PathBuf, usize)> = VecDeque::from([(root.clone(), 0)]);
|
||||
let mut truncated_by_dir_limit = false;
|
||||
|
||||
while let Some((dir, depth)) = queue.pop_front() {
|
||||
let entries = match fs::read_dir(&dir) {
|
||||
Ok(entries) => entries,
|
||||
Err(e) => {
|
||||
error!("failed to read skills dir {}: {e:#}", dir.display());
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
let file_name = match path.file_name().and_then(|f| f.to_str()) {
|
||||
Some(name) => name,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
if file_name.starts_with('.') {
|
||||
continue;
|
||||
}
|
||||
|
||||
let Ok(file_type) = entry.file_type() else {
|
||||
continue;
|
||||
};
|
||||
|
||||
if file_type.is_symlink() {
|
||||
if !follow_symlinks {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Follow the symlink to determine what it points to.
|
||||
let metadata = match fs::metadata(&path) {
|
||||
Ok(metadata) => metadata,
|
||||
Err(e) => {
|
||||
error!(
|
||||
"failed to stat skills entry {} (symlink): {e:#}",
|
||||
path.display()
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
if metadata.is_dir() {
|
||||
let Ok(resolved_dir) = canonicalize_path(&path) else {
|
||||
continue;
|
||||
};
|
||||
enqueue_dir(
|
||||
&mut queue,
|
||||
&mut visited_dirs,
|
||||
&mut truncated_by_dir_limit,
|
||||
resolved_dir,
|
||||
depth + 1,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if file_type.is_dir() {
|
||||
let Ok(resolved_dir) = canonicalize_path(&path) else {
|
||||
continue;
|
||||
};
|
||||
enqueue_dir(
|
||||
&mut queue,
|
||||
&mut visited_dirs,
|
||||
&mut truncated_by_dir_limit,
|
||||
resolved_dir,
|
||||
depth + 1,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if file_type.is_file() && file_name == SKILLS_FILENAME {
|
||||
match parse_skill_file(&path, scope) {
|
||||
Ok(skill) => {
|
||||
outcome.skills.push(skill);
|
||||
}
|
||||
Err(err) => {
|
||||
if scope != SkillScope::System {
|
||||
outcome.errors.push(SkillError {
|
||||
path,
|
||||
message: err.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if truncated_by_dir_limit {
|
||||
tracing::warn!(
|
||||
"skills scan truncated after {} directories (root: {})",
|
||||
MAX_SKILLS_DIRS_PER_ROOT,
|
||||
root.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_skill_file(path: &Path, scope: SkillScope) -> Result<SkillMetadata, SkillParseError> {
|
||||
let contents = fs::read_to_string(path).map_err(SkillParseError::Read)?;
|
||||
|
||||
let frontmatter = extract_frontmatter(&contents).ok_or(SkillParseError::MissingFrontmatter)?;
|
||||
|
||||
let parsed: SkillFrontmatter =
|
||||
serde_yaml::from_str(&frontmatter).map_err(SkillParseError::InvalidYaml)?;
|
||||
|
||||
let base_name = parsed
|
||||
.name
|
||||
.as_deref()
|
||||
.map(sanitize_single_line)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or_else(|| default_skill_name(path));
|
||||
let name = namespaced_skill_name(path, &base_name);
|
||||
let description = parsed
|
||||
.description
|
||||
.as_deref()
|
||||
.map(sanitize_single_line)
|
||||
.unwrap_or_default();
|
||||
let short_description = parsed
|
||||
.metadata
|
||||
.short_description
|
||||
.as_deref()
|
||||
.map(sanitize_single_line)
|
||||
.filter(|value| !value.is_empty());
|
||||
let LoadedSkillMetadata {
|
||||
interface,
|
||||
dependencies,
|
||||
policy,
|
||||
permission_profile,
|
||||
managed_network_override,
|
||||
} = load_skill_metadata(path);
|
||||
|
||||
validate_len(&name, MAX_NAME_LEN, "name")?;
|
||||
validate_len(&description, MAX_DESCRIPTION_LEN, "description")?;
|
||||
if let Some(short_description) = short_description.as_deref() {
|
||||
validate_len(
|
||||
short_description,
|
||||
MAX_SHORT_DESCRIPTION_LEN,
|
||||
"metadata.short-description",
|
||||
)?;
|
||||
}
|
||||
|
||||
let resolved_path = canonicalize_path(path).unwrap_or_else(|_| path.to_path_buf());
|
||||
|
||||
Ok(SkillMetadata {
|
||||
name,
|
||||
description,
|
||||
short_description,
|
||||
interface,
|
||||
dependencies,
|
||||
policy,
|
||||
permission_profile,
|
||||
managed_network_override,
|
||||
path_to_skills_md: resolved_path,
|
||||
scope,
|
||||
})
|
||||
}
|
||||
|
||||
fn default_skill_name(path: &Path) -> String {
|
||||
path.parent()
|
||||
.and_then(Path::file_name)
|
||||
.and_then(|name| name.to_str())
|
||||
.map(sanitize_single_line)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or_else(|| "skill".to_string())
|
||||
}
|
||||
|
||||
fn namespaced_skill_name(path: &Path, base_name: &str) -> String {
|
||||
plugin_namespace_for_skill_path(path)
|
||||
.map(|namespace| format!("{namespace}:{base_name}"))
|
||||
.unwrap_or_else(|| base_name.to_string())
|
||||
}
|
||||
|
||||
fn load_skill_metadata(skill_path: &Path) -> LoadedSkillMetadata {
|
||||
// Fail open: optional metadata should not block loading SKILL.md.
|
||||
let Some(skill_dir) = skill_path.parent() else {
|
||||
return LoadedSkillMetadata::default();
|
||||
};
|
||||
let metadata_path = skill_dir
|
||||
.join(SKILLS_METADATA_DIR)
|
||||
.join(SKILLS_METADATA_FILENAME);
|
||||
if !metadata_path.exists() {
|
||||
return LoadedSkillMetadata::default();
|
||||
}
|
||||
|
||||
let contents = match fs::read_to_string(&metadata_path) {
|
||||
Ok(contents) => contents,
|
||||
Err(error) => {
|
||||
tracing::warn!(
|
||||
"ignoring {path}: failed to read {label}: {error}",
|
||||
path = metadata_path.display(),
|
||||
label = SKILLS_METADATA_FILENAME
|
||||
);
|
||||
return LoadedSkillMetadata::default();
|
||||
}
|
||||
};
|
||||
|
||||
let parsed: SkillMetadataFile = {
|
||||
let _guard = AbsolutePathBufGuard::new(skill_dir);
|
||||
match serde_yaml::from_str(&contents) {
|
||||
Ok(parsed) => parsed,
|
||||
Err(error) => {
|
||||
tracing::warn!(
|
||||
"ignoring {path}: invalid {label}: {error}",
|
||||
path = metadata_path.display(),
|
||||
label = SKILLS_METADATA_FILENAME
|
||||
);
|
||||
return LoadedSkillMetadata::default();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let SkillMetadataFile {
|
||||
interface,
|
||||
dependencies,
|
||||
policy,
|
||||
permissions,
|
||||
} = parsed;
|
||||
let (permission_profile, managed_network_override) = normalize_permissions(permissions);
|
||||
LoadedSkillMetadata {
|
||||
interface: resolve_interface(interface, skill_dir),
|
||||
dependencies: resolve_dependencies(dependencies),
|
||||
policy: resolve_policy(policy),
|
||||
permission_profile,
|
||||
managed_network_override,
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_permissions(
|
||||
permissions: Option<SkillPermissionProfile>,
|
||||
) -> (
|
||||
Option<PermissionProfile>,
|
||||
Option<SkillManagedNetworkOverride>,
|
||||
) {
|
||||
let Some(permissions) = permissions else {
|
||||
return (None, None);
|
||||
};
|
||||
let managed_network_override = permissions
|
||||
.network
|
||||
.as_ref()
|
||||
.map(|network| SkillManagedNetworkOverride {
|
||||
allowed_domains: network.allowed_domains.clone(),
|
||||
denied_domains: network.denied_domains.clone(),
|
||||
})
|
||||
.filter(SkillManagedNetworkOverride::has_domain_overrides);
|
||||
let permission_profile = PermissionProfile {
|
||||
network: permissions.network.and_then(|network| {
|
||||
let network = NetworkPermissions {
|
||||
enabled: network.enabled,
|
||||
};
|
||||
(!network.is_empty()).then_some(network)
|
||||
}),
|
||||
file_system: permissions
|
||||
.file_system
|
||||
.filter(|file_system| !file_system.is_empty()),
|
||||
macos: permissions.macos,
|
||||
};
|
||||
|
||||
(
|
||||
(!permission_profile.is_empty()).then_some(permission_profile),
|
||||
managed_network_override,
|
||||
)
|
||||
}
|
||||
|
||||
fn resolve_interface(interface: Option<Interface>, skill_dir: &Path) -> Option<SkillInterface> {
|
||||
let interface = interface?;
|
||||
let interface = SkillInterface {
|
||||
display_name: resolve_str(
|
||||
interface.display_name,
|
||||
MAX_NAME_LEN,
|
||||
"interface.display_name",
|
||||
),
|
||||
short_description: resolve_str(
|
||||
interface.short_description,
|
||||
MAX_SHORT_DESCRIPTION_LEN,
|
||||
"interface.short_description",
|
||||
),
|
||||
icon_small: resolve_asset_path(skill_dir, "interface.icon_small", interface.icon_small),
|
||||
icon_large: resolve_asset_path(skill_dir, "interface.icon_large", interface.icon_large),
|
||||
brand_color: resolve_color_str(interface.brand_color, "interface.brand_color"),
|
||||
default_prompt: resolve_str(
|
||||
interface.default_prompt,
|
||||
MAX_DEFAULT_PROMPT_LEN,
|
||||
"interface.default_prompt",
|
||||
),
|
||||
};
|
||||
let has_fields = interface.display_name.is_some()
|
||||
|| interface.short_description.is_some()
|
||||
|| interface.icon_small.is_some()
|
||||
|| interface.icon_large.is_some()
|
||||
|| interface.brand_color.is_some()
|
||||
|| interface.default_prompt.is_some();
|
||||
if has_fields { Some(interface) } else { None }
|
||||
}
|
||||
|
||||
fn resolve_dependencies(dependencies: Option<Dependencies>) -> Option<SkillDependencies> {
|
||||
let dependencies = dependencies?;
|
||||
let tools: Vec<SkillToolDependency> = dependencies
|
||||
.tools
|
||||
.into_iter()
|
||||
.filter_map(resolve_dependency_tool)
|
||||
.collect();
|
||||
if tools.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(SkillDependencies { tools })
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_policy(policy: Option<Policy>) -> Option<SkillPolicy> {
|
||||
policy.map(|policy| SkillPolicy {
|
||||
allow_implicit_invocation: policy.allow_implicit_invocation,
|
||||
products: policy.products,
|
||||
})
|
||||
}
|
||||
|
||||
fn resolve_dependency_tool(tool: DependencyTool) -> Option<SkillToolDependency> {
|
||||
let r#type = resolve_required_str(
|
||||
tool.kind,
|
||||
MAX_DEPENDENCY_TYPE_LEN,
|
||||
"dependencies.tools.type",
|
||||
)?;
|
||||
let value = resolve_required_str(
|
||||
tool.value,
|
||||
MAX_DEPENDENCY_VALUE_LEN,
|
||||
"dependencies.tools.value",
|
||||
)?;
|
||||
let description = resolve_str(
|
||||
tool.description,
|
||||
MAX_DEPENDENCY_DESCRIPTION_LEN,
|
||||
"dependencies.tools.description",
|
||||
);
|
||||
let transport = resolve_str(
|
||||
tool.transport,
|
||||
MAX_DEPENDENCY_TRANSPORT_LEN,
|
||||
"dependencies.tools.transport",
|
||||
);
|
||||
let command = resolve_str(
|
||||
tool.command,
|
||||
MAX_DEPENDENCY_COMMAND_LEN,
|
||||
"dependencies.tools.command",
|
||||
);
|
||||
let url = resolve_str(tool.url, MAX_DEPENDENCY_URL_LEN, "dependencies.tools.url");
|
||||
|
||||
Some(SkillToolDependency {
|
||||
r#type,
|
||||
value,
|
||||
description,
|
||||
transport,
|
||||
command,
|
||||
url,
|
||||
})
|
||||
}
|
||||
|
||||
fn resolve_asset_path(
|
||||
skill_dir: &Path,
|
||||
field: &'static str,
|
||||
path: Option<PathBuf>,
|
||||
) -> Option<PathBuf> {
|
||||
// Icons must be relative paths under the skill's assets/ directory; otherwise return None.
|
||||
let path = path?;
|
||||
if path.as_os_str().is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let assets_dir = skill_dir.join("assets");
|
||||
if path.is_absolute() {
|
||||
tracing::warn!(
|
||||
"ignoring {field}: icon must be a relative assets path (not {})",
|
||||
assets_dir.display()
|
||||
);
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut normalized = PathBuf::new();
|
||||
for component in path.components() {
|
||||
match component {
|
||||
Component::CurDir => {}
|
||||
Component::Normal(component) => normalized.push(component),
|
||||
Component::ParentDir => {
|
||||
tracing::warn!("ignoring {field}: icon path must not contain '..'");
|
||||
return None;
|
||||
}
|
||||
_ => {
|
||||
tracing::warn!("ignoring {field}: icon path must be under assets/");
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let mut components = normalized.components();
|
||||
match components.next() {
|
||||
Some(Component::Normal(component)) if component == "assets" => {}
|
||||
_ => {
|
||||
tracing::warn!("ignoring {field}: icon path must be under assets/");
|
||||
return None;
|
||||
}
|
||||
}
|
||||
|
||||
Some(skill_dir.join(normalized))
|
||||
}
|
||||
|
||||
fn sanitize_single_line(raw: &str) -> String {
|
||||
raw.split_whitespace().collect::<Vec<_>>().join(" ")
|
||||
}
|
||||
|
||||
fn validate_len(
|
||||
value: &str,
|
||||
max_len: usize,
|
||||
field_name: &'static str,
|
||||
) -> Result<(), SkillParseError> {
|
||||
if value.is_empty() {
|
||||
return Err(SkillParseError::MissingField(field_name));
|
||||
}
|
||||
if value.chars().count() > max_len {
|
||||
return Err(SkillParseError::InvalidField {
|
||||
field: field_name,
|
||||
reason: format!("exceeds maximum length of {max_len} characters"),
|
||||
});
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn resolve_str(value: Option<String>, max_len: usize, field: &'static str) -> Option<String> {
|
||||
let value = value?;
|
||||
let value = sanitize_single_line(&value);
|
||||
if value.is_empty() {
|
||||
tracing::warn!("ignoring {field}: value is empty");
|
||||
return None;
|
||||
}
|
||||
if value.chars().count() > max_len {
|
||||
tracing::warn!("ignoring {field}: exceeds maximum length of {max_len} characters");
|
||||
return None;
|
||||
}
|
||||
Some(value)
|
||||
}
|
||||
|
||||
fn resolve_required_str(
|
||||
value: Option<String>,
|
||||
max_len: usize,
|
||||
field: &'static str,
|
||||
) -> Option<String> {
|
||||
let Some(value) = value else {
|
||||
tracing::warn!("ignoring {field}: value is missing");
|
||||
return None;
|
||||
};
|
||||
resolve_str(Some(value), max_len, field)
|
||||
}
|
||||
|
||||
fn resolve_color_str(value: Option<String>, field: &'static str) -> Option<String> {
|
||||
let value = value?;
|
||||
let value = value.trim();
|
||||
if value.is_empty() {
|
||||
tracing::warn!("ignoring {field}: value is empty");
|
||||
return None;
|
||||
}
|
||||
let mut chars = value.chars();
|
||||
if value.len() == 7 && chars.next() == Some('#') && chars.all(|c| c.is_ascii_hexdigit()) {
|
||||
Some(value.to_string())
|
||||
} else {
|
||||
tracing::warn!("ignoring {field}: expected #RRGGBB, got {value}");
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn extract_frontmatter(contents: &str) -> Option<String> {
|
||||
let mut lines = contents.lines();
|
||||
if !matches!(lines.next(), Some(line) if line.trim() == "---") {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut frontmatter_lines: Vec<&str> = Vec::new();
|
||||
let mut found_closing = false;
|
||||
for line in lines.by_ref() {
|
||||
if line.trim() == "---" {
|
||||
found_closing = true;
|
||||
break;
|
||||
}
|
||||
frontmatter_lines.push(line);
|
||||
}
|
||||
|
||||
if frontmatter_lines.is_empty() || !found_closing {
|
||||
return None;
|
||||
}
|
||||
|
||||
Some(frontmatter_lines.join("\n"))
|
||||
}
|
||||
#[cfg(test)]
|
||||
pub(crate) fn skill_roots_from_layer_stack(
|
||||
config_layer_stack: &ConfigLayerStack,
|
||||
home_dir: Option<&Path>,
|
||||
) -> Vec<SkillRoot> {
|
||||
skill_roots_with_home_dir(config_layer_stack, Path::new("."), home_dir, Vec::new())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "loader_tests.rs"]
|
||||
mod tests;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,331 +0,0 @@
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::sync::RwLock;
|
||||
|
||||
use codex_protocol::protocol::Product;
|
||||
use codex_protocol::protocol::SkillScope;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use toml::Value as TomlValue;
|
||||
use tracing::info;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::config::types::SkillsConfig;
|
||||
use crate::config_loader::CloudRequirementsLoader;
|
||||
use crate::config_loader::LoaderOverrides;
|
||||
use crate::config_loader::load_config_layers_state;
|
||||
use crate::plugins::PluginsManager;
|
||||
use crate::skills::SkillLoadOutcome;
|
||||
use crate::skills::build_implicit_skill_path_indexes;
|
||||
use crate::skills::config_rules::SkillConfigRules;
|
||||
use crate::skills::config_rules::resolve_disabled_skill_paths;
|
||||
use crate::skills::config_rules::skill_config_rules_from_stack;
|
||||
use crate::skills::loader::SkillRoot;
|
||||
use crate::skills::loader::load_skills_from_roots;
|
||||
use crate::skills::loader::skill_roots;
|
||||
use crate::skills::system::install_system_skills;
|
||||
use crate::skills::system::uninstall_system_skills;
|
||||
|
||||
pub struct SkillsManager {
|
||||
codex_home: PathBuf,
|
||||
plugins_manager: Arc<PluginsManager>,
|
||||
restriction_product: Option<Product>,
|
||||
cache_by_cwd: RwLock<HashMap<PathBuf, SkillLoadOutcome>>,
|
||||
cache_by_config: RwLock<HashMap<ConfigSkillsCacheKey, SkillLoadOutcome>>,
|
||||
}
|
||||
|
||||
impl SkillsManager {
|
||||
pub fn new(
|
||||
codex_home: PathBuf,
|
||||
plugins_manager: Arc<PluginsManager>,
|
||||
bundled_skills_enabled: bool,
|
||||
) -> Self {
|
||||
Self::new_with_restriction_product(
|
||||
codex_home,
|
||||
plugins_manager,
|
||||
bundled_skills_enabled,
|
||||
Some(Product::Codex),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn new_with_restriction_product(
|
||||
codex_home: PathBuf,
|
||||
plugins_manager: Arc<PluginsManager>,
|
||||
bundled_skills_enabled: bool,
|
||||
restriction_product: Option<Product>,
|
||||
) -> Self {
|
||||
let manager = Self {
|
||||
codex_home,
|
||||
plugins_manager,
|
||||
restriction_product,
|
||||
cache_by_cwd: RwLock::new(HashMap::new()),
|
||||
cache_by_config: RwLock::new(HashMap::new()),
|
||||
};
|
||||
if !bundled_skills_enabled {
|
||||
// The loader caches bundled skills under `skills/.system`. Clearing that directory is
|
||||
// best-effort cleanup; root selection still enforces the config even if removal fails.
|
||||
uninstall_system_skills(&manager.codex_home);
|
||||
} else if let Err(err) = install_system_skills(&manager.codex_home) {
|
||||
tracing::error!("failed to install system skills: {err}");
|
||||
}
|
||||
manager
|
||||
}
|
||||
|
||||
/// Load skills for an already-constructed [`Config`], avoiding any additional config-layer
|
||||
/// loading.
|
||||
///
|
||||
/// This path uses a cache keyed by the effective skill-relevant config state rather than just
|
||||
/// cwd so role-local and session-local skill overrides cannot bleed across sessions that happen
|
||||
/// to share a directory.
|
||||
pub fn skills_for_config(&self, config: &Config) -> SkillLoadOutcome {
|
||||
let roots = self.skill_roots_for_config(config);
|
||||
let skill_config_rules = skill_config_rules_from_stack(&config.config_layer_stack);
|
||||
let cache_key = config_skills_cache_key(&roots, &skill_config_rules);
|
||||
if let Some(outcome) = self.cached_outcome_for_config(&cache_key) {
|
||||
return outcome;
|
||||
}
|
||||
|
||||
let outcome = self.build_skill_outcome(roots, &skill_config_rules);
|
||||
let mut cache = self
|
||||
.cache_by_config
|
||||
.write()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
cache.insert(cache_key, outcome.clone());
|
||||
outcome
|
||||
}
|
||||
|
||||
pub(crate) fn skill_roots_for_config(&self, config: &Config) -> Vec<SkillRoot> {
|
||||
let loaded_plugins = self.plugins_manager.plugins_for_config(config);
|
||||
let mut roots = skill_roots(
|
||||
&config.config_layer_stack,
|
||||
&config.cwd,
|
||||
loaded_plugins.effective_skill_roots(),
|
||||
);
|
||||
if !config.bundled_skills_enabled() {
|
||||
roots.retain(|root| root.scope != SkillScope::System);
|
||||
}
|
||||
roots
|
||||
}
|
||||
|
||||
pub async fn skills_for_cwd(
|
||||
&self,
|
||||
cwd: &Path,
|
||||
config: &Config,
|
||||
force_reload: bool,
|
||||
) -> SkillLoadOutcome {
|
||||
if !force_reload && let Some(outcome) = self.cached_outcome_for_cwd(cwd) {
|
||||
return outcome;
|
||||
}
|
||||
|
||||
self.skills_for_cwd_with_extra_user_roots(cwd, config, force_reload, &[])
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn skills_for_cwd_with_extra_user_roots(
|
||||
&self,
|
||||
cwd: &Path,
|
||||
config: &Config,
|
||||
force_reload: bool,
|
||||
extra_user_roots: &[PathBuf],
|
||||
) -> SkillLoadOutcome {
|
||||
if !force_reload && let Some(outcome) = self.cached_outcome_for_cwd(cwd) {
|
||||
return outcome;
|
||||
}
|
||||
let normalized_extra_user_roots = normalize_extra_user_roots(extra_user_roots);
|
||||
|
||||
let cwd_abs = match AbsolutePathBuf::try_from(cwd) {
|
||||
Ok(cwd_abs) => cwd_abs,
|
||||
Err(err) => {
|
||||
return SkillLoadOutcome {
|
||||
errors: vec![crate::skills::model::SkillError {
|
||||
path: cwd.to_path_buf(),
|
||||
message: err.to_string(),
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
let cli_overrides: Vec<(String, TomlValue)> = Vec::new();
|
||||
let config_layer_stack = match load_config_layers_state(
|
||||
&self.codex_home,
|
||||
Some(cwd_abs),
|
||||
&cli_overrides,
|
||||
LoaderOverrides::default(),
|
||||
CloudRequirementsLoader::default(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(config_layer_stack) => config_layer_stack,
|
||||
Err(err) => {
|
||||
return SkillLoadOutcome {
|
||||
errors: vec![crate::skills::model::SkillError {
|
||||
path: cwd.to_path_buf(),
|
||||
message: err.to_string(),
|
||||
}],
|
||||
..Default::default()
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
let loaded_plugins = self
|
||||
.plugins_manager
|
||||
.plugins_for_config_with_force_reload(config, force_reload);
|
||||
let mut roots = skill_roots(
|
||||
&config_layer_stack,
|
||||
cwd,
|
||||
loaded_plugins.effective_skill_roots(),
|
||||
);
|
||||
if !bundled_skills_enabled_from_stack(&config_layer_stack) {
|
||||
roots.retain(|root| root.scope != SkillScope::System);
|
||||
}
|
||||
roots.extend(
|
||||
normalized_extra_user_roots
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(|path| SkillRoot {
|
||||
path,
|
||||
scope: SkillScope::User,
|
||||
}),
|
||||
);
|
||||
let skill_config_rules = skill_config_rules_from_stack(&config_layer_stack);
|
||||
let outcome = self.build_skill_outcome(roots, &skill_config_rules);
|
||||
let mut cache = self
|
||||
.cache_by_cwd
|
||||
.write()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
cache.insert(cwd.to_path_buf(), outcome.clone());
|
||||
outcome
|
||||
}
|
||||
|
||||
fn build_skill_outcome(
|
||||
&self,
|
||||
roots: Vec<SkillRoot>,
|
||||
skill_config_rules: &SkillConfigRules,
|
||||
) -> SkillLoadOutcome {
|
||||
let outcome = crate::skills::filter_skill_load_outcome_for_product(
|
||||
load_skills_from_roots(roots),
|
||||
self.restriction_product,
|
||||
);
|
||||
let disabled_paths = resolve_disabled_skill_paths(&outcome.skills, skill_config_rules);
|
||||
finalize_skill_outcome(outcome, disabled_paths)
|
||||
}
|
||||
|
||||
pub fn clear_cache(&self) {
|
||||
let cleared_cwd = {
|
||||
let mut cache = self
|
||||
.cache_by_cwd
|
||||
.write()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let cleared = cache.len();
|
||||
cache.clear();
|
||||
cleared
|
||||
};
|
||||
let cleared_config = {
|
||||
let mut cache = self
|
||||
.cache_by_config
|
||||
.write()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let cleared = cache.len();
|
||||
cache.clear();
|
||||
cleared
|
||||
};
|
||||
let cleared = cleared_cwd + cleared_config;
|
||||
info!("skills cache cleared ({cleared} entries)");
|
||||
}
|
||||
|
||||
fn cached_outcome_for_cwd(&self, cwd: &Path) -> Option<SkillLoadOutcome> {
|
||||
match self.cache_by_cwd.read() {
|
||||
Ok(cache) => cache.get(cwd).cloned(),
|
||||
Err(err) => err.into_inner().get(cwd).cloned(),
|
||||
}
|
||||
}
|
||||
|
||||
fn cached_outcome_for_config(
|
||||
&self,
|
||||
cache_key: &ConfigSkillsCacheKey,
|
||||
) -> Option<SkillLoadOutcome> {
|
||||
match self.cache_by_config.read() {
|
||||
Ok(cache) => cache.get(cache_key).cloned(),
|
||||
Err(err) => err.into_inner().get(cache_key).cloned(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
struct ConfigSkillsCacheKey {
|
||||
roots: Vec<(PathBuf, u8)>,
|
||||
skill_config_rules: SkillConfigRules,
|
||||
}
|
||||
|
||||
pub(crate) fn bundled_skills_enabled_from_stack(
|
||||
config_layer_stack: &crate::config_loader::ConfigLayerStack,
|
||||
) -> bool {
|
||||
let effective_config = config_layer_stack.effective_config();
|
||||
let Some(skills_value) = effective_config
|
||||
.as_table()
|
||||
.and_then(|table| table.get("skills"))
|
||||
else {
|
||||
return true;
|
||||
};
|
||||
|
||||
let skills: SkillsConfig = match skills_value.clone().try_into() {
|
||||
Ok(skills) => skills,
|
||||
Err(err) => {
|
||||
warn!("invalid skills config: {err}");
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
skills.bundled.unwrap_or_default().enabled
|
||||
}
|
||||
|
||||
fn config_skills_cache_key(
|
||||
roots: &[SkillRoot],
|
||||
skill_config_rules: &SkillConfigRules,
|
||||
) -> ConfigSkillsCacheKey {
|
||||
ConfigSkillsCacheKey {
|
||||
roots: roots
|
||||
.iter()
|
||||
.map(|root| {
|
||||
let scope_rank = match root.scope {
|
||||
SkillScope::Repo => 0,
|
||||
SkillScope::User => 1,
|
||||
SkillScope::System => 2,
|
||||
SkillScope::Admin => 3,
|
||||
};
|
||||
(root.path.clone(), scope_rank)
|
||||
})
|
||||
.collect(),
|
||||
skill_config_rules: skill_config_rules.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn finalize_skill_outcome(
|
||||
mut outcome: SkillLoadOutcome,
|
||||
disabled_paths: HashSet<PathBuf>,
|
||||
) -> SkillLoadOutcome {
|
||||
outcome.disabled_paths = disabled_paths;
|
||||
let (by_scripts_dir, by_doc_path) =
|
||||
build_implicit_skill_path_indexes(outcome.allowed_skills_for_implicit_invocation());
|
||||
outcome.implicit_skills_by_scripts_dir = Arc::new(by_scripts_dir);
|
||||
outcome.implicit_skills_by_doc_path = Arc::new(by_doc_path);
|
||||
outcome
|
||||
}
|
||||
|
||||
fn normalize_extra_user_roots(extra_user_roots: &[PathBuf]) -> Vec<PathBuf> {
|
||||
let mut normalized: Vec<PathBuf> = extra_user_roots
|
||||
.iter()
|
||||
.map(|path| dunce::canonicalize(path).unwrap_or_else(|_| path.clone()))
|
||||
.collect();
|
||||
normalized.sort_unstable();
|
||||
normalized.dedup();
|
||||
normalized
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "manager_tests.rs"]
|
||||
mod tests;
|
||||
@@ -1,634 +0,0 @@
|
||||
use super::*;
|
||||
use crate::config::ConfigBuilder;
|
||||
use crate::config::ConfigOverrides;
|
||||
use crate::config_loader::ConfigLayerEntry;
|
||||
use crate::config_loader::ConfigLayerStack;
|
||||
use crate::config_loader::ConfigRequirementsToml;
|
||||
use crate::plugins::PluginsManager;
|
||||
use crate::skills::SkillMetadata;
|
||||
use crate::skills::config_rules::resolve_disabled_skill_paths;
|
||||
use crate::skills::config_rules::skill_config_rules_from_stack;
|
||||
use codex_app_server_protocol::ConfigLayerSource;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn write_user_skill(codex_home: &TempDir, dir: &str, name: &str, description: &str) {
|
||||
let skill_dir = codex_home.path().join("skills").join(dir);
|
||||
fs::create_dir_all(&skill_dir).unwrap();
|
||||
let content = format!("---\nname: {name}\ndescription: {description}\n---\n\n# Body\n");
|
||||
fs::write(skill_dir.join("SKILL.md"), content).unwrap();
|
||||
}
|
||||
|
||||
fn write_plugin_skill(
|
||||
codex_home: &TempDir,
|
||||
marketplace: &str,
|
||||
plugin_name: &str,
|
||||
dir: &str,
|
||||
name: &str,
|
||||
description: &str,
|
||||
) -> PathBuf {
|
||||
let plugin_root = codex_home
|
||||
.path()
|
||||
.join("plugins/cache")
|
||||
.join(marketplace)
|
||||
.join(plugin_name)
|
||||
.join("local");
|
||||
let skill_dir = plugin_root.join("skills").join(dir);
|
||||
fs::create_dir_all(plugin_root.join(".codex-plugin")).unwrap();
|
||||
fs::create_dir_all(&skill_dir).unwrap();
|
||||
fs::write(
|
||||
plugin_root.join(".codex-plugin/plugin.json"),
|
||||
format!(r#"{{"name":"{plugin_name}"}}"#),
|
||||
)
|
||||
.unwrap();
|
||||
let content = format!("---\nname: {name}\ndescription: {description}\n---\n\n# Body\n");
|
||||
let skill_path = skill_dir.join("SKILL.md");
|
||||
fs::write(&skill_path, content).unwrap();
|
||||
skill_path
|
||||
}
|
||||
|
||||
fn test_skill(name: &str, path: PathBuf) -> SkillMetadata {
|
||||
SkillMetadata {
|
||||
name: name.to_string(),
|
||||
description: "test".to_string(),
|
||||
short_description: None,
|
||||
interface: None,
|
||||
dependencies: None,
|
||||
policy: None,
|
||||
permission_profile: None,
|
||||
managed_network_override: None,
|
||||
path_to_skills_md: path,
|
||||
scope: SkillScope::User,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_with_disabled_bundled_skills_removes_stale_cached_system_skills() {
|
||||
let codex_home = tempfile::tempdir().expect("tempdir");
|
||||
let stale_system_skill_dir = codex_home.path().join("skills/.system/stale-skill");
|
||||
fs::create_dir_all(&stale_system_skill_dir).expect("create stale system skill dir");
|
||||
fs::write(stale_system_skill_dir.join("SKILL.md"), "# stale\n")
|
||||
.expect("write stale system skill");
|
||||
|
||||
let plugins_manager = Arc::new(PluginsManager::new(codex_home.path().to_path_buf()));
|
||||
let _skills_manager =
|
||||
SkillsManager::new(codex_home.path().to_path_buf(), plugins_manager, false);
|
||||
|
||||
assert!(
|
||||
!codex_home.path().join("skills/.system").exists(),
|
||||
"expected disabling system skills to remove stale cached bundled skills"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn skills_for_config_reuses_cache_for_same_effective_config() {
|
||||
let codex_home = tempfile::tempdir().expect("tempdir");
|
||||
let cwd = tempfile::tempdir().expect("tempdir");
|
||||
|
||||
let cfg = ConfigBuilder::default()
|
||||
.codex_home(codex_home.path().to_path_buf())
|
||||
.harness_overrides(ConfigOverrides {
|
||||
cwd: Some(cwd.path().to_path_buf()),
|
||||
..Default::default()
|
||||
})
|
||||
.build()
|
||||
.await
|
||||
.expect("defaults for test should always succeed");
|
||||
|
||||
let plugins_manager = Arc::new(PluginsManager::new(codex_home.path().to_path_buf()));
|
||||
let skills_manager = SkillsManager::new(codex_home.path().to_path_buf(), plugins_manager, true);
|
||||
|
||||
write_user_skill(&codex_home, "a", "skill-a", "from a");
|
||||
let outcome1 = skills_manager.skills_for_config(&cfg);
|
||||
assert!(
|
||||
outcome1.skills.iter().any(|s| s.name == "skill-a"),
|
||||
"expected skill-a to be discovered"
|
||||
);
|
||||
|
||||
// Write a new skill after the first call; the second call should reuse the config-aware cache
|
||||
// entry because the effective skill config is unchanged.
|
||||
write_user_skill(&codex_home, "b", "skill-b", "from b");
|
||||
let outcome2 = skills_manager.skills_for_config(&cfg);
|
||||
assert_eq!(outcome2.errors, outcome1.errors);
|
||||
assert_eq!(outcome2.skills, outcome1.skills);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn skills_for_config_disables_plugin_skills_by_name() {
|
||||
let codex_home = tempfile::tempdir().expect("tempdir");
|
||||
let cwd = tempfile::tempdir().expect("tempdir");
|
||||
let skill_path = write_plugin_skill(
|
||||
&codex_home,
|
||||
"test",
|
||||
"sample",
|
||||
"sample-search",
|
||||
"sample-search",
|
||||
"search sample data",
|
||||
);
|
||||
fs::write(
|
||||
codex_home.path().join(crate::config::CONFIG_TOML_FILE),
|
||||
r#"[features]
|
||||
plugins = true
|
||||
|
||||
[[skills.config]]
|
||||
name = "sample:sample-search"
|
||||
enabled = false
|
||||
|
||||
[plugins."sample@test"]
|
||||
enabled = true
|
||||
"#,
|
||||
)
|
||||
.expect("write config");
|
||||
|
||||
let config = ConfigBuilder::default()
|
||||
.codex_home(codex_home.path().to_path_buf())
|
||||
.harness_overrides(ConfigOverrides {
|
||||
cwd: Some(cwd.path().to_path_buf()),
|
||||
..Default::default()
|
||||
})
|
||||
.build()
|
||||
.await
|
||||
.expect("load config");
|
||||
|
||||
let plugins_manager = Arc::new(PluginsManager::new(codex_home.path().to_path_buf()));
|
||||
let skills_manager = SkillsManager::new(
|
||||
codex_home.path().to_path_buf(),
|
||||
plugins_manager,
|
||||
config.bundled_skills_enabled(),
|
||||
);
|
||||
|
||||
let outcome = skills_manager.skills_for_config(&config);
|
||||
let skill = outcome
|
||||
.skills
|
||||
.iter()
|
||||
.find(|skill| skill.name == "sample:sample-search")
|
||||
.expect("plugin skill should load");
|
||||
let skill_path = dunce::canonicalize(skill_path).expect("skill path should canonicalize");
|
||||
|
||||
assert_eq!(skill.path_to_skills_md, skill_path);
|
||||
assert!(outcome.disabled_paths.contains(&skill.path_to_skills_md));
|
||||
assert!(
|
||||
!outcome
|
||||
.allowed_skills_for_implicit_invocation()
|
||||
.iter()
|
||||
.any(|allowed_skill| allowed_skill.path_to_skills_md == skill.path_to_skills_md)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn skills_for_cwd_reuses_cached_entry_even_when_entry_has_extra_roots() {
|
||||
let codex_home = tempfile::tempdir().expect("tempdir");
|
||||
let cwd = tempfile::tempdir().expect("tempdir");
|
||||
let extra_root = tempfile::tempdir().expect("tempdir");
|
||||
|
||||
let config = ConfigBuilder::default()
|
||||
.codex_home(codex_home.path().to_path_buf())
|
||||
.harness_overrides(ConfigOverrides {
|
||||
cwd: Some(cwd.path().to_path_buf()),
|
||||
..Default::default()
|
||||
})
|
||||
.build()
|
||||
.await
|
||||
.expect("defaults for test should always succeed");
|
||||
|
||||
let plugins_manager = Arc::new(PluginsManager::new(codex_home.path().to_path_buf()));
|
||||
let skills_manager = SkillsManager::new(codex_home.path().to_path_buf(), plugins_manager, true);
|
||||
let _ = skills_manager.skills_for_config(&config);
|
||||
|
||||
write_user_skill(&extra_root, "x", "extra-skill", "from extra root");
|
||||
let extra_root_path = extra_root.path().to_path_buf();
|
||||
let outcome_with_extra = skills_manager
|
||||
.skills_for_cwd_with_extra_user_roots(
|
||||
cwd.path(),
|
||||
&config,
|
||||
true,
|
||||
std::slice::from_ref(&extra_root_path),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
outcome_with_extra
|
||||
.skills
|
||||
.iter()
|
||||
.any(|skill| skill.name == "extra-skill")
|
||||
);
|
||||
assert!(
|
||||
outcome_with_extra
|
||||
.skills
|
||||
.iter()
|
||||
.any(|skill| skill.scope == SkillScope::System)
|
||||
);
|
||||
|
||||
// The cwd-only API returns the current cached entry for this cwd, even when that entry
|
||||
// was produced with extra roots.
|
||||
let outcome_without_extra = skills_manager
|
||||
.skills_for_cwd(cwd.path(), &config, false)
|
||||
.await;
|
||||
assert_eq!(outcome_without_extra.skills, outcome_with_extra.skills);
|
||||
assert_eq!(outcome_without_extra.errors, outcome_with_extra.errors);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn skills_for_config_excludes_bundled_skills_when_disabled_in_config() {
|
||||
let codex_home = tempfile::tempdir().expect("tempdir");
|
||||
let cwd = tempfile::tempdir().expect("tempdir");
|
||||
let bundled_skill_dir = codex_home.path().join("skills/.system/bundled-skill");
|
||||
fs::create_dir_all(&bundled_skill_dir).expect("create bundled skill dir");
|
||||
fs::write(
|
||||
bundled_skill_dir.join("SKILL.md"),
|
||||
"---\nname: bundled-skill\ndescription: from bundled root\n---\n\n# Body\n",
|
||||
)
|
||||
.expect("write bundled skill");
|
||||
|
||||
fs::write(
|
||||
codex_home.path().join(crate::config::CONFIG_TOML_FILE),
|
||||
"[skills.bundled]\nenabled = false\n",
|
||||
)
|
||||
.expect("write config");
|
||||
|
||||
let config = ConfigBuilder::default()
|
||||
.codex_home(codex_home.path().to_path_buf())
|
||||
.harness_overrides(ConfigOverrides {
|
||||
cwd: Some(cwd.path().to_path_buf()),
|
||||
..Default::default()
|
||||
})
|
||||
.build()
|
||||
.await
|
||||
.expect("load config");
|
||||
|
||||
let plugins_manager = Arc::new(PluginsManager::new(codex_home.path().to_path_buf()));
|
||||
let skills_manager = SkillsManager::new(
|
||||
codex_home.path().to_path_buf(),
|
||||
plugins_manager,
|
||||
config.bundled_skills_enabled(),
|
||||
);
|
||||
|
||||
// Recreate the cached bundled skill after startup cleanup so this assertion exercises
|
||||
// root selection rather than relying on directory removal succeeding.
|
||||
fs::create_dir_all(&bundled_skill_dir).expect("recreate bundled skill dir");
|
||||
fs::write(
|
||||
bundled_skill_dir.join("SKILL.md"),
|
||||
"---\nname: bundled-skill\ndescription: from bundled root\n---\n\n# Body\n",
|
||||
)
|
||||
.expect("rewrite bundled skill");
|
||||
|
||||
let outcome = skills_manager.skills_for_config(&config);
|
||||
assert!(
|
||||
outcome
|
||||
.skills
|
||||
.iter()
|
||||
.all(|skill| skill.name != "bundled-skill")
|
||||
);
|
||||
assert!(
|
||||
outcome
|
||||
.skills
|
||||
.iter()
|
||||
.all(|skill| skill.scope != SkillScope::System)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn skills_for_cwd_with_extra_roots_only_refreshes_on_force_reload() {
|
||||
let codex_home = tempfile::tempdir().expect("tempdir");
|
||||
let cwd = tempfile::tempdir().expect("tempdir");
|
||||
let extra_root_a = tempfile::tempdir().expect("tempdir");
|
||||
let extra_root_b = tempfile::tempdir().expect("tempdir");
|
||||
|
||||
let config = ConfigBuilder::default()
|
||||
.codex_home(codex_home.path().to_path_buf())
|
||||
.harness_overrides(ConfigOverrides {
|
||||
cwd: Some(cwd.path().to_path_buf()),
|
||||
..Default::default()
|
||||
})
|
||||
.build()
|
||||
.await
|
||||
.expect("defaults for test should always succeed");
|
||||
|
||||
let plugins_manager = Arc::new(PluginsManager::new(codex_home.path().to_path_buf()));
|
||||
let skills_manager = SkillsManager::new(codex_home.path().to_path_buf(), plugins_manager, true);
|
||||
let _ = skills_manager.skills_for_config(&config);
|
||||
|
||||
write_user_skill(&extra_root_a, "x", "extra-skill-a", "from extra root a");
|
||||
write_user_skill(&extra_root_b, "x", "extra-skill-b", "from extra root b");
|
||||
|
||||
let extra_root_a_path = extra_root_a.path().to_path_buf();
|
||||
let outcome_a = skills_manager
|
||||
.skills_for_cwd_with_extra_user_roots(
|
||||
cwd.path(),
|
||||
&config,
|
||||
true,
|
||||
std::slice::from_ref(&extra_root_a_path),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
outcome_a
|
||||
.skills
|
||||
.iter()
|
||||
.any(|skill| skill.name == "extra-skill-a")
|
||||
);
|
||||
assert!(
|
||||
outcome_a
|
||||
.skills
|
||||
.iter()
|
||||
.all(|skill| skill.name != "extra-skill-b")
|
||||
);
|
||||
|
||||
let extra_root_b_path = extra_root_b.path().to_path_buf();
|
||||
let outcome_b = skills_manager
|
||||
.skills_for_cwd_with_extra_user_roots(
|
||||
cwd.path(),
|
||||
&config,
|
||||
false,
|
||||
std::slice::from_ref(&extra_root_b_path),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
outcome_b
|
||||
.skills
|
||||
.iter()
|
||||
.any(|skill| skill.name == "extra-skill-a")
|
||||
);
|
||||
assert!(
|
||||
outcome_b
|
||||
.skills
|
||||
.iter()
|
||||
.all(|skill| skill.name != "extra-skill-b")
|
||||
);
|
||||
|
||||
let outcome_reloaded = skills_manager
|
||||
.skills_for_cwd_with_extra_user_roots(
|
||||
cwd.path(),
|
||||
&config,
|
||||
true,
|
||||
std::slice::from_ref(&extra_root_b_path),
|
||||
)
|
||||
.await;
|
||||
assert!(
|
||||
outcome_reloaded
|
||||
.skills
|
||||
.iter()
|
||||
.any(|skill| skill.name == "extra-skill-b")
|
||||
);
|
||||
assert!(
|
||||
outcome_reloaded
|
||||
.skills
|
||||
.iter()
|
||||
.all(|skill| skill.name != "extra-skill-a")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_extra_user_roots_is_stable_for_equivalent_inputs() {
|
||||
let a = PathBuf::from("/tmp/a");
|
||||
let b = PathBuf::from("/tmp/b");
|
||||
|
||||
let first = normalize_extra_user_roots(&[a.clone(), b.clone(), a.clone()]);
|
||||
let second = normalize_extra_user_roots(&[b, a]);
|
||||
|
||||
assert_eq!(first, second);
|
||||
}
|
||||
|
||||
#[cfg_attr(windows, ignore)]
|
||||
#[test]
|
||||
fn disabled_paths_for_skills_allows_session_flags_to_override_user_layer() {
|
||||
let tempdir = tempfile::tempdir().expect("tempdir");
|
||||
let skill_path = tempdir.path().join("skills").join("demo").join("SKILL.md");
|
||||
let skill = test_skill("demo-skill", skill_path.clone());
|
||||
let user_file = AbsolutePathBuf::try_from(tempdir.path().join("config.toml"))
|
||||
.expect("user config path should be absolute");
|
||||
let user_layer = ConfigLayerEntry::new(
|
||||
ConfigLayerSource::User { file: user_file },
|
||||
toml::from_str(&format!(
|
||||
r#"[[skills.config]]
|
||||
path = "{}"
|
||||
enabled = false
|
||||
"#,
|
||||
skill_path.display()
|
||||
))
|
||||
.expect("user layer toml"),
|
||||
);
|
||||
let session_layer = ConfigLayerEntry::new(
|
||||
ConfigLayerSource::SessionFlags,
|
||||
toml::from_str(&format!(
|
||||
r#"[[skills.config]]
|
||||
path = "{}"
|
||||
enabled = true
|
||||
"#,
|
||||
skill_path.display()
|
||||
))
|
||||
.expect("session layer toml"),
|
||||
);
|
||||
let stack = ConfigLayerStack::new(
|
||||
vec![user_layer, session_layer],
|
||||
Default::default(),
|
||||
ConfigRequirementsToml::default(),
|
||||
)
|
||||
.expect("valid config layer stack");
|
||||
|
||||
let skill_config_rules = skill_config_rules_from_stack(&stack);
|
||||
assert_eq!(
|
||||
resolve_disabled_skill_paths(&[skill], &skill_config_rules),
|
||||
HashSet::new()
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg_attr(windows, ignore)]
|
||||
#[test]
|
||||
fn disabled_paths_for_skills_allows_session_flags_to_disable_user_enabled_skill() {
|
||||
let tempdir = tempfile::tempdir().expect("tempdir");
|
||||
let skill_path = tempdir.path().join("skills").join("demo").join("SKILL.md");
|
||||
let skill = test_skill("demo-skill", skill_path.clone());
|
||||
let user_file = AbsolutePathBuf::try_from(tempdir.path().join("config.toml"))
|
||||
.expect("user config path should be absolute");
|
||||
let user_layer = ConfigLayerEntry::new(
|
||||
ConfigLayerSource::User { file: user_file },
|
||||
toml::from_str(&format!(
|
||||
r#"[[skills.config]]
|
||||
path = "{}"
|
||||
enabled = true
|
||||
"#,
|
||||
skill_path.display()
|
||||
))
|
||||
.expect("user layer toml"),
|
||||
);
|
||||
let session_layer = ConfigLayerEntry::new(
|
||||
ConfigLayerSource::SessionFlags,
|
||||
toml::from_str(&format!(
|
||||
r#"[[skills.config]]
|
||||
path = "{}"
|
||||
enabled = false
|
||||
"#,
|
||||
skill_path.display()
|
||||
))
|
||||
.expect("session layer toml"),
|
||||
);
|
||||
let stack = ConfigLayerStack::new(
|
||||
vec![user_layer, session_layer],
|
||||
Default::default(),
|
||||
ConfigRequirementsToml::default(),
|
||||
)
|
||||
.expect("valid config layer stack");
|
||||
|
||||
let skill_config_rules = skill_config_rules_from_stack(&stack);
|
||||
assert_eq!(
|
||||
resolve_disabled_skill_paths(&[skill], &skill_config_rules),
|
||||
HashSet::from([skill_path])
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg_attr(windows, ignore)]
|
||||
#[test]
|
||||
fn disabled_paths_for_skills_disables_matching_name_selectors() {
|
||||
let tempdir = tempfile::tempdir().expect("tempdir");
|
||||
let skill_path = tempdir.path().join("skills").join("demo").join("SKILL.md");
|
||||
let skill = test_skill("github:yeet", skill_path.clone());
|
||||
let user_file = AbsolutePathBuf::try_from(tempdir.path().join("config.toml"))
|
||||
.expect("user config path should be absolute");
|
||||
let user_layer = ConfigLayerEntry::new(
|
||||
ConfigLayerSource::User { file: user_file },
|
||||
toml::from_str(
|
||||
r#"[[skills.config]]
|
||||
name = "github:yeet"
|
||||
enabled = false
|
||||
"#,
|
||||
)
|
||||
.expect("user layer toml"),
|
||||
);
|
||||
let stack = ConfigLayerStack::new(
|
||||
vec![user_layer],
|
||||
Default::default(),
|
||||
ConfigRequirementsToml::default(),
|
||||
)
|
||||
.expect("valid config layer stack");
|
||||
|
||||
let skill_config_rules = skill_config_rules_from_stack(&stack);
|
||||
assert_eq!(
|
||||
resolve_disabled_skill_paths(&[skill], &skill_config_rules),
|
||||
HashSet::from([skill_path])
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg_attr(windows, ignore)]
|
||||
#[test]
|
||||
fn disabled_paths_for_skills_allows_name_selector_to_override_path_selector() {
|
||||
let tempdir = tempfile::tempdir().expect("tempdir");
|
||||
let skill_path = tempdir.path().join("skills").join("demo").join("SKILL.md");
|
||||
let skill = test_skill("github:yeet", skill_path.clone());
|
||||
let user_file = AbsolutePathBuf::try_from(tempdir.path().join("config.toml"))
|
||||
.expect("user config path should be absolute");
|
||||
let user_layer = ConfigLayerEntry::new(
|
||||
ConfigLayerSource::User { file: user_file },
|
||||
toml::from_str(&format!(
|
||||
r#"[[skills.config]]
|
||||
path = "{}"
|
||||
enabled = false
|
||||
"#,
|
||||
skill_path.display()
|
||||
))
|
||||
.expect("user layer toml"),
|
||||
);
|
||||
let session_layer = ConfigLayerEntry::new(
|
||||
ConfigLayerSource::SessionFlags,
|
||||
toml::from_str(
|
||||
r#"[[skills.config]]
|
||||
name = "github:yeet"
|
||||
enabled = true
|
||||
"#,
|
||||
)
|
||||
.expect("session layer toml"),
|
||||
);
|
||||
let stack = ConfigLayerStack::new(
|
||||
vec![user_layer, session_layer],
|
||||
Default::default(),
|
||||
ConfigRequirementsToml::default(),
|
||||
)
|
||||
.expect("valid config layer stack");
|
||||
|
||||
let skill_config_rules = skill_config_rules_from_stack(&stack);
|
||||
assert_eq!(
|
||||
resolve_disabled_skill_paths(&[skill], &skill_config_rules),
|
||||
HashSet::new()
|
||||
);
|
||||
}
|
||||
|
||||
#[cfg_attr(windows, ignore)]
|
||||
#[tokio::test]
|
||||
async fn skills_for_config_ignores_cwd_cache_when_session_flags_reenable_skill() {
|
||||
let codex_home = tempfile::tempdir().expect("tempdir");
|
||||
let cwd = tempfile::tempdir().expect("tempdir");
|
||||
let skill_dir = codex_home.path().join("skills").join("demo");
|
||||
fs::create_dir_all(&skill_dir).expect("create skill dir");
|
||||
let skill_path = skill_dir.join("SKILL.md");
|
||||
fs::write(
|
||||
&skill_path,
|
||||
"---\nname: demo-skill\ndescription: demo description\n---\n\n# Body\n",
|
||||
)
|
||||
.expect("write skill");
|
||||
fs::write(
|
||||
codex_home.path().join(crate::config::CONFIG_TOML_FILE),
|
||||
format!(
|
||||
r#"[[skills.config]]
|
||||
path = "{}"
|
||||
enabled = false
|
||||
"#,
|
||||
skill_path.display()
|
||||
),
|
||||
)
|
||||
.expect("write config");
|
||||
|
||||
let parent_config = ConfigBuilder::default()
|
||||
.codex_home(codex_home.path().to_path_buf())
|
||||
.harness_overrides(ConfigOverrides {
|
||||
cwd: Some(cwd.path().to_path_buf()),
|
||||
..Default::default()
|
||||
})
|
||||
.build()
|
||||
.await
|
||||
.expect("load parent config");
|
||||
let role_path = codex_home.path().join("enable-role.toml");
|
||||
fs::write(
|
||||
&role_path,
|
||||
format!(
|
||||
r#"[[skills.config]]
|
||||
path = "{}"
|
||||
enabled = true
|
||||
"#,
|
||||
skill_path.display()
|
||||
),
|
||||
)
|
||||
.expect("write role config");
|
||||
let mut child_config = parent_config.clone();
|
||||
child_config.agent_roles.insert(
|
||||
"custom".to_string(),
|
||||
crate::config::AgentRoleConfig {
|
||||
description: None,
|
||||
config_file: Some(role_path),
|
||||
nickname_candidates: None,
|
||||
},
|
||||
);
|
||||
crate::agent::role::apply_role_to_config(&mut child_config, Some("custom"))
|
||||
.await
|
||||
.expect("custom role should apply");
|
||||
|
||||
let plugins_manager = Arc::new(PluginsManager::new(codex_home.path().to_path_buf()));
|
||||
let skills_manager = SkillsManager::new(codex_home.path().to_path_buf(), plugins_manager, true);
|
||||
|
||||
let parent_outcome = skills_manager
|
||||
.skills_for_cwd(cwd.path(), &parent_config, true)
|
||||
.await;
|
||||
let parent_skill = parent_outcome
|
||||
.skills
|
||||
.iter()
|
||||
.find(|skill| skill.name == "demo-skill")
|
||||
.expect("demo skill should be discovered");
|
||||
assert_eq!(parent_outcome.is_skill_enabled(parent_skill), false);
|
||||
|
||||
let child_outcome = skills_manager.skills_for_config(&child_config);
|
||||
let child_skill = child_outcome
|
||||
.skills
|
||||
.iter()
|
||||
.find(|skill| skill.name == "demo-skill")
|
||||
.expect("demo skill should be discovered");
|
||||
assert_eq!(child_outcome.is_skill_enabled(child_skill), true);
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
pub(crate) mod config_rules;
|
||||
mod env_var_dependencies;
|
||||
pub mod injection;
|
||||
pub(crate) mod invocation_utils;
|
||||
pub mod loader;
|
||||
pub mod manager;
|
||||
pub mod model;
|
||||
pub mod remote;
|
||||
pub mod render;
|
||||
pub mod system;
|
||||
|
||||
pub(crate) use env_var_dependencies::collect_env_var_dependencies;
|
||||
pub(crate) use env_var_dependencies::resolve_skill_dependencies_for_turn;
|
||||
pub(crate) use injection::SkillInjections;
|
||||
pub(crate) use injection::build_skill_injections;
|
||||
pub(crate) use injection::collect_explicit_skill_mentions;
|
||||
pub(crate) use invocation_utils::build_implicit_skill_path_indexes;
|
||||
pub(crate) use invocation_utils::maybe_emit_implicit_skill_invocation;
|
||||
pub use manager::SkillsManager;
|
||||
pub use model::SkillError;
|
||||
pub use model::SkillLoadOutcome;
|
||||
pub use model::SkillMetadata;
|
||||
pub use model::SkillPolicy;
|
||||
pub use model::filter_skill_load_outcome_for_product;
|
||||
pub use render::render_skills_section;
|
||||
@@ -1,158 +0,0 @@
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use codex_protocol::models::PermissionProfile;
|
||||
use codex_protocol::protocol::Product;
|
||||
use codex_protocol::protocol::SkillScope;
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Debug, Clone, Default, Deserialize, PartialEq, Eq)]
|
||||
pub struct SkillManagedNetworkOverride {
|
||||
pub allowed_domains: Option<Vec<String>>,
|
||||
pub denied_domains: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
impl SkillManagedNetworkOverride {
|
||||
pub fn has_domain_overrides(&self) -> bool {
|
||||
self.allowed_domains.is_some() || self.denied_domains.is_some()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct SkillMetadata {
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
pub short_description: Option<String>,
|
||||
pub interface: Option<SkillInterface>,
|
||||
pub dependencies: Option<SkillDependencies>,
|
||||
pub policy: Option<SkillPolicy>,
|
||||
pub permission_profile: Option<PermissionProfile>,
|
||||
pub managed_network_override: Option<SkillManagedNetworkOverride>,
|
||||
/// Path to the SKILLS.md file that declares this skill.
|
||||
pub path_to_skills_md: PathBuf,
|
||||
pub scope: SkillScope,
|
||||
}
|
||||
|
||||
impl SkillMetadata {
|
||||
fn allow_implicit_invocation(&self) -> bool {
|
||||
self.policy
|
||||
.as_ref()
|
||||
.and_then(|policy| policy.allow_implicit_invocation)
|
||||
.unwrap_or(true)
|
||||
}
|
||||
|
||||
pub fn matches_product_restriction_for_product(
|
||||
&self,
|
||||
restriction_product: Option<Product>,
|
||||
) -> bool {
|
||||
match &self.policy {
|
||||
Some(policy) => {
|
||||
policy.products.is_empty()
|
||||
|| restriction_product.is_some_and(|product| {
|
||||
product.matches_product_restriction(&policy.products)
|
||||
})
|
||||
}
|
||||
None => true,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub struct SkillPolicy {
|
||||
pub allow_implicit_invocation: Option<bool>,
|
||||
// TODO: Enforce product gating in Codex skill selection/injection instead of only parsing and
|
||||
// storing this metadata.
|
||||
pub products: Vec<Product>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SkillInterface {
|
||||
pub display_name: Option<String>,
|
||||
pub short_description: Option<String>,
|
||||
pub icon_small: Option<PathBuf>,
|
||||
pub icon_large: Option<PathBuf>,
|
||||
pub brand_color: Option<String>,
|
||||
pub default_prompt: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SkillDependencies {
|
||||
pub tools: Vec<SkillToolDependency>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SkillToolDependency {
|
||||
pub r#type: String,
|
||||
pub value: String,
|
||||
pub description: Option<String>,
|
||||
pub transport: Option<String>,
|
||||
pub command: Option<String>,
|
||||
pub url: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SkillError {
|
||||
pub path: PathBuf,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct SkillLoadOutcome {
|
||||
pub skills: Vec<SkillMetadata>,
|
||||
pub errors: Vec<SkillError>,
|
||||
pub disabled_paths: HashSet<PathBuf>,
|
||||
pub(crate) implicit_skills_by_scripts_dir: Arc<HashMap<PathBuf, SkillMetadata>>,
|
||||
pub(crate) implicit_skills_by_doc_path: Arc<HashMap<PathBuf, SkillMetadata>>,
|
||||
}
|
||||
|
||||
impl SkillLoadOutcome {
|
||||
pub fn is_skill_enabled(&self, skill: &SkillMetadata) -> bool {
|
||||
!self.disabled_paths.contains(&skill.path_to_skills_md)
|
||||
}
|
||||
|
||||
pub fn is_skill_allowed_for_implicit_invocation(&self, skill: &SkillMetadata) -> bool {
|
||||
self.is_skill_enabled(skill) && skill.allow_implicit_invocation()
|
||||
}
|
||||
|
||||
pub fn allowed_skills_for_implicit_invocation(&self) -> Vec<SkillMetadata> {
|
||||
self.skills
|
||||
.iter()
|
||||
.filter(|skill| self.is_skill_allowed_for_implicit_invocation(skill))
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn skills_with_enabled(&self) -> impl Iterator<Item = (&SkillMetadata, bool)> {
|
||||
self.skills
|
||||
.iter()
|
||||
.map(|skill| (skill, self.is_skill_enabled(skill)))
|
||||
}
|
||||
}
|
||||
|
||||
pub fn filter_skill_load_outcome_for_product(
|
||||
mut outcome: SkillLoadOutcome,
|
||||
restriction_product: Option<Product>,
|
||||
) -> SkillLoadOutcome {
|
||||
outcome
|
||||
.skills
|
||||
.retain(|skill| skill.matches_product_restriction_for_product(restriction_product));
|
||||
outcome.implicit_skills_by_scripts_dir = Arc::new(
|
||||
outcome
|
||||
.implicit_skills_by_scripts_dir
|
||||
.iter()
|
||||
.filter(|(_, skill)| skill.matches_product_restriction_for_product(restriction_product))
|
||||
.map(|(path, skill)| (path.clone(), skill.clone()))
|
||||
.collect(),
|
||||
);
|
||||
outcome.implicit_skills_by_doc_path = Arc::new(
|
||||
outcome
|
||||
.implicit_skills_by_doc_path
|
||||
.iter()
|
||||
.filter(|(_, skill)| skill.matches_product_restriction_for_product(restriction_product))
|
||||
.map(|(path, skill)| (path.clone(), skill.clone()))
|
||||
.collect(),
|
||||
);
|
||||
outcome
|
||||
}
|
||||
@@ -1,270 +0,0 @@
|
||||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
use serde::Deserialize;
|
||||
use std::path::Component;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::auth::CodexAuth;
|
||||
use crate::config::Config;
|
||||
use crate::default_client::build_reqwest_client;
|
||||
|
||||
const REMOTE_SKILLS_API_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
|
||||
// Low-level client for the remote skill API. This is intentionally kept around for
|
||||
// future wiring, but it is not used yet by any active product surface.
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum RemoteSkillScope {
|
||||
WorkspaceShared,
|
||||
AllShared,
|
||||
Personal,
|
||||
Example,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum RemoteSkillProductSurface {
|
||||
Chatgpt,
|
||||
Codex,
|
||||
Api,
|
||||
Atlas,
|
||||
}
|
||||
|
||||
fn as_query_scope(scope: RemoteSkillScope) -> Option<&'static str> {
|
||||
match scope {
|
||||
RemoteSkillScope::WorkspaceShared => Some("workspace-shared"),
|
||||
RemoteSkillScope::AllShared => Some("all-shared"),
|
||||
RemoteSkillScope::Personal => Some("personal"),
|
||||
RemoteSkillScope::Example => Some("example"),
|
||||
}
|
||||
}
|
||||
|
||||
fn as_query_product_surface(product_surface: RemoteSkillProductSurface) -> &'static str {
|
||||
match product_surface {
|
||||
RemoteSkillProductSurface::Chatgpt => "chatgpt",
|
||||
RemoteSkillProductSurface::Codex => "codex",
|
||||
RemoteSkillProductSurface::Api => "api",
|
||||
RemoteSkillProductSurface::Atlas => "atlas",
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_chatgpt_auth(auth: Option<&CodexAuth>) -> Result<&CodexAuth> {
|
||||
let Some(auth) = auth else {
|
||||
anyhow::bail!("chatgpt authentication required for remote skill scopes");
|
||||
};
|
||||
if !auth.is_chatgpt_auth() {
|
||||
anyhow::bail!(
|
||||
"chatgpt authentication required for remote skill scopes; api key auth is not supported"
|
||||
);
|
||||
}
|
||||
Ok(auth)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RemoteSkillSummary {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub description: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RemoteSkillDownloadResult {
|
||||
pub id: String,
|
||||
pub path: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct RemoteSkillsResponse {
|
||||
#[serde(rename = "hazelnuts")]
|
||||
skills: Vec<RemoteSkill>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct RemoteSkill {
|
||||
id: String,
|
||||
name: String,
|
||||
description: String,
|
||||
}
|
||||
|
||||
pub async fn list_remote_skills(
|
||||
config: &Config,
|
||||
auth: Option<&CodexAuth>,
|
||||
scope: RemoteSkillScope,
|
||||
product_surface: RemoteSkillProductSurface,
|
||||
enabled: Option<bool>,
|
||||
) -> Result<Vec<RemoteSkillSummary>> {
|
||||
let base_url = config.chatgpt_base_url.trim_end_matches('/');
|
||||
let auth = ensure_chatgpt_auth(auth)?;
|
||||
|
||||
let url = format!("{base_url}/hazelnuts");
|
||||
let product_surface = as_query_product_surface(product_surface);
|
||||
let mut query_params = vec![("product_surface", product_surface)];
|
||||
if let Some(scope) = as_query_scope(scope) {
|
||||
query_params.push(("scope", scope));
|
||||
}
|
||||
if let Some(enabled) = enabled {
|
||||
let enabled = if enabled { "true" } else { "false" };
|
||||
query_params.push(("enabled", enabled));
|
||||
}
|
||||
|
||||
let client = build_reqwest_client();
|
||||
let mut request = client
|
||||
.get(&url)
|
||||
.timeout(REMOTE_SKILLS_API_TIMEOUT)
|
||||
.query(&query_params);
|
||||
let token = auth
|
||||
.get_token()
|
||||
.context("Failed to read auth token for remote skills")?;
|
||||
request = request.bearer_auth(token);
|
||||
if let Some(account_id) = auth.get_account_id() {
|
||||
request = request.header("chatgpt-account-id", account_id);
|
||||
}
|
||||
let response = request
|
||||
.send()
|
||||
.await
|
||||
.with_context(|| format!("Failed to send request to {url}"))?;
|
||||
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
if !status.is_success() {
|
||||
anyhow::bail!("Request failed with status {status} from {url}: {body}");
|
||||
}
|
||||
|
||||
let parsed: RemoteSkillsResponse =
|
||||
serde_json::from_str(&body).context("Failed to parse skills response")?;
|
||||
|
||||
Ok(parsed
|
||||
.skills
|
||||
.into_iter()
|
||||
.map(|skill| RemoteSkillSummary {
|
||||
id: skill.id,
|
||||
name: skill.name,
|
||||
description: skill.description,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
pub async fn export_remote_skill(
|
||||
config: &Config,
|
||||
auth: Option<&CodexAuth>,
|
||||
skill_id: &str,
|
||||
) -> Result<RemoteSkillDownloadResult> {
|
||||
let auth = ensure_chatgpt_auth(auth)?;
|
||||
|
||||
let client = build_reqwest_client();
|
||||
let base_url = config.chatgpt_base_url.trim_end_matches('/');
|
||||
let url = format!("{base_url}/hazelnuts/{skill_id}/export");
|
||||
let mut request = client.get(&url).timeout(REMOTE_SKILLS_API_TIMEOUT);
|
||||
|
||||
let token = auth
|
||||
.get_token()
|
||||
.context("Failed to read auth token for remote skills")?;
|
||||
request = request.bearer_auth(token);
|
||||
if let Some(account_id) = auth.get_account_id() {
|
||||
request = request.header("chatgpt-account-id", account_id);
|
||||
}
|
||||
|
||||
let response = request
|
||||
.send()
|
||||
.await
|
||||
.with_context(|| format!("Failed to send download request to {url}"))?;
|
||||
|
||||
let status = response.status();
|
||||
let body = response.bytes().await.context("Failed to read download")?;
|
||||
if !status.is_success() {
|
||||
let body_text = String::from_utf8_lossy(&body);
|
||||
anyhow::bail!("Download failed with status {status} from {url}: {body_text}");
|
||||
}
|
||||
|
||||
if !is_zip_payload(&body) {
|
||||
anyhow::bail!("Downloaded remote skill payload is not a zip archive");
|
||||
}
|
||||
|
||||
let output_dir = config.codex_home.join("skills").join(skill_id);
|
||||
tokio::fs::create_dir_all(&output_dir)
|
||||
.await
|
||||
.context("Failed to create downloaded skills directory")?;
|
||||
|
||||
let zip_bytes = body.to_vec();
|
||||
let output_dir_clone = output_dir.clone();
|
||||
let prefix_candidates = vec![skill_id.to_string()];
|
||||
tokio::task::spawn_blocking(move || {
|
||||
extract_zip_to_dir(zip_bytes, &output_dir_clone, &prefix_candidates)
|
||||
})
|
||||
.await
|
||||
.context("Zip extraction task failed")??;
|
||||
|
||||
Ok(RemoteSkillDownloadResult {
|
||||
id: skill_id.to_string(),
|
||||
path: output_dir,
|
||||
})
|
||||
}
|
||||
|
||||
fn safe_join(base: &Path, name: &str) -> Result<PathBuf> {
|
||||
let path = Path::new(name);
|
||||
for component in path.components() {
|
||||
match component {
|
||||
Component::Normal(_) => {}
|
||||
_ => {
|
||||
anyhow::bail!("Invalid file path in remote skill payload: {name}");
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(base.join(path))
|
||||
}
|
||||
|
||||
fn is_zip_payload(bytes: &[u8]) -> bool {
|
||||
bytes.starts_with(b"PK\x03\x04")
|
||||
|| bytes.starts_with(b"PK\x05\x06")
|
||||
|| bytes.starts_with(b"PK\x07\x08")
|
||||
}
|
||||
|
||||
fn extract_zip_to_dir(
|
||||
bytes: Vec<u8>,
|
||||
output_dir: &Path,
|
||||
prefix_candidates: &[String],
|
||||
) -> Result<()> {
|
||||
let cursor = std::io::Cursor::new(bytes);
|
||||
let mut archive = zip::ZipArchive::new(cursor).context("Failed to open zip archive")?;
|
||||
for i in 0..archive.len() {
|
||||
let mut file = archive.by_index(i).context("Failed to read zip entry")?;
|
||||
if file.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let raw_name = file.name().to_string();
|
||||
let normalized = normalize_zip_name(&raw_name, prefix_candidates);
|
||||
let Some(normalized) = normalized else {
|
||||
continue;
|
||||
};
|
||||
let file_path = safe_join(output_dir, &normalized)?;
|
||||
if let Some(parent) = file_path.parent() {
|
||||
std::fs::create_dir_all(parent)
|
||||
.with_context(|| format!("Failed to create parent dir for {normalized}"))?;
|
||||
}
|
||||
let mut out = std::fs::File::create(&file_path)
|
||||
.with_context(|| format!("Failed to create file {normalized}"))?;
|
||||
std::io::copy(&mut file, &mut out)
|
||||
.with_context(|| format!("Failed to write skill file {normalized}"))?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn normalize_zip_name(name: &str, prefix_candidates: &[String]) -> Option<String> {
|
||||
let mut trimmed = name.trim_start_matches("./");
|
||||
for prefix in prefix_candidates {
|
||||
if prefix.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let prefix = format!("{prefix}/");
|
||||
if let Some(rest) = trimmed.strip_prefix(&prefix) {
|
||||
trimmed = rest;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if trimmed.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(trimmed.to_string())
|
||||
}
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
use crate::skills::model::SkillMetadata;
|
||||
use codex_protocol::protocol::SKILLS_INSTRUCTIONS_CLOSE_TAG;
|
||||
use codex_protocol::protocol::SKILLS_INSTRUCTIONS_OPEN_TAG;
|
||||
|
||||
pub fn render_skills_section(skills: &[SkillMetadata]) -> Option<String> {
|
||||
if skills.is_empty() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut lines: Vec<String> = Vec::new();
|
||||
lines.push("## Skills".to_string());
|
||||
lines.push("A skill is a set of local instructions to follow that is stored in a `SKILL.md` file. Below is the list of skills that can be used. Each entry includes a name, description, and file path so you can open the source for full instructions when using a specific skill.".to_string());
|
||||
lines.push("### Available skills".to_string());
|
||||
|
||||
for skill in skills {
|
||||
let path_str = skill.path_to_skills_md.to_string_lossy().replace('\\', "/");
|
||||
let name = skill.name.as_str();
|
||||
let description = skill.description.as_str();
|
||||
lines.push(format!("- {name}: {description} (file: {path_str})"));
|
||||
}
|
||||
|
||||
lines.push("### How to use skills".to_string());
|
||||
lines.push(
|
||||
r###"- Discovery: The list above is the skills available in this session (name + description + file path). Skill bodies live on disk at the listed paths.
|
||||
- Trigger rules: If the user names a skill (with `$SkillName` or plain text) OR the task clearly matches a skill's description shown above, you must use that skill for that turn. Multiple mentions mean use them all. Do not carry skills across turns unless re-mentioned.
|
||||
- Missing/blocked: If a named skill isn't in the list or the path can't be read, say so briefly and continue with the best fallback.
|
||||
- How to use a skill (progressive disclosure):
|
||||
1) After deciding to use a skill, open its `SKILL.md`. Read only enough to follow the workflow.
|
||||
2) When `SKILL.md` references relative paths (e.g., `scripts/foo.py`), resolve them relative to the skill directory listed above first, and only consider other paths if needed.
|
||||
3) If `SKILL.md` points to extra folders such as `references/`, load only the specific files needed for the request; don't bulk-load everything.
|
||||
4) If `scripts/` exist, prefer running or patching them instead of retyping large code blocks.
|
||||
5) If `assets/` or templates exist, reuse them instead of recreating from scratch.
|
||||
- Coordination and sequencing:
|
||||
- If multiple skills apply, choose the minimal set that covers the request and state the order you'll use them.
|
||||
- Announce which skill(s) you're using and why (one short line). If you skip an obvious skill, say why.
|
||||
- Context hygiene:
|
||||
- Keep context small: summarize long sections instead of pasting them; only load extra files when needed.
|
||||
- Avoid deep reference-chasing: prefer opening only files directly linked from `SKILL.md` unless you're blocked.
|
||||
- When variants exist (frameworks, providers, domains), pick only the relevant reference file(s) and note that choice.
|
||||
- Safety and fallback: If a skill can't be applied cleanly (missing files, unclear instructions), state the issue, pick the next-best approach, and continue."###
|
||||
.to_string(),
|
||||
);
|
||||
|
||||
let body = lines.join("\n");
|
||||
Some(format!(
|
||||
"{SKILLS_INSTRUCTIONS_OPEN_TAG}\n{body}\n{SKILLS_INSTRUCTIONS_CLOSE_TAG}"
|
||||
))
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
pub(crate) use codex_skills::install_system_skills;
|
||||
pub(crate) use codex_skills::system_cache_root_dir;
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
pub(crate) fn uninstall_system_skills(codex_home: &Path) {
|
||||
let system_skills_dir = system_cache_root_dir(codex_home);
|
||||
let _ = std::fs::remove_dir_all(&system_skills_dir);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ use tokio::runtime::Handle;
|
||||
use tokio::sync::broadcast;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::SkillsManager;
|
||||
use crate::config::Config;
|
||||
use crate::file_watcher::FileWatcher;
|
||||
use crate::file_watcher::FileWatcherSubscriber;
|
||||
@@ -15,7 +16,8 @@ use crate::file_watcher::Receiver;
|
||||
use crate::file_watcher::ThrottledWatchReceiver;
|
||||
use crate::file_watcher::WatchPath;
|
||||
use crate::file_watcher::WatchRegistration;
|
||||
use crate::skills::SkillsManager;
|
||||
use crate::plugins::PluginsManager;
|
||||
use crate::skills_load_input_from_config;
|
||||
|
||||
#[cfg(not(test))]
|
||||
const WATCHER_THROTTLE_INTERVAL: Duration = Duration::from_secs(10);
|
||||
@@ -56,9 +58,13 @@ impl SkillsWatcher {
|
||||
&self,
|
||||
config: &Config,
|
||||
skills_manager: &SkillsManager,
|
||||
plugins_manager: &PluginsManager,
|
||||
) -> WatchRegistration {
|
||||
let plugin_outcome = plugins_manager.plugins_for_config(config);
|
||||
let effective_skill_roots = plugin_outcome.effective_skill_roots();
|
||||
let skills_input = skills_load_input_from_config(config, effective_skill_roots);
|
||||
let roots = skills_manager
|
||||
.skill_roots_for_config(config)
|
||||
.skill_roots_for_config(&skills_input)
|
||||
.into_iter()
|
||||
.map(|root| WatchPath {
|
||||
path: root.path,
|
||||
|
||||
+4
-6
@@ -1,17 +1,15 @@
|
||||
---
|
||||
source: core/src/codex_tests.rs
|
||||
assertion_line: 1282
|
||||
assertion_line: 1254
|
||||
expression: snapshot
|
||||
---
|
||||
Scenario: First request after fork when fork startup changes approval policy and the first forked turn changes approval policy again and enters plan mode.
|
||||
Scenario: First request after fork when startup preserves the parent baseline, the fork changes approval policy, and the first forked turn enters plan mode.
|
||||
|
||||
## First Forked Turn Request
|
||||
00:message/developer:<PERMISSIONS_INSTRUCTIONS>
|
||||
01:message/user:<ENVIRONMENT_CONTEXT:cwd=<CWD>>
|
||||
02:message/user:fork seed
|
||||
03:message/developer:<PERMISSIONS_INSTRUCTIONS>
|
||||
04:message/user:<ENVIRONMENT_CONTEXT:cwd=<CWD>>
|
||||
05:message/developer[2]:
|
||||
03:message/developer[2]:
|
||||
[01] <PERMISSIONS_INSTRUCTIONS>
|
||||
[02] <collaboration_mode>Fork turn collaboration instructions.</collaboration_mode>
|
||||
06:message/user:after fork
|
||||
04:message/user:after fork
|
||||
|
||||
@@ -3,8 +3,8 @@ use std::sync::Arc;
|
||||
|
||||
use crate::AuthManager;
|
||||
use crate::RolloutRecorder;
|
||||
use crate::SkillsManager;
|
||||
use crate::agent::AgentControl;
|
||||
use crate::analytics_client::AnalyticsEventsClient;
|
||||
use crate::client::ModelClient;
|
||||
use crate::config::StartedNetworkProxy;
|
||||
use crate::exec_policy::ExecPolicyManager;
|
||||
@@ -12,7 +12,6 @@ use crate::mcp::McpManager;
|
||||
use crate::mcp_connection_manager::McpConnectionManager;
|
||||
use crate::models_manager::manager::ModelsManager;
|
||||
use crate::plugins::PluginsManager;
|
||||
use crate::skills::SkillsManager;
|
||||
use crate::skills_watcher::SkillsWatcher;
|
||||
use crate::state_db::StateDbHandle;
|
||||
use crate::tools::code_mode::CodeModeService;
|
||||
@@ -20,6 +19,7 @@ use crate::tools::network_approval::NetworkApprovalService;
|
||||
use crate::tools::runtimes::ExecveSessionApproval;
|
||||
use crate::tools::sandboxing::ApprovalStore;
|
||||
use crate::unified_exec::UnifiedExecProcessManager;
|
||||
use codex_analytics::AnalyticsEventsClient;
|
||||
use codex_exec_server::Environment;
|
||||
use codex_hooks::Hooks;
|
||||
use codex_otel::SessionTelemetry;
|
||||
|
||||
@@ -13,8 +13,8 @@ use crate::protocol::RateLimitSnapshot;
|
||||
use crate::protocol::TokenUsage;
|
||||
use crate::protocol::TokenUsageInfo;
|
||||
use crate::session_startup_prewarm::SessionStartupPrewarmHandle;
|
||||
use crate::truncate::TruncationPolicy;
|
||||
use codex_protocol::protocol::TurnContextItem;
|
||||
use codex_utils_output_truncation::TruncationPolicy;
|
||||
|
||||
/// Persistent, session-scoped state previously stored directly on `Session`.
|
||||
pub(crate) struct SessionState {
|
||||
|
||||
@@ -1,547 +0,0 @@
|
||||
use crate::config::Config;
|
||||
use crate::path_utils::normalize_for_path_comparison;
|
||||
use crate::rollout::list::Cursor;
|
||||
use crate::rollout::list::ThreadSortKey;
|
||||
use crate::rollout::metadata;
|
||||
use chrono::DateTime;
|
||||
use chrono::NaiveDateTime;
|
||||
use chrono::Timelike;
|
||||
use chrono::Utc;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::dynamic_tools::DynamicToolSpec;
|
||||
use codex_protocol::protocol::RolloutItem;
|
||||
use codex_protocol::protocol::SessionSource;
|
||||
pub use codex_state::LogEntry;
|
||||
use codex_state::ThreadMetadataBuilder;
|
||||
use serde_json::Value;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use tracing::warn;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// Core-facing handle to the SQLite-backed state runtime.
|
||||
pub type StateDbHandle = Arc<codex_state::StateRuntime>;
|
||||
|
||||
/// Initialize the state runtime for thread state persistence and backfill checks. To only be used
|
||||
/// inside `core`. The initialization should not be done anywhere else.
|
||||
pub(crate) async fn init(config: &Config) -> Option<StateDbHandle> {
|
||||
let runtime = match codex_state::StateRuntime::init(
|
||||
config.sqlite_home.clone(),
|
||||
config.model_provider_id.clone(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(runtime) => runtime,
|
||||
Err(err) => {
|
||||
warn!(
|
||||
"failed to initialize state runtime at {}: {err}",
|
||||
config.sqlite_home.display()
|
||||
);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
let backfill_state = match runtime.get_backfill_state().await {
|
||||
Ok(state) => state,
|
||||
Err(err) => {
|
||||
warn!(
|
||||
"failed to read backfill state at {}: {err}",
|
||||
config.codex_home.display()
|
||||
);
|
||||
return None;
|
||||
}
|
||||
};
|
||||
if backfill_state.status != codex_state::BackfillStatus::Complete {
|
||||
let runtime_for_backfill = runtime.clone();
|
||||
let config = config.clone();
|
||||
tokio::spawn(async move {
|
||||
metadata::backfill_sessions(runtime_for_backfill.as_ref(), &config).await;
|
||||
});
|
||||
}
|
||||
Some(runtime)
|
||||
}
|
||||
|
||||
/// Get the DB if the feature is enabled and the DB exists.
|
||||
pub async fn get_state_db(config: &Config) -> Option<StateDbHandle> {
|
||||
let state_path = codex_state::state_db_path(config.sqlite_home.as_path());
|
||||
if !tokio::fs::try_exists(&state_path).await.unwrap_or(false) {
|
||||
return None;
|
||||
}
|
||||
let runtime = codex_state::StateRuntime::init(
|
||||
config.sqlite_home.clone(),
|
||||
config.model_provider_id.clone(),
|
||||
)
|
||||
.await
|
||||
.ok()?;
|
||||
require_backfill_complete(runtime, config.sqlite_home.as_path()).await
|
||||
}
|
||||
|
||||
/// Open the state runtime when the SQLite file exists, without feature gating.
|
||||
///
|
||||
/// This is used for parity checks during the SQLite migration phase.
|
||||
pub async fn open_if_present(codex_home: &Path, default_provider: &str) -> Option<StateDbHandle> {
|
||||
let db_path = codex_state::state_db_path(codex_home);
|
||||
if !tokio::fs::try_exists(&db_path).await.unwrap_or(false) {
|
||||
return None;
|
||||
}
|
||||
let runtime =
|
||||
codex_state::StateRuntime::init(codex_home.to_path_buf(), default_provider.to_string())
|
||||
.await
|
||||
.ok()?;
|
||||
require_backfill_complete(runtime, codex_home).await
|
||||
}
|
||||
|
||||
async fn require_backfill_complete(
|
||||
runtime: StateDbHandle,
|
||||
codex_home: &Path,
|
||||
) -> Option<StateDbHandle> {
|
||||
match runtime.get_backfill_state().await {
|
||||
Ok(state) if state.status == codex_state::BackfillStatus::Complete => Some(runtime),
|
||||
Ok(state) => {
|
||||
warn!(
|
||||
"state db backfill not complete at {} (status: {})",
|
||||
codex_home.display(),
|
||||
state.status.as_str()
|
||||
);
|
||||
None
|
||||
}
|
||||
Err(err) => {
|
||||
warn!(
|
||||
"failed to read backfill state at {}: {err}",
|
||||
codex_home.display()
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn cursor_to_anchor(cursor: Option<&Cursor>) -> Option<codex_state::Anchor> {
|
||||
let cursor = cursor?;
|
||||
let value = serde_json::to_value(cursor).ok()?;
|
||||
let cursor_str = value.as_str()?;
|
||||
let (ts_str, id_str) = cursor_str.split_once('|')?;
|
||||
if id_str.contains('|') {
|
||||
return None;
|
||||
}
|
||||
let id = Uuid::parse_str(id_str).ok()?;
|
||||
let ts = if let Ok(naive) = NaiveDateTime::parse_from_str(ts_str, "%Y-%m-%dT%H-%M-%S") {
|
||||
DateTime::<Utc>::from_naive_utc_and_offset(naive, Utc)
|
||||
} else if let Ok(dt) = DateTime::parse_from_rfc3339(ts_str) {
|
||||
dt.with_timezone(&Utc)
|
||||
} else {
|
||||
return None;
|
||||
}
|
||||
.with_nanosecond(0)?;
|
||||
Some(codex_state::Anchor { ts, id })
|
||||
}
|
||||
|
||||
pub(crate) fn normalize_cwd_for_state_db(cwd: &Path) -> PathBuf {
|
||||
normalize_for_path_comparison(cwd).unwrap_or_else(|_| cwd.to_path_buf())
|
||||
}
|
||||
|
||||
/// List thread ids from SQLite for parity checks without rollout scanning.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn list_thread_ids_db(
|
||||
context: Option<&codex_state::StateRuntime>,
|
||||
codex_home: &Path,
|
||||
page_size: usize,
|
||||
cursor: Option<&Cursor>,
|
||||
sort_key: ThreadSortKey,
|
||||
allowed_sources: &[SessionSource],
|
||||
model_providers: Option<&[String]>,
|
||||
archived_only: bool,
|
||||
stage: &str,
|
||||
) -> Option<Vec<ThreadId>> {
|
||||
let ctx = context?;
|
||||
if ctx.codex_home() != codex_home {
|
||||
warn!(
|
||||
"state db codex_home mismatch: expected {}, got {}",
|
||||
ctx.codex_home().display(),
|
||||
codex_home.display()
|
||||
);
|
||||
}
|
||||
|
||||
let anchor = cursor_to_anchor(cursor);
|
||||
let allowed_sources: Vec<String> = allowed_sources
|
||||
.iter()
|
||||
.map(|value| match serde_json::to_value(value) {
|
||||
Ok(Value::String(s)) => s,
|
||||
Ok(other) => other.to_string(),
|
||||
Err(_) => String::new(),
|
||||
})
|
||||
.collect();
|
||||
let model_providers = model_providers.map(<[String]>::to_vec);
|
||||
match ctx
|
||||
.list_thread_ids(
|
||||
page_size,
|
||||
anchor.as_ref(),
|
||||
match sort_key {
|
||||
ThreadSortKey::CreatedAt => codex_state::SortKey::CreatedAt,
|
||||
ThreadSortKey::UpdatedAt => codex_state::SortKey::UpdatedAt,
|
||||
},
|
||||
allowed_sources.as_slice(),
|
||||
model_providers.as_deref(),
|
||||
archived_only,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(ids) => Some(ids),
|
||||
Err(err) => {
|
||||
warn!("state db list_thread_ids failed during {stage}: {err}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// List thread metadata from SQLite without rollout directory traversal.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn list_threads_db(
|
||||
context: Option<&codex_state::StateRuntime>,
|
||||
codex_home: &Path,
|
||||
page_size: usize,
|
||||
cursor: Option<&Cursor>,
|
||||
sort_key: ThreadSortKey,
|
||||
allowed_sources: &[SessionSource],
|
||||
model_providers: Option<&[String]>,
|
||||
archived: bool,
|
||||
search_term: Option<&str>,
|
||||
) -> Option<codex_state::ThreadsPage> {
|
||||
let ctx = context?;
|
||||
if ctx.codex_home() != codex_home {
|
||||
warn!(
|
||||
"state db codex_home mismatch: expected {}, got {}",
|
||||
ctx.codex_home().display(),
|
||||
codex_home.display()
|
||||
);
|
||||
}
|
||||
|
||||
let anchor = cursor_to_anchor(cursor);
|
||||
let allowed_sources: Vec<String> = allowed_sources
|
||||
.iter()
|
||||
.map(|value| match serde_json::to_value(value) {
|
||||
Ok(Value::String(s)) => s,
|
||||
Ok(other) => other.to_string(),
|
||||
Err(_) => String::new(),
|
||||
})
|
||||
.collect();
|
||||
let model_providers = model_providers.map(<[String]>::to_vec);
|
||||
match ctx
|
||||
.list_threads(
|
||||
page_size,
|
||||
anchor.as_ref(),
|
||||
match sort_key {
|
||||
ThreadSortKey::CreatedAt => codex_state::SortKey::CreatedAt,
|
||||
ThreadSortKey::UpdatedAt => codex_state::SortKey::UpdatedAt,
|
||||
},
|
||||
allowed_sources.as_slice(),
|
||||
model_providers.as_deref(),
|
||||
archived,
|
||||
search_term,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(mut page) => {
|
||||
let mut valid_items = Vec::with_capacity(page.items.len());
|
||||
for item in page.items {
|
||||
if tokio::fs::try_exists(&item.rollout_path)
|
||||
.await
|
||||
.unwrap_or(false)
|
||||
{
|
||||
valid_items.push(item);
|
||||
} else {
|
||||
warn!(
|
||||
"state db list_threads returned stale rollout path for thread {}: {}",
|
||||
item.id,
|
||||
item.rollout_path.display()
|
||||
);
|
||||
warn!("state db discrepancy during list_threads_db: stale_db_path_dropped");
|
||||
let _ = ctx.delete_thread(item.id).await;
|
||||
}
|
||||
}
|
||||
page.items = valid_items;
|
||||
Some(page)
|
||||
}
|
||||
Err(err) => {
|
||||
warn!("state db list_threads failed: {err}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Look up the rollout path for a thread id using SQLite.
|
||||
pub async fn find_rollout_path_by_id(
|
||||
context: Option<&codex_state::StateRuntime>,
|
||||
thread_id: ThreadId,
|
||||
archived_only: Option<bool>,
|
||||
stage: &str,
|
||||
) -> Option<PathBuf> {
|
||||
let ctx = context?;
|
||||
ctx.find_rollout_path_by_id(thread_id, archived_only)
|
||||
.await
|
||||
.unwrap_or_else(|err| {
|
||||
warn!("state db find_rollout_path_by_id failed during {stage}: {err}");
|
||||
None
|
||||
})
|
||||
}
|
||||
|
||||
/// Get dynamic tools for a thread id using SQLite.
|
||||
pub async fn get_dynamic_tools(
|
||||
context: Option<&codex_state::StateRuntime>,
|
||||
thread_id: ThreadId,
|
||||
stage: &str,
|
||||
) -> Option<Vec<DynamicToolSpec>> {
|
||||
let ctx = context?;
|
||||
match ctx.get_dynamic_tools(thread_id).await {
|
||||
Ok(tools) => tools,
|
||||
Err(err) => {
|
||||
warn!("state db get_dynamic_tools failed during {stage}: {err}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Persist dynamic tools for a thread id using SQLite, if none exist yet.
|
||||
pub async fn persist_dynamic_tools(
|
||||
context: Option<&codex_state::StateRuntime>,
|
||||
thread_id: ThreadId,
|
||||
tools: Option<&[DynamicToolSpec]>,
|
||||
stage: &str,
|
||||
) {
|
||||
let Some(ctx) = context else {
|
||||
return;
|
||||
};
|
||||
if let Err(err) = ctx.persist_dynamic_tools(thread_id, tools).await {
|
||||
warn!("state db persist_dynamic_tools failed during {stage}: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn mark_thread_memory_mode_polluted(
|
||||
context: Option<&codex_state::StateRuntime>,
|
||||
thread_id: ThreadId,
|
||||
stage: &str,
|
||||
) {
|
||||
let Some(ctx) = context else {
|
||||
return;
|
||||
};
|
||||
if let Err(err) = ctx.mark_thread_memory_mode_polluted(thread_id).await {
|
||||
warn!("state db mark_thread_memory_mode_polluted failed during {stage}: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Reconcile rollout items into SQLite, falling back to scanning the rollout file.
|
||||
pub async fn reconcile_rollout(
|
||||
context: Option<&codex_state::StateRuntime>,
|
||||
rollout_path: &Path,
|
||||
default_provider: &str,
|
||||
builder: Option<&ThreadMetadataBuilder>,
|
||||
items: &[RolloutItem],
|
||||
archived_only: Option<bool>,
|
||||
new_thread_memory_mode: Option<&str>,
|
||||
) {
|
||||
let Some(ctx) = context else {
|
||||
return;
|
||||
};
|
||||
if builder.is_some() || !items.is_empty() {
|
||||
apply_rollout_items(
|
||||
Some(ctx),
|
||||
rollout_path,
|
||||
default_provider,
|
||||
builder,
|
||||
items,
|
||||
"reconcile_rollout",
|
||||
new_thread_memory_mode,
|
||||
/*updated_at_override*/ None,
|
||||
)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
let outcome =
|
||||
match metadata::extract_metadata_from_rollout(rollout_path, default_provider).await {
|
||||
Ok(outcome) => outcome,
|
||||
Err(err) => {
|
||||
warn!(
|
||||
"state db reconcile_rollout extraction failed {}: {err}",
|
||||
rollout_path.display()
|
||||
);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let mut metadata = outcome.metadata;
|
||||
let memory_mode = outcome.memory_mode.unwrap_or_else(|| "enabled".to_string());
|
||||
metadata.cwd = normalize_cwd_for_state_db(&metadata.cwd);
|
||||
if let Ok(Some(existing_metadata)) = ctx.get_thread(metadata.id).await {
|
||||
metadata.prefer_existing_git_info(&existing_metadata);
|
||||
}
|
||||
match archived_only {
|
||||
Some(true) if metadata.archived_at.is_none() => {
|
||||
metadata.archived_at = Some(metadata.updated_at);
|
||||
}
|
||||
Some(false) => {
|
||||
metadata.archived_at = None;
|
||||
}
|
||||
Some(true) | None => {}
|
||||
}
|
||||
if let Err(err) = ctx.upsert_thread(&metadata).await {
|
||||
warn!(
|
||||
"state db reconcile_rollout upsert failed {}: {err}",
|
||||
rollout_path.display()
|
||||
);
|
||||
return;
|
||||
}
|
||||
if let Err(err) = ctx
|
||||
.set_thread_memory_mode(metadata.id, memory_mode.as_str())
|
||||
.await
|
||||
{
|
||||
warn!(
|
||||
"state db reconcile_rollout memory_mode update failed {}: {err}",
|
||||
rollout_path.display()
|
||||
);
|
||||
return;
|
||||
}
|
||||
if let Ok(meta_line) = crate::rollout::list::read_session_meta_line(rollout_path).await {
|
||||
persist_dynamic_tools(
|
||||
Some(ctx),
|
||||
meta_line.meta.id,
|
||||
meta_line.meta.dynamic_tools.as_deref(),
|
||||
"reconcile_rollout",
|
||||
)
|
||||
.await;
|
||||
} else {
|
||||
warn!(
|
||||
"state db reconcile_rollout missing session meta {}",
|
||||
rollout_path.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Repair a thread's rollout path after filesystem fallback succeeds.
|
||||
pub async fn read_repair_rollout_path(
|
||||
context: Option<&codex_state::StateRuntime>,
|
||||
thread_id: Option<ThreadId>,
|
||||
archived_only: Option<bool>,
|
||||
rollout_path: &Path,
|
||||
) {
|
||||
let Some(ctx) = context else {
|
||||
return;
|
||||
};
|
||||
|
||||
// Fast path: update an existing metadata row in place, but avoid writes when
|
||||
// read-repair computes no effective change.
|
||||
let mut saw_existing_metadata = false;
|
||||
if let Some(thread_id) = thread_id
|
||||
&& let Ok(Some(metadata)) = ctx.get_thread(thread_id).await
|
||||
{
|
||||
saw_existing_metadata = true;
|
||||
let mut repaired = metadata.clone();
|
||||
repaired.rollout_path = rollout_path.to_path_buf();
|
||||
repaired.cwd = normalize_cwd_for_state_db(&repaired.cwd);
|
||||
match archived_only {
|
||||
Some(true) if repaired.archived_at.is_none() => {
|
||||
repaired.archived_at = Some(repaired.updated_at);
|
||||
}
|
||||
Some(false) => {
|
||||
repaired.archived_at = None;
|
||||
}
|
||||
Some(true) | None => {}
|
||||
}
|
||||
if repaired == metadata {
|
||||
return;
|
||||
}
|
||||
warn!("state db discrepancy during read_repair_rollout_path: upsert_needed (fast path)");
|
||||
if let Err(err) = ctx.upsert_thread(&repaired).await {
|
||||
warn!(
|
||||
"state db read-repair upsert failed for {}: {err}",
|
||||
rollout_path.display()
|
||||
);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Slow path: when the row is missing/unreadable (or direct upsert failed),
|
||||
// rebuild metadata from rollout contents and reconcile it into SQLite.
|
||||
if !saw_existing_metadata {
|
||||
warn!("state db discrepancy during read_repair_rollout_path: upsert_needed (slow path)");
|
||||
}
|
||||
let default_provider = crate::rollout::list::read_session_meta_line(rollout_path)
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|meta| meta.meta.model_provider)
|
||||
.unwrap_or_default();
|
||||
reconcile_rollout(
|
||||
Some(ctx),
|
||||
rollout_path,
|
||||
default_provider.as_str(),
|
||||
/*builder*/ None,
|
||||
&[],
|
||||
archived_only,
|
||||
/*new_thread_memory_mode*/ None,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
/// Apply rollout items incrementally to SQLite.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub async fn apply_rollout_items(
|
||||
context: Option<&codex_state::StateRuntime>,
|
||||
rollout_path: &Path,
|
||||
_default_provider: &str,
|
||||
builder: Option<&ThreadMetadataBuilder>,
|
||||
items: &[RolloutItem],
|
||||
stage: &str,
|
||||
new_thread_memory_mode: Option<&str>,
|
||||
updated_at_override: Option<DateTime<Utc>>,
|
||||
) {
|
||||
let Some(ctx) = context else {
|
||||
return;
|
||||
};
|
||||
let mut builder = match builder {
|
||||
Some(builder) => builder.clone(),
|
||||
None => match metadata::builder_from_items(items, rollout_path) {
|
||||
Some(builder) => builder,
|
||||
None => {
|
||||
warn!(
|
||||
"state db apply_rollout_items missing builder during {stage}: {}",
|
||||
rollout_path.display()
|
||||
);
|
||||
warn!("state db discrepancy during apply_rollout_items: {stage}, missing_builder");
|
||||
return;
|
||||
}
|
||||
},
|
||||
};
|
||||
builder.rollout_path = rollout_path.to_path_buf();
|
||||
builder.cwd = normalize_cwd_for_state_db(&builder.cwd);
|
||||
if let Err(err) = ctx
|
||||
.apply_rollout_items(&builder, items, new_thread_memory_mode, updated_at_override)
|
||||
.await
|
||||
{
|
||||
warn!(
|
||||
"state db apply_rollout_items failed during {stage} for {}: {err}",
|
||||
rollout_path.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn touch_thread_updated_at(
|
||||
context: Option<&codex_state::StateRuntime>,
|
||||
thread_id: Option<ThreadId>,
|
||||
updated_at: DateTime<Utc>,
|
||||
stage: &str,
|
||||
) -> bool {
|
||||
let Some(ctx) = context else {
|
||||
return false;
|
||||
};
|
||||
let Some(thread_id) = thread_id else {
|
||||
return false;
|
||||
};
|
||||
ctx.touch_thread_updated_at(thread_id, updated_at)
|
||||
.await
|
||||
.unwrap_or_else(|err| {
|
||||
warn!("state db touch_thread_updated_at failed during {stage} for {thread_id}: {err}");
|
||||
false
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "state_db_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,21 @@
|
||||
use codex_rollout::state_db as rollout_state_db;
|
||||
pub use codex_rollout::state_db::StateDbHandle;
|
||||
pub use codex_rollout::state_db::apply_rollout_items;
|
||||
pub use codex_rollout::state_db::find_rollout_path_by_id;
|
||||
pub use codex_rollout::state_db::get_dynamic_tools;
|
||||
pub use codex_rollout::state_db::list_thread_ids_db;
|
||||
pub use codex_rollout::state_db::list_threads_db;
|
||||
pub use codex_rollout::state_db::mark_thread_memory_mode_polluted;
|
||||
pub use codex_rollout::state_db::normalize_cwd_for_state_db;
|
||||
pub use codex_rollout::state_db::open_if_present;
|
||||
pub use codex_rollout::state_db::persist_dynamic_tools;
|
||||
pub use codex_rollout::state_db::read_repair_rollout_path;
|
||||
pub use codex_rollout::state_db::reconcile_rollout;
|
||||
pub use codex_rollout::state_db::touch_thread_updated_at;
|
||||
pub use codex_state::LogEntry;
|
||||
|
||||
use crate::config::Config;
|
||||
|
||||
pub async fn get_state_db(config: &Config) -> Option<StateDbHandle> {
|
||||
rollout_state_db::get_state_db(config).await
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
use super::*;
|
||||
use crate::rollout::list::parse_cursor;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn cursor_to_anchor_normalizes_timestamp_format() {
|
||||
let uuid = Uuid::new_v4();
|
||||
let ts_str = "2026-01-27T12-34-56";
|
||||
let token = format!("{ts_str}|{uuid}");
|
||||
let cursor = parse_cursor(token.as_str()).expect("cursor should parse");
|
||||
let anchor = cursor_to_anchor(Some(&cursor)).expect("anchor should parse");
|
||||
|
||||
let naive =
|
||||
NaiveDateTime::parse_from_str(ts_str, "%Y-%m-%dT%H-%M-%S").expect("ts should parse");
|
||||
let expected_ts = DateTime::<Utc>::from_naive_utc_and_offset(naive, Utc)
|
||||
.with_nanosecond(0)
|
||||
.expect("nanosecond");
|
||||
|
||||
assert_eq!(anchor.id, uuid);
|
||||
assert_eq!(anchor.ts, expected_ts);
|
||||
}
|
||||
@@ -25,7 +25,6 @@ use crate::protocol::ExecCommandStatus;
|
||||
use crate::protocol::SandboxPolicy;
|
||||
use crate::protocol::TurnStartedEvent;
|
||||
use crate::sandboxing::ExecRequest;
|
||||
use crate::sandboxing::SandboxPermissions;
|
||||
use crate::state::TaskKind;
|
||||
use crate::tools::format_exec_output_str;
|
||||
use crate::tools::runtimes::maybe_wrap_shell_lc_with_snapshot;
|
||||
@@ -146,7 +145,7 @@ pub(crate) async fn execute_user_shell_command(
|
||||
process_id: None,
|
||||
turn_id: turn_context.sub_id.clone(),
|
||||
command: display_command.clone(),
|
||||
cwd: cwd.clone(),
|
||||
cwd: cwd.to_path_buf(),
|
||||
parsed_cmd: parsed_cmd.clone(),
|
||||
source: ExecCommandSource::UserShell,
|
||||
interaction_input: None,
|
||||
@@ -157,7 +156,7 @@ pub(crate) async fn execute_user_shell_command(
|
||||
let sandbox_policy = SandboxPolicy::DangerFullAccess;
|
||||
let exec_env = ExecRequest {
|
||||
command: exec_command.clone(),
|
||||
cwd: cwd.clone(),
|
||||
cwd: cwd.to_path_buf(),
|
||||
env: create_env(
|
||||
&turn_context.shell_environment_policy,
|
||||
Some(session.conversation_id),
|
||||
@@ -173,11 +172,10 @@ pub(crate) async fn execute_user_shell_command(
|
||||
.config
|
||||
.permissions
|
||||
.windows_sandbox_private_desktop,
|
||||
sandbox_permissions: SandboxPermissions::UseDefault,
|
||||
sandbox_policy: sandbox_policy.clone(),
|
||||
file_system_sandbox_policy: FileSystemSandboxPolicy::from(&sandbox_policy),
|
||||
network_sandbox_policy: NetworkSandboxPolicy::from(&sandbox_policy),
|
||||
justification: None,
|
||||
windows_restricted_token_filesystem_overlay: None,
|
||||
arg0: None,
|
||||
};
|
||||
|
||||
@@ -223,7 +221,7 @@ pub(crate) async fn execute_user_shell_command(
|
||||
process_id: None,
|
||||
turn_id: turn_context.sub_id.clone(),
|
||||
command: display_command.clone(),
|
||||
cwd: cwd.clone(),
|
||||
cwd: cwd.to_path_buf(),
|
||||
parsed_cmd: parsed_cmd.clone(),
|
||||
source: ExecCommandSource::UserShell,
|
||||
interaction_input: None,
|
||||
@@ -247,7 +245,7 @@ pub(crate) async fn execute_user_shell_command(
|
||||
process_id: None,
|
||||
turn_id: turn_context.sub_id.clone(),
|
||||
command: display_command.clone(),
|
||||
cwd: cwd.clone(),
|
||||
cwd: cwd.to_path_buf(),
|
||||
parsed_cmd: parsed_cmd.clone(),
|
||||
source: ExecCommandSource::UserShell,
|
||||
interaction_input: None,
|
||||
@@ -291,7 +289,7 @@ pub(crate) async fn execute_user_shell_command(
|
||||
process_id: None,
|
||||
turn_id: turn_context.sub_id.clone(),
|
||||
command: display_command,
|
||||
cwd,
|
||||
cwd: cwd.to_path_buf(),
|
||||
parsed_cmd,
|
||||
source: ExecCommandSource::UserShell,
|
||||
interaction_input: None,
|
||||
|
||||
@@ -2,6 +2,7 @@ use crate::AuthManager;
|
||||
use crate::CodexAuth;
|
||||
use crate::ModelProviderInfo;
|
||||
use crate::OPENAI_PROVIDER_ID;
|
||||
use crate::SkillsManager;
|
||||
use crate::agent::AgentControl;
|
||||
use crate::codex::Codex;
|
||||
use crate::codex::CodexSpawnArgs;
|
||||
@@ -22,7 +23,6 @@ use crate::protocol::SessionConfiguredEvent;
|
||||
use crate::rollout::RolloutRecorder;
|
||||
use crate::rollout::truncation;
|
||||
use crate::shell_snapshot::ShellSnapshot;
|
||||
use crate::skills::SkillsManager;
|
||||
use crate::skills_watcher::SkillsWatcher;
|
||||
use crate::skills_watcher::SkillsWatcherEvent;
|
||||
use crate::tasks::interrupted_turn_history_marker;
|
||||
@@ -231,7 +231,6 @@ impl ThreadManager {
|
||||
let mcp_manager = Arc::new(McpManager::new(Arc::clone(&plugins_manager)));
|
||||
let skills_manager = Arc::new(SkillsManager::new_with_restriction_product(
|
||||
codex_home.clone(),
|
||||
Arc::clone(&plugins_manager),
|
||||
config.bundled_skills_enabled(),
|
||||
restriction_product,
|
||||
));
|
||||
@@ -297,7 +296,6 @@ impl ThreadManager {
|
||||
let mcp_manager = Arc::new(McpManager::new(Arc::clone(&plugins_manager)));
|
||||
let skills_manager = Arc::new(SkillsManager::new_with_restriction_product(
|
||||
codex_home.clone(),
|
||||
Arc::clone(&plugins_manager),
|
||||
/*bundled_skills_enabled*/ true,
|
||||
restriction_product,
|
||||
));
|
||||
@@ -835,9 +833,11 @@ impl ThreadManagerState {
|
||||
parent_trace: Option<W3cTraceContext>,
|
||||
user_shell_override: Option<crate::shell::Shell>,
|
||||
) -> CodexResult<NewThread> {
|
||||
let watch_registration = self
|
||||
.skills_watcher
|
||||
.register_config(&config, self.skills_manager.as_ref());
|
||||
let watch_registration = self.skills_watcher.register_config(
|
||||
&config,
|
||||
self.skills_manager.as_ref(),
|
||||
self.plugins_manager.as_ref(),
|
||||
);
|
||||
let CodexSpawnOk {
|
||||
codex, thread_id, ..
|
||||
} = Codex::spawn(CodexSpawnArgs {
|
||||
|
||||
@@ -12,6 +12,7 @@ use codex_protocol::openai_models::ModelsResponse;
|
||||
use codex_protocol::protocol::AgentMessageEvent;
|
||||
use codex_protocol::protocol::TurnStartedEvent;
|
||||
use codex_protocol::protocol::UserMessageEvent;
|
||||
use core_test_support::PathExt;
|
||||
use core_test_support::responses::mount_models_once;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::time::Duration;
|
||||
@@ -236,7 +237,7 @@ async fn shutdown_all_threads_bounded_submits_shutdown_to_every_thread() {
|
||||
let temp_dir = tempdir().expect("tempdir");
|
||||
let mut config = test_config();
|
||||
config.codex_home = temp_dir.path().join("codex-home");
|
||||
config.cwd = config.codex_home.clone();
|
||||
config.cwd = config.codex_home.abs();
|
||||
std::fs::create_dir_all(&config.codex_home).expect("create codex home");
|
||||
|
||||
let manager = ThreadManager::with_models_provider_and_home_for_tests(
|
||||
@@ -275,7 +276,7 @@ async fn new_uses_configured_openai_provider_for_model_refresh() {
|
||||
let temp_dir = tempdir().expect("tempdir");
|
||||
let mut config = test_config();
|
||||
config.codex_home = temp_dir.path().join("codex-home");
|
||||
config.cwd = config.codex_home.clone();
|
||||
config.cwd = config.codex_home.abs();
|
||||
std::fs::create_dir_all(&config.codex_home).expect("create codex home");
|
||||
config.model_catalog = None;
|
||||
config
|
||||
@@ -408,7 +409,7 @@ async fn interrupted_fork_snapshot_does_not_synthesize_turn_id_for_legacy_histor
|
||||
let temp_dir = tempdir().expect("tempdir");
|
||||
let mut config = test_config();
|
||||
config.codex_home = temp_dir.path().join("codex-home");
|
||||
config.cwd = config.codex_home.clone();
|
||||
config.cwd = config.codex_home.abs();
|
||||
std::fs::create_dir_all(&config.codex_home).expect("create codex home");
|
||||
|
||||
let auth_manager =
|
||||
@@ -505,7 +506,7 @@ async fn interrupted_fork_snapshot_preserves_explicit_turn_id() {
|
||||
let temp_dir = tempdir().expect("tempdir");
|
||||
let mut config = test_config();
|
||||
config.codex_home = temp_dir.path().join("codex-home");
|
||||
config.cwd = config.codex_home.clone();
|
||||
config.cwd = config.codex_home.abs();
|
||||
std::fs::create_dir_all(&config.codex_home).expect("create codex home");
|
||||
|
||||
let auth_manager =
|
||||
@@ -591,7 +592,7 @@ async fn interrupted_fork_snapshot_uses_persisted_mid_turn_history_without_live_
|
||||
let temp_dir = tempdir().expect("tempdir");
|
||||
let mut config = test_config();
|
||||
config.codex_home = temp_dir.path().join("codex-home");
|
||||
config.cwd = config.codex_home.clone();
|
||||
config.cwd = config.codex_home.abs();
|
||||
std::fs::create_dir_all(&config.codex_home).expect("create codex home");
|
||||
|
||||
let auth_manager =
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user