mirror of
https://github.com/openai/codex.git
synced 2026-04-18 11:44:46 +00:00
Compare commits
1 Commits
pr18028
...
exec-serve
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e4cc738a5c |
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user