Compare commits

...

1 Commits

Author SHA1 Message Date
starr-openai
e4cc738a5c Add exec-server MVP startup and surface seams 2026-04-04 14:32:43 -07:00
14 changed files with 610 additions and 69 deletions

View File

@@ -2538,6 +2538,35 @@ pub enum CommandExecOutputStream {
// === Threads, Turns, and Items ===
// Thread APIs
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub enum ThreadBundleMountMode {
MaterializedSnapshot,
HostedReference,
}
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq, JsonSchema, TS)]
#[serde(rename_all = "camelCase")]
#[ts(export_to = "v2/")]
pub struct ThreadBundleStartup {
/// Stable control-plane identifier for the bundle/version the thread should
/// use when exec-server prepares its startup view.
pub bundle_id: String,
/// Optional bundle version or revision for auditability/debugging.
#[ts(optional = nullable)]
pub bundle_version: Option<String>,
/// Optional materialization root to use before session startup.
#[ts(optional = nullable)]
pub install_root: Option<String>,
/// How downstream startup code should interpret the bundle input.
#[ts(optional = nullable)]
pub mount_mode: Option<ThreadBundleMountMode>,
/// Optional manifest blob to preserve for later startup plumbing.
#[ts(optional = nullable)]
pub manifest: Option<JsonValue>,
}
#[derive(
Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS, ExperimentalApi,
)]
@@ -2556,19 +2585,35 @@ pub struct ThreadStartParams {
)]
#[ts(optional = nullable)]
pub service_tier: Option<Option<ServiceTier>>,
/// Session working directory. This is the cwd contract for project config
/// resolution, trust checks, and the initial exec-server workspace. Under
/// a materialized-bundle model, this should point at the materialized
/// workspace root rather than bundle metadata storage.
#[ts(optional = nullable)]
pub cwd: Option<String>,
#[experimental(nested)]
/// Default approval policy for this thread and subsequent turns unless a
/// later `turn/start` override is applied.
#[ts(optional = nullable)]
pub approval_policy: Option<AskForApproval>,
/// Override where approval requests are routed for review on this thread
/// and subsequent turns.
#[ts(optional = nullable)]
pub approvals_reviewer: Option<ApprovalsReviewer>,
/// Default sandbox mode for this thread and subsequent turns. Exec-server
/// should treat this as part of the session contract, not as a one-off
/// tool-call preference.
#[ts(optional = nullable)]
pub sandbox: Option<SandboxMode>,
/// Free-form request config overrides layered after CLI/project config and
/// before explicit typed overrides such as `cwd`, approval, and sandbox.
#[ts(optional = nullable)]
pub config: Option<HashMap<String, JsonValue>>,
/// Optional bundle-aware exec-server startup payload that should travel
/// with this thread and be available during config derivation/startup.
#[experimental("thread/start.bundleStartup")]
#[ts(optional = nullable)]
pub bundle_startup: Option<ThreadBundleStartup>,
#[ts(optional = nullable)]
pub service_name: Option<String>,
#[ts(optional = nullable)]
@@ -3941,7 +3986,8 @@ pub enum TurnStatus {
pub struct TurnStartParams {
pub thread_id: String,
pub input: Vec<UserInput>,
/// Override the working directory for this turn and subsequent turns.
/// Override the working directory for this turn and subsequent turns. This
/// mutates the same logical session cwd established by `thread/start`.
#[ts(optional = nullable)]
pub cwd: Option<PathBuf>,
/// Override the approval policy for this turn and subsequent turns.
@@ -3952,7 +3998,9 @@ pub struct TurnStartParams {
/// subsequent turns.
#[ts(optional = nullable)]
pub approvals_reviewer: Option<ApprovalsReviewer>,
/// Override the sandbox policy for this turn and subsequent turns.
/// Override the sandbox policy for this turn and subsequent turns. This is
/// expected to stay aligned with approval/cwd overrides when exec-server
/// changes session execution context.
#[ts(optional = nullable)]
pub sandbox_policy: Option<SandboxPolicy>,
/// Override the model for this turn and subsequent turns.

View File

@@ -117,6 +117,7 @@ use codex_app_server_protocol::ThreadArchiveResponse;
use codex_app_server_protocol::ThreadArchivedNotification;
use codex_app_server_protocol::ThreadBackgroundTerminalsCleanParams;
use codex_app_server_protocol::ThreadBackgroundTerminalsCleanResponse;
use codex_app_server_protocol::ThreadBundleStartup;
use codex_app_server_protocol::ThreadClosedNotification;
use codex_app_server_protocol::ThreadCompactStartParams;
use codex_app_server_protocol::ThreadCompactStartResponse;
@@ -337,6 +338,7 @@ use crate::thread_state::ThreadStateManager;
const THREAD_LIST_DEFAULT_LIMIT: usize = 25;
const THREAD_LIST_MAX_LIMIT: usize = 100;
const THREAD_BUNDLE_STARTUP_CONFIG_KEY: &str = "execServer.bundleStartup";
struct ThreadListFilters {
model_providers: Option<Vec<String>>,
@@ -446,6 +448,34 @@ struct ListenerTaskContext {
codex_home: PathBuf,
}
/// Phase-1 exec-server sketch: keep cwd + approval + sandbox grouped as one
/// session contract so thread bootstrap, config derivation, and later
/// propagation points all share the same normalization seam.
struct ExecServerSessionContract {
cwd: Option<PathBuf>,
approval_policy: Option<codex_protocol::protocol::AskForApproval>,
approvals_reviewer: Option<codex_protocol::config_types::ApprovalsReviewer>,
sandbox_mode: Option<codex_protocol::config_types::SandboxMode>,
}
impl ExecServerSessionContract {
fn new(
cwd: Option<String>,
approval_policy: Option<codex_app_server_protocol::AskForApproval>,
approvals_reviewer: Option<codex_app_server_protocol::ApprovalsReviewer>,
sandbox: Option<SandboxMode>,
) -> Self {
Self {
cwd: cwd.map(PathBuf::from),
approval_policy: approval_policy
.map(codex_app_server_protocol::AskForApproval::to_core),
approvals_reviewer: approvals_reviewer
.map(codex_app_server_protocol::ApprovalsReviewer::to_core),
sandbox_mode: sandbox.map(SandboxMode::to_core),
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum EnsureConversationListenerResult {
Attached,
@@ -2074,6 +2104,7 @@ impl CodexMessageProcessor {
approvals_reviewer,
sandbox,
config,
bundle_startup,
service_name,
base_instructions,
developer_instructions,
@@ -2084,19 +2115,19 @@ impl CodexMessageProcessor {
ephemeral,
persist_extended_history,
} = params;
let session_contract =
ExecServerSessionContract::new(cwd, approval_policy, approvals_reviewer, sandbox);
let mut typesafe_overrides = self.build_thread_config_overrides(
model,
model_provider,
service_tier,
cwd,
approval_policy,
approvals_reviewer,
sandbox,
session_contract,
base_instructions,
developer_instructions,
personality,
);
typesafe_overrides.ephemeral = ephemeral;
let config = Self::merge_thread_start_config_with_bundle_startup(config, bundle_startup);
let cloud_requirements = self.current_cloud_requirements();
let cli_overrides = self.current_cli_overrides();
let listener_task_context = ListenerTaskContext {
@@ -2134,6 +2165,18 @@ impl CodexMessageProcessor {
.spawn(thread_start_task.instrument(request_context.span()));
}
fn merge_thread_start_config_with_bundle_startup(
config_overrides: Option<HashMap<String, serde_json::Value>>,
bundle_startup: Option<ThreadBundleStartup>,
) -> Option<HashMap<String, serde_json::Value>> {
let bundle_startup = bundle_startup?;
let mut config_overrides = config_overrides.unwrap_or_default();
let bundle_json = serde_json::to_value(bundle_startup)
.expect("ThreadBundleStartup must always serialize for config plumbing");
config_overrides.insert(THREAD_BUNDLE_STARTUP_CONFIG_KEY.to_string(), bundle_json);
Some(config_overrides)
}
pub(crate) async fn drain_background_tasks(&self) {
self.background_tasks.close();
if tokio::time::timeout(Duration::from_secs(10), self.background_tasks.wait())
@@ -2470,10 +2513,7 @@ impl CodexMessageProcessor {
model: Option<String>,
model_provider: Option<String>,
service_tier: Option<Option<codex_protocol::config_types::ServiceTier>>,
cwd: Option<String>,
approval_policy: Option<codex_app_server_protocol::AskForApproval>,
approvals_reviewer: Option<codex_app_server_protocol::ApprovalsReviewer>,
sandbox: Option<SandboxMode>,
session_contract: ExecServerSessionContract,
base_instructions: Option<String>,
developer_instructions: Option<String>,
personality: Option<Personality>,
@@ -2482,12 +2522,10 @@ impl CodexMessageProcessor {
model,
model_provider,
service_tier,
cwd: cwd.map(PathBuf::from),
approval_policy: approval_policy
.map(codex_app_server_protocol::AskForApproval::to_core),
approvals_reviewer: approvals_reviewer
.map(codex_app_server_protocol::ApprovalsReviewer::to_core),
sandbox_mode: sandbox.map(SandboxMode::to_core),
cwd: session_contract.cwd,
approval_policy: session_contract.approval_policy,
approvals_reviewer: session_contract.approvals_reviewer,
sandbox_mode: session_contract.sandbox_mode,
codex_linux_sandbox_exe: self.arg0_paths.codex_linux_sandbox_exe.clone(),
main_execve_wrapper_exe: self.arg0_paths.main_execve_wrapper_exe.clone(),
base_instructions,
@@ -3795,10 +3833,7 @@ impl CodexMessageProcessor {
model,
model_provider,
service_tier,
cwd,
approval_policy,
approvals_reviewer,
sandbox,
ExecServerSessionContract::new(cwd, approval_policy, approvals_reviewer, sandbox),
base_instructions,
developer_instructions,
personality,
@@ -4356,10 +4391,7 @@ impl CodexMessageProcessor {
model,
model_provider,
service_tier,
cwd,
approval_policy,
approvals_reviewer,
sandbox,
ExecServerSessionContract::new(cwd, approval_policy, approvals_reviewer, sandbox),
base_instructions,
developer_instructions,
/*personality*/ None,

View File

@@ -149,6 +149,26 @@ pub struct SkillRoot {
pub scope: SkillScope,
}
/// Exec-server can stage a versioned materialized skill bundle before app-server startup.
///
/// The staged root is intentionally modeled as "just another skill root" so the existing
/// discovery/injection pipeline can stay mostly unchanged while remote bundle delivery is wired up.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MaterializedSkillBundle {
pub root: PathBuf,
pub scope: SkillScope,
}
pub fn materialized_skill_bundle_roots(bundles: &[MaterializedSkillBundle]) -> Vec<SkillRoot> {
bundles
.iter()
.map(|bundle| SkillRoot {
path: bundle.root.clone(),
scope: bundle.scope,
})
.collect()
}
pub fn load_skills_from_roots<I>(roots: I) -> SkillLoadOutcome
where
I: IntoIterator<Item = SkillRoot>,

View File

@@ -16,8 +16,10 @@ use crate::build_implicit_skill_path_indexes;
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::MaterializedSkillBundle;
use crate::loader::SkillRoot;
use crate::loader::load_skills_from_roots;
use crate::loader::materialized_skill_bundle_roots;
use crate::loader::skill_roots;
use crate::system::install_system_skills;
use crate::system::uninstall_system_skills;
@@ -27,6 +29,7 @@ use codex_config::SkillsConfig;
pub struct SkillsLoadInput {
pub cwd: PathBuf,
pub effective_skill_roots: Vec<PathBuf>,
pub materialized_skill_bundles: Vec<MaterializedSkillBundle>,
pub config_layer_stack: ConfigLayerStack,
pub bundled_skills_enabled: bool,
}
@@ -41,10 +44,20 @@ impl SkillsLoadInput {
Self {
cwd,
effective_skill_roots,
materialized_skill_bundles: Vec::new(),
config_layer_stack,
bundled_skills_enabled,
}
}
pub fn with_materialized_skill_bundles(
mut self,
materialized_skill_bundles: Vec<MaterializedSkillBundle>,
) -> Self {
self.materialized_skill_bundles =
normalize_materialized_skill_bundles(&materialized_skill_bundles);
self
}
}
pub struct SkillsManager {
@@ -104,15 +117,7 @@ impl SkillsManager {
}
pub fn skill_roots_for_config(&self, input: &SkillsLoadInput) -> Vec<SkillRoot> {
let mut roots = skill_roots(
&input.config_layer_stack,
input.cwd.as_path(),
input.effective_skill_roots.clone(),
);
if !input.bundled_skills_enabled {
roots.retain(|root| root.scope != SkillScope::System);
}
roots
self.resolve_roots(input, &[])
}
pub async fn skills_for_cwd(
@@ -139,23 +144,7 @@ impl SkillsManager {
}
let normalized_extra_user_roots = normalize_extra_user_roots(extra_user_roots);
let mut roots = skill_roots(
&input.config_layer_stack,
input.cwd.as_path(),
input.effective_skill_roots.clone(),
);
if !bundled_skills_enabled_from_stack(&input.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 roots = self.resolve_roots(input, &normalized_extra_user_roots);
let skill_config_rules = skill_config_rules_from_stack(&input.config_layer_stack);
let outcome = self.build_skill_outcome(roots, &skill_config_rules);
let mut cache = self
@@ -202,6 +191,34 @@ impl SkillsManager {
info!("skills cache cleared ({cleared} entries)");
}
fn resolve_roots(
&self,
input: &SkillsLoadInput,
normalized_extra_user_roots: &[PathBuf],
) -> Vec<SkillRoot> {
let mut roots = skill_roots(
&input.config_layer_stack,
input.cwd.as_path(),
input.effective_skill_roots.clone(),
);
if !bundled_skills_enabled_from_stack(&input.config_layer_stack) {
roots.retain(|root| root.scope != SkillScope::System);
}
roots.extend(materialized_skill_bundle_roots(
&input.materialized_skill_bundles,
));
roots.extend(
normalized_extra_user_roots
.iter()
.cloned()
.map(|path| SkillRoot {
path,
scope: SkillScope::User,
}),
);
roots
}
fn cached_outcome_for_cwd(&self, cwd: &Path) -> Option<SkillLoadOutcome> {
match self.cache_by_cwd.read() {
Ok(cache) => cache.get(cwd).cloned(),
@@ -291,6 +308,34 @@ fn normalize_extra_user_roots(extra_user_roots: &[PathBuf]) -> Vec<PathBuf> {
normalized
}
fn normalize_materialized_skill_bundles(
materialized_skill_bundles: &[MaterializedSkillBundle],
) -> Vec<MaterializedSkillBundle> {
let mut normalized: Vec<MaterializedSkillBundle> = materialized_skill_bundles
.iter()
.map(|bundle| MaterializedSkillBundle {
root: dunce::canonicalize(&bundle.root).unwrap_or_else(|_| bundle.root.clone()),
scope: bundle.scope,
})
.collect();
normalized.sort_by(|left, right| {
skill_scope_rank(left.scope)
.cmp(&skill_scope_rank(right.scope))
.then_with(|| left.root.cmp(&right.root))
});
normalized.dedup();
normalized
}
fn skill_scope_rank(scope: SkillScope) -> u8 {
match scope {
SkillScope::Repo => 0,
SkillScope::User => 1,
SkillScope::System => 2,
SkillScope::Admin => 3,
}
}
#[cfg(test)]
#[path = "manager_tests.rs"]
mod tests;

View File

@@ -896,7 +896,123 @@ pub(crate) struct TurnContext {
pub(crate) turn_skills: TurnSkillsContext,
pub(crate) turn_timing_state: Arc<TurnTimingState>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum PersistentPromptSurfaceRole {
Developer,
User,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct PersistentPromptSurfaceEntry {
pub(crate) key: &'static str,
pub(crate) role: PersistentPromptSurfaceRole,
pub(crate) covered_by_initial_context: bool,
pub(crate) covered_by_context_updates: bool,
}
impl PersistentPromptSurfaceEntry {
fn new(
key: &'static str,
role: PersistentPromptSurfaceRole,
covered_by_initial_context: bool,
covered_by_context_updates: bool,
) -> Self {
Self {
key,
role,
covered_by_initial_context,
covered_by_context_updates,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ExecServerPromptSurfaceProposal {
/// TODO(exec-server-prompt-surface): If thread-start or exec-server setup
/// ever needs to bootstrap prompt bundles out-of-process, treat this
/// inventory as the single typed planning seam before adding transport- or
/// session-specific behavior.
pub(crate) entries: Vec<PersistentPromptSurfaceEntry>,
}
impl TurnContext {
pub(crate) fn exec_server_prompt_surface_proposal(&self) -> ExecServerPromptSurfaceProposal {
let mut entries = Vec::with_capacity(11);
entries.push(PersistentPromptSurfaceEntry::new(
"project_doc_user_instructions",
PersistentPromptSurfaceRole::User,
self.user_instructions.is_some(),
false,
));
entries.push(PersistentPromptSurfaceEntry::new(
"environment_context",
PersistentPromptSurfaceRole::User,
self.config.include_environment_context,
self.config.include_environment_context,
));
entries.push(PersistentPromptSurfaceEntry::new(
"permissions_instructions",
PersistentPromptSurfaceRole::Developer,
self.config.include_permissions_instructions,
self.config.include_permissions_instructions,
));
entries.push(PersistentPromptSurfaceEntry::new(
"developer_instructions",
PersistentPromptSurfaceRole::Developer,
self.developer_instructions.is_some(),
false,
));
entries.push(PersistentPromptSurfaceEntry::new(
"memory_tool_instructions",
PersistentPromptSurfaceRole::Developer,
self.features.enabled(Feature::MemoryTool) && self.config.memories.use_memories,
false,
));
entries.push(PersistentPromptSurfaceEntry::new(
"collaboration_mode_instructions",
PersistentPromptSurfaceRole::Developer,
DeveloperInstructions::from_collaboration_mode(&self.collaboration_mode).is_some(),
true,
));
entries.push(PersistentPromptSurfaceEntry::new(
"realtime_instructions",
PersistentPromptSurfaceRole::Developer,
self.realtime_active,
true,
));
entries.push(PersistentPromptSurfaceEntry::new(
"personality_instructions",
PersistentPromptSurfaceRole::Developer,
self.personality.is_some(),
true,
));
entries.push(PersistentPromptSurfaceEntry::new(
"apps_section",
PersistentPromptSurfaceRole::Developer,
self.config.include_apps_instructions && self.apps_enabled(),
false,
));
entries.push(PersistentPromptSurfaceEntry::new(
"skills_section",
PersistentPromptSurfaceRole::Developer,
!self
.turn_skills
.outcome
.allowed_skills_for_implicit_invocation()
.is_empty(),
false,
));
entries.push(PersistentPromptSurfaceEntry::new(
"plugins_and_commit_helpers",
PersistentPromptSurfaceRole::Developer,
true,
false,
));
ExecServerPromptSurfaceProposal { entries }
}
pub(crate) fn model_context_window(&self) -> Option<i64> {
let effective_context_window_percent = self.model_info.effective_context_window_percent;
self.model_info.context_window.map(|context_window| {
@@ -3594,6 +3710,7 @@ impl Session {
&self,
turn_context: &TurnContext,
) -> Vec<ResponseItem> {
let _prompt_surface_proposal = turn_context.exec_server_prompt_surface_proposal();
let mut developer_sections = Vec::<String>::with_capacity(8);
let mut contextual_user_sections = Vec::<String>::with_capacity(2);
let shell = self.user_shell();

View File

@@ -205,6 +205,13 @@ pub(crate) fn build_settings_update_items(
// model-visible item emitted by build_initial_context. Persist the remaining
// inputs or add explicit replay events so fork/resume can diff everything
// deterministically.
//
// TODO(exec-server-prompt-surface): Before adding thread-start or
// exec-server bootstrap coverage for AGENTS/project-doc, skills/apps,
// plugins, or other persistent bundles that currently only exist in the
// initial context, extend `TurnContext::exec_server_prompt_surface_proposal()`
// and then promote the missing entries into explicit update items here.
let _prompt_surface_proposal = next.exec_server_prompt_surface_proposal();
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

View File

@@ -31,6 +31,22 @@ pub struct ExternalAgentConfigMigrationItem {
pub cwd: Option<PathBuf>,
}
/// Phase-1 sketch for the exec-server materialized-bundle path.
///
/// The current import flow writes directly into the user's repo/home targets.
/// For the planned exec-server startup flow, we likely need to stage the same
/// imported assets into a deterministic bundle root before thread start, then
/// point the downstream startup/config/discovery paths at that staged tree.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExternalAgentBundleStagePlan {
pub repo_root: Option<PathBuf>,
pub bundle_root: PathBuf,
pub config_target: PathBuf,
pub skills_target: PathBuf,
pub agents_target: PathBuf,
pub mcp_config_target: PathBuf,
}
#[derive(Clone)]
pub struct ExternalAgentConfigService {
codex_home: PathBuf,
@@ -107,6 +123,51 @@ impl ExternalAgentConfigService {
Ok(())
}
/// Sketch the deterministic bundle layout needed by the planned
/// materialized-bundle/thread-start flow.
///
/// This does not change runtime behavior yet. It exists so the next phase
/// can wire a bundle root through `thread/start` and then reuse the normal
/// AGENTS/skills/MCP discovery paths against those staged locations.
pub fn materialized_bundle_stage_plan(
&self,
cwd: Option<&Path>,
bundle_root: &Path,
) -> io::Result<ExternalAgentBundleStagePlan> {
let repo_root = find_repo_root(cwd)?;
Ok(self.default_bundle_stage_plan(repo_root.as_deref(), bundle_root))
}
fn default_bundle_stage_plan(
&self,
repo_root: Option<&Path>,
bundle_root: &Path,
) -> ExternalAgentBundleStagePlan {
let bundle_root = bundle_root.to_path_buf();
let repo_prefix = bundle_root.join("repo");
let home_prefix = bundle_root.join("home");
if let Some(repo_root) = repo_root {
return ExternalAgentBundleStagePlan {
repo_root: Some(repo_root.to_path_buf()),
bundle_root: bundle_root.clone(),
config_target: repo_prefix.join(".codex").join("config.toml"),
skills_target: repo_prefix.join(".agents").join("skills"),
agents_target: repo_prefix.join("AGENTS.md"),
mcp_config_target: repo_prefix.join(".codex").join("config.toml"),
};
}
ExternalAgentBundleStagePlan {
repo_root: None,
bundle_root: bundle_root.clone(),
config_target: home_prefix.join(".codex").join("config.toml"),
skills_target: home_prefix.join(".agents").join("skills"),
agents_target: home_prefix.join("AGENTS.md"),
mcp_config_target: home_prefix.join(".codex").join("config.toml"),
}
}
fn detect_migrations(
&self,
repo_root: Option<&Path>,

View File

@@ -23,6 +23,7 @@ use crate::config_loader::project_root_markers_from_config;
use codex_app_server_protocol::ConfigLayerSource;
use codex_features::Feature;
use dunce::canonicalize as normalize_path;
use std::path::Path;
use std::path::PathBuf;
use tokio::io::AsyncReadExt;
use toml::Value as TomlValue;
@@ -35,6 +36,13 @@ pub(crate) const HIERARCHICAL_AGENTS_MESSAGE: &str =
pub const DEFAULT_PROJECT_DOC_FILENAME: &str = "AGENTS.md";
/// Preferred local override for project-level docs.
pub const LOCAL_PROJECT_DOC_FILENAME: &str = "AGENTS.override.md";
/// Optional exec-server/materialized-bundle root for project-doc discovery.
///
/// Phase-1 sketch: exec-server can set this to the root of the materialized
/// bundle that mirrors project docs such as `AGENTS.md`. When present, we scan
/// that tree before the live workspace tree using the same hierarchical search
/// rules as `cwd`.
const PROJECT_DOC_BUNDLE_ROOT_ENV: &str = "CODEX_PROJECT_DOC_BUNDLE_ROOT";
/// When both `Config::instructions` and the project doc are present, they will
/// be concatenated with the following separator.
@@ -228,24 +236,20 @@ pub fn discover_project_doc_paths(config: &Config) -> std::io::Result<Vec<PathBu
}
}
let search_dirs: Vec<PathBuf> = if let Some(root) = project_root {
let mut dirs = Vec::new();
let mut cursor = dir.as_path();
loop {
dirs.push(cursor.to_path_buf());
if cursor == root {
break;
let mut search_dirs = search_dirs_from_rooted_cwd(project_root.as_deref(), &dir);
if let Some(bundle_dirs) = discover_materialized_bundle_search_dirs(
project_root.as_deref(),
&dir,
std::env::var_os(PROJECT_DOC_BUNDLE_ROOT_ENV)
.map(PathBuf::from)
.as_deref(),
) {
bundle_dirs.into_iter().rev().for_each(|bundle_dir| {
if !search_dirs.contains(&bundle_dir) {
search_dirs.insert(0, bundle_dir);
}
let Some(parent) = cursor.parent() else {
break;
};
cursor = parent;
}
dirs.reverse();
dirs
} else {
vec![dir]
};
});
}
let mut found: Vec<PathBuf> = Vec::new();
let candidate_filenames = candidate_filenames(config);
@@ -270,6 +274,53 @@ pub fn discover_project_doc_paths(config: &Config) -> std::io::Result<Vec<PathBu
Ok(found)
}
fn search_dirs_from_rooted_cwd(project_root: Option<&Path>, cwd: &Path) -> Vec<PathBuf> {
if let Some(root) = project_root {
let mut dirs = Vec::new();
let mut cursor = cwd;
loop {
dirs.push(cursor.to_path_buf());
if cursor == root {
break;
}
let Some(parent) = cursor.parent() else {
break;
};
cursor = parent;
}
dirs.reverse();
dirs
} else {
vec![cwd.to_path_buf()]
}
}
fn discover_materialized_bundle_search_dirs(
project_root: Option<&Path>,
cwd: &Path,
bundle_root: Option<&Path>,
) -> Option<Vec<PathBuf>> {
let bundle_root = bundle_root
.map(Path::to_path_buf)
.and_then(|path| normalize_path(&path).ok().or(Some(path)))?;
// The materialized bundle is expected to mirror the project-root-relative
// layout of the live workspace. This gives exec-server a concrete hook to
// surface AGENTS.md / project-doc files from outside `config.cwd` without
// teaching project-doc discovery about every bundle detail.
let bundle_cwd = match project_root.and_then(|root| cwd.strip_prefix(root).ok()) {
Some(relative_cwd) if !relative_cwd.as_os_str().is_empty() => {
bundle_root.join(relative_cwd)
}
_ => bundle_root.clone(),
};
Some(search_dirs_from_rooted_cwd(
Some(bundle_root.as_path()),
&bundle_cwd,
))
}
fn candidate_filenames<'a>(config: &'a Config) -> Vec<&'a str> {
let mut names: Vec<&'a str> =
Vec::with_capacity(2 + config.project_doc_fallback_filenames.len());

View File

@@ -41,16 +41,51 @@ pub use codex_core_skills::render;
pub use codex_core_skills::render_skills_section;
pub use codex_core_skills::system;
const CODEX_EXEC_SERVER_SKILLS_BUNDLE_ROOTS: &str = "CODEX_EXEC_SERVER_SKILLS_BUNDLE_ROOTS";
const CODEX_EXEC_SERVER_SKILLS_BUNDLE_SCOPE: &str = "CODEX_EXEC_SERVER_SKILLS_BUNDLE_SCOPE";
pub(crate) fn skills_load_input_from_config(
config: &Config,
effective_skill_roots: Vec<PathBuf>,
) -> SkillsLoadInput {
SkillsLoadInput::new(
let input = SkillsLoadInput::new(
config.cwd.clone().to_path_buf(),
effective_skill_roots,
config.config_layer_stack.clone(),
config.bundled_skills_enabled(),
)
);
input.with_materialized_skill_bundles(materialized_skill_bundles_from_env())
}
fn materialized_skill_bundles_from_env() -> Vec<loader::MaterializedSkillBundle> {
let scope = env::var(CODEX_EXEC_SERVER_SKILLS_BUNDLE_SCOPE)
.ok()
.and_then(|raw| parse_skill_scope(&raw))
.unwrap_or(SkillScope::User);
env::var_os(CODEX_EXEC_SERVER_SKILLS_BUNDLE_ROOTS)
.into_iter()
.flat_map(|raw| env::split_paths(&raw).collect::<Vec<_>>())
.map(|root| loader::MaterializedSkillBundle { root, scope })
.collect()
}
fn parse_skill_scope(raw: &str) -> Option<SkillScope> {
let normalized = raw.trim();
if normalized.eq_ignore_ascii_case("repo") {
Some(SkillScope::Repo)
} else if normalized.eq_ignore_ascii_case("user") {
Some(SkillScope::User)
} else if normalized.eq_ignore_ascii_case("system") {
Some(SkillScope::System)
} else if normalized.eq_ignore_ascii_case("admin") {
Some(SkillScope::Admin)
} else {
warn!(
"{CODEX_EXEC_SERVER_SKILLS_BUNDLE_SCOPE}={raw} is invalid; defaulting staged skills to user scope"
);
None
}
}
pub(crate) async fn resolve_skill_dependencies_for_turn(

View File

@@ -860,6 +860,7 @@ fn thread_start_params_from_config(config: &Config) -> ThreadStartParams {
approvals_reviewer: approvals_reviewer_override_from_config(config),
sandbox: sandbox_mode_from_policy(config.permissions.sandbox_policy.get()),
config: config_request_overrides_from_config(config),
bundle_startup: thread_bundle_startup_from_config(config),
ephemeral: Some(config.ephemeral),
..ThreadStartParams::default()
}
@@ -886,6 +887,15 @@ fn config_request_overrides_from_config(config: &Config) -> Option<HashMap<Strin
.map(|profile| HashMap::from([("profile".to_string(), Value::String(profile.clone()))]))
}
fn thread_bundle_startup_from_config(
_config: &Config,
) -> Option<codex_app_server_protocol::ThreadBundleStartup> {
// Phase-1 sketch only: once exec-server startup learns bundle selection,
// this helper is the CLI-side seam that can source a per-thread bundle
// payload and forward it through `thread/start`.
None
}
fn approvals_reviewer_override_from_config(
config: &Config,
) -> Option<codex_app_server_protocol::ApprovalsReviewer> {

View File

@@ -85,6 +85,9 @@ pub use responses_api::dynamic_tool_to_responses_api_tool;
pub use responses_api::mcp_tool_to_deferred_responses_api_tool;
pub use responses_api::mcp_tool_to_responses_api_tool;
pub use responses_api::tool_definition_to_responses_api_tool;
pub use tool_config::BuiltinToolCoverage;
pub use tool_config::BuiltinToolName;
pub use tool_config::BuiltinToolSurface;
pub use tool_config::ShellCommandBackendConfig;
pub use tool_config::ToolUserShellType;
pub use tool_config::ToolsConfig;

View File

@@ -37,6 +37,52 @@ pub enum UnifiedExecShellMode {
ZshFork(ZshForkConfig),
}
/// Phase-1 exec-server seam: a small typed summary of where a builtin tool
/// logically executes today. This is descriptive only and does not change tool
/// routing yet.
#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash)]
pub enum BuiltinToolSurface {
RemoteExecution,
LocalExecution,
WorkspaceFileAccess,
UserInteraction,
}
#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash)]
pub enum BuiltinToolName {
ApplyPatch,
ExecCommand,
RequestUserInput,
ShellCommand,
ViewImage,
WriteStdin,
}
impl BuiltinToolName {
pub const fn as_str(self) -> &'static str {
match self {
Self::ApplyPatch => "apply_patch",
Self::ExecCommand => "exec_command",
Self::RequestUserInput => "request_user_input",
Self::ShellCommand => "shell_command",
Self::ViewImage => "view_image",
Self::WriteStdin => "write_stdin",
}
}
}
#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash)]
pub struct BuiltinToolCoverage {
pub tool: BuiltinToolName,
pub surface: BuiltinToolSurface,
}
impl BuiltinToolCoverage {
pub const fn new(tool: BuiltinToolName, surface: BuiltinToolSurface) -> Self {
Self { tool, surface }
}
}
#[derive(Debug, Clone, Eq, PartialEq)]
pub struct ZshForkConfig {
pub shell_zsh_path: AbsolutePathBuf,
@@ -264,6 +310,55 @@ impl ToolsConfig {
self
}
/// Descriptive coverage that future exec-server negotiation can inspect
/// without coupling to the concrete handler map.
pub fn builtin_tool_coverage(&self) -> Vec<BuiltinToolCoverage> {
let mut coverage = Vec::with_capacity(5);
match self.shell_type {
ConfigShellToolType::UnifiedExec => {
coverage.push(BuiltinToolCoverage::new(
BuiltinToolName::ExecCommand,
BuiltinToolSurface::RemoteExecution,
));
coverage.push(BuiltinToolCoverage::new(
BuiltinToolName::WriteStdin,
BuiltinToolSurface::RemoteExecution,
));
}
ConfigShellToolType::ShellCommand => {
coverage.push(BuiltinToolCoverage::new(
BuiltinToolName::ShellCommand,
BuiltinToolSurface::LocalExecution,
));
}
ConfigShellToolType::Default
| ConfigShellToolType::Disabled
| ConfigShellToolType::Local => {}
}
if self.apply_patch_tool_type.is_some() {
coverage.push(BuiltinToolCoverage::new(
BuiltinToolName::ApplyPatch,
BuiltinToolSurface::WorkspaceFileAccess,
));
}
coverage.push(BuiltinToolCoverage::new(
BuiltinToolName::ViewImage,
BuiltinToolSurface::WorkspaceFileAccess,
));
if self.request_user_input {
coverage.push(BuiltinToolCoverage::new(
BuiltinToolName::RequestUserInput,
BuiltinToolSurface::UserInteraction,
));
}
coverage
}
pub fn for_code_mode_nested_tools(&self) -> Self {
let mut nested = self.clone();
nested.code_mode_enabled = false;

View File

@@ -69,6 +69,11 @@ pub fn build_tool_registry_plan(
let mut plan = ToolRegistryPlan::new();
let exec_permission_approvals_enabled = config.exec_permission_approvals_enabled;
// Phase-1 exec-server seam: keep a typed builtin surface summary next to
// tool registration so later hosted/exec-server work can reason about the
// same builtin set without changing handler wiring in this slice.
plan.set_builtin_tool_coverage(config.builtin_tool_coverage());
if config.code_mode_enabled {
let nested_config = config.for_code_mode_nested_tools();
let nested_plan = build_tool_registry_plan(

View File

@@ -1,3 +1,4 @@
use crate::BuiltinToolCoverage;
use crate::ConfiguredToolSpec;
use crate::DiscoverableTool;
use crate::ToolSpec;
@@ -53,6 +54,9 @@ pub struct ToolHandlerSpec {
pub struct ToolRegistryPlan {
pub specs: Vec<ConfiguredToolSpec>,
pub handlers: Vec<ToolHandlerSpec>,
/// Phase-1 exec-server seam: a typed builtin coverage summary that can be
/// inspected alongside the registry plan before transport/runtime changes.
pub builtin_tool_coverage: Vec<BuiltinToolCoverage>,
}
#[derive(Debug, Clone, Copy)]
@@ -80,6 +84,7 @@ impl ToolRegistryPlan {
Self {
specs: Vec::new(),
handlers: Vec::new(),
builtin_tool_coverage: Vec::new(),
}
}
@@ -104,6 +109,13 @@ impl ToolRegistryPlan {
kind,
});
}
pub(crate) fn set_builtin_tool_coverage(
&mut self,
builtin_tool_coverage: Vec<BuiltinToolCoverage>,
) {
self.builtin_tool_coverage = builtin_tool_coverage;
}
}
pub(crate) fn agent_type_description(