diff --git a/codex-rs/app-server-protocol/src/protocol/v2.rs b/codex-rs/app-server-protocol/src/protocol/v2.rs index 82b568515c..266e8f43e4 100644 --- a/codex-rs/app-server-protocol/src/protocol/v2.rs +++ b/codex-rs/app-server-protocol/src/protocol/v2.rs @@ -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, + /// Optional materialization root to use before session startup. + #[ts(optional = nullable)] + pub install_root: Option, + /// How downstream startup code should interpret the bundle input. + #[ts(optional = nullable)] + pub mount_mode: Option, + /// Optional manifest blob to preserve for later startup plumbing. + #[ts(optional = nullable)] + pub manifest: Option, +} + #[derive( Serialize, Deserialize, Debug, Clone, PartialEq, Default, JsonSchema, TS, ExperimentalApi, )] @@ -2556,19 +2585,35 @@ pub struct ThreadStartParams { )] #[ts(optional = nullable)] pub service_tier: Option>, + /// 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, #[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, /// Override where approval requests are routed for review on this thread /// and subsequent turns. #[ts(optional = nullable)] pub approvals_reviewer: Option, + /// 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, + /// 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>, + /// 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, #[ts(optional = nullable)] pub service_name: Option, #[ts(optional = nullable)] @@ -3941,7 +3986,8 @@ pub enum TurnStatus { pub struct TurnStartParams { pub thread_id: String, pub input: Vec, - /// 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, /// 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, - /// 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, /// Override the model for this turn and subsequent turns. diff --git a/codex-rs/app-server/src/codex_message_processor.rs b/codex-rs/app-server/src/codex_message_processor.rs index 6475531c07..869fceed18 100644 --- a/codex-rs/app-server/src/codex_message_processor.rs +++ b/codex-rs/app-server/src/codex_message_processor.rs @@ -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>, @@ -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, + approval_policy: Option, + approvals_reviewer: Option, + sandbox_mode: Option, +} + +impl ExecServerSessionContract { + fn new( + cwd: Option, + approval_policy: Option, + approvals_reviewer: Option, + sandbox: Option, + ) -> 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>, + bundle_startup: Option, + ) -> Option> { + 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, model_provider: Option, service_tier: Option>, - cwd: Option, - approval_policy: Option, - approvals_reviewer: Option, - sandbox: Option, + session_contract: ExecServerSessionContract, base_instructions: Option, developer_instructions: Option, personality: Option, @@ -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, diff --git a/codex-rs/core-skills/src/loader.rs b/codex-rs/core-skills/src/loader.rs index 42de9fb288..dd218e54f2 100644 --- a/codex-rs/core-skills/src/loader.rs +++ b/codex-rs/core-skills/src/loader.rs @@ -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 { + bundles + .iter() + .map(|bundle| SkillRoot { + path: bundle.root.clone(), + scope: bundle.scope, + }) + .collect() +} + pub fn load_skills_from_roots(roots: I) -> SkillLoadOutcome where I: IntoIterator, diff --git a/codex-rs/core-skills/src/manager.rs b/codex-rs/core-skills/src/manager.rs index cd3b427714..2df18f04d7 100644 --- a/codex-rs/core-skills/src/manager.rs +++ b/codex-rs/core-skills/src/manager.rs @@ -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, + pub materialized_skill_bundles: Vec, 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, + ) -> 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 { - 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 { + 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 { 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 { normalized } +fn normalize_materialized_skill_bundles( + materialized_skill_bundles: &[MaterializedSkillBundle], +) -> Vec { + let mut normalized: Vec = 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; diff --git a/codex-rs/core/src/codex.rs b/codex-rs/core/src/codex.rs index 758b592047..ff41c34e59 100644 --- a/codex-rs/core/src/codex.rs +++ b/codex-rs/core/src/codex.rs @@ -896,7 +896,123 @@ pub(crate) struct TurnContext { pub(crate) turn_skills: TurnSkillsContext, pub(crate) turn_timing_state: Arc, } + +#[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, +} + 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 { 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 { + let _prompt_surface_proposal = turn_context.exec_server_prompt_surface_proposal(); let mut developer_sections = Vec::::with_capacity(8); let mut contextual_user_sections = Vec::::with_capacity(2); let shell = self.user_shell(); diff --git a/codex-rs/core/src/context_manager/updates.rs b/codex-rs/core/src/context_manager/updates.rs index e92aad3d77..b62d1ad686 100644 --- a/codex-rs/core/src/context_manager/updates.rs +++ b/codex-rs/core/src/context_manager/updates.rs @@ -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 diff --git a/codex-rs/core/src/external_agent_config.rs b/codex-rs/core/src/external_agent_config.rs index 6011d18ee4..704342f9e9 100644 --- a/codex-rs/core/src/external_agent_config.rs +++ b/codex-rs/core/src/external_agent_config.rs @@ -31,6 +31,22 @@ pub struct ExternalAgentConfigMigrationItem { pub cwd: Option, } +/// 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, + 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 { + 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>, diff --git a/codex-rs/core/src/project_doc.rs b/codex-rs/core/src/project_doc.rs index 1b72f64610..08e8fd5f2d 100644 --- a/codex-rs/core/src/project_doc.rs +++ b/codex-rs/core/src/project_doc.rs @@ -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 = 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 = Vec::new(); let candidate_filenames = candidate_filenames(config); @@ -270,6 +274,53 @@ pub fn discover_project_doc_paths(config: &Config) -> std::io::Result, cwd: &Path) -> Vec { + 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> { + 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()); diff --git a/codex-rs/core/src/skills.rs b/codex-rs/core/src/skills.rs index 8d6886222a..ba8b54a5d2 100644 --- a/codex-rs/core/src/skills.rs +++ b/codex-rs/core/src/skills.rs @@ -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, ) -> 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 { + 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::>()) + .map(|root| loader::MaterializedSkillBundle { root, scope }) + .collect() +} + +fn parse_skill_scope(raw: &str) -> Option { + 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( diff --git a/codex-rs/exec/src/lib.rs b/codex-rs/exec/src/lib.rs index b7779e0c07..7a98eff60b 100644 --- a/codex-rs/exec/src/lib.rs +++ b/codex-rs/exec/src/lib.rs @@ -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 Option { + // 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 { diff --git a/codex-rs/tools/src/lib.rs b/codex-rs/tools/src/lib.rs index 16e84700bb..fbec275bf4 100644 --- a/codex-rs/tools/src/lib.rs +++ b/codex-rs/tools/src/lib.rs @@ -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; diff --git a/codex-rs/tools/src/tool_config.rs b/codex-rs/tools/src/tool_config.rs index f48f1b8fcc..ecf744c37b 100644 --- a/codex-rs/tools/src/tool_config.rs +++ b/codex-rs/tools/src/tool_config.rs @@ -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 { + 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; diff --git a/codex-rs/tools/src/tool_registry_plan.rs b/codex-rs/tools/src/tool_registry_plan.rs index cc706f24fc..67d47f244c 100644 --- a/codex-rs/tools/src/tool_registry_plan.rs +++ b/codex-rs/tools/src/tool_registry_plan.rs @@ -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( diff --git a/codex-rs/tools/src/tool_registry_plan_types.rs b/codex-rs/tools/src/tool_registry_plan_types.rs index d15cf15d5a..ce2ba7f0f4 100644 --- a/codex-rs/tools/src/tool_registry_plan_types.rs +++ b/codex-rs/tools/src/tool_registry_plan_types.rs @@ -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, pub handlers: Vec, + /// 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, } #[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, + ) { + self.builtin_tool_coverage = builtin_tool_coverage; + } } pub(crate) fn agent_type_description(