mirror of
https://github.com/openai/codex.git
synced 2026-11-08 20:40:33 +00:00
Merge origin/main into codex/workspace-owner-usage-nudge
Resolve the TUI and config schema conflicts between the workspace owner usage nudge work and the latest main changes without rewriting branch history. Made-with: Cursor
This commit is contained in:
@@ -53,6 +53,7 @@ codex_rust_crate(
|
||||
"//codex-rs/linux-sandbox:codex-linux-sandbox",
|
||||
"//codex-rs/rmcp-client:test_stdio_server",
|
||||
"//codex-rs/rmcp-client:test_streamable_http_server",
|
||||
"//codex-rs/responses-api-proxy:codex-responses-api-proxy",
|
||||
"//codex-rs/cli:codex",
|
||||
],
|
||||
)
|
||||
|
||||
@@ -33,6 +33,7 @@ codex-async-utils = { workspace = true }
|
||||
codex-code-mode = { workspace = true }
|
||||
codex-connectors = { workspace = true }
|
||||
codex-config = { workspace = true }
|
||||
codex-core-plugins = { workspace = true }
|
||||
codex-core-skills = { workspace = true }
|
||||
codex-exec-server = { workspace = true }
|
||||
codex-features = { workspace = true }
|
||||
@@ -41,6 +42,7 @@ codex-login = { workspace = true }
|
||||
codex-mcp = { workspace = true }
|
||||
codex-model-provider-info = { workspace = true }
|
||||
codex-models-manager = { workspace = true }
|
||||
ed25519-dalek = { workspace = true }
|
||||
codex-shell-command = { workspace = true }
|
||||
codex-execpolicy = { workspace = true }
|
||||
codex-git-utils = { workspace = true }
|
||||
@@ -56,6 +58,7 @@ codex-rmcp-client = { workspace = true }
|
||||
codex-sandboxing = { workspace = true }
|
||||
codex-state = { workspace = true }
|
||||
codex-terminal-detection = { workspace = true }
|
||||
codex-thread-store = { workspace = true }
|
||||
codex-tools = { workspace = true }
|
||||
codex-utils-absolute-path = { workspace = true }
|
||||
codex-utils-cache = { workspace = true }
|
||||
@@ -143,6 +146,7 @@ codex-shell-escalation = { workspace = true }
|
||||
[dev-dependencies]
|
||||
assert_cmd = { workspace = true }
|
||||
assert_matches = { workspace = true }
|
||||
codex-arg0 = { workspace = true }
|
||||
codex-otel = { workspace = true }
|
||||
codex-test-binary-support = { workspace = true }
|
||||
codex-utils-cargo-bin = { workspace = true }
|
||||
|
||||
@@ -500,6 +500,9 @@
|
||||
"tui_app_server": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"unavailable_dummy_tools": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"undo": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -524,6 +527,9 @@
|
||||
"web_search_request": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"workspace_dependencies": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"workspace_owner_usage_nudge": {
|
||||
"type": "boolean"
|
||||
}
|
||||
@@ -776,6 +782,11 @@
|
||||
"MarketplaceConfig": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"last_revision": {
|
||||
"default": null,
|
||||
"description": "Git revision Codex last successfully activated for this marketplace.",
|
||||
"type": "string"
|
||||
},
|
||||
"last_updated": {
|
||||
"default": null,
|
||||
"description": "Last time Codex successfully added or refreshed this marketplace.",
|
||||
@@ -852,7 +863,8 @@
|
||||
"max_raw_memories_for_consolidation": {
|
||||
"description": "Maximum number of recent raw memories retained for global consolidation.",
|
||||
"format": "uint",
|
||||
"minimum": 0.0,
|
||||
"maximum": 4096.0,
|
||||
"minimum": 1.0,
|
||||
"type": "integer"
|
||||
},
|
||||
"max_rollout_age_days": {
|
||||
@@ -863,7 +875,8 @@
|
||||
"max_rollouts_per_startup": {
|
||||
"description": "Maximum number of rollout candidates processed per pass.",
|
||||
"format": "uint",
|
||||
"minimum": 0.0,
|
||||
"maximum": 128.0,
|
||||
"minimum": 1.0,
|
||||
"type": "integer"
|
||||
},
|
||||
"max_unused_days": {
|
||||
@@ -1453,6 +1466,14 @@
|
||||
"default": null,
|
||||
"type": "string"
|
||||
},
|
||||
"default_tools_approval_mode": {
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/AppToolApproval"
|
||||
}
|
||||
],
|
||||
"default": null
|
||||
},
|
||||
"disabled_tools": {
|
||||
"default": null,
|
||||
"items": {
|
||||
@@ -1492,6 +1513,10 @@
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"experimental_environment": {
|
||||
"default": null,
|
||||
"type": "string"
|
||||
},
|
||||
"http_headers": {
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
@@ -2360,6 +2385,9 @@
|
||||
"tui_app_server": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"unavailable_dummy_tools": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"undo": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -2384,6 +2412,9 @@
|
||||
"web_search_request": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"workspace_dependencies": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"workspace_owner_usage_nudge": {
|
||||
"type": "boolean"
|
||||
}
|
||||
|
||||
@@ -7,7 +7,6 @@ use crate::config::Config;
|
||||
use crate::config::ConfigBuilder;
|
||||
use crate::contextual_user_message::SUBAGENT_NOTIFICATION_OPEN_TAG;
|
||||
use assert_matches::assert_matches;
|
||||
use chrono::Utc;
|
||||
use codex_features::Feature;
|
||||
use codex_login::CodexAuth;
|
||||
use codex_protocol::AgentPath;
|
||||
@@ -24,6 +23,9 @@ use codex_protocol::protocol::TurnAbortReason;
|
||||
use codex_protocol::protocol::TurnAbortedEvent;
|
||||
use codex_protocol::protocol::TurnCompleteEvent;
|
||||
use codex_protocol::protocol::TurnStartedEvent;
|
||||
use codex_thread_store::ArchiveThreadParams;
|
||||
use codex_thread_store::LocalThreadStore;
|
||||
use codex_thread_store::ThreadStore;
|
||||
use pretty_assertions::assert_eq;
|
||||
use tempfile::TempDir;
|
||||
use tokio::time::Duration;
|
||||
@@ -186,7 +188,9 @@ async fn wait_for_subagent_notification(parent_thread: &Arc<CodexThread>) -> boo
|
||||
sleep(Duration::from_millis(25)).await;
|
||||
}
|
||||
};
|
||||
timeout(Duration::from_secs(2), wait).await.is_ok()
|
||||
// CI can take several seconds to schedule the detached completion watcher,
|
||||
// especially on slower Windows runners.
|
||||
timeout(Duration::from_secs(10), wait).await.is_ok()
|
||||
}
|
||||
|
||||
async fn persist_thread_for_tree_resume(thread: &Arc<CodexThread>, message: &str) {
|
||||
@@ -1658,38 +1662,18 @@ async fn resume_agent_from_rollout_reads_archived_rollout_path() {
|
||||
.await
|
||||
.expect("child thread should exist");
|
||||
persist_thread_for_tree_resume(&child_thread, "persist before archiving").await;
|
||||
let rollout_path = child_thread
|
||||
.rollout_path()
|
||||
.expect("thread should have rollout path");
|
||||
let state_db = child_thread
|
||||
.state_db()
|
||||
.expect("thread should have state db handle");
|
||||
|
||||
let _ = harness
|
||||
.control
|
||||
.shutdown_live_agent(child_thread_id)
|
||||
.await
|
||||
.expect("child shutdown should succeed");
|
||||
|
||||
let archived_root = harness
|
||||
.config
|
||||
.codex_home
|
||||
.join(crate::ARCHIVED_SESSIONS_SUBDIR);
|
||||
tokio::fs::create_dir_all(&archived_root)
|
||||
let store = LocalThreadStore::new(codex_rollout::RolloutConfig::from_view(&harness.config));
|
||||
store
|
||||
.archive_thread(ArchiveThreadParams {
|
||||
thread_id: child_thread_id,
|
||||
})
|
||||
.await
|
||||
.expect("archived root should exist");
|
||||
let archived_rollout_path = archived_root.join(
|
||||
rollout_path
|
||||
.file_name()
|
||||
.expect("rollout file name should be present"),
|
||||
);
|
||||
tokio::fs::rename(&rollout_path, &archived_rollout_path)
|
||||
.await
|
||||
.expect("rollout should move to archived path");
|
||||
state_db
|
||||
.mark_archived(child_thread_id, archived_rollout_path.as_path(), Utc::now())
|
||||
.await
|
||||
.expect("state db archive update should succeed");
|
||||
.expect("child thread should archive");
|
||||
|
||||
let resumed_thread_id = harness
|
||||
.control
|
||||
|
||||
@@ -78,7 +78,8 @@ async fn apply_role_to_config_inner(
|
||||
role_layer_toml,
|
||||
preserve_current_profile,
|
||||
preserve_current_provider,
|
||||
)?;
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -150,7 +151,7 @@ fn preservation_policy(config: &Config, role_layer_toml: &TomlValue) -> (bool, b
|
||||
mod reload {
|
||||
use super::*;
|
||||
|
||||
pub(super) fn build_next_config(
|
||||
pub(super) async fn build_next_config(
|
||||
config: &Config,
|
||||
role_layer_toml: TomlValue,
|
||||
preserve_current_profile: bool,
|
||||
@@ -171,7 +172,8 @@ mod reload {
|
||||
reload_overrides(config, preserve_current_provider),
|
||||
config.codex_home.clone(),
|
||||
config_layer_stack,
|
||||
)?;
|
||||
)
|
||||
.await?;
|
||||
if preserve_current_profile {
|
||||
next_config.active_profile = config.active_profile.clone();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,767 @@
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Context;
|
||||
use anyhow::Result;
|
||||
use base64::Engine as _;
|
||||
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
|
||||
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||
use chrono::SecondsFormat;
|
||||
use chrono::Utc;
|
||||
use codex_features::Feature;
|
||||
use codex_login::AgentIdentityAuthRecord;
|
||||
use codex_login::AuthManager;
|
||||
use codex_login::CodexAuth;
|
||||
use codex_login::default_client::create_client;
|
||||
use codex_protocol::protocol::SessionSource;
|
||||
use ed25519_dalek::SigningKey;
|
||||
use ed25519_dalek::VerifyingKey;
|
||||
use ed25519_dalek::pkcs8::DecodePrivateKey;
|
||||
use ed25519_dalek::pkcs8::EncodePrivateKey;
|
||||
use rand::TryRngCore;
|
||||
use rand::rngs::OsRng;
|
||||
use serde::Deserialize;
|
||||
use serde::Serialize;
|
||||
use tokio::sync::Mutex;
|
||||
use tracing::debug;
|
||||
use tracing::info;
|
||||
use tracing::warn;
|
||||
|
||||
use crate::config::Config;
|
||||
|
||||
const AGENT_REGISTRATION_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
const AGENT_IDENTITY_BISCUIT_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct AgentIdentityManager {
|
||||
auth_manager: Arc<AuthManager>,
|
||||
chatgpt_base_url: String,
|
||||
feature_enabled: bool,
|
||||
abom: AgentBillOfMaterials,
|
||||
ensure_lock: Arc<Mutex<()>>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for AgentIdentityManager {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("AgentIdentityManager")
|
||||
.field("chatgpt_base_url", &self.chatgpt_base_url)
|
||||
.field("feature_enabled", &self.feature_enabled)
|
||||
.field("abom", &self.abom)
|
||||
.finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub(crate) struct StoredAgentIdentity {
|
||||
pub(crate) binding_id: String,
|
||||
pub(crate) chatgpt_account_id: String,
|
||||
pub(crate) chatgpt_user_id: Option<String>,
|
||||
pub(crate) agent_runtime_id: String,
|
||||
pub(crate) private_key_pkcs8_base64: String,
|
||||
pub(crate) public_key_ssh: String,
|
||||
pub(crate) registered_at: String,
|
||||
pub(crate) abom: AgentBillOfMaterials,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub(crate) struct AgentBillOfMaterials {
|
||||
pub(crate) agent_version: String,
|
||||
pub(crate) agent_harness_id: String,
|
||||
pub(crate) running_location: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
struct RegisterAgentRequest {
|
||||
abom: AgentBillOfMaterials,
|
||||
agent_public_key: String,
|
||||
capabilities: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct RegisterAgentResponse {
|
||||
agent_runtime_id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct AgentIdentityBinding {
|
||||
binding_id: String,
|
||||
chatgpt_account_id: String,
|
||||
chatgpt_user_id: Option<String>,
|
||||
access_token: String,
|
||||
}
|
||||
|
||||
struct GeneratedAgentKeyMaterial {
|
||||
private_key_pkcs8_base64: String,
|
||||
public_key_ssh: String,
|
||||
}
|
||||
|
||||
impl AgentIdentityManager {
|
||||
pub(crate) fn new(
|
||||
config: &Config,
|
||||
auth_manager: Arc<AuthManager>,
|
||||
session_source: SessionSource,
|
||||
) -> Self {
|
||||
Self {
|
||||
auth_manager,
|
||||
chatgpt_base_url: config.chatgpt_base_url.clone(),
|
||||
feature_enabled: config.features.enabled(Feature::UseAgentIdentity),
|
||||
abom: build_abom(session_source),
|
||||
ensure_lock: Arc::new(Mutex::new(())),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn is_enabled(&self) -> bool {
|
||||
self.feature_enabled
|
||||
}
|
||||
|
||||
pub(crate) async fn ensure_registered_identity(&self) -> Result<Option<StoredAgentIdentity>> {
|
||||
if !self.feature_enabled {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let Some(auth) = self.auth_manager.auth().await else {
|
||||
debug!("skipping agent identity registration because no auth is available");
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let Some(binding) =
|
||||
AgentIdentityBinding::from_auth(&auth, self.auth_manager.forced_chatgpt_workspace_id())
|
||||
else {
|
||||
debug!("skipping agent identity registration because ChatGPT auth is unavailable");
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let _guard = self.ensure_lock.lock().await;
|
||||
|
||||
if let Some(stored_identity) = self.load_stored_identity(&auth, &binding)? {
|
||||
info!(
|
||||
agent_runtime_id = %stored_identity.agent_runtime_id,
|
||||
binding_id = %binding.binding_id,
|
||||
"reusing stored agent identity"
|
||||
);
|
||||
return Ok(Some(stored_identity));
|
||||
}
|
||||
|
||||
let stored_identity = self.register_agent_identity(&binding).await?;
|
||||
self.store_identity(&auth, &stored_identity)?;
|
||||
Ok(Some(stored_identity))
|
||||
}
|
||||
|
||||
async fn register_agent_identity(
|
||||
&self,
|
||||
binding: &AgentIdentityBinding,
|
||||
) -> Result<StoredAgentIdentity> {
|
||||
let key_material = generate_agent_key_material()?;
|
||||
let request_body = RegisterAgentRequest {
|
||||
abom: self.abom.clone(),
|
||||
agent_public_key: key_material.public_key_ssh.clone(),
|
||||
capabilities: Vec::new(),
|
||||
};
|
||||
|
||||
let url = agent_registration_url(&self.chatgpt_base_url);
|
||||
let human_biscuit = self.mint_human_biscuit(binding, "POST", &url).await?;
|
||||
let client = create_client();
|
||||
let response = client
|
||||
.post(&url)
|
||||
.header("X-OpenAI-Authorization", human_biscuit)
|
||||
.json(&request_body)
|
||||
.timeout(AGENT_REGISTRATION_TIMEOUT)
|
||||
.send()
|
||||
.await
|
||||
.with_context(|| {
|
||||
format!("failed to send agent identity registration request to {url}")
|
||||
})?;
|
||||
|
||||
if response.status().is_success() {
|
||||
let response_body = response
|
||||
.json::<RegisterAgentResponse>()
|
||||
.await
|
||||
.with_context(|| format!("failed to parse agent identity response from {url}"))?;
|
||||
let stored_identity = StoredAgentIdentity {
|
||||
binding_id: binding.binding_id.clone(),
|
||||
chatgpt_account_id: binding.chatgpt_account_id.clone(),
|
||||
chatgpt_user_id: binding.chatgpt_user_id.clone(),
|
||||
agent_runtime_id: response_body.agent_runtime_id,
|
||||
private_key_pkcs8_base64: key_material.private_key_pkcs8_base64,
|
||||
public_key_ssh: key_material.public_key_ssh,
|
||||
registered_at: Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true),
|
||||
abom: self.abom.clone(),
|
||||
};
|
||||
info!(
|
||||
agent_runtime_id = %stored_identity.agent_runtime_id,
|
||||
binding_id = %binding.binding_id,
|
||||
"registered agent identity"
|
||||
);
|
||||
return Ok(stored_identity);
|
||||
}
|
||||
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
anyhow::bail!("agent identity registration failed with status {status} from {url}: {body}")
|
||||
}
|
||||
|
||||
async fn mint_human_biscuit(
|
||||
&self,
|
||||
binding: &AgentIdentityBinding,
|
||||
target_method: &str,
|
||||
target_url: &str,
|
||||
) -> Result<String> {
|
||||
let url = agent_identity_biscuit_url(&self.chatgpt_base_url);
|
||||
let request_id = agent_identity_request_id()?;
|
||||
let client = create_client();
|
||||
let response = client
|
||||
.get(&url)
|
||||
.bearer_auth(&binding.access_token)
|
||||
.header("X-Request-Id", request_id.clone())
|
||||
.header("X-Original-Method", target_method)
|
||||
.header("X-Original-Url", target_url)
|
||||
.timeout(AGENT_IDENTITY_BISCUIT_TIMEOUT)
|
||||
.send()
|
||||
.await
|
||||
.with_context(|| format!("failed to send agent identity biscuit request to {url}"))?;
|
||||
|
||||
if response.status().is_success() {
|
||||
let human_biscuit = response
|
||||
.headers()
|
||||
.get("x-openai-authorization")
|
||||
.context("agent identity biscuit response did not include x-openai-authorization")?
|
||||
.to_str()
|
||||
.context("agent identity biscuit response header was not valid UTF-8")?
|
||||
.to_string();
|
||||
info!(
|
||||
request_id = %request_id,
|
||||
"minted human biscuit for agent identity registration"
|
||||
);
|
||||
return Ok(human_biscuit);
|
||||
}
|
||||
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
anyhow::bail!(
|
||||
"agent identity biscuit minting failed with status {status} from {url}: {body}"
|
||||
)
|
||||
}
|
||||
|
||||
fn load_stored_identity(
|
||||
&self,
|
||||
auth: &CodexAuth,
|
||||
binding: &AgentIdentityBinding,
|
||||
) -> Result<Option<StoredAgentIdentity>> {
|
||||
let Some(record) = auth.get_agent_identity(&binding.chatgpt_account_id) else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
let stored_identity =
|
||||
match StoredAgentIdentity::from_auth_record(binding, record, self.abom.clone()) {
|
||||
Ok(stored_identity) => stored_identity,
|
||||
Err(error) => {
|
||||
warn!(
|
||||
binding_id = %binding.binding_id,
|
||||
error = %error,
|
||||
"stored agent identity is invalid; deleting cached value"
|
||||
);
|
||||
auth.remove_agent_identity()?;
|
||||
return Ok(None);
|
||||
}
|
||||
};
|
||||
|
||||
if !stored_identity.matches_binding(binding) {
|
||||
warn!(
|
||||
binding_id = %binding.binding_id,
|
||||
"stored agent identity binding no longer matches current auth; deleting cached value"
|
||||
);
|
||||
auth.remove_agent_identity()?;
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
if let Err(error) = stored_identity.validate_key_material() {
|
||||
warn!(
|
||||
agent_runtime_id = %stored_identity.agent_runtime_id,
|
||||
binding_id = %binding.binding_id,
|
||||
error = %error,
|
||||
"stored agent identity key material is invalid; deleting cached value"
|
||||
);
|
||||
auth.remove_agent_identity()?;
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
Ok(Some(stored_identity))
|
||||
}
|
||||
|
||||
fn store_identity(
|
||||
&self,
|
||||
auth: &CodexAuth,
|
||||
stored_identity: &StoredAgentIdentity,
|
||||
) -> Result<()> {
|
||||
auth.set_agent_identity(stored_identity.to_auth_record())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
fn new_for_tests(
|
||||
auth_manager: Arc<AuthManager>,
|
||||
feature_enabled: bool,
|
||||
chatgpt_base_url: String,
|
||||
session_source: SessionSource,
|
||||
) -> Self {
|
||||
Self {
|
||||
auth_manager,
|
||||
chatgpt_base_url,
|
||||
feature_enabled,
|
||||
abom: build_abom(session_source),
|
||||
ensure_lock: Arc::new(Mutex::new(())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl StoredAgentIdentity {
|
||||
fn from_auth_record(
|
||||
binding: &AgentIdentityBinding,
|
||||
record: AgentIdentityAuthRecord,
|
||||
abom: AgentBillOfMaterials,
|
||||
) -> Result<Self> {
|
||||
if record.workspace_id != binding.chatgpt_account_id {
|
||||
anyhow::bail!(
|
||||
"stored agent identity workspace {:?} does not match current workspace {:?}",
|
||||
record.workspace_id,
|
||||
binding.chatgpt_account_id
|
||||
);
|
||||
}
|
||||
let signing_key = signing_key_from_private_key_pkcs8_base64(&record.agent_private_key)?;
|
||||
Ok(Self {
|
||||
binding_id: binding.binding_id.clone(),
|
||||
chatgpt_account_id: binding.chatgpt_account_id.clone(),
|
||||
chatgpt_user_id: record.chatgpt_user_id,
|
||||
agent_runtime_id: record.agent_runtime_id,
|
||||
private_key_pkcs8_base64: record.agent_private_key,
|
||||
public_key_ssh: encode_ssh_ed25519_public_key(&signing_key.verifying_key()),
|
||||
registered_at: record.registered_at,
|
||||
abom,
|
||||
})
|
||||
}
|
||||
|
||||
fn to_auth_record(&self) -> AgentIdentityAuthRecord {
|
||||
AgentIdentityAuthRecord {
|
||||
workspace_id: self.chatgpt_account_id.clone(),
|
||||
chatgpt_user_id: self.chatgpt_user_id.clone(),
|
||||
agent_runtime_id: self.agent_runtime_id.clone(),
|
||||
agent_private_key: self.private_key_pkcs8_base64.clone(),
|
||||
registered_at: self.registered_at.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn matches_binding(&self, binding: &AgentIdentityBinding) -> bool {
|
||||
self.binding_id == binding.binding_id
|
||||
&& self.chatgpt_account_id == binding.chatgpt_account_id
|
||||
&& match binding.chatgpt_user_id.as_deref() {
|
||||
Some(chatgpt_user_id) => self.chatgpt_user_id.as_deref() == Some(chatgpt_user_id),
|
||||
None => true,
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_key_material(&self) -> Result<()> {
|
||||
let signing_key = self.signing_key()?;
|
||||
let derived_public_key = encode_ssh_ed25519_public_key(&signing_key.verifying_key());
|
||||
anyhow::ensure!(
|
||||
self.public_key_ssh == derived_public_key,
|
||||
"stored public key does not match the private key"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) fn signing_key(&self) -> Result<SigningKey> {
|
||||
signing_key_from_private_key_pkcs8_base64(&self.private_key_pkcs8_base64)
|
||||
}
|
||||
}
|
||||
|
||||
impl AgentIdentityBinding {
|
||||
fn from_auth(auth: &CodexAuth, forced_workspace_id: Option<String>) -> Option<Self> {
|
||||
if !auth.is_chatgpt_auth() {
|
||||
return None;
|
||||
}
|
||||
|
||||
let token_data = auth.get_token_data().ok()?;
|
||||
let resolved_account_id =
|
||||
forced_workspace_id
|
||||
.filter(|value| !value.is_empty())
|
||||
.or(token_data
|
||||
.account_id
|
||||
.clone()
|
||||
.filter(|value| !value.is_empty()))?;
|
||||
|
||||
Some(Self {
|
||||
binding_id: format!("chatgpt-account-{resolved_account_id}"),
|
||||
chatgpt_account_id: resolved_account_id,
|
||||
chatgpt_user_id: token_data
|
||||
.id_token
|
||||
.chatgpt_user_id
|
||||
.filter(|value| !value.is_empty()),
|
||||
access_token: token_data.access_token,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn build_abom(session_source: SessionSource) -> AgentBillOfMaterials {
|
||||
AgentBillOfMaterials {
|
||||
agent_version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
agent_harness_id: match &session_source {
|
||||
SessionSource::VSCode => "codex-app".to_string(),
|
||||
SessionSource::Cli
|
||||
| SessionSource::Exec
|
||||
| SessionSource::Mcp
|
||||
| SessionSource::Custom(_)
|
||||
| SessionSource::SubAgent(_)
|
||||
| SessionSource::Unknown => "codex-cli".to_string(),
|
||||
},
|
||||
running_location: format!("{}-{}", session_source, std::env::consts::OS),
|
||||
}
|
||||
}
|
||||
|
||||
fn generate_agent_key_material() -> Result<GeneratedAgentKeyMaterial> {
|
||||
let mut secret_key_bytes = [0u8; 32];
|
||||
OsRng
|
||||
.try_fill_bytes(&mut secret_key_bytes)
|
||||
.context("failed to generate agent identity private key bytes")?;
|
||||
let signing_key = SigningKey::from_bytes(&secret_key_bytes);
|
||||
let private_key_pkcs8 = signing_key
|
||||
.to_pkcs8_der()
|
||||
.context("failed to encode agent identity private key as PKCS#8")?;
|
||||
|
||||
Ok(GeneratedAgentKeyMaterial {
|
||||
private_key_pkcs8_base64: BASE64_STANDARD.encode(private_key_pkcs8.as_bytes()),
|
||||
public_key_ssh: encode_ssh_ed25519_public_key(&signing_key.verifying_key()),
|
||||
})
|
||||
}
|
||||
|
||||
fn encode_ssh_ed25519_public_key(verifying_key: &VerifyingKey) -> String {
|
||||
let mut blob = Vec::with_capacity(4 + 11 + 4 + 32);
|
||||
append_ssh_string(&mut blob, b"ssh-ed25519");
|
||||
append_ssh_string(&mut blob, verifying_key.as_bytes());
|
||||
format!("ssh-ed25519 {}", BASE64_STANDARD.encode(blob))
|
||||
}
|
||||
|
||||
fn append_ssh_string(buf: &mut Vec<u8>, value: &[u8]) {
|
||||
buf.extend_from_slice(&(value.len() as u32).to_be_bytes());
|
||||
buf.extend_from_slice(value);
|
||||
}
|
||||
|
||||
fn agent_registration_url(chatgpt_base_url: &str) -> String {
|
||||
let trimmed = chatgpt_base_url.trim_end_matches('/');
|
||||
format!("{trimmed}/v1/agent/register")
|
||||
}
|
||||
|
||||
fn signing_key_from_private_key_pkcs8_base64(private_key_pkcs8_base64: &str) -> Result<SigningKey> {
|
||||
let private_key = BASE64_STANDARD
|
||||
.decode(private_key_pkcs8_base64)
|
||||
.context("stored agent identity private key is not valid base64")?;
|
||||
SigningKey::from_pkcs8_der(&private_key)
|
||||
.context("stored agent identity private key is not valid PKCS#8")
|
||||
}
|
||||
|
||||
fn agent_identity_biscuit_url(chatgpt_base_url: &str) -> String {
|
||||
let trimmed = chatgpt_base_url.trim_end_matches('/');
|
||||
format!("{trimmed}/authenticate_app_v2")
|
||||
}
|
||||
|
||||
fn agent_identity_request_id() -> Result<String> {
|
||||
let mut request_id_bytes = [0u8; 16];
|
||||
OsRng
|
||||
.try_fill_bytes(&mut request_id_bytes)
|
||||
.context("failed to generate agent identity request id")?;
|
||||
Ok(format!(
|
||||
"codex-agent-identity-{}",
|
||||
URL_SAFE_NO_PAD.encode(request_id_bytes)
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||
use codex_app_server_protocol::AuthMode as ApiAuthMode;
|
||||
use codex_login::AuthCredentialsStoreMode;
|
||||
use codex_login::AuthDotJson;
|
||||
use codex_login::save_auth;
|
||||
use codex_login::token_data::IdTokenInfo;
|
||||
use codex_login::token_data::TokenData;
|
||||
use pretty_assertions::assert_eq;
|
||||
use wiremock::Mock;
|
||||
use wiremock::MockServer;
|
||||
use wiremock::ResponseTemplate;
|
||||
use wiremock::matchers::header;
|
||||
use wiremock::matchers::method;
|
||||
use wiremock::matchers::path;
|
||||
|
||||
#[tokio::test]
|
||||
async fn ensure_registered_identity_skips_when_feature_is_disabled() {
|
||||
let auth_manager =
|
||||
AuthManager::from_auth_for_testing(make_chatgpt_auth("account-123", Some("user-123")));
|
||||
let manager = AgentIdentityManager::new_for_tests(
|
||||
auth_manager,
|
||||
/*feature_enabled*/ false,
|
||||
"https://chatgpt.com/backend-api/".to_string(),
|
||||
SessionSource::Cli,
|
||||
);
|
||||
|
||||
assert_eq!(manager.ensure_registered_identity().await.unwrap(), None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ensure_registered_identity_skips_for_api_key_auth() {
|
||||
let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("test-key"));
|
||||
let manager = AgentIdentityManager::new_for_tests(
|
||||
auth_manager,
|
||||
/*feature_enabled*/ true,
|
||||
"https://chatgpt.com/backend-api/".to_string(),
|
||||
SessionSource::Cli,
|
||||
);
|
||||
|
||||
assert_eq!(manager.ensure_registered_identity().await.unwrap(), None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ensure_registered_identity_registers_and_reuses_cached_identity() {
|
||||
let server = MockServer::start().await;
|
||||
let chatgpt_base_url = server.uri();
|
||||
mount_human_biscuit(&server, &chatgpt_base_url).await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/v1/agent/register"))
|
||||
.and(header("x-openai-authorization", "human-biscuit"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"agent_runtime_id": "agent_123",
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let auth_manager =
|
||||
AuthManager::from_auth_for_testing(make_chatgpt_auth("account-123", Some("user-123")));
|
||||
let manager = AgentIdentityManager::new_for_tests(
|
||||
auth_manager,
|
||||
/*feature_enabled*/ true,
|
||||
chatgpt_base_url,
|
||||
SessionSource::Cli,
|
||||
);
|
||||
|
||||
let first = manager
|
||||
.ensure_registered_identity()
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("identity should be registered");
|
||||
let second = manager
|
||||
.ensure_registered_identity()
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("identity should be reused");
|
||||
|
||||
assert_eq!(first.agent_runtime_id, "agent_123");
|
||||
assert_eq!(first, second);
|
||||
assert_eq!(first.abom.agent_harness_id, "codex-cli");
|
||||
assert_eq!(first.chatgpt_account_id, "account-123");
|
||||
assert_eq!(first.chatgpt_user_id.as_deref(), Some("user-123"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ensure_registered_identity_deletes_invalid_cached_identity_and_reregisters() {
|
||||
let server = MockServer::start().await;
|
||||
let chatgpt_base_url = server.uri();
|
||||
mount_human_biscuit(&server, &chatgpt_base_url).await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/v1/agent/register"))
|
||||
.and(header("x-openai-authorization", "human-biscuit"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"agent_runtime_id": "agent_456",
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let auth = make_chatgpt_auth("account-123", Some("user-123"));
|
||||
let auth_manager = AuthManager::from_auth_for_testing(auth.clone());
|
||||
let manager = AgentIdentityManager::new_for_tests(
|
||||
auth_manager,
|
||||
/*feature_enabled*/ true,
|
||||
chatgpt_base_url,
|
||||
SessionSource::Cli,
|
||||
);
|
||||
|
||||
let binding =
|
||||
AgentIdentityBinding::from_auth(&auth, /*forced_workspace_id*/ None).expect("binding");
|
||||
auth.set_agent_identity(AgentIdentityAuthRecord {
|
||||
workspace_id: "account-123".to_string(),
|
||||
chatgpt_user_id: Some("user-123".to_string()),
|
||||
agent_runtime_id: "agent_invalid".to_string(),
|
||||
agent_private_key: "not-valid-base64".to_string(),
|
||||
registered_at: "2026-01-01T00:00:00Z".to_string(),
|
||||
})
|
||||
.expect("seed invalid identity");
|
||||
|
||||
let stored = manager
|
||||
.ensure_registered_identity()
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("identity should be registered");
|
||||
|
||||
assert_eq!(stored.agent_runtime_id, "agent_456");
|
||||
let persisted = auth
|
||||
.get_agent_identity(&binding.chatgpt_account_id)
|
||||
.expect("stored identity");
|
||||
assert_eq!(persisted.agent_runtime_id, "agent_456");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ensure_registered_identity_deletes_different_user_identity_and_reregisters() {
|
||||
let server = MockServer::start().await;
|
||||
let chatgpt_base_url = server.uri();
|
||||
mount_human_biscuit(&server, &chatgpt_base_url).await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/v1/agent/register"))
|
||||
.and(header("x-openai-authorization", "human-biscuit"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"agent_runtime_id": "agent_new",
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let auth = make_chatgpt_auth("account-123", Some("user-new"));
|
||||
let stale_key = generate_agent_key_material().expect("key material");
|
||||
auth.set_agent_identity(AgentIdentityAuthRecord {
|
||||
workspace_id: "account-123".to_string(),
|
||||
chatgpt_user_id: Some("user-old".to_string()),
|
||||
agent_runtime_id: "agent_old".to_string(),
|
||||
agent_private_key: stale_key.private_key_pkcs8_base64,
|
||||
registered_at: "2026-01-01T00:00:00Z".to_string(),
|
||||
})
|
||||
.expect("seed stale identity");
|
||||
|
||||
let auth_manager = AuthManager::from_auth_for_testing(auth.clone());
|
||||
let manager = AgentIdentityManager::new_for_tests(
|
||||
auth_manager,
|
||||
/*feature_enabled*/ true,
|
||||
chatgpt_base_url,
|
||||
SessionSource::Cli,
|
||||
);
|
||||
|
||||
let stored = manager
|
||||
.ensure_registered_identity()
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("identity should be registered");
|
||||
|
||||
assert_eq!(stored.agent_runtime_id, "agent_new");
|
||||
assert_eq!(stored.chatgpt_user_id.as_deref(), Some("user-new"));
|
||||
let persisted = auth
|
||||
.get_agent_identity("account-123")
|
||||
.expect("stored identity");
|
||||
assert_eq!(persisted.agent_runtime_id, "agent_new");
|
||||
assert_eq!(persisted.chatgpt_user_id.as_deref(), Some("user-new"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ensure_registered_identity_uses_chatgpt_base_url() {
|
||||
let server = MockServer::start().await;
|
||||
let chatgpt_base_url = format!("{}/backend-api", server.uri());
|
||||
mount_human_biscuit(&server, &chatgpt_base_url).await;
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/backend-api/v1/agent/register"))
|
||||
.and(header("x-openai-authorization", "human-biscuit"))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"agent_runtime_id": "agent_canonical",
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
|
||||
let auth_manager =
|
||||
AuthManager::from_auth_for_testing(make_chatgpt_auth("account-123", Some("user-123")));
|
||||
let manager = AgentIdentityManager::new_for_tests(
|
||||
auth_manager,
|
||||
/*feature_enabled*/ true,
|
||||
chatgpt_base_url,
|
||||
SessionSource::Cli,
|
||||
);
|
||||
|
||||
let stored = manager
|
||||
.ensure_registered_identity()
|
||||
.await
|
||||
.unwrap()
|
||||
.expect("identity should be registered");
|
||||
assert_eq!(stored.agent_runtime_id, "agent_canonical");
|
||||
}
|
||||
|
||||
async fn mount_human_biscuit(server: &MockServer, chatgpt_base_url: &str) {
|
||||
let biscuit_url = agent_identity_biscuit_url(chatgpt_base_url);
|
||||
let biscuit_path = reqwest::Url::parse(&biscuit_url)
|
||||
.expect("biscuit URL parses")
|
||||
.path()
|
||||
.to_string();
|
||||
let target_url = agent_registration_url(chatgpt_base_url);
|
||||
Mock::given(method("GET"))
|
||||
.and(path(biscuit_path))
|
||||
.and(header("authorization", "Bearer access-token-account-123"))
|
||||
.and(header("x-original-method", "POST"))
|
||||
.and(header("x-original-url", target_url))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(200).insert_header("x-openai-authorization", "human-biscuit"),
|
||||
)
|
||||
.expect(1)
|
||||
.mount(server)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn encode_ssh_ed25519_public_key_matches_expected_wire_shape() {
|
||||
let key_material = generate_agent_key_material().expect("key material");
|
||||
let (_, encoded_blob) = key_material
|
||||
.public_key_ssh
|
||||
.split_once(' ')
|
||||
.expect("public key contains scheme");
|
||||
let decoded = BASE64_STANDARD.decode(encoded_blob).expect("base64");
|
||||
|
||||
assert_eq!(&decoded[..4], 11u32.to_be_bytes().as_slice());
|
||||
assert_eq!(&decoded[4..15], b"ssh-ed25519");
|
||||
assert_eq!(&decoded[15..19], 32u32.to_be_bytes().as_slice());
|
||||
assert_eq!(decoded.len(), 51);
|
||||
}
|
||||
|
||||
fn make_chatgpt_auth(account_id: &str, user_id: Option<&str>) -> CodexAuth {
|
||||
let tempdir = tempfile::tempdir().expect("tempdir");
|
||||
let auth_json = AuthDotJson {
|
||||
auth_mode: Some(ApiAuthMode::Chatgpt),
|
||||
openai_api_key: None,
|
||||
tokens: Some(TokenData {
|
||||
id_token: IdTokenInfo {
|
||||
email: None,
|
||||
chatgpt_plan_type: None,
|
||||
chatgpt_user_id: user_id.map(ToOwned::to_owned),
|
||||
chatgpt_account_id: Some(account_id.to_string()),
|
||||
chatgpt_account_is_fedramp: false,
|
||||
raw_jwt: fake_id_token(account_id, user_id),
|
||||
},
|
||||
access_token: format!("access-token-{account_id}"),
|
||||
refresh_token: "refresh-token".to_string(),
|
||||
account_id: Some(account_id.to_string()),
|
||||
}),
|
||||
last_refresh: Some(Utc::now()),
|
||||
agent_identity: None,
|
||||
};
|
||||
save_auth(tempdir.path(), &auth_json, AuthCredentialsStoreMode::File).expect("save auth");
|
||||
CodexAuth::from_auth_storage(tempdir.path(), AuthCredentialsStoreMode::File)
|
||||
.expect("load auth")
|
||||
.expect("auth")
|
||||
}
|
||||
|
||||
fn fake_id_token(account_id: &str, user_id: Option<&str>) -> String {
|
||||
let header = URL_SAFE_NO_PAD.encode(r#"{"alg":"none","typ":"JWT"}"#);
|
||||
let payload = serde_json::json!({
|
||||
"https://api.openai.com/auth": {
|
||||
"chatgpt_user_id": user_id,
|
||||
"chatgpt_account_id": account_id,
|
||||
}
|
||||
});
|
||||
let payload = URL_SAFE_NO_PAD.encode(payload.to_string());
|
||||
format!("{header}.{payload}.signature")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
//! AGENTS.md discovery and user instruction assembly.
|
||||
//!
|
||||
//! Project-level documentation is primarily stored in files named `AGENTS.md`.
|
||||
//! Additional fallback filenames can be configured via `project_doc_fallback_filenames`.
|
||||
//! We include the concatenation of all files found along the path from the
|
||||
//! project root to the current working directory as follows:
|
||||
//!
|
||||
//! 1. Determine the project root by walking upwards from the current working
|
||||
//! directory until a configured `project_root_markers` entry is found.
|
||||
//! When `project_root_markers` is unset, the default marker list is used
|
||||
//! (`.git`). If no marker is found, only the current working directory is
|
||||
//! considered. An empty marker list disables parent traversal.
|
||||
//! 2. Collect every `AGENTS.md` found from the project root down to the
|
||||
//! current working directory (inclusive) and concatenate their contents in
|
||||
//! that order.
|
||||
//! 3. We do **not** walk past the project root.
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::config_loader::ConfigLayerStackOrdering;
|
||||
use crate::config_loader::default_project_root_markers;
|
||||
use crate::config_loader::merge_toml_values;
|
||||
use crate::config_loader::project_root_markers_from_config;
|
||||
use codex_app_server_protocol::ConfigLayerSource;
|
||||
use codex_exec_server::Environment;
|
||||
use codex_exec_server::ExecutorFileSystem;
|
||||
use codex_features::Feature;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use dunce::canonicalize as normalize_path;
|
||||
use std::io;
|
||||
use toml::Value as TomlValue;
|
||||
use tracing::error;
|
||||
|
||||
pub(crate) const HIERARCHICAL_AGENTS_MESSAGE: &str =
|
||||
include_str!("../hierarchical_agents_message.md");
|
||||
|
||||
/// Default filename scanned for AGENTS.md instructions.
|
||||
pub const DEFAULT_AGENTS_MD_FILENAME: &str = "AGENTS.md";
|
||||
/// Preferred local override for AGENTS.md instructions.
|
||||
pub const LOCAL_AGENTS_MD_FILENAME: &str = "AGENTS.override.md";
|
||||
|
||||
/// When both `Config::instructions` and AGENTS.md docs are present, they will
|
||||
/// be concatenated with the following separator.
|
||||
const AGENTS_MD_SEPARATOR: &str = "\n\n--- project-doc ---\n\n";
|
||||
|
||||
fn render_js_repl_instructions(config: &Config) -> Option<String> {
|
||||
if !config.features.enabled(Feature::JsRepl) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut section = String::from("## JavaScript REPL (Node)\n");
|
||||
section.push_str(
|
||||
"- Use `js_repl` for Node-backed JavaScript with top-level await in a persistent kernel.\n",
|
||||
);
|
||||
section.push_str("- `js_repl` is a freeform/custom tool. Direct `js_repl` calls must send raw JavaScript tool input (optionally with first-line `// codex-js-repl: timeout_ms=15000`). Do not wrap code in JSON (for example `{\"code\":\"...\"}`), quotes, or markdown code fences.\n");
|
||||
section.push_str(
|
||||
"- Helpers: `codex.cwd`, `codex.homeDir`, `codex.tmpDir`, `codex.tool(name, args?)`, and `codex.emitImage(imageLike)`.\n",
|
||||
);
|
||||
section.push_str("- `codex.tool` executes a normal tool call and resolves to the raw tool output object. Use it for shell and non-shell tools alike. Nested tool outputs stay inside JavaScript unless you emit them explicitly.\n");
|
||||
section.push_str("- `codex.emitImage(...)` adds one image to the outer `js_repl` function output each time you call it, so you can call it multiple times to emit multiple images. It accepts a data URL, a single `input_image` item, an object like `{ bytes, mimeType }`, or a raw tool response object with exactly one image and no text. It rejects mixed text-and-image content.\n");
|
||||
section.push_str("- `codex.tool(...)` and `codex.emitImage(...)` keep stable helper identities across cells. Saved references and persisted objects can reuse them in later cells, but async callbacks that fire after a cell finishes still fail because no exec is active.\n");
|
||||
section.push_str("- Request full-resolution image processing with `detail: \"original\"` only when the `view_image` tool schema includes a `detail` argument. The same availability applies to `codex.emitImage(...)`: if `view_image.detail` is present, you may also pass `detail: \"original\"` there. Use this when high-fidelity image perception or precise localization is needed, especially for CUA agents.\n");
|
||||
section.push_str("- Raw MCP image blocks can request the same behavior by returning `_meta: { \"codex/imageDetail\": \"original\" }` on the image content item.\n");
|
||||
section.push_str("- Example of sharing an in-memory Playwright screenshot: `await codex.emitImage({ bytes: await page.screenshot({ type: \"jpeg\", quality: 85 }), mimeType: \"image/jpeg\", detail: \"original\" })`.\n");
|
||||
section.push_str("- Example of sharing a local image tool result: `await codex.emitImage(codex.tool(\"view_image\", { path: \"/absolute/path\", detail: \"original\" }))`.\n");
|
||||
section.push_str("- When encoding an image to send with `codex.emitImage(...)` or `view_image`, prefer JPEG at about 85 quality when lossy compression is acceptable; use PNG when transparency or lossless detail matters. Smaller uploads are faster and less likely to hit size limits.\n");
|
||||
section.push_str("- Top-level bindings persist across cells. If a cell throws, prior bindings remain available and bindings that finished initializing before the throw often remain usable in later cells. For code you plan to reuse across cells, prefer declaring or assigning it in direct top-level statements before operations that might throw. If you hit `SyntaxError: Identifier 'x' has already been declared`, first reuse the existing binding, reassign a previously declared `let`, or pick a new descriptive name. Use `{ ... }` only for a short temporary block when you specifically need local scratch names; do not wrap an entire cell in block scope if you want those names reusable later. Reset the kernel with `js_repl_reset` only when you need a clean state.\n");
|
||||
section.push_str("- Top-level static import declarations (for example `import x from \"./file.js\"`) are currently unsupported in `js_repl`; use dynamic imports with `await import(\"pkg\")`, `await import(\"./file.js\")`, or `await import(\"/abs/path/file.mjs\")` instead. Imported local files must be ESM `.js`/`.mjs` files and run in the same REPL VM context. Bare package imports always resolve from REPL-global search roots (`CODEX_JS_REPL_NODE_MODULE_DIRS`, then cwd), not relative to the imported file location. Local files may statically import only other local relative/absolute/`file://` `.js`/`.mjs` files; package and builtin imports from local files must stay dynamic. `import.meta.resolve()` returns importable strings such as `file://...`, bare package names, and `node:...` specifiers. Local file modules reload between execs, while top-level bindings persist until `js_repl_reset`.\n");
|
||||
|
||||
if config.features.enabled(Feature::JsReplToolsOnly) {
|
||||
section.push_str("- Do not call tools directly; use `js_repl` + `codex.tool(...)` for all tool calls, including shell commands.\n");
|
||||
section
|
||||
.push_str("- MCP tools (if any) can also be called by name via `codex.tool(...)`.\n");
|
||||
}
|
||||
|
||||
section.push_str("- Avoid direct access to `process.stdout` / `process.stderr` / `process.stdin`; it can corrupt the JSON line protocol. Use `console.log`, `codex.tool(...)`, and `codex.emitImage(...)`.");
|
||||
|
||||
Some(section)
|
||||
}
|
||||
|
||||
/// Resolves AGENTS.md files into model-visible user instructions and source
|
||||
/// paths.
|
||||
pub struct AgentsMdManager<'a> {
|
||||
config: &'a Config,
|
||||
}
|
||||
|
||||
pub(crate) struct LoadedAgentsMd {
|
||||
pub(crate) contents: String,
|
||||
pub(crate) path: AbsolutePathBuf,
|
||||
}
|
||||
|
||||
impl<'a> AgentsMdManager<'a> {
|
||||
pub fn new(config: &'a Config) -> Self {
|
||||
Self { config }
|
||||
}
|
||||
|
||||
pub(crate) fn load_global_instructions(
|
||||
codex_dir: Option<&AbsolutePathBuf>,
|
||||
) -> Option<LoadedAgentsMd> {
|
||||
let base = codex_dir?;
|
||||
for candidate in [LOCAL_AGENTS_MD_FILENAME, DEFAULT_AGENTS_MD_FILENAME] {
|
||||
let path = base.join(candidate);
|
||||
if let Ok(contents) = std::fs::read_to_string(&path) {
|
||||
let trimmed = contents.trim();
|
||||
if !trimmed.is_empty() {
|
||||
return Some(LoadedAgentsMd {
|
||||
contents: trimmed.to_string(),
|
||||
path,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Combines configured user instructions and AGENTS.md content into a
|
||||
/// single model-visible instruction string.
|
||||
pub(crate) async fn user_instructions(
|
||||
&self,
|
||||
environment: Option<&Environment>,
|
||||
) -> Option<String> {
|
||||
let fs = environment?.get_filesystem();
|
||||
self.user_instructions_with_fs(fs.as_ref()).await
|
||||
}
|
||||
|
||||
pub(crate) async fn user_instructions_with_fs(
|
||||
&self,
|
||||
fs: &dyn ExecutorFileSystem,
|
||||
) -> Option<String> {
|
||||
let agents_md_docs = self.read_agents_md(fs).await;
|
||||
|
||||
let mut output = String::new();
|
||||
|
||||
if let Some(instructions) = self.config.user_instructions.clone() {
|
||||
output.push_str(&instructions);
|
||||
}
|
||||
|
||||
match agents_md_docs {
|
||||
Ok(Some(docs)) => {
|
||||
if !output.is_empty() {
|
||||
output.push_str(AGENTS_MD_SEPARATOR);
|
||||
}
|
||||
output.push_str(&docs);
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(e) => {
|
||||
error!("error trying to find AGENTS.md docs: {e:#}");
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(js_repl_section) = render_js_repl_instructions(self.config) {
|
||||
if !output.is_empty() {
|
||||
output.push_str("\n\n");
|
||||
}
|
||||
output.push_str(&js_repl_section);
|
||||
}
|
||||
|
||||
if self.config.features.enabled(Feature::ChildAgentsMd) {
|
||||
if !output.is_empty() {
|
||||
output.push_str("\n\n");
|
||||
}
|
||||
output.push_str(HIERARCHICAL_AGENTS_MESSAGE);
|
||||
}
|
||||
|
||||
if !output.is_empty() {
|
||||
Some(output)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns all instruction source files included in the current config.
|
||||
pub async fn instruction_sources(&self, fs: &dyn ExecutorFileSystem) -> Vec<AbsolutePathBuf> {
|
||||
let mut paths = Self::load_global_instructions(Some(&self.config.codex_home))
|
||||
.map(|loaded| vec![loaded.path])
|
||||
.unwrap_or_default();
|
||||
match self.agents_md_paths(fs).await {
|
||||
Ok(agents_md_paths) => paths.extend(agents_md_paths),
|
||||
Err(err) => {
|
||||
tracing::warn!(error = %err, "failed to discover AGENTS.md docs for instruction sources");
|
||||
}
|
||||
}
|
||||
paths
|
||||
}
|
||||
|
||||
/// Attempt to locate and load AGENTS.md documentation.
|
||||
///
|
||||
/// On success returns `Ok(Some(contents))` where `contents` is the
|
||||
/// concatenation of all discovered docs. If no documentation file is found
|
||||
/// the function returns `Ok(None)`. Unexpected I/O failures bubble up as
|
||||
/// `Err` so callers can decide how to handle them.
|
||||
async fn read_agents_md(&self, fs: &dyn ExecutorFileSystem) -> io::Result<Option<String>> {
|
||||
let max_total = self.config.project_doc_max_bytes;
|
||||
|
||||
if max_total == 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let paths = self.agents_md_paths(fs).await?;
|
||||
if paths.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let mut remaining: u64 = max_total as u64;
|
||||
let mut parts: Vec<String> = Vec::new();
|
||||
|
||||
for p in paths {
|
||||
if remaining == 0 {
|
||||
break;
|
||||
}
|
||||
|
||||
match fs.get_metadata(&p, /*sandbox*/ None).await {
|
||||
Ok(metadata) if !metadata.is_file => continue,
|
||||
Ok(_) => {}
|
||||
Err(err) if err.kind() == io::ErrorKind::NotFound => continue,
|
||||
Err(err) => return Err(err),
|
||||
}
|
||||
|
||||
let mut data = match fs.read_file(&p, /*sandbox*/ None).await {
|
||||
Ok(data) => data,
|
||||
Err(err) if err.kind() == io::ErrorKind::NotFound => continue,
|
||||
Err(err) => return Err(err),
|
||||
};
|
||||
let size = data.len() as u64;
|
||||
if size > remaining {
|
||||
data.truncate(remaining as usize);
|
||||
}
|
||||
|
||||
if size > remaining {
|
||||
tracing::warn!(
|
||||
"Project doc `{}` exceeds remaining budget ({} bytes) - truncating.",
|
||||
p.display(),
|
||||
remaining,
|
||||
);
|
||||
}
|
||||
|
||||
let text = String::from_utf8_lossy(&data).to_string();
|
||||
if !text.trim().is_empty() {
|
||||
parts.push(text);
|
||||
remaining = remaining.saturating_sub(data.len() as u64);
|
||||
}
|
||||
}
|
||||
|
||||
if parts.is_empty() {
|
||||
Ok(None)
|
||||
} else {
|
||||
Ok(Some(parts.join("\n\n")))
|
||||
}
|
||||
}
|
||||
|
||||
/// Discover the list of AGENTS.md files using the same search rules as
|
||||
/// `read_agents_md`, but return the file paths instead of concatenated
|
||||
/// contents. The list is ordered from project root to the current working
|
||||
/// directory (inclusive). Symlinks are allowed. When `project_doc_max_bytes`
|
||||
/// is zero, returns an empty list.
|
||||
async fn agents_md_paths(
|
||||
&self,
|
||||
fs: &dyn ExecutorFileSystem,
|
||||
) -> io::Result<Vec<AbsolutePathBuf>> {
|
||||
if self.config.project_doc_max_bytes == 0 {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let mut dir = self.config.cwd.clone();
|
||||
if let Ok(canon) = normalize_path(&dir) {
|
||||
dir = AbsolutePathBuf::try_from(canon)?;
|
||||
}
|
||||
|
||||
let mut merged = TomlValue::Table(toml::map::Map::new());
|
||||
for layer in self.config.config_layer_stack.get_layers(
|
||||
ConfigLayerStackOrdering::LowestPrecedenceFirst,
|
||||
/*include_disabled*/ false,
|
||||
) {
|
||||
if matches!(layer.name, ConfigLayerSource::Project { .. }) {
|
||||
continue;
|
||||
}
|
||||
merge_toml_values(&mut merged, &layer.config);
|
||||
}
|
||||
let project_root_markers = match project_root_markers_from_config(&merged) {
|
||||
Ok(Some(markers)) => markers,
|
||||
Ok(None) => default_project_root_markers(),
|
||||
Err(err) => {
|
||||
tracing::warn!("invalid project_root_markers: {err}");
|
||||
default_project_root_markers()
|
||||
}
|
||||
};
|
||||
let mut project_root = None;
|
||||
if !project_root_markers.is_empty() {
|
||||
for ancestor in dir.ancestors() {
|
||||
for marker in &project_root_markers {
|
||||
let marker_path = ancestor.join(marker);
|
||||
let marker_exists = match fs.get_metadata(&marker_path, /*sandbox*/ None).await
|
||||
{
|
||||
Ok(_) => true,
|
||||
Err(err) if err.kind() == io::ErrorKind::NotFound => false,
|
||||
Err(err) => return Err(err),
|
||||
};
|
||||
if marker_exists {
|
||||
project_root = Some(ancestor.clone());
|
||||
break;
|
||||
}
|
||||
}
|
||||
if project_root.is_some() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let search_dirs: Vec<AbsolutePathBuf> = if let Some(root) = project_root {
|
||||
let mut dirs = Vec::new();
|
||||
let mut cursor = dir.clone();
|
||||
loop {
|
||||
dirs.push(cursor.clone());
|
||||
if cursor == root {
|
||||
break;
|
||||
}
|
||||
let Some(parent) = cursor.parent() else {
|
||||
break;
|
||||
};
|
||||
cursor = parent;
|
||||
}
|
||||
dirs.reverse();
|
||||
dirs
|
||||
} else {
|
||||
vec![dir]
|
||||
};
|
||||
|
||||
let mut found: Vec<AbsolutePathBuf> = Vec::new();
|
||||
let candidate_filenames = self.candidate_filenames();
|
||||
for d in search_dirs {
|
||||
for name in &candidate_filenames {
|
||||
let candidate = d.join(name);
|
||||
match fs.get_metadata(&candidate, /*sandbox*/ None).await {
|
||||
Ok(md) if md.is_file => {
|
||||
found.push(candidate);
|
||||
break;
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(err) if err.kind() == io::ErrorKind::NotFound => continue,
|
||||
Err(err) => return Err(err),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(found)
|
||||
}
|
||||
|
||||
fn candidate_filenames(&self) -> Vec<&str> {
|
||||
let mut names: Vec<&str> =
|
||||
Vec::with_capacity(2 + self.config.project_doc_fallback_filenames.len());
|
||||
names.push(LOCAL_AGENTS_MD_FILENAME);
|
||||
names.push(DEFAULT_AGENTS_MD_FILENAME);
|
||||
for candidate in &self.config.project_doc_fallback_filenames {
|
||||
let candidate = candidate.as_str();
|
||||
if candidate.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if !names.contains(&candidate) {
|
||||
names.push(candidate);
|
||||
}
|
||||
}
|
||||
names
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "agents_md_tests.rs"]
|
||||
mod tests;
|
||||
@@ -11,11 +11,15 @@ use std::path::PathBuf;
|
||||
use tempfile::TempDir;
|
||||
|
||||
async fn get_user_instructions(config: &Config) -> Option<String> {
|
||||
super::get_user_instructions_with_fs(config, LOCAL_FS.as_ref()).await
|
||||
AgentsMdManager::new(config)
|
||||
.user_instructions_with_fs(LOCAL_FS.as_ref())
|
||||
.await
|
||||
}
|
||||
|
||||
async fn discover_project_doc_paths(config: &Config) -> std::io::Result<Vec<AbsolutePathBuf>> {
|
||||
super::discover_project_doc_paths(config, LOCAL_FS.as_ref()).await
|
||||
async fn agents_md_paths(config: &Config) -> std::io::Result<Vec<AbsolutePathBuf>> {
|
||||
AgentsMdManager::new(config)
|
||||
.agents_md_paths(LOCAL_FS.as_ref())
|
||||
.await
|
||||
}
|
||||
|
||||
/// Helper that returns a `Config` pointing at `root` and using `limit` as
|
||||
@@ -101,7 +105,9 @@ async fn no_environment_returns_none() {
|
||||
let tmp = tempfile::tempdir().expect("tempdir");
|
||||
let config = make_config(&tmp, /*limit*/ 4096, Some("user instructions")).await;
|
||||
|
||||
let res = super::get_user_instructions(&config, /*environment*/ None).await;
|
||||
let res = AgentsMdManager::new(&config)
|
||||
.user_instructions(/*environment*/ None)
|
||||
.await;
|
||||
|
||||
assert_eq!(res, None);
|
||||
}
|
||||
@@ -187,10 +193,9 @@ async fn zero_byte_limit_disables_discovery() {
|
||||
let tmp = tempfile::tempdir().expect("tempdir");
|
||||
fs::write(tmp.path().join("AGENTS.md"), "something").unwrap();
|
||||
|
||||
let discovery =
|
||||
discover_project_doc_paths(&make_config(&tmp, /*limit*/ 0, /*instructions*/ None).await)
|
||||
.await
|
||||
.expect("discover paths");
|
||||
let discovery = agents_md_paths(&make_config(&tmp, /*limit*/ 0, /*instructions*/ None).await)
|
||||
.await
|
||||
.expect("discover paths");
|
||||
assert_eq!(discovery, Vec::<AbsolutePathBuf>::new());
|
||||
}
|
||||
|
||||
@@ -205,7 +210,7 @@ async fn js_repl_instructions_are_appended_when_enabled() {
|
||||
let res = get_user_instructions(&cfg)
|
||||
.await
|
||||
.expect("js_repl instructions expected");
|
||||
let expected = "## JavaScript REPL (Node)\n- Use `js_repl` for Node-backed JavaScript with top-level await in a persistent kernel.\n- `js_repl` is a freeform/custom tool. Direct `js_repl` calls must send raw JavaScript tool input (optionally with first-line `// codex-js-repl: timeout_ms=15000`). Do not wrap code in JSON (for example `{\"code\":\"...\"}`), quotes, or markdown code fences.\n- Helpers: `codex.cwd`, `codex.homeDir`, `codex.tmpDir`, `codex.tool(name, args?)`, and `codex.emitImage(imageLike)`.\n- `codex.tool` executes a normal tool call and resolves to the raw tool output object. Use it for shell and non-shell tools alike. Nested tool outputs stay inside JavaScript unless you emit them explicitly.\n- `codex.emitImage(...)` adds one image to the outer `js_repl` function output each time you call it, so you can call it multiple times to emit multiple images. It accepts a data URL, a single `input_image` item, an object like `{ bytes, mimeType }`, or a raw tool response object with exactly one image and no text. It rejects mixed text-and-image content.\n- `codex.tool(...)` and `codex.emitImage(...)` keep stable helper identities across cells. Saved references and persisted objects can reuse them in later cells, but async callbacks that fire after a cell finishes still fail because no exec is active.\n- Request full-resolution image processing with `detail: \"original\"` only when the `view_image` tool schema includes a `detail` argument. The same availability applies to `codex.emitImage(...)`: if `view_image.detail` is present, you may also pass `detail: \"original\"` there. Use this when high-fidelity image perception or precise localization is needed, especially for CUA agents.\n- Example of sharing an in-memory Playwright screenshot: `await codex.emitImage({ bytes: await page.screenshot({ type: \"jpeg\", quality: 85 }), mimeType: \"image/jpeg\", detail: \"original\" })`.\n- Example of sharing a local image tool result: `await codex.emitImage(codex.tool(\"view_image\", { path: \"/absolute/path\", detail: \"original\" }))`.\n- When encoding an image to send with `codex.emitImage(...)` or `view_image`, prefer JPEG at about 85 quality when lossy compression is acceptable; use PNG when transparency or lossless detail matters. Smaller uploads are faster and less likely to hit size limits.\n- Top-level bindings persist across cells. If a cell throws, prior bindings remain available and bindings that finished initializing before the throw often remain usable in later cells. For code you plan to reuse across cells, prefer declaring or assigning it in direct top-level statements before operations that might throw. If you hit `SyntaxError: Identifier 'x' has already been declared`, first reuse the existing binding, reassign a previously declared `let`, or pick a new descriptive name. Use `{ ... }` only for a short temporary block when you specifically need local scratch names; do not wrap an entire cell in block scope if you want those names reusable later. Reset the kernel with `js_repl_reset` only when you need a clean state.\n- Top-level static import declarations (for example `import x from \"./file.js\"`) are currently unsupported in `js_repl`; use dynamic imports with `await import(\"pkg\")`, `await import(\"./file.js\")`, or `await import(\"/abs/path/file.mjs\")` instead. Imported local files must be ESM `.js`/`.mjs` files and run in the same REPL VM context. Bare package imports always resolve from REPL-global search roots (`CODEX_JS_REPL_NODE_MODULE_DIRS`, then cwd), not relative to the imported file location. Local files may statically import only other local relative/absolute/`file://` `.js`/`.mjs` files; package and builtin imports from local files must stay dynamic. `import.meta.resolve()` returns importable strings such as `file://...`, bare package names, and `node:...` specifiers. Local file modules reload between execs, while top-level bindings persist until `js_repl_reset`.\n- Avoid direct access to `process.stdout` / `process.stderr` / `process.stdin`; it can corrupt the JSON line protocol. Use `console.log`, `codex.tool(...)`, and `codex.emitImage(...)`.";
|
||||
let expected = "## JavaScript REPL (Node)\n- Use `js_repl` for Node-backed JavaScript with top-level await in a persistent kernel.\n- `js_repl` is a freeform/custom tool. Direct `js_repl` calls must send raw JavaScript tool input (optionally with first-line `// codex-js-repl: timeout_ms=15000`). Do not wrap code in JSON (for example `{\"code\":\"...\"}`), quotes, or markdown code fences.\n- Helpers: `codex.cwd`, `codex.homeDir`, `codex.tmpDir`, `codex.tool(name, args?)`, and `codex.emitImage(imageLike)`.\n- `codex.tool` executes a normal tool call and resolves to the raw tool output object. Use it for shell and non-shell tools alike. Nested tool outputs stay inside JavaScript unless you emit them explicitly.\n- `codex.emitImage(...)` adds one image to the outer `js_repl` function output each time you call it, so you can call it multiple times to emit multiple images. It accepts a data URL, a single `input_image` item, an object like `{ bytes, mimeType }`, or a raw tool response object with exactly one image and no text. It rejects mixed text-and-image content.\n- `codex.tool(...)` and `codex.emitImage(...)` keep stable helper identities across cells. Saved references and persisted objects can reuse them in later cells, but async callbacks that fire after a cell finishes still fail because no exec is active.\n- Request full-resolution image processing with `detail: \"original\"` only when the `view_image` tool schema includes a `detail` argument. The same availability applies to `codex.emitImage(...)`: if `view_image.detail` is present, you may also pass `detail: \"original\"` there. Use this when high-fidelity image perception or precise localization is needed, especially for CUA agents.\n- Raw MCP image blocks can request the same behavior by returning `_meta: { \"codex/imageDetail\": \"original\" }` on the image content item.\n- Example of sharing an in-memory Playwright screenshot: `await codex.emitImage({ bytes: await page.screenshot({ type: \"jpeg\", quality: 85 }), mimeType: \"image/jpeg\", detail: \"original\" })`.\n- Example of sharing a local image tool result: `await codex.emitImage(codex.tool(\"view_image\", { path: \"/absolute/path\", detail: \"original\" }))`.\n- When encoding an image to send with `codex.emitImage(...)` or `view_image`, prefer JPEG at about 85 quality when lossy compression is acceptable; use PNG when transparency or lossless detail matters. Smaller uploads are faster and less likely to hit size limits.\n- Top-level bindings persist across cells. If a cell throws, prior bindings remain available and bindings that finished initializing before the throw often remain usable in later cells. For code you plan to reuse across cells, prefer declaring or assigning it in direct top-level statements before operations that might throw. If you hit `SyntaxError: Identifier 'x' has already been declared`, first reuse the existing binding, reassign a previously declared `let`, or pick a new descriptive name. Use `{ ... }` only for a short temporary block when you specifically need local scratch names; do not wrap an entire cell in block scope if you want those names reusable later. Reset the kernel with `js_repl_reset` only when you need a clean state.\n- Top-level static import declarations (for example `import x from \"./file.js\"`) are currently unsupported in `js_repl`; use dynamic imports with `await import(\"pkg\")`, `await import(\"./file.js\")`, or `await import(\"/abs/path/file.mjs\")` instead. Imported local files must be ESM `.js`/`.mjs` files and run in the same REPL VM context. Bare package imports always resolve from REPL-global search roots (`CODEX_JS_REPL_NODE_MODULE_DIRS`, then cwd), not relative to the imported file location. Local files may statically import only other local relative/absolute/`file://` `.js`/`.mjs` files; package and builtin imports from local files must stay dynamic. `import.meta.resolve()` returns importable strings such as `file://...`, bare package names, and `node:...` specifiers. Local file modules reload between execs, while top-level bindings persist until `js_repl_reset`.\n- Avoid direct access to `process.stdout` / `process.stderr` / `process.stdin`; it can corrupt the JSON line protocol. Use `console.log`, `codex.tool(...)`, and `codex.emitImage(...)`.";
|
||||
assert_eq!(res, expected);
|
||||
}
|
||||
|
||||
@@ -224,14 +229,14 @@ async fn js_repl_tools_only_instructions_are_feature_gated() {
|
||||
let res = get_user_instructions(&cfg)
|
||||
.await
|
||||
.expect("js_repl instructions expected");
|
||||
let expected = "## JavaScript REPL (Node)\n- Use `js_repl` for Node-backed JavaScript with top-level await in a persistent kernel.\n- `js_repl` is a freeform/custom tool. Direct `js_repl` calls must send raw JavaScript tool input (optionally with first-line `// codex-js-repl: timeout_ms=15000`). Do not wrap code in JSON (for example `{\"code\":\"...\"}`), quotes, or markdown code fences.\n- Helpers: `codex.cwd`, `codex.homeDir`, `codex.tmpDir`, `codex.tool(name, args?)`, and `codex.emitImage(imageLike)`.\n- `codex.tool` executes a normal tool call and resolves to the raw tool output object. Use it for shell and non-shell tools alike. Nested tool outputs stay inside JavaScript unless you emit them explicitly.\n- `codex.emitImage(...)` adds one image to the outer `js_repl` function output each time you call it, so you can call it multiple times to emit multiple images. It accepts a data URL, a single `input_image` item, an object like `{ bytes, mimeType }`, or a raw tool response object with exactly one image and no text. It rejects mixed text-and-image content.\n- `codex.tool(...)` and `codex.emitImage(...)` keep stable helper identities across cells. Saved references and persisted objects can reuse them in later cells, but async callbacks that fire after a cell finishes still fail because no exec is active.\n- Request full-resolution image processing with `detail: \"original\"` only when the `view_image` tool schema includes a `detail` argument. The same availability applies to `codex.emitImage(...)`: if `view_image.detail` is present, you may also pass `detail: \"original\"` there. Use this when high-fidelity image perception or precise localization is needed, especially for CUA agents.\n- Example of sharing an in-memory Playwright screenshot: `await codex.emitImage({ bytes: await page.screenshot({ type: \"jpeg\", quality: 85 }), mimeType: \"image/jpeg\", detail: \"original\" })`.\n- Example of sharing a local image tool result: `await codex.emitImage(codex.tool(\"view_image\", { path: \"/absolute/path\", detail: \"original\" }))`.\n- When encoding an image to send with `codex.emitImage(...)` or `view_image`, prefer JPEG at about 85 quality when lossy compression is acceptable; use PNG when transparency or lossless detail matters. Smaller uploads are faster and less likely to hit size limits.\n- Top-level bindings persist across cells. If a cell throws, prior bindings remain available and bindings that finished initializing before the throw often remain usable in later cells. For code you plan to reuse across cells, prefer declaring or assigning it in direct top-level statements before operations that might throw. If you hit `SyntaxError: Identifier 'x' has already been declared`, first reuse the existing binding, reassign a previously declared `let`, or pick a new descriptive name. Use `{ ... }` only for a short temporary block when you specifically need local scratch names; do not wrap an entire cell in block scope if you want those names reusable later. Reset the kernel with `js_repl_reset` only when you need a clean state.\n- Top-level static import declarations (for example `import x from \"./file.js\"`) are currently unsupported in `js_repl`; use dynamic imports with `await import(\"pkg\")`, `await import(\"./file.js\")`, or `await import(\"/abs/path/file.mjs\")` instead. Imported local files must be ESM `.js`/`.mjs` files and run in the same REPL VM context. Bare package imports always resolve from REPL-global search roots (`CODEX_JS_REPL_NODE_MODULE_DIRS`, then cwd), not relative to the imported file location. Local files may statically import only other local relative/absolute/`file://` `.js`/`.mjs` files; package and builtin imports from local files must stay dynamic. `import.meta.resolve()` returns importable strings such as `file://...`, bare package names, and `node:...` specifiers. Local file modules reload between execs, while top-level bindings persist until `js_repl_reset`.\n- Do not call tools directly; use `js_repl` + `codex.tool(...)` for all tool calls, including shell commands.\n- MCP tools (if any) can also be called by name via `codex.tool(...)`.\n- Avoid direct access to `process.stdout` / `process.stderr` / `process.stdin`; it can corrupt the JSON line protocol. Use `console.log`, `codex.tool(...)`, and `codex.emitImage(...)`.";
|
||||
let expected = "## JavaScript REPL (Node)\n- Use `js_repl` for Node-backed JavaScript with top-level await in a persistent kernel.\n- `js_repl` is a freeform/custom tool. Direct `js_repl` calls must send raw JavaScript tool input (optionally with first-line `// codex-js-repl: timeout_ms=15000`). Do not wrap code in JSON (for example `{\"code\":\"...\"}`), quotes, or markdown code fences.\n- Helpers: `codex.cwd`, `codex.homeDir`, `codex.tmpDir`, `codex.tool(name, args?)`, and `codex.emitImage(imageLike)`.\n- `codex.tool` executes a normal tool call and resolves to the raw tool output object. Use it for shell and non-shell tools alike. Nested tool outputs stay inside JavaScript unless you emit them explicitly.\n- `codex.emitImage(...)` adds one image to the outer `js_repl` function output each time you call it, so you can call it multiple times to emit multiple images. It accepts a data URL, a single `input_image` item, an object like `{ bytes, mimeType }`, or a raw tool response object with exactly one image and no text. It rejects mixed text-and-image content.\n- `codex.tool(...)` and `codex.emitImage(...)` keep stable helper identities across cells. Saved references and persisted objects can reuse them in later cells, but async callbacks that fire after a cell finishes still fail because no exec is active.\n- Request full-resolution image processing with `detail: \"original\"` only when the `view_image` tool schema includes a `detail` argument. The same availability applies to `codex.emitImage(...)`: if `view_image.detail` is present, you may also pass `detail: \"original\"` there. Use this when high-fidelity image perception or precise localization is needed, especially for CUA agents.\n- Raw MCP image blocks can request the same behavior by returning `_meta: { \"codex/imageDetail\": \"original\" }` on the image content item.\n- Example of sharing an in-memory Playwright screenshot: `await codex.emitImage({ bytes: await page.screenshot({ type: \"jpeg\", quality: 85 }), mimeType: \"image/jpeg\", detail: \"original\" })`.\n- Example of sharing a local image tool result: `await codex.emitImage(codex.tool(\"view_image\", { path: \"/absolute/path\", detail: \"original\" }))`.\n- When encoding an image to send with `codex.emitImage(...)` or `view_image`, prefer JPEG at about 85 quality when lossy compression is acceptable; use PNG when transparency or lossless detail matters. Smaller uploads are faster and less likely to hit size limits.\n- Top-level bindings persist across cells. If a cell throws, prior bindings remain available and bindings that finished initializing before the throw often remain usable in later cells. For code you plan to reuse across cells, prefer declaring or assigning it in direct top-level statements before operations that might throw. If you hit `SyntaxError: Identifier 'x' has already been declared`, first reuse the existing binding, reassign a previously declared `let`, or pick a new descriptive name. Use `{ ... }` only for a short temporary block when you specifically need local scratch names; do not wrap an entire cell in block scope if you want those names reusable later. Reset the kernel with `js_repl_reset` only when you need a clean state.\n- Top-level static import declarations (for example `import x from \"./file.js\"`) are currently unsupported in `js_repl`; use dynamic imports with `await import(\"pkg\")`, `await import(\"./file.js\")`, or `await import(\"/abs/path/file.mjs\")` instead. Imported local files must be ESM `.js`/`.mjs` files and run in the same REPL VM context. Bare package imports always resolve from REPL-global search roots (`CODEX_JS_REPL_NODE_MODULE_DIRS`, then cwd), not relative to the imported file location. Local files may statically import only other local relative/absolute/`file://` `.js`/`.mjs` files; package and builtin imports from local files must stay dynamic. `import.meta.resolve()` returns importable strings such as `file://...`, bare package names, and `node:...` specifiers. Local file modules reload between execs, while top-level bindings persist until `js_repl_reset`.\n- Do not call tools directly; use `js_repl` + `codex.tool(...)` for all tool calls, including shell commands.\n- MCP tools (if any) can also be called by name via `codex.tool(...)`.\n- Avoid direct access to `process.stdout` / `process.stderr` / `process.stdin`; it can corrupt the JSON line protocol. Use `console.log`, `codex.tool(...)`, and `codex.emitImage(...)`.";
|
||||
assert_eq!(res, expected);
|
||||
}
|
||||
|
||||
/// When both system instructions *and* a project doc are present the two
|
||||
/// When both system instructions and AGENTS.md docs are present the two
|
||||
/// should be concatenated with the separator.
|
||||
#[tokio::test]
|
||||
async fn merges_existing_instructions_with_project_doc() {
|
||||
async fn merges_existing_instructions_with_agents_md() {
|
||||
let tmp = tempfile::tempdir().expect("tempdir");
|
||||
fs::write(tmp.path().join("AGENTS.md"), "proj doc").unwrap();
|
||||
|
||||
@@ -241,12 +246,12 @@ async fn merges_existing_instructions_with_project_doc() {
|
||||
.await
|
||||
.expect("should produce a combined instruction string");
|
||||
|
||||
let expected = format!("{INSTRUCTIONS}{PROJECT_DOC_SEPARATOR}{}", "proj doc");
|
||||
let expected = format!("{INSTRUCTIONS}{AGENTS_MD_SEPARATOR}{}", "proj doc");
|
||||
|
||||
assert_eq!(res, expected);
|
||||
}
|
||||
|
||||
/// If there are existing system instructions but the project doc is
|
||||
/// If there are existing system instructions but AGENTS.md docs are
|
||||
/// missing we expect the original instructions to be returned unchanged.
|
||||
#[tokio::test]
|
||||
async fn keeps_existing_instructions_when_doc_missing() {
|
||||
@@ -307,9 +312,7 @@ async fn project_root_markers_are_honored_for_agents_discovery() {
|
||||
.await;
|
||||
cfg.cwd = nested.abs();
|
||||
|
||||
let discovery = discover_project_doc_paths(&cfg)
|
||||
.await
|
||||
.expect("discover paths");
|
||||
let discovery = agents_md_paths(&cfg).await.expect("discover paths");
|
||||
let expected_parent = AbsolutePathBuf::try_from(
|
||||
dunce::canonicalize(root.path().join("AGENTS.md")).expect("canonical parent doc path"),
|
||||
)
|
||||
@@ -326,12 +329,33 @@ async fn project_root_markers_are_honored_for_agents_discovery() {
|
||||
assert_eq!(res, "parent doc\n\nchild doc");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn instruction_sources_include_global_before_agents_md_docs() {
|
||||
let tmp = tempfile::tempdir().expect("tempdir");
|
||||
fs::write(tmp.path().join("AGENTS.md"), "project doc").unwrap();
|
||||
|
||||
let cfg = make_config(&tmp, /*limit*/ 4096, Some("global doc")).await;
|
||||
let global_agents = cfg.codex_home.join(DEFAULT_AGENTS_MD_FILENAME);
|
||||
fs::create_dir_all(&cfg.codex_home).unwrap();
|
||||
fs::write(&global_agents, "global doc").unwrap();
|
||||
|
||||
let sources = AgentsMdManager::new(&cfg)
|
||||
.instruction_sources(LOCAL_FS.as_ref())
|
||||
.await;
|
||||
let project_agents = AbsolutePathBuf::try_from(
|
||||
dunce::canonicalize(cfg.cwd.join("AGENTS.md")).expect("canonical project doc path"),
|
||||
)
|
||||
.expect("absolute project doc path");
|
||||
|
||||
assert_eq!(sources, vec![global_agents, project_agents]);
|
||||
}
|
||||
|
||||
/// AGENTS.override.md is preferred over AGENTS.md when both are present.
|
||||
#[tokio::test]
|
||||
async fn agents_local_md_preferred() {
|
||||
let tmp = tempfile::tempdir().expect("tempdir");
|
||||
fs::write(tmp.path().join(DEFAULT_PROJECT_DOC_FILENAME), "versioned").unwrap();
|
||||
fs::write(tmp.path().join(LOCAL_PROJECT_DOC_FILENAME), "local").unwrap();
|
||||
fs::write(tmp.path().join(DEFAULT_AGENTS_MD_FILENAME), "versioned").unwrap();
|
||||
fs::write(tmp.path().join(LOCAL_AGENTS_MD_FILENAME), "local").unwrap();
|
||||
|
||||
let cfg = make_config(&tmp, /*limit*/ 4096, /*instructions*/ None).await;
|
||||
|
||||
@@ -341,13 +365,11 @@ async fn agents_local_md_preferred() {
|
||||
|
||||
assert_eq!(res, "local");
|
||||
|
||||
let discovery = discover_project_doc_paths(&cfg)
|
||||
.await
|
||||
.expect("discover paths");
|
||||
let discovery = agents_md_paths(&cfg).await.expect("discover paths");
|
||||
assert_eq!(discovery.len(), 1);
|
||||
assert_eq!(
|
||||
discovery[0].file_name().unwrap().to_string_lossy(),
|
||||
LOCAL_PROJECT_DOC_FILENAME
|
||||
LOCAL_AGENTS_MD_FILENAME
|
||||
);
|
||||
}
|
||||
|
||||
@@ -393,16 +415,14 @@ async fn agents_md_preferred_over_fallbacks() {
|
||||
|
||||
assert_eq!(res, "primary");
|
||||
|
||||
let discovery = discover_project_doc_paths(&cfg)
|
||||
.await
|
||||
.expect("discover paths");
|
||||
let discovery = agents_md_paths(&cfg).await.expect("discover paths");
|
||||
assert_eq!(discovery.len(), 1);
|
||||
assert!(
|
||||
discovery[0]
|
||||
.file_name()
|
||||
.unwrap()
|
||||
.to_string_lossy()
|
||||
.eq(DEFAULT_PROJECT_DOC_FILENAME)
|
||||
.eq(DEFAULT_AGENTS_MD_FILENAME)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -416,9 +436,7 @@ async fn agents_md_directory_is_ignored() {
|
||||
let res = get_user_instructions(&cfg).await;
|
||||
assert_eq!(res, None);
|
||||
|
||||
let discovery = discover_project_doc_paths(&cfg)
|
||||
.await
|
||||
.expect("discover paths");
|
||||
let discovery = agents_md_paths(&cfg).await.expect("discover paths");
|
||||
assert_eq!(discovery, Vec::<AbsolutePathBuf>::new());
|
||||
}
|
||||
|
||||
@@ -441,17 +459,15 @@ async fn agents_md_special_file_is_ignored() {
|
||||
let res = get_user_instructions(&cfg).await;
|
||||
assert_eq!(res, None);
|
||||
|
||||
let discovery = discover_project_doc_paths(&cfg)
|
||||
.await
|
||||
.expect("discover paths");
|
||||
let discovery = agents_md_paths(&cfg).await.expect("discover paths");
|
||||
assert_eq!(discovery, Vec::<AbsolutePathBuf>::new());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn override_directory_falls_back_to_agents_md_file() {
|
||||
let tmp = tempfile::tempdir().expect("tempdir");
|
||||
fs::create_dir(tmp.path().join(LOCAL_PROJECT_DOC_FILENAME)).unwrap();
|
||||
fs::write(tmp.path().join(DEFAULT_PROJECT_DOC_FILENAME), "primary").unwrap();
|
||||
fs::create_dir(tmp.path().join(LOCAL_AGENTS_MD_FILENAME)).unwrap();
|
||||
fs::write(tmp.path().join(DEFAULT_AGENTS_MD_FILENAME), "primary").unwrap();
|
||||
|
||||
let cfg = make_config(&tmp, /*limit*/ 4096, /*instructions*/ None).await;
|
||||
|
||||
@@ -460,21 +476,19 @@ async fn override_directory_falls_back_to_agents_md_file() {
|
||||
.expect("AGENTS.md should be used when override is a directory");
|
||||
assert_eq!(res, "primary");
|
||||
|
||||
let discovery = discover_project_doc_paths(&cfg)
|
||||
.await
|
||||
.expect("discover paths");
|
||||
let discovery = agents_md_paths(&cfg).await.expect("discover paths");
|
||||
assert_eq!(discovery.len(), 1);
|
||||
assert_eq!(
|
||||
discovery[0]
|
||||
.file_name()
|
||||
.expect("file name")
|
||||
.to_string_lossy(),
|
||||
DEFAULT_PROJECT_DOC_FILENAME
|
||||
DEFAULT_AGENTS_MD_FILENAME
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn skills_are_not_appended_to_project_doc() {
|
||||
async fn skills_are_not_appended_to_agents_md() {
|
||||
let tmp = tempfile::tempdir().expect("tempdir");
|
||||
fs::write(tmp.path().join("AGENTS.md"), "base doc").unwrap();
|
||||
|
||||
@@ -504,7 +518,7 @@ async fn apps_feature_does_not_emit_user_instructions_by_itself() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn apps_feature_does_not_append_to_project_doc_user_instructions() {
|
||||
async fn apps_feature_does_not_append_to_agents_md_user_instructions() {
|
||||
let tmp = tempfile::tempdir().expect("tempdir");
|
||||
fs::write(tmp.path().join("AGENTS.md"), "base doc").unwrap();
|
||||
|
||||
@@ -32,6 +32,7 @@ use std::sync::atomic::AtomicU64;
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
use codex_api::ApiError;
|
||||
use codex_api::AuthProvider;
|
||||
use codex_api::CompactClient as ApiCompactClient;
|
||||
use codex_api::CompactionInput as ApiCompactionInput;
|
||||
use codex_api::Compression;
|
||||
@@ -83,7 +84,6 @@ use futures::StreamExt;
|
||||
use http::HeaderMap as ApiHeaderMap;
|
||||
use http::HeaderValue;
|
||||
use http::StatusCode as HttpStatusCode;
|
||||
use http::header::AUTHORIZATION;
|
||||
use reqwest::StatusCode;
|
||||
use std::time::Duration;
|
||||
use std::time::Instant;
|
||||
@@ -277,16 +277,7 @@ pub(crate) struct RealtimeWebrtcCallStart {
|
||||
/// `api.openai.com` sideband path.
|
||||
fn sideband_websocket_auth_headers(api_auth: &CoreAuthProvider) -> ApiHeaderMap {
|
||||
let mut headers = ApiHeaderMap::new();
|
||||
if let Some(token) = api_auth.token.as_ref()
|
||||
&& let Ok(value) = HeaderValue::from_str(&format!("Bearer {token}"))
|
||||
{
|
||||
headers.insert(AUTHORIZATION, value);
|
||||
}
|
||||
if let Some(account_id) = api_auth.account_id.as_ref()
|
||||
&& let Ok(value) = HeaderValue::from_str(account_id)
|
||||
{
|
||||
headers.insert("ChatGPT-Account-ID", value);
|
||||
}
|
||||
api_auth.add_auth_headers(&mut headers);
|
||||
headers
|
||||
}
|
||||
|
||||
|
||||
+170
-69
@@ -13,6 +13,7 @@ use crate::agent::Mailbox;
|
||||
use crate::agent::MailboxReceiver;
|
||||
use crate::agent::agent_status_from_event;
|
||||
use crate::agent::status::is_final;
|
||||
use crate::agent_identity::AgentIdentityManager;
|
||||
use crate::apps::render_apps_section;
|
||||
use crate::commit_attribution::commit_message_trailer_instruction;
|
||||
use crate::compact;
|
||||
@@ -40,9 +41,11 @@ use crate::stream_events_utils::HandleOutputCtx;
|
||||
use crate::stream_events_utils::handle_non_tool_response_item;
|
||||
use crate::stream_events_utils::handle_output_item_done;
|
||||
use crate::stream_events_utils::last_assistant_message_from_item;
|
||||
use crate::stream_events_utils::mark_thread_memory_mode_polluted_if_external_context;
|
||||
use crate::stream_events_utils::raw_assistant_output_text_from_item;
|
||||
use crate::stream_events_utils::record_completed_response_item;
|
||||
use crate::turn_metadata::TurnMetadataState;
|
||||
use crate::unavailable_tool::collect_unavailable_called_tools;
|
||||
use crate::util::error_or_panic;
|
||||
use async_channel::Receiver;
|
||||
use async_channel::Sender;
|
||||
@@ -77,7 +80,6 @@ use codex_login::CodexAuth;
|
||||
use codex_login::auth_env_telemetry::collect_auth_env_telemetry;
|
||||
use codex_login::default_client::originator;
|
||||
use codex_mcp::McpConnectionManager;
|
||||
use codex_mcp::SandboxState;
|
||||
use codex_mcp::ToolInfo;
|
||||
use codex_mcp::codex_apps_tools_cache_key;
|
||||
#[cfg(test)]
|
||||
@@ -91,6 +93,7 @@ use codex_otel::current_span_trace_id;
|
||||
use codex_otel::current_span_w3c_trace_context;
|
||||
use codex_otel::set_parent_from_w3c_trace_context;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::ToolName;
|
||||
use codex_protocol::approvals::ElicitationRequestEvent;
|
||||
use codex_protocol::approvals::ExecPolicyAmendment;
|
||||
use codex_protocol::approvals::NetworkPolicyAmendment;
|
||||
@@ -134,9 +137,11 @@ use codex_protocol::request_permissions::RequestPermissionsResponse;
|
||||
use codex_protocol::request_user_input::RequestUserInputArgs;
|
||||
use codex_protocol::request_user_input::RequestUserInputResponse;
|
||||
use codex_rmcp_client::ElicitationResponse;
|
||||
use codex_rollout::RolloutConfig;
|
||||
use codex_rollout::state_db;
|
||||
use codex_shell_command::parse_command::parse_command;
|
||||
use codex_terminal_detection::user_agent;
|
||||
use codex_thread_store::LocalThreadStore;
|
||||
use codex_tools::filter_tool_suggest_discoverable_tools_for_client;
|
||||
use codex_utils_output_truncation::TruncationPolicy;
|
||||
use codex_utils_stream_parser::AssistantTextChunk;
|
||||
@@ -260,6 +265,7 @@ use crate::SkillInjections;
|
||||
use crate::SkillLoadOutcome;
|
||||
use crate::SkillMetadata;
|
||||
use crate::SkillsManager;
|
||||
use crate::agents_md::AgentsMdManager;
|
||||
use crate::build_skill_injections;
|
||||
use crate::collect_env_var_dependencies;
|
||||
use crate::collect_explicit_skill_mentions;
|
||||
@@ -288,7 +294,6 @@ use crate::network_policy_decision::execpolicy_network_rule_amendment;
|
||||
use crate::plugins::PluginsManager;
|
||||
use crate::plugins::build_plugin_injections;
|
||||
use crate::plugins::render_plugins_section;
|
||||
use crate::project_doc::get_user_instructions;
|
||||
use crate::resolve_skill_dependencies_for_turn;
|
||||
use crate::rollout::RolloutRecorder;
|
||||
use crate::rollout::RolloutRecorderParams;
|
||||
@@ -551,7 +556,9 @@ impl Codex {
|
||||
config.startup_warnings.push(message);
|
||||
}
|
||||
|
||||
let user_instructions = get_user_instructions(&config, environment.as_deref()).await;
|
||||
let user_instructions = AgentsMdManager::new(&config)
|
||||
.user_instructions(environment.as_deref())
|
||||
.await;
|
||||
|
||||
let exec_policy = if crate::guardian::is_guardian_reviewer_source(&session_source) {
|
||||
// Guardian review should rely on the built-in shell safety checks,
|
||||
@@ -1078,6 +1085,18 @@ impl TurnContext {
|
||||
}
|
||||
|
||||
pub(crate) fn to_turn_context_item(&self) -> TurnContextItem {
|
||||
let legacy_file_system_sandbox_policy = FileSystemSandboxPolicy::from_legacy_sandbox_policy(
|
||||
self.sandbox_policy.get(),
|
||||
&self.cwd,
|
||||
);
|
||||
// Omit the derived split filesystem policy when it is equivalent to
|
||||
// the legacy sandbox policy. This keeps turn-context payloads stable
|
||||
// while both fields exist; once callers consume only the split policy,
|
||||
// this comparison and the legacy projection should go away.
|
||||
let file_system_sandbox_policy = (self.file_system_sandbox_policy
|
||||
!= legacy_file_system_sandbox_policy)
|
||||
.then(|| self.file_system_sandbox_policy.clone());
|
||||
|
||||
TurnContextItem {
|
||||
turn_id: Some(self.sub_id.clone()),
|
||||
trace_id: self.trace_id.clone(),
|
||||
@@ -1087,6 +1106,7 @@ impl TurnContext {
|
||||
approval_policy: self.approval_policy.value(),
|
||||
sandbox_policy: self.sandbox_policy.get().clone(),
|
||||
network: self.turn_context_network_item(),
|
||||
file_system_sandbox_policy,
|
||||
model: self.model_info.slug.clone(),
|
||||
personality: self.personality,
|
||||
collaboration_mode: Some(self.collaboration_mode.clone()),
|
||||
@@ -1282,7 +1302,14 @@ impl SessionConfiguration {
|
||||
|
||||
let cwd_changed = absolute_cwd.as_path() != self.cwd.as_path();
|
||||
next_configuration.cwd = absolute_cwd;
|
||||
if sandbox_policy_changed || (cwd_changed && file_system_policy_matches_legacy) {
|
||||
if sandbox_policy_changed {
|
||||
next_configuration.file_system_sandbox_policy =
|
||||
FileSystemSandboxPolicy::from_legacy_sandbox_policy_preserving_deny_entries(
|
||||
next_configuration.sandbox_policy.get(),
|
||||
&next_configuration.cwd,
|
||||
&self.file_system_sandbox_policy,
|
||||
);
|
||||
} else if cwd_changed && file_system_policy_matches_legacy {
|
||||
// Preserve richer split policies across cwd-only updates; only
|
||||
// rederive when the session is already using the legacy bridge.
|
||||
next_configuration.file_system_sandbox_policy =
|
||||
@@ -1334,6 +1361,10 @@ impl Session {
|
||||
}
|
||||
}
|
||||
|
||||
fn managed_network_proxy_active_for_sandbox_policy(sandbox_policy: &SandboxPolicy) -> bool {
|
||||
!matches!(sandbox_policy, SandboxPolicy::DangerFullAccess)
|
||||
}
|
||||
|
||||
/// Builds the `x-codex-beta-features` header value for this session.
|
||||
///
|
||||
/// `ModelClient` is session-scoped and intentionally does not depend on the full `Config`, so
|
||||
@@ -1509,6 +1540,56 @@ impl Session {
|
||||
});
|
||||
}
|
||||
|
||||
fn start_agent_identity_registration(self: &Arc<Self>) {
|
||||
if !self.services.agent_identity_manager.is_enabled() {
|
||||
return;
|
||||
}
|
||||
|
||||
let weak_sess = Arc::downgrade(self);
|
||||
let mut auth_state_rx = self.services.auth_manager.subscribe_auth_state();
|
||||
tokio::spawn(async move {
|
||||
loop {
|
||||
let Some(sess) = weak_sess.upgrade() else {
|
||||
return;
|
||||
};
|
||||
match sess
|
||||
.services
|
||||
.agent_identity_manager
|
||||
.ensure_registered_identity()
|
||||
.await
|
||||
{
|
||||
Ok(Some(_)) => return,
|
||||
Ok(None) => {
|
||||
drop(sess);
|
||||
if auth_state_rx.changed().await.is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
sess.fail_agent_identity_registration(error).await;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async fn fail_agent_identity_registration(self: &Arc<Self>, error: anyhow::Error) {
|
||||
warn!(error = %error, "agent identity registration failed");
|
||||
let message = format!(
|
||||
"Agent identity registration failed. Codex cannot continue while `features.use_agent_identity` is enabled: {error}"
|
||||
);
|
||||
self.send_event_raw(Event {
|
||||
id: self.next_internal_sub_id(),
|
||||
msg: EventMsg::Error(ErrorEvent {
|
||||
message,
|
||||
codex_error_info: Some(CodexErrorInfo::Other),
|
||||
}),
|
||||
})
|
||||
.await;
|
||||
handlers::shutdown(self, self.next_internal_sub_id()).await;
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
fn make_turn_context(
|
||||
conversation_id: ThreadId,
|
||||
@@ -1950,10 +2031,15 @@ impl Session {
|
||||
.await;
|
||||
session_configuration.thread_name = thread_name.clone();
|
||||
let state = SessionState::new(session_configuration.clone());
|
||||
let managed_network_requirements_configured = config
|
||||
.config_layer_stack
|
||||
.requirements_toml()
|
||||
.network
|
||||
.is_some();
|
||||
let managed_network_requirements_enabled = config.managed_network_requirements_enabled();
|
||||
let network_approval = Arc::new(NetworkApprovalService::default());
|
||||
// The managed proxy can call back into core for allowlist-miss decisions.
|
||||
let network_policy_decider_session = if managed_network_requirements_enabled {
|
||||
let network_policy_decider_session = if managed_network_requirements_configured {
|
||||
config
|
||||
.permissions
|
||||
.network
|
||||
@@ -1962,7 +2048,7 @@ impl Session {
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let blocked_request_observer = if managed_network_requirements_enabled {
|
||||
let blocked_request_observer = if managed_network_requirements_configured {
|
||||
config
|
||||
.permissions
|
||||
.network
|
||||
@@ -1989,7 +2075,7 @@ impl Session {
|
||||
config.permissions.sandbox_policy.get(),
|
||||
network_policy_decider.as_ref().map(Arc::clone),
|
||||
blocked_request_observer.as_ref().map(Arc::clone),
|
||||
managed_network_requirements_enabled,
|
||||
managed_network_requirements_configured,
|
||||
network_proxy_audit_metadata,
|
||||
)
|
||||
.instrument(info_span!(
|
||||
@@ -2054,6 +2140,11 @@ impl Session {
|
||||
hooks,
|
||||
rollout: Mutex::new(rollout_recorder),
|
||||
user_shell: Arc::new(default_shell),
|
||||
agent_identity_manager: Arc::new(AgentIdentityManager::new(
|
||||
config.as_ref(),
|
||||
Arc::clone(&auth_manager),
|
||||
session_configuration.session_source.clone(),
|
||||
)),
|
||||
shell_snapshot_tx,
|
||||
show_raw_agent_reasoning: config.show_raw_agent_reasoning,
|
||||
exec_policy,
|
||||
@@ -2070,6 +2161,7 @@ impl Session {
|
||||
network_proxy,
|
||||
network_approval: Arc::clone(&network_approval),
|
||||
state_db: state_db_ctx.clone(),
|
||||
thread_store: LocalThreadStore::new(RolloutConfig::from_view(config.as_ref())),
|
||||
model_client: ModelClient::new(
|
||||
Some(Arc::clone(&auth_manager)),
|
||||
conversation_id,
|
||||
@@ -2140,7 +2232,11 @@ impl Session {
|
||||
history_log_id,
|
||||
history_entry_count,
|
||||
initial_messages,
|
||||
network_proxy: session_network_proxy,
|
||||
network_proxy: session_network_proxy.filter(|_| {
|
||||
Self::managed_network_proxy_active_for_sandbox_policy(
|
||||
session_configuration.sandbox_policy.get(),
|
||||
)
|
||||
}),
|
||||
rollout_path,
|
||||
}),
|
||||
})
|
||||
@@ -2151,14 +2247,7 @@ impl Session {
|
||||
|
||||
// Start the watcher after SessionConfigured so it cannot emit earlier events.
|
||||
sess.start_skills_watcher_listener();
|
||||
// Construct sandbox_state before MCP startup so it can be sent to each
|
||||
// MCP server immediately after it becomes ready (avoiding blocking).
|
||||
let sandbox_state = SandboxState {
|
||||
sandbox_policy: session_configuration.sandbox_policy.get().clone(),
|
||||
codex_linux_sandbox_exe: config.codex_linux_sandbox_exe.clone(),
|
||||
sandbox_cwd: session_configuration.cwd.to_path_buf(),
|
||||
use_legacy_landlock: config.features.use_legacy_landlock(),
|
||||
};
|
||||
sess.start_agent_identity_registration();
|
||||
let mut required_mcp_servers: Vec<String> = mcp_servers
|
||||
.iter()
|
||||
.filter(|(_, server)| server.enabled && server.required)
|
||||
@@ -2180,7 +2269,7 @@ impl Session {
|
||||
&session_configuration.approval_policy,
|
||||
INITIAL_SUBMIT_ID.to_owned(),
|
||||
tx_event.clone(),
|
||||
sandbox_state,
|
||||
session_configuration.sandbox_policy.get().clone(),
|
||||
config.codex_home.to_path_buf(),
|
||||
codex_apps_tools_cache_key(auth),
|
||||
tool_plugin_provenance,
|
||||
@@ -2612,12 +2701,16 @@ impl Session {
|
||||
&session_source,
|
||||
);
|
||||
|
||||
if sandbox_policy_changed {
|
||||
self.refresh_managed_network_proxy_for_current_sandbox_policy()
|
||||
.await;
|
||||
}
|
||||
|
||||
Ok(self
|
||||
.new_turn_from_configuration(
|
||||
sub_id,
|
||||
session_configuration,
|
||||
updates.final_output_json_schema,
|
||||
sandbox_policy_changed,
|
||||
)
|
||||
.await)
|
||||
}
|
||||
@@ -2627,7 +2720,6 @@ impl Session {
|
||||
sub_id: String,
|
||||
session_configuration: SessionConfiguration,
|
||||
final_output_json_schema: Option<Option<Value>>,
|
||||
sandbox_policy_changed: bool,
|
||||
) -> Arc<TurnContext> {
|
||||
let per_turn_config = Self::build_per_turn_config(&session_configuration);
|
||||
{
|
||||
@@ -2637,27 +2729,6 @@ impl Session {
|
||||
.set_sandbox_policy(per_turn_config.permissions.sandbox_policy.get());
|
||||
}
|
||||
|
||||
if sandbox_policy_changed {
|
||||
self.refresh_managed_network_proxy_for_current_sandbox_policy()
|
||||
.await;
|
||||
let sandbox_state = SandboxState {
|
||||
sandbox_policy: per_turn_config.permissions.sandbox_policy.get().clone(),
|
||||
codex_linux_sandbox_exe: per_turn_config.codex_linux_sandbox_exe.clone(),
|
||||
sandbox_cwd: per_turn_config.cwd.to_path_buf(),
|
||||
use_legacy_landlock: per_turn_config.features.use_legacy_landlock(),
|
||||
};
|
||||
if let Err(e) = self
|
||||
.services
|
||||
.mcp_connection_manager
|
||||
.read()
|
||||
.await
|
||||
.notify_sandbox_state_change(&sandbox_state)
|
||||
.await
|
||||
{
|
||||
warn!("Failed to notify sandbox state change to MCP servers: {e:#}");
|
||||
}
|
||||
}
|
||||
|
||||
let model_info = self
|
||||
.services
|
||||
.models_manager
|
||||
@@ -2699,7 +2770,12 @@ impl Session {
|
||||
self.services
|
||||
.network_proxy
|
||||
.as_ref()
|
||||
.map(StartedNetworkProxy::proxy),
|
||||
.and_then(|started_proxy| {
|
||||
Self::managed_network_proxy_active_for_sandbox_policy(
|
||||
session_configuration.sandbox_policy.get(),
|
||||
)
|
||||
.then(|| started_proxy.proxy())
|
||||
}),
|
||||
self.services.environment.clone(),
|
||||
sub_id,
|
||||
Arc::clone(&self.js_repl),
|
||||
@@ -2806,7 +2882,6 @@ impl Session {
|
||||
sub_id,
|
||||
session_configuration,
|
||||
/*final_output_json_schema*/ None,
|
||||
/*sandbox_policy_changed*/ false,
|
||||
)
|
||||
.await
|
||||
}
|
||||
@@ -4453,16 +4528,12 @@ impl Session {
|
||||
.await
|
||||
}
|
||||
|
||||
pub(crate) async fn resolve_mcp_tool_info(
|
||||
&self,
|
||||
name: &str,
|
||||
namespace: Option<&str>,
|
||||
) -> Option<ToolInfo> {
|
||||
pub(crate) async fn resolve_mcp_tool_info(&self, tool_name: &ToolName) -> Option<ToolInfo> {
|
||||
self.services
|
||||
.mcp_connection_manager
|
||||
.read()
|
||||
.await
|
||||
.resolve_tool_info(name, namespace)
|
||||
.resolve_tool_info(tool_name)
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -4522,12 +4593,6 @@ impl Session {
|
||||
.await;
|
||||
let mcp_servers = with_codex_apps_mcp(mcp_servers, auth.as_ref(), &mcp_config);
|
||||
let auth_statuses = compute_auth_statuses(mcp_servers.iter(), store_mode).await;
|
||||
let sandbox_state = SandboxState {
|
||||
sandbox_policy: turn_context.sandbox_policy.get().clone(),
|
||||
codex_linux_sandbox_exe: turn_context.codex_linux_sandbox_exe.clone(),
|
||||
sandbox_cwd: turn_context.cwd.to_path_buf(),
|
||||
use_legacy_landlock: turn_context.features.use_legacy_landlock(),
|
||||
};
|
||||
{
|
||||
let mut guard = self.services.mcp_startup_cancellation_token.lock().await;
|
||||
guard.cancel();
|
||||
@@ -4540,7 +4605,7 @@ impl Session {
|
||||
&turn_context.config.permissions.approval_policy,
|
||||
turn_context.sub_id.clone(),
|
||||
self.get_tx_event(),
|
||||
sandbox_state,
|
||||
turn_context.sandbox_policy.get().clone(),
|
||||
config.codex_home.to_path_buf(),
|
||||
codex_apps_tools_cache_key(auth.as_ref()),
|
||||
tool_plugin_provenance,
|
||||
@@ -4920,6 +4985,8 @@ mod handlers {
|
||||
use crate::config_loader::load_config_layers_state;
|
||||
use crate::realtime_context::REALTIME_TURN_TOKEN_BUDGET;
|
||||
use crate::realtime_context::truncate_realtime_text_to_token_budget;
|
||||
use crate::realtime_conversation::REALTIME_USER_TEXT_PREFIX;
|
||||
use crate::realtime_conversation::prefix_realtime_v2_text;
|
||||
use codex_features::Feature;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
|
||||
@@ -5140,6 +5207,11 @@ mod handlers {
|
||||
if text.is_empty() {
|
||||
return;
|
||||
}
|
||||
let text = if sess.conversation.is_running_v2().await {
|
||||
prefix_realtime_v2_text(text, REALTIME_USER_TEXT_PREFIX)
|
||||
} else {
|
||||
text
|
||||
};
|
||||
let text = truncate_realtime_text_to_token_budget(&text, REALTIME_TURN_TOKEN_BUDGET);
|
||||
if text.is_empty() {
|
||||
return;
|
||||
@@ -5518,15 +5590,15 @@ mod handlers {
|
||||
errors.push("state db unavailable; memory rows were not cleared".to_string());
|
||||
}
|
||||
|
||||
let memory_root = crate::memories::memory_root(&config.codex_home);
|
||||
if let Err(err) = crate::memories::clear_memory_root_contents(&memory_root).await {
|
||||
if let Err(err) = crate::memories::clear_memory_roots_contents(&config.codex_home).await {
|
||||
errors.push(format!(
|
||||
"failed clearing memory directory {}: {err}",
|
||||
memory_root.display()
|
||||
"failed clearing memory directories under {}: {err}",
|
||||
config.codex_home.display()
|
||||
));
|
||||
}
|
||||
|
||||
if errors.is_empty() {
|
||||
let memory_root = crate::memories::memory_root(&config.codex_home);
|
||||
sess.send_event_raw(Event {
|
||||
id: sub_id,
|
||||
msg: EventMsg::Warning(WarningEvent {
|
||||
@@ -6195,8 +6267,11 @@ pub(crate) async fn run_turn(
|
||||
HashMap::new()
|
||||
};
|
||||
let available_connectors = if turn_context.apps_enabled() {
|
||||
let connectors = connectors::merge_plugin_apps_with_accessible(
|
||||
loaded_plugins.effective_apps(),
|
||||
let connectors = codex_connectors::merge::merge_plugin_connectors_with_accessible(
|
||||
loaded_plugins
|
||||
.effective_apps()
|
||||
.into_iter()
|
||||
.map(|connector_id| connector_id.0),
|
||||
connectors::accessible_connectors_from_mcp_tools(&mcp_tools),
|
||||
);
|
||||
connectors::with_app_enabled_state(connectors, &turn_context.config)
|
||||
@@ -6842,7 +6917,7 @@ fn collect_explicit_app_ids_from_skill_items(
|
||||
|
||||
let connector_slug_counts = build_connector_slug_counts(connectors);
|
||||
for connector in connectors {
|
||||
let slug = connectors::connector_mention_slug(connector);
|
||||
let slug = codex_connectors::metadata::connector_mention_slug(connector);
|
||||
let connector_count = connector_slug_counts.get(&slug).copied().unwrap_or(0);
|
||||
let skill_count = skill_name_counts_lower.get(&slug).copied().unwrap_or(0);
|
||||
if connector_count == 1 && skill_count == 0 && mention_names_lower.contains(&slug) {
|
||||
@@ -6917,7 +6992,7 @@ fn connector_inserted_in_messages(
|
||||
return true;
|
||||
}
|
||||
|
||||
let mention_slug = connectors::connector_mention_slug(connector);
|
||||
let mention_slug = codex_connectors::metadata::connector_mention_slug(connector);
|
||||
let connector_count = connector_slug_counts
|
||||
.get(&mention_slug)
|
||||
.copied()
|
||||
@@ -7145,8 +7220,11 @@ pub(crate) async fn built_tools(
|
||||
connectors::with_app_enabled_state(connectors.clone(), &turn_context.config)
|
||||
});
|
||||
let connectors = if apps_enabled {
|
||||
let connectors = connectors::merge_plugin_apps_with_accessible(
|
||||
loaded_plugins.effective_apps(),
|
||||
let connectors = codex_connectors::merge::merge_plugin_connectors_with_accessible(
|
||||
loaded_plugins
|
||||
.effective_apps()
|
||||
.into_iter()
|
||||
.map(|connector_id| connector_id.0),
|
||||
accessible_connectors.clone().unwrap_or_default(),
|
||||
);
|
||||
Some(connectors::with_app_enabled_state(
|
||||
@@ -7206,7 +7284,22 @@ pub(crate) async fn built_tools(
|
||||
&turn_context.config,
|
||||
&turn_context.tools_config,
|
||||
);
|
||||
let direct_mcp_tools = has_mcp_servers.then_some(mcp_tool_exposure.direct_tools);
|
||||
let mcp_tools = has_mcp_servers.then_some(mcp_tool_exposure.direct_tools);
|
||||
let deferred_mcp_tools = mcp_tool_exposure.deferred_tools;
|
||||
let unavailable_called_tools = if turn_context
|
||||
.config
|
||||
.features
|
||||
.enabled(Feature::UnavailableDummyTools)
|
||||
{
|
||||
let exposed_tool_names = mcp_tools
|
||||
.iter()
|
||||
.chain(deferred_mcp_tools.iter())
|
||||
.flat_map(|tools| tools.keys().map(String::as_str))
|
||||
.collect::<HashSet<_>>();
|
||||
collect_unavailable_called_tools(input, &exposed_tool_names)
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
let parallel_mcp_server_names = turn_context
|
||||
.config
|
||||
@@ -7223,8 +7316,9 @@ pub(crate) async fn built_tools(
|
||||
Ok(Arc::new(ToolRouter::from_config(
|
||||
&turn_context.tools_config,
|
||||
ToolRouterParams {
|
||||
mcp_tools: direct_mcp_tools,
|
||||
deferred_mcp_tools: mcp_tool_exposure.deferred_tools,
|
||||
mcp_tools,
|
||||
deferred_mcp_tools,
|
||||
unavailable_called_tools,
|
||||
parallel_mcp_server_names,
|
||||
discoverable_tools,
|
||||
dynamic_tools: turn_context.dynamic_tools.as_slice(),
|
||||
@@ -7765,8 +7859,15 @@ async fn drain_in_flight(
|
||||
while let Some(res) = in_flight.next().await {
|
||||
match res {
|
||||
Ok(response_input) => {
|
||||
sess.record_conversation_items(&turn_context, &[response_input.into()])
|
||||
let response_item = response_input.into();
|
||||
sess.record_conversation_items(&turn_context, std::slice::from_ref(&response_item))
|
||||
.await;
|
||||
mark_thread_memory_mode_polluted_if_external_context(
|
||||
sess.as_ref(),
|
||||
turn_context.as_ref(),
|
||||
&response_item,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
Err(err) => {
|
||||
error_or_panic(format!("in-flight tool future failed during drain: {err}"));
|
||||
|
||||
@@ -68,6 +68,7 @@ async fn record_initial_history_resumed_bare_turn_context_does_not_hydrate_previ
|
||||
approval_policy: turn_context.approval_policy.value(),
|
||||
sandbox_policy: turn_context.sandbox_policy.get().clone(),
|
||||
network: None,
|
||||
file_system_sandbox_policy: None,
|
||||
model: previous_model.to_string(),
|
||||
personality: turn_context.personality,
|
||||
collaboration_mode: Some(turn_context.collaboration_mode.clone()),
|
||||
@@ -107,6 +108,7 @@ async fn record_initial_history_resumed_hydrates_previous_turn_settings_from_lif
|
||||
approval_policy: turn_context.approval_policy.value(),
|
||||
sandbox_policy: turn_context.sandbox_policy.get().clone(),
|
||||
network: None,
|
||||
file_system_sandbox_policy: None,
|
||||
model: previous_model.to_string(),
|
||||
personality: turn_context.personality,
|
||||
collaboration_mode: Some(turn_context.collaboration_mode.clone()),
|
||||
@@ -901,6 +903,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis
|
||||
approval_policy: turn_context.approval_policy.value(),
|
||||
sandbox_policy: turn_context.sandbox_policy.get().clone(),
|
||||
network: None,
|
||||
file_system_sandbox_policy: None,
|
||||
model: previous_model.to_string(),
|
||||
personality: turn_context.personality,
|
||||
collaboration_mode: Some(turn_context.collaboration_mode.clone()),
|
||||
@@ -976,6 +979,7 @@ async fn record_initial_history_resumed_turn_context_after_compaction_reestablis
|
||||
approval_policy: turn_context.approval_policy.value(),
|
||||
sandbox_policy: turn_context.sandbox_policy.get().clone(),
|
||||
network: None,
|
||||
file_system_sandbox_policy: None,
|
||||
model: previous_model.to_string(),
|
||||
personality: turn_context.personality,
|
||||
collaboration_mode: Some(turn_context.collaboration_mode.clone()),
|
||||
@@ -1005,6 +1009,7 @@ async fn record_initial_history_resumed_aborted_turn_without_id_clears_active_tu
|
||||
approval_policy: turn_context.approval_policy.value(),
|
||||
sandbox_policy: turn_context.sandbox_policy.get().clone(),
|
||||
network: None,
|
||||
file_system_sandbox_policy: None,
|
||||
model: previous_model.to_string(),
|
||||
personality: turn_context.personality,
|
||||
collaboration_mode: Some(turn_context.collaboration_mode.clone()),
|
||||
@@ -1117,6 +1122,7 @@ async fn record_initial_history_resumed_unmatched_abort_preserves_active_turn_fo
|
||||
approval_policy: turn_context.approval_policy.value(),
|
||||
sandbox_policy: turn_context.sandbox_policy.get().clone(),
|
||||
network: None,
|
||||
file_system_sandbox_policy: None,
|
||||
model: current_model.to_string(),
|
||||
personality: turn_context.personality,
|
||||
collaboration_mode: Some(turn_context.collaboration_mode.clone()),
|
||||
@@ -1227,6 +1233,7 @@ async fn record_initial_history_resumed_trailing_incomplete_turn_compaction_clea
|
||||
approval_policy: turn_context.approval_policy.value(),
|
||||
sandbox_policy: turn_context.sandbox_policy.get().clone(),
|
||||
network: None,
|
||||
file_system_sandbox_policy: None,
|
||||
model: previous_model.to_string(),
|
||||
personality: turn_context.personality,
|
||||
collaboration_mode: Some(turn_context.collaboration_mode.clone()),
|
||||
@@ -1376,6 +1383,7 @@ async fn record_initial_history_resumed_replaced_incomplete_compacted_turn_clear
|
||||
approval_policy: turn_context.approval_policy.value(),
|
||||
sandbox_policy: turn_context.sandbox_policy.get().clone(),
|
||||
network: None,
|
||||
file_system_sandbox_policy: None,
|
||||
model: previous_model.to_string(),
|
||||
personality: turn_context.personality,
|
||||
collaboration_mode: Some(turn_context.collaboration_mode.clone()),
|
||||
|
||||
@@ -45,6 +45,8 @@ use crate::rollout::recorder::RolloutRecorder;
|
||||
use crate::state::TaskKind;
|
||||
use crate::tasks::SessionTask;
|
||||
use crate::tasks::SessionTaskContext;
|
||||
use crate::tasks::UserShellCommandMode;
|
||||
use crate::tasks::execute_user_shell_command;
|
||||
use crate::tools::ToolRouter;
|
||||
use crate::tools::context::ToolInvocation;
|
||||
use crate::tools::context::ToolPayload;
|
||||
@@ -110,6 +112,7 @@ use opentelemetry::trace::TraceId;
|
||||
use std::path::Path;
|
||||
use std::time::Duration;
|
||||
use tokio::time::sleep;
|
||||
use tokio::time::timeout;
|
||||
use tracing_opentelemetry::OpenTelemetrySpanExt;
|
||||
|
||||
use codex_protocol::mcp::CallToolResult as McpCallToolResult;
|
||||
@@ -312,6 +315,7 @@ fn test_tool_runtime(session: Arc<Session>, turn_context: Arc<TurnContext>) -> T
|
||||
crate::tools::router::ToolRouterParams {
|
||||
mcp_tools: None,
|
||||
deferred_mcp_tools: None,
|
||||
unavailable_called_tools: Vec::new(),
|
||||
parallel_mcp_server_names: HashSet::new(),
|
||||
discoverable_tools: None,
|
||||
dynamic_tools: turn_context.dynamic_tools.as_slice(),
|
||||
@@ -409,7 +413,7 @@ fn make_mcp_tool(
|
||||
) -> ToolInfo {
|
||||
let tool_namespace = if server_name == CODEX_APPS_MCP_SERVER_NAME {
|
||||
connector_name
|
||||
.map(crate::connectors::sanitize_name)
|
||||
.map(codex_connectors::metadata::sanitize_name)
|
||||
.map(|connector_name| format!("mcp__{server_name}__{connector_name}"))
|
||||
.unwrap_or_else(|| server_name.to_string())
|
||||
} else {
|
||||
@@ -453,8 +457,8 @@ fn numbered_mcp_tools(count: usize) -> HashMap<String, ToolInfo> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn tools_config_for_mcp_tool_exposure(search_tool: bool) -> ToolsConfig {
|
||||
let config = test_config();
|
||||
async fn tools_config_for_mcp_tool_exposure(search_tool: bool) -> ToolsConfig {
|
||||
let config = test_config().await;
|
||||
let model_info = ModelsManager::construct_model_info_offline_for_tests(
|
||||
"gpt-5-codex",
|
||||
&config.to_models_manager_config(),
|
||||
@@ -655,6 +659,293 @@ async fn managed_network_proxy_decider_survives_full_access_start() -> anyhow::R
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn new_turn_refreshes_managed_network_proxy_for_sandbox_change() -> anyhow::Result<()> {
|
||||
let (mut session, _turn_context) = make_session_and_context().await;
|
||||
let initial_policy = SandboxPolicy::new_workspace_write_policy();
|
||||
|
||||
let mut network_config = NetworkProxyConfig::default();
|
||||
network_config
|
||||
.network
|
||||
.set_allowed_domains(vec!["evil.com".to_string()]);
|
||||
let requirements = NetworkConstraints {
|
||||
domains: Some(NetworkDomainPermissionsToml {
|
||||
entries: std::collections::BTreeMap::from([(
|
||||
"*.example.com".to_string(),
|
||||
NetworkDomainPermissionToml::Allow,
|
||||
)]),
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
let spec = crate::config::NetworkProxySpec::from_config_and_constraints(
|
||||
network_config,
|
||||
Some(requirements),
|
||||
&initial_policy,
|
||||
)?;
|
||||
let (started_proxy, _) = Session::start_managed_network_proxy(
|
||||
&spec,
|
||||
&Policy::empty(),
|
||||
&initial_policy,
|
||||
/*network_policy_decider*/ None,
|
||||
/*blocked_request_observer*/ None,
|
||||
/*managed_network_requirements_enabled*/ false,
|
||||
crate::config::NetworkProxyAuditMetadata::default(),
|
||||
)
|
||||
.await?;
|
||||
assert_eq!(
|
||||
started_proxy
|
||||
.proxy()
|
||||
.current_cfg()
|
||||
.await?
|
||||
.network
|
||||
.allowed_domains(),
|
||||
Some(vec!["*.example.com".to_string(), "evil.com".to_string()])
|
||||
);
|
||||
|
||||
{
|
||||
let mut state = session.state.lock().await;
|
||||
let mut config = (*state.session_configuration.original_config_do_not_use).clone();
|
||||
config.permissions.network = Some(spec);
|
||||
config.permissions.sandbox_policy =
|
||||
codex_config::Constrained::allow_any(initial_policy.clone());
|
||||
state.session_configuration.original_config_do_not_use = Arc::new(config);
|
||||
state.session_configuration.sandbox_policy =
|
||||
codex_config::Constrained::allow_any(initial_policy);
|
||||
}
|
||||
session.services.network_proxy = Some(started_proxy);
|
||||
|
||||
session
|
||||
.new_turn_with_sub_id(
|
||||
"sandbox-policy-change".to_string(),
|
||||
SessionSettingsUpdate {
|
||||
sandbox_policy: Some(SandboxPolicy::DangerFullAccess),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
|
||||
let started_proxy = session
|
||||
.services
|
||||
.network_proxy
|
||||
.as_ref()
|
||||
.expect("managed network proxy should be present");
|
||||
assert_eq!(
|
||||
started_proxy
|
||||
.proxy()
|
||||
.current_cfg()
|
||||
.await?
|
||||
.network
|
||||
.allowed_domains(),
|
||||
Some(vec!["*.example.com".to_string()])
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn danger_full_access_turns_do_not_expose_managed_network_proxy() -> anyhow::Result<()> {
|
||||
let network_spec = crate::config::NetworkProxySpec::from_config_and_constraints(
|
||||
NetworkProxyConfig::default(),
|
||||
Some(NetworkConstraints {
|
||||
enabled: Some(true),
|
||||
..Default::default()
|
||||
}),
|
||||
&SandboxPolicy::DangerFullAccess,
|
||||
)?;
|
||||
|
||||
let session = make_session_with_config(move |config| {
|
||||
config.permissions.sandbox_policy =
|
||||
codex_config::Constrained::allow_any(SandboxPolicy::DangerFullAccess);
|
||||
config.permissions.network = Some(network_spec);
|
||||
})
|
||||
.await?;
|
||||
|
||||
let turn_context = session.new_default_turn().await;
|
||||
assert!(turn_context.network.is_none());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn danger_full_access_tool_attempts_do_not_enforce_managed_network() -> anyhow::Result<()> {
|
||||
#[derive(Default)]
|
||||
struct ProbeToolRuntime {
|
||||
enforce_managed_network: Vec<bool>,
|
||||
}
|
||||
|
||||
impl crate::tools::sandboxing::Approvable<()> for ProbeToolRuntime {
|
||||
type ApprovalKey = String;
|
||||
|
||||
fn approval_keys(&self, _req: &()) -> Vec<Self::ApprovalKey> {
|
||||
vec!["probe".to_string()]
|
||||
}
|
||||
|
||||
fn start_approval_async<'a>(
|
||||
&'a mut self,
|
||||
_req: &'a (),
|
||||
_ctx: crate::tools::sandboxing::ApprovalCtx<'a>,
|
||||
) -> futures::future::BoxFuture<'a, ReviewDecision> {
|
||||
Box::pin(async { ReviewDecision::Approved })
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::tools::sandboxing::Sandboxable for ProbeToolRuntime {
|
||||
fn sandbox_preference(&self) -> codex_sandboxing::SandboxablePreference {
|
||||
codex_sandboxing::SandboxablePreference::Auto
|
||||
}
|
||||
}
|
||||
|
||||
impl crate::tools::sandboxing::ToolRuntime<(), ()> for ProbeToolRuntime {
|
||||
async fn run(
|
||||
&mut self,
|
||||
_req: &(),
|
||||
attempt: &crate::tools::sandboxing::SandboxAttempt<'_>,
|
||||
_ctx: &crate::tools::sandboxing::ToolCtx,
|
||||
) -> Result<(), crate::tools::sandboxing::ToolError> {
|
||||
self.enforce_managed_network
|
||||
.push(attempt.enforce_managed_network);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
let network_spec = crate::config::NetworkProxySpec::from_config_and_constraints(
|
||||
NetworkProxyConfig::default(),
|
||||
Some(NetworkConstraints {
|
||||
enabled: Some(true),
|
||||
..Default::default()
|
||||
}),
|
||||
&SandboxPolicy::DangerFullAccess,
|
||||
)?;
|
||||
|
||||
let session = make_session_with_config(move |config| {
|
||||
config.permissions.sandbox_policy =
|
||||
codex_config::Constrained::allow_any(SandboxPolicy::DangerFullAccess);
|
||||
config.permissions.network = Some(network_spec);
|
||||
|
||||
let layers = config
|
||||
.config_layer_stack
|
||||
.get_layers(
|
||||
ConfigLayerStackOrdering::LowestPrecedenceFirst,
|
||||
/*include_disabled*/ true,
|
||||
)
|
||||
.into_iter()
|
||||
.cloned()
|
||||
.collect();
|
||||
let mut requirements = config.config_layer_stack.requirements().clone();
|
||||
requirements.network = Some(Sourced::new(
|
||||
NetworkConstraints {
|
||||
enabled: Some(true),
|
||||
..Default::default()
|
||||
},
|
||||
RequirementSource::CloudRequirements,
|
||||
));
|
||||
let mut requirements_toml = config.config_layer_stack.requirements_toml().clone();
|
||||
requirements_toml.network = Some(crate::config_loader::NetworkRequirementsToml {
|
||||
enabled: Some(true),
|
||||
..Default::default()
|
||||
});
|
||||
config.config_layer_stack = ConfigLayerStack::new(layers, requirements, requirements_toml)
|
||||
.expect("rebuild config layer stack with network requirements");
|
||||
})
|
||||
.await?;
|
||||
|
||||
let turn = session.new_default_turn().await;
|
||||
assert!(turn.network.is_none());
|
||||
|
||||
let mut orchestrator = crate::tools::orchestrator::ToolOrchestrator::new();
|
||||
let mut tool = ProbeToolRuntime::default();
|
||||
let tool_ctx = crate::tools::sandboxing::ToolCtx {
|
||||
session: Arc::clone(&session),
|
||||
turn: Arc::clone(&turn),
|
||||
call_id: "probe-call".to_string(),
|
||||
tool_name: "probe".to_string(),
|
||||
};
|
||||
|
||||
orchestrator
|
||||
.run(
|
||||
&mut tool,
|
||||
&(),
|
||||
&tool_ctx,
|
||||
turn.as_ref(),
|
||||
AskForApproval::Never,
|
||||
)
|
||||
.await
|
||||
.expect("probe runtime should succeed");
|
||||
|
||||
assert_eq!(tool.enforce_managed_network, vec![false]);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn workspace_write_turns_continue_to_expose_managed_network_proxy() -> anyhow::Result<()> {
|
||||
let sandbox_policy = SandboxPolicy::new_workspace_write_policy();
|
||||
let network_spec = crate::config::NetworkProxySpec::from_config_and_constraints(
|
||||
NetworkProxyConfig::default(),
|
||||
Some(NetworkConstraints {
|
||||
enabled: Some(true),
|
||||
..Default::default()
|
||||
}),
|
||||
&sandbox_policy,
|
||||
)?;
|
||||
|
||||
let session = make_session_with_config(move |config| {
|
||||
config.permissions.sandbox_policy = codex_config::Constrained::allow_any(sandbox_policy);
|
||||
config.permissions.network = Some(network_spec);
|
||||
})
|
||||
.await?;
|
||||
|
||||
let turn_context = session.new_default_turn().await;
|
||||
assert!(turn_context.network.is_some());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn user_shell_commands_do_not_inherit_managed_network_proxy() -> anyhow::Result<()> {
|
||||
let sandbox_policy = SandboxPolicy::new_workspace_write_policy();
|
||||
let network_spec = crate::config::NetworkProxySpec::from_config_and_constraints(
|
||||
NetworkProxyConfig::default(),
|
||||
Some(NetworkConstraints {
|
||||
enabled: Some(true),
|
||||
..Default::default()
|
||||
}),
|
||||
&sandbox_policy,
|
||||
)?;
|
||||
|
||||
let (session, rx) = make_session_with_config_and_rx(move |config| {
|
||||
config.permissions.sandbox_policy = codex_config::Constrained::allow_any(sandbox_policy);
|
||||
config.permissions.network = Some(network_spec);
|
||||
})
|
||||
.await?;
|
||||
|
||||
let turn_context = session.new_default_turn().await;
|
||||
assert!(turn_context.network.is_some());
|
||||
|
||||
#[cfg(windows)]
|
||||
let command = r#"$val = $env:HTTP_PROXY; if ([string]::IsNullOrEmpty($val)) { $val = 'not-set' } ; [System.Console]::Write($val)"#.to_string();
|
||||
#[cfg(not(windows))]
|
||||
let command = r#"sh -c "printf '%s' \"${HTTP_PROXY:-not-set}\"""#.to_string();
|
||||
|
||||
execute_user_shell_command(
|
||||
Arc::clone(&session),
|
||||
turn_context,
|
||||
command,
|
||||
CancellationToken::new(),
|
||||
UserShellCommandMode::StandaloneTurn,
|
||||
)
|
||||
.await;
|
||||
|
||||
loop {
|
||||
let event = rx.recv().await.expect("channel open");
|
||||
if let EventMsg::ExecCommandEnd(event) = event.msg {
|
||||
assert_eq!(event.exit_code, 0);
|
||||
assert_eq!(event.stdout.trim(), "not-set");
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn get_base_instructions_no_user_content() {
|
||||
let prompt_with_apply_patch_instructions =
|
||||
@@ -690,7 +981,7 @@ async fn get_base_instructions_no_user_content() {
|
||||
];
|
||||
|
||||
let (session, _turn_context) = make_session_and_context().await;
|
||||
let config = test_config();
|
||||
let config = test_config().await;
|
||||
|
||||
for test_case in test_cases {
|
||||
let model_info = model_info_for_slug(test_case.slug, &config);
|
||||
@@ -855,10 +1146,10 @@ fn collect_explicit_app_ids_from_skill_items_skips_plain_mentions_with_skill_con
|
||||
assert_eq!(connector_ids, HashSet::<String>::new());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_tool_exposure_directly_exposes_small_effective_tool_sets() {
|
||||
let config = test_config();
|
||||
let tools_config = tools_config_for_mcp_tool_exposure(/*search_tool*/ true);
|
||||
#[tokio::test]
|
||||
async fn mcp_tool_exposure_directly_exposes_small_effective_tool_sets() {
|
||||
let config = test_config().await;
|
||||
let tools_config = tools_config_for_mcp_tool_exposure(/*search_tool*/ true).await;
|
||||
let mcp_tools = numbered_mcp_tools(DIRECT_MCP_TOOL_EXPOSURE_THRESHOLD - 1);
|
||||
|
||||
let exposure = build_mcp_tool_exposure(
|
||||
@@ -877,10 +1168,10 @@ fn mcp_tool_exposure_directly_exposes_small_effective_tool_sets() {
|
||||
assert!(exposure.deferred_tools.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_tool_exposure_searches_large_effective_tool_sets() {
|
||||
let config = test_config();
|
||||
let tools_config = tools_config_for_mcp_tool_exposure(/*search_tool*/ true);
|
||||
#[tokio::test]
|
||||
async fn mcp_tool_exposure_searches_large_effective_tool_sets() {
|
||||
let config = test_config().await;
|
||||
let tools_config = tools_config_for_mcp_tool_exposure(/*search_tool*/ true).await;
|
||||
let mcp_tools = numbered_mcp_tools(DIRECT_MCP_TOOL_EXPOSURE_THRESHOLD);
|
||||
|
||||
let exposure = build_mcp_tool_exposure(
|
||||
@@ -903,10 +1194,10 @@ fn mcp_tool_exposure_searches_large_effective_tool_sets() {
|
||||
assert_eq!(deferred_tool_names, expected_tool_names);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_tool_exposure_directly_exposes_explicit_apps_in_large_search_sets() {
|
||||
let config = test_config();
|
||||
let tools_config = tools_config_for_mcp_tool_exposure(/*search_tool*/ true);
|
||||
#[tokio::test]
|
||||
async fn mcp_tool_exposure_directly_exposes_explicit_apps_without_deferred_overlap() {
|
||||
let config = test_config().await;
|
||||
let tools_config = tools_config_for_mcp_tool_exposure(/*search_tool*/ true).await;
|
||||
let mut mcp_tools = numbered_mcp_tools(DIRECT_MCP_TOOL_EXPOSURE_THRESHOLD - 1);
|
||||
mcp_tools.extend([(
|
||||
"mcp__codex_apps__calendar_create_event".to_string(),
|
||||
@@ -935,13 +1226,19 @@ fn mcp_tool_exposure_directly_exposes_explicit_apps_in_large_search_sets() {
|
||||
);
|
||||
assert_eq!(
|
||||
exposure.deferred_tools.as_ref().map(HashMap::len),
|
||||
Some(DIRECT_MCP_TOOL_EXPOSURE_THRESHOLD)
|
||||
Some(DIRECT_MCP_TOOL_EXPOSURE_THRESHOLD - 1)
|
||||
);
|
||||
let deferred_tools = exposure
|
||||
.deferred_tools
|
||||
.as_ref()
|
||||
.expect("large tool sets should be discoverable through tool_search");
|
||||
assert!(deferred_tools.contains_key("mcp__codex_apps__calendar_create_event"));
|
||||
assert!(
|
||||
tool_names
|
||||
.iter()
|
||||
.all(|direct_tool_name| !deferred_tools.contains_key(direct_tool_name)),
|
||||
"direct tools should not also be deferred: {tool_names:?}"
|
||||
);
|
||||
assert!(!deferred_tools.contains_key("mcp__codex_apps__calendar_create_event"));
|
||||
assert!(deferred_tools.contains_key("mcp__rmcp__tool_0"));
|
||||
}
|
||||
|
||||
@@ -1342,6 +1639,7 @@ async fn record_initial_history_forked_hydrates_previous_turn_settings() {
|
||||
approval_policy: turn_context.approval_policy.value(),
|
||||
sandbox_policy: turn_context.sandbox_policy.get().clone(),
|
||||
network: None,
|
||||
file_system_sandbox_policy: None,
|
||||
model: previous_model.to_string(),
|
||||
personality: turn_context.personality,
|
||||
collaboration_mode: Some(turn_context.collaboration_mode.clone()),
|
||||
@@ -2814,6 +3112,11 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) {
|
||||
}),
|
||||
rollout: Mutex::new(None),
|
||||
user_shell: Arc::new(default_user_shell()),
|
||||
agent_identity_manager: Arc::new(crate::agent_identity::AgentIdentityManager::new(
|
||||
config.as_ref(),
|
||||
Arc::clone(&auth_manager),
|
||||
session_configuration.session_source.clone(),
|
||||
)),
|
||||
shell_snapshot_tx: watch::channel(None).0,
|
||||
show_raw_agent_reasoning: config.show_raw_agent_reasoning,
|
||||
exec_policy,
|
||||
@@ -2830,6 +3133,9 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) {
|
||||
network_proxy: None,
|
||||
network_approval: Arc::clone(&network_approval),
|
||||
state_db: None,
|
||||
thread_store: codex_thread_store::LocalThreadStore::new(
|
||||
codex_rollout::RolloutConfig::from_view(config.as_ref()),
|
||||
),
|
||||
model_client: ModelClient::new(
|
||||
Some(auth_manager.clone()),
|
||||
conversation_id,
|
||||
@@ -2908,6 +3214,109 @@ pub(crate) async fn make_session_and_context() -> (Session, TurnContext) {
|
||||
(session, turn_context)
|
||||
}
|
||||
|
||||
async fn make_session_with_config(
|
||||
mutator: impl FnOnce(&mut Config),
|
||||
) -> anyhow::Result<Arc<Session>> {
|
||||
let (session, _rx_event) = make_session_with_config_and_rx(mutator).await?;
|
||||
Ok(session)
|
||||
}
|
||||
|
||||
async fn make_session_with_config_and_rx(
|
||||
mutator: impl FnOnce(&mut Config),
|
||||
) -> anyhow::Result<(Arc<Session>, async_channel::Receiver<Event>)> {
|
||||
let codex_home = tempfile::tempdir().expect("create temp dir");
|
||||
let mut config = build_test_config(codex_home.path()).await;
|
||||
mutator(&mut config);
|
||||
let config = Arc::new(config);
|
||||
let auth_manager = AuthManager::from_auth_for_testing(CodexAuth::from_api_key("Test API Key"));
|
||||
let models_manager = Arc::new(ModelsManager::new(
|
||||
config.codex_home.to_path_buf(),
|
||||
auth_manager.clone(),
|
||||
/*model_catalog*/ None,
|
||||
CollaborationModesConfig::default(),
|
||||
));
|
||||
let model = ModelsManager::get_model_offline_for_tests(config.model.as_deref());
|
||||
let model_info = ModelsManager::construct_model_info_offline_for_tests(
|
||||
model.as_str(),
|
||||
&config.to_models_manager_config(),
|
||||
);
|
||||
let collaboration_mode = CollaborationMode {
|
||||
mode: ModeKind::Default,
|
||||
settings: Settings {
|
||||
model,
|
||||
reasoning_effort: config.model_reasoning_effort,
|
||||
developer_instructions: None,
|
||||
},
|
||||
};
|
||||
let session_configuration = SessionConfiguration {
|
||||
provider: config.model_provider.clone(),
|
||||
collaboration_mode,
|
||||
model_reasoning_summary: config.model_reasoning_summary,
|
||||
developer_instructions: config.developer_instructions.clone(),
|
||||
user_instructions: config.user_instructions.clone(),
|
||||
service_tier: None,
|
||||
personality: config.personality,
|
||||
base_instructions: config
|
||||
.base_instructions
|
||||
.clone()
|
||||
.unwrap_or_else(|| model_info.get_model_instructions(config.personality)),
|
||||
compact_prompt: config.compact_prompt.clone(),
|
||||
approval_policy: config.permissions.approval_policy.clone(),
|
||||
approvals_reviewer: config.approvals_reviewer,
|
||||
sandbox_policy: config.permissions.sandbox_policy.clone(),
|
||||
file_system_sandbox_policy: config.permissions.file_system_sandbox_policy.clone(),
|
||||
network_sandbox_policy: config.permissions.network_sandbox_policy,
|
||||
windows_sandbox_level: WindowsSandboxLevel::from_config(&config),
|
||||
cwd: config.cwd.clone(),
|
||||
codex_home: config.codex_home.clone(),
|
||||
thread_name: None,
|
||||
original_config_do_not_use: Arc::clone(&config),
|
||||
metrics_service_name: None,
|
||||
app_server_client_name: None,
|
||||
app_server_client_version: None,
|
||||
session_source: SessionSource::Exec,
|
||||
dynamic_tools: Vec::new(),
|
||||
persist_extended_history: false,
|
||||
inherited_shell_snapshot: None,
|
||||
user_shell_override: None,
|
||||
};
|
||||
|
||||
let (tx_event, rx_event) = async_channel::unbounded();
|
||||
let (agent_status_tx, _agent_status_rx) = watch::channel(AgentStatus::PendingInit);
|
||||
let plugins_manager = Arc::new(PluginsManager::new(config.codex_home.to_path_buf()));
|
||||
let mcp_manager = Arc::new(McpManager::new(Arc::clone(&plugins_manager)));
|
||||
let skills_manager = Arc::new(SkillsManager::new(
|
||||
config.codex_home.clone(),
|
||||
/*bundled_skills_enabled*/ true,
|
||||
));
|
||||
|
||||
let session = Session::new(
|
||||
session_configuration,
|
||||
Arc::clone(&config),
|
||||
auth_manager,
|
||||
models_manager,
|
||||
Arc::new(ExecPolicyManager::default()),
|
||||
tx_event,
|
||||
agent_status_tx,
|
||||
InitialHistory::New,
|
||||
SessionSource::Exec,
|
||||
skills_manager,
|
||||
plugins_manager,
|
||||
mcp_manager,
|
||||
Arc::new(SkillsWatcher::noop()),
|
||||
AgentControl::default(),
|
||||
Some(Arc::new(
|
||||
codex_exec_server::Environment::create(/*exec_server_url*/ None)
|
||||
.await
|
||||
.expect("create environment"),
|
||||
)),
|
||||
/*analytics_events_client*/ None,
|
||||
)
|
||||
.await?;
|
||||
|
||||
Ok((session, rx_event))
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn notify_request_permissions_response_ignores_unmatched_call_id() {
|
||||
let (session, _turn_context) = make_session_and_context().await;
|
||||
@@ -3666,6 +4075,11 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx(
|
||||
}),
|
||||
rollout: Mutex::new(None),
|
||||
user_shell: Arc::new(default_user_shell()),
|
||||
agent_identity_manager: Arc::new(crate::agent_identity::AgentIdentityManager::new(
|
||||
config.as_ref(),
|
||||
Arc::clone(&auth_manager),
|
||||
session_configuration.session_source.clone(),
|
||||
)),
|
||||
shell_snapshot_tx: watch::channel(None).0,
|
||||
show_raw_agent_reasoning: config.show_raw_agent_reasoning,
|
||||
exec_policy,
|
||||
@@ -3682,6 +4096,9 @@ pub(crate) async fn make_session_and_context_with_dynamic_tools_and_rx(
|
||||
network_proxy: None,
|
||||
network_approval: Arc::clone(&network_approval),
|
||||
state_db: None,
|
||||
thread_store: codex_thread_store::LocalThreadStore::new(
|
||||
codex_rollout::RolloutConfig::from_view(config.as_ref()),
|
||||
),
|
||||
model_client: ModelClient::new(
|
||||
Some(Arc::clone(&auth_manager)),
|
||||
conversation_id,
|
||||
@@ -3770,6 +4187,42 @@ pub(crate) async fn make_session_and_context_with_rx() -> (
|
||||
make_session_and_context_with_dynamic_tools_and_rx(Vec::new()).await
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fail_agent_identity_registration_emits_error_and_shutdown() {
|
||||
let (session, _turn_context, rx_event) = make_session_and_context_with_rx().await;
|
||||
|
||||
session
|
||||
.fail_agent_identity_registration(anyhow::anyhow!("registration exploded"))
|
||||
.await;
|
||||
|
||||
let error_event = timeout(Duration::from_secs(1), rx_event.recv())
|
||||
.await
|
||||
.expect("error event should arrive")
|
||||
.expect("error event should be readable");
|
||||
match error_event.msg {
|
||||
EventMsg::Error(ErrorEvent {
|
||||
message,
|
||||
codex_error_info,
|
||||
}) => {
|
||||
assert_eq!(
|
||||
message,
|
||||
"Agent identity registration failed. Codex cannot continue while `features.use_agent_identity` is enabled: registration exploded".to_string()
|
||||
);
|
||||
assert_eq!(codex_error_info, Some(CodexErrorInfo::Other));
|
||||
}
|
||||
other => panic!("expected error event, got {other:?}"),
|
||||
}
|
||||
|
||||
let shutdown_event = timeout(Duration::from_secs(1), rx_event.recv())
|
||||
.await
|
||||
.expect("shutdown event should arrive")
|
||||
.expect("shutdown event should be readable");
|
||||
match shutdown_event.msg {
|
||||
EventMsg::ShutdownComplete => {}
|
||||
other => panic!("expected shutdown event, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn refresh_mcp_servers_is_deferred_until_next_turn() {
|
||||
let (session, turn_context) = make_session_and_context().await;
|
||||
@@ -4268,6 +4721,47 @@ async fn build_initial_context_restates_realtime_start_when_reference_context_is
|
||||
);
|
||||
}
|
||||
|
||||
fn file_system_policy_with_unreadable_glob(turn_context: &TurnContext) -> FileSystemSandboxPolicy {
|
||||
let mut policy = FileSystemSandboxPolicy::from_legacy_sandbox_policy(
|
||||
turn_context.sandbox_policy.get(),
|
||||
&turn_context.cwd,
|
||||
);
|
||||
policy.entries.push(FileSystemSandboxEntry {
|
||||
path: FileSystemPath::GlobPattern {
|
||||
pattern: format!("{}/**/*.env", turn_context.cwd.as_path().display()),
|
||||
},
|
||||
access: FileSystemAccessMode::None,
|
||||
});
|
||||
policy
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn turn_context_item_omits_legacy_equivalent_file_system_sandbox_policy() {
|
||||
let (_session, mut turn_context) = make_session_and_context().await;
|
||||
turn_context.file_system_sandbox_policy = FileSystemSandboxPolicy::from_legacy_sandbox_policy(
|
||||
turn_context.sandbox_policy.get(),
|
||||
&turn_context.cwd,
|
||||
);
|
||||
|
||||
let item = turn_context.to_turn_context_item();
|
||||
|
||||
assert_eq!(item.file_system_sandbox_policy, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn turn_context_item_stores_split_file_system_sandbox_policy_when_different() {
|
||||
let (_session, mut turn_context) = make_session_and_context().await;
|
||||
let file_system_sandbox_policy = file_system_policy_with_unreadable_glob(&turn_context);
|
||||
turn_context.file_system_sandbox_policy = file_system_sandbox_policy.clone();
|
||||
|
||||
let item = turn_context.to_turn_context_item();
|
||||
|
||||
assert_eq!(
|
||||
item.file_system_sandbox_policy,
|
||||
Some(file_system_sandbox_policy)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn record_context_updates_and_set_reference_context_item_injects_full_context_when_baseline_missing()
|
||||
{
|
||||
@@ -4406,6 +4900,56 @@ async fn record_context_updates_and_set_reference_context_item_persists_baseline
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn record_context_updates_and_set_reference_context_item_persists_split_file_system_policy_to_rollout()
|
||||
{
|
||||
let (session, mut turn_context) = make_session_and_context().await;
|
||||
let file_system_sandbox_policy = file_system_policy_with_unreadable_glob(&turn_context);
|
||||
turn_context.file_system_sandbox_policy = file_system_sandbox_policy.clone();
|
||||
let config = session.get_config().await;
|
||||
let recorder = RolloutRecorder::new(
|
||||
config.as_ref(),
|
||||
RolloutRecorderParams::new(
|
||||
ThreadId::default(),
|
||||
/*forked_from_id*/ None,
|
||||
SessionSource::Exec,
|
||||
BaseInstructions::default(),
|
||||
Vec::new(),
|
||||
EventPersistenceMode::Limited,
|
||||
),
|
||||
/*state_db_ctx*/ None,
|
||||
/*state_builder*/ None,
|
||||
)
|
||||
.await
|
||||
.expect("create rollout recorder");
|
||||
let rollout_path = recorder.rollout_path().to_path_buf();
|
||||
{
|
||||
let mut rollout = session.services.rollout.lock().await;
|
||||
*rollout = Some(recorder);
|
||||
}
|
||||
|
||||
session
|
||||
.record_context_updates_and_set_reference_context_item(&turn_context)
|
||||
.await;
|
||||
session.ensure_rollout_materialized().await;
|
||||
session.flush_rollout().await.expect("rollout should flush");
|
||||
|
||||
let InitialHistory::Resumed(resumed) = RolloutRecorder::get_rollout_history(&rollout_path)
|
||||
.await
|
||||
.expect("read rollout history")
|
||||
else {
|
||||
panic!("expected resumed rollout history");
|
||||
};
|
||||
let persisted_file_system_sandbox_policy = resumed.history.iter().find_map(|item| match item {
|
||||
RolloutItem::TurnContext(ctx) => ctx.file_system_sandbox_policy.clone(),
|
||||
_ => None,
|
||||
});
|
||||
assert_eq!(
|
||||
persisted_file_system_sandbox_policy,
|
||||
Some(file_system_sandbox_policy)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn build_initial_context_prepends_model_switch_message() {
|
||||
let (session, turn_context) = make_session_and_context().await;
|
||||
@@ -5315,6 +5859,7 @@ async fn fatal_tool_error_stops_turn_and_reports_error() {
|
||||
crate::tools::router::ToolRouterParams {
|
||||
deferred_mcp_tools,
|
||||
mcp_tools: Some(tools),
|
||||
unavailable_called_tools: Vec::new(),
|
||||
parallel_mcp_server_names: HashSet::new(),
|
||||
discoverable_tools: None,
|
||||
dynamic_tools: turn_context.dynamic_tools.as_slice(),
|
||||
|
||||
@@ -58,7 +58,7 @@ pub(crate) enum InitialContextInjection {
|
||||
}
|
||||
|
||||
pub(crate) fn should_use_remote_compact_task(provider: &ModelProviderInfo) -> bool {
|
||||
provider.is_openai()
|
||||
provider.supports_remote_compaction()
|
||||
}
|
||||
|
||||
pub(crate) async fn run_inline_auto_compact_task(
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
use super::*;
|
||||
use codex_model_provider_info::ModelProviderInfo;
|
||||
use codex_model_provider_info::WireApi;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
async fn process_compacted_history_with_test_session(
|
||||
@@ -185,6 +187,30 @@ fn build_token_limited_compacted_history_appends_summary_message() {
|
||||
assert_eq!(summary, summary_text);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_use_remote_compact_task_for_azure_provider() {
|
||||
let provider = ModelProviderInfo {
|
||||
name: "Azure".into(),
|
||||
base_url: Some("https://example.com/openai".into()),
|
||||
env_key: Some("AZURE_OPENAI_API_KEY".into()),
|
||||
env_key_instructions: None,
|
||||
experimental_bearer_token: None,
|
||||
auth: None,
|
||||
wire_api: WireApi::Responses,
|
||||
query_params: None,
|
||||
http_headers: None,
|
||||
env_http_headers: None,
|
||||
request_max_retries: None,
|
||||
stream_max_retries: None,
|
||||
stream_idle_timeout_ms: None,
|
||||
websocket_connect_timeout_ms: None,
|
||||
requires_openai_auth: false,
|
||||
supports_websockets: false,
|
||||
};
|
||||
|
||||
assert!(should_use_remote_compact_task(&provider));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn process_compacted_history_replaces_developer_messages() {
|
||||
let compacted_history = vec![
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -222,6 +222,9 @@ mod document_helpers {
|
||||
if !config.enabled {
|
||||
entry["enabled"] = value(false);
|
||||
}
|
||||
if let Some(environment) = &config.experimental_environment {
|
||||
entry["experimental_environment"] = value(environment.clone());
|
||||
}
|
||||
if config.required {
|
||||
entry["required"] = value(true);
|
||||
}
|
||||
@@ -234,6 +237,13 @@ mod document_helpers {
|
||||
if let Some(timeout) = config.tool_timeout_sec {
|
||||
entry["tool_timeout_sec"] = value(timeout.as_secs_f64());
|
||||
}
|
||||
if let Some(approval_mode) = config.default_tools_approval_mode {
|
||||
entry["default_tools_approval_mode"] = value(match approval_mode {
|
||||
AppToolApproval::Auto => "auto",
|
||||
AppToolApproval::Prompt => "prompt",
|
||||
AppToolApproval::Approve => "approve",
|
||||
});
|
||||
}
|
||||
if let Some(enabled_tools) = &config.enabled_tools
|
||||
&& !enabled_tools.is_empty()
|
||||
{
|
||||
|
||||
@@ -575,12 +575,14 @@ fn blocking_replace_mcp_servers_round_trips() {
|
||||
env_vars: vec!["FOO".to_string()],
|
||||
cwd: None,
|
||||
},
|
||||
experimental_environment: None,
|
||||
enabled: true,
|
||||
required: false,
|
||||
supports_parallel_tool_calls: true,
|
||||
disabled_reason: None,
|
||||
startup_timeout_sec: None,
|
||||
tool_timeout_sec: None,
|
||||
default_tools_approval_mode: None,
|
||||
enabled_tools: Some(vec!["one".to_string(), "two".to_string()]),
|
||||
disabled_tools: None,
|
||||
scopes: None,
|
||||
@@ -602,12 +604,14 @@ fn blocking_replace_mcp_servers_round_trips() {
|
||||
),
|
||||
env_http_headers: None,
|
||||
},
|
||||
experimental_environment: None,
|
||||
enabled: false,
|
||||
required: false,
|
||||
supports_parallel_tool_calls: false,
|
||||
disabled_reason: None,
|
||||
startup_timeout_sec: Some(std::time::Duration::from_secs(5)),
|
||||
tool_timeout_sec: None,
|
||||
default_tools_approval_mode: None,
|
||||
enabled_tools: None,
|
||||
disabled_tools: Some(vec!["forbidden".to_string()]),
|
||||
scopes: None,
|
||||
@@ -666,12 +670,14 @@ fn blocking_replace_mcp_servers_serializes_tool_approval_overrides() {
|
||||
env_vars: Vec::new(),
|
||||
cwd: None,
|
||||
},
|
||||
experimental_environment: None,
|
||||
enabled: true,
|
||||
required: false,
|
||||
supports_parallel_tool_calls: false,
|
||||
disabled_reason: None,
|
||||
startup_timeout_sec: None,
|
||||
tool_timeout_sec: None,
|
||||
default_tools_approval_mode: Some(AppToolApproval::Prompt),
|
||||
enabled_tools: None,
|
||||
disabled_tools: None,
|
||||
scopes: None,
|
||||
@@ -696,6 +702,7 @@ fn blocking_replace_mcp_servers_serializes_tool_approval_overrides() {
|
||||
let expected = "\
|
||||
[mcp_servers.docs]
|
||||
command = \"docs-server\"
|
||||
default_tools_approval_mode = \"prompt\"
|
||||
|
||||
[mcp_servers.docs.tools.search]
|
||||
approval_mode = \"approve\"
|
||||
@@ -727,12 +734,14 @@ foo = { command = "cmd" }
|
||||
env_vars: Vec::new(),
|
||||
cwd: None,
|
||||
},
|
||||
experimental_environment: None,
|
||||
enabled: true,
|
||||
required: false,
|
||||
supports_parallel_tool_calls: false,
|
||||
disabled_reason: None,
|
||||
startup_timeout_sec: None,
|
||||
tool_timeout_sec: None,
|
||||
default_tools_approval_mode: None,
|
||||
enabled_tools: None,
|
||||
disabled_tools: None,
|
||||
scopes: None,
|
||||
@@ -779,12 +788,14 @@ foo = { command = "cmd" } # keep me
|
||||
env_vars: Vec::new(),
|
||||
cwd: None,
|
||||
},
|
||||
experimental_environment: None,
|
||||
enabled: false,
|
||||
required: false,
|
||||
supports_parallel_tool_calls: false,
|
||||
disabled_reason: None,
|
||||
startup_timeout_sec: None,
|
||||
tool_timeout_sec: None,
|
||||
default_tools_approval_mode: None,
|
||||
enabled_tools: None,
|
||||
disabled_tools: None,
|
||||
scopes: None,
|
||||
@@ -830,12 +841,14 @@ foo = { command = "cmd", args = ["--flag"] } # keep me
|
||||
env_vars: Vec::new(),
|
||||
cwd: None,
|
||||
},
|
||||
experimental_environment: None,
|
||||
enabled: true,
|
||||
required: false,
|
||||
supports_parallel_tool_calls: false,
|
||||
disabled_reason: None,
|
||||
startup_timeout_sec: None,
|
||||
tool_timeout_sec: None,
|
||||
default_tools_approval_mode: None,
|
||||
enabled_tools: None,
|
||||
disabled_tools: None,
|
||||
scopes: None,
|
||||
@@ -882,12 +895,14 @@ foo = { command = "cmd" }
|
||||
env_vars: Vec::new(),
|
||||
cwd: None,
|
||||
},
|
||||
experimental_environment: None,
|
||||
enabled: false,
|
||||
required: false,
|
||||
supports_parallel_tool_calls: false,
|
||||
disabled_reason: None,
|
||||
startup_timeout_sec: None,
|
||||
tool_timeout_sec: None,
|
||||
default_tools_approval_mode: None,
|
||||
enabled_tools: None,
|
||||
disabled_tools: None,
|
||||
scopes: None,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
use crate::agents_md::AgentsMdManager;
|
||||
use crate::config::edit::ConfigEdit;
|
||||
use crate::config::edit::ConfigEditsBuilder;
|
||||
use crate::config_loader::CloudRequirementsLoader;
|
||||
@@ -15,8 +16,6 @@ use crate::config_loader::load_config_layers_state;
|
||||
use crate::config_loader::project_trust_key;
|
||||
use crate::memories::memory_root;
|
||||
use crate::path_utils::normalize_for_native_workdir;
|
||||
use crate::project_doc::DEFAULT_PROJECT_DOC_FILENAME;
|
||||
use crate::project_doc::LOCAL_PROJECT_DOC_FILENAME;
|
||||
use crate::unified_exec::DEFAULT_MAX_BACKGROUND_TERMINAL_TIMEOUT_MS;
|
||||
use crate::unified_exec::MIN_EMPTY_YIELD_TIME_MS;
|
||||
use crate::windows_sandbox::WindowsSandboxLevelExt;
|
||||
@@ -120,7 +119,7 @@ pub use codex_git_utils::GhostSnapshotConfig;
|
||||
/// Maximum number of bytes of the documentation that will be embedded. Larger
|
||||
/// files are *silently truncated* to this size so we do not take up too much of
|
||||
/// the context window.
|
||||
pub(crate) const PROJECT_DOC_MAX_BYTES: usize = 32 * 1024; // 32 KiB
|
||||
pub(crate) const AGENTS_MD_MAX_BYTES: usize = 32 * 1024; // 32 KiB
|
||||
pub(crate) const DEFAULT_AGENT_MAX_THREADS: Option<usize> = Some(6);
|
||||
pub(crate) const DEFAULT_AGENT_MAX_DEPTH: i32 = 1;
|
||||
pub(crate) const DEFAULT_AGENT_JOB_MAX_RUNTIME_SECONDS: Option<u64> = None;
|
||||
@@ -169,13 +168,14 @@ fn resolve_mcp_oauth_credentials_store_mode(
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn test_config() -> Config {
|
||||
pub(crate) async fn test_config() -> Config {
|
||||
let codex_home = tempfile::tempdir().expect("create temp dir");
|
||||
Config::load_from_base_config_with_overrides(
|
||||
ConfigToml::default(),
|
||||
ConfigOverrides::default(),
|
||||
AbsolutePathBuf::from_absolute_path(codex_home.path()).expect("temp dir should resolve"),
|
||||
)
|
||||
.await
|
||||
.expect("load default test config")
|
||||
}
|
||||
|
||||
@@ -271,9 +271,6 @@ pub struct Config {
|
||||
/// User-provided instructions from AGENTS.md.
|
||||
pub user_instructions: Option<String>,
|
||||
|
||||
/// Path to the global AGENTS file loaded into `user_instructions`.
|
||||
pub user_instructions_path: Option<AbsolutePathBuf>,
|
||||
|
||||
/// Base instructions override.
|
||||
pub base_instructions: Option<String>,
|
||||
|
||||
@@ -726,6 +723,7 @@ impl ConfigBuilder {
|
||||
codex_home,
|
||||
config_layer_stack,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -786,7 +784,7 @@ impl Config {
|
||||
}
|
||||
|
||||
/// Load a default configuration when user config files are invalid.
|
||||
pub fn load_default_with_cli_overrides(
|
||||
pub async fn load_default_with_cli_overrides(
|
||||
cli_overrides: Vec<(String, TomlValue)>,
|
||||
) -> std::io::Result<Self> {
|
||||
let codex_home = find_codex_home()?;
|
||||
@@ -794,11 +792,12 @@ impl Config {
|
||||
codex_home.to_path_buf(),
|
||||
cli_overrides,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Load a default configuration for a specific Codex home without reading
|
||||
/// user, project, or system config layers.
|
||||
pub fn load_default_with_cli_overrides_for_codex_home(
|
||||
pub async fn load_default_with_cli_overrides_for_codex_home(
|
||||
codex_home: PathBuf,
|
||||
cli_overrides: Vec<(String, TomlValue)>,
|
||||
) -> std::io::Result<Self> {
|
||||
@@ -818,6 +817,7 @@ impl Config {
|
||||
codex_home,
|
||||
ConfigLayerStack::default(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// This is a secondary way of creating [Config], which is appropriate when
|
||||
@@ -1413,22 +1413,24 @@ pub(crate) fn resolve_web_search_mode_for_turn(
|
||||
|
||||
impl Config {
|
||||
#[cfg(test)]
|
||||
fn load_from_base_config_with_overrides(
|
||||
async fn load_from_base_config_with_overrides(
|
||||
cfg: ConfigToml,
|
||||
overrides: ConfigOverrides,
|
||||
codex_home: AbsolutePathBuf,
|
||||
) -> std::io::Result<Self> {
|
||||
// Note this ignores requirements.toml enforcement for tests.
|
||||
let config_layer_stack = ConfigLayerStack::default();
|
||||
Self::load_config_with_layer_stack(cfg, overrides, codex_home, config_layer_stack)
|
||||
Self::load_config_with_layer_stack(cfg, overrides, codex_home, config_layer_stack).await
|
||||
}
|
||||
|
||||
pub(crate) fn load_config_with_layer_stack(
|
||||
pub(crate) async fn load_config_with_layer_stack(
|
||||
cfg: ConfigToml,
|
||||
overrides: ConfigOverrides,
|
||||
codex_home: AbsolutePathBuf,
|
||||
config_layer_stack: ConfigLayerStack,
|
||||
) -> std::io::Result<Self> {
|
||||
// Keep the large config-construction future off small test thread stacks.
|
||||
Box::pin(async move {
|
||||
validate_model_providers(&cfg.model_providers)
|
||||
.map_err(|message| std::io::Error::new(std::io::ErrorKind::InvalidInput, message))?;
|
||||
// Ensure that every field of ConfigRequirements is applied to the final
|
||||
@@ -1445,10 +1447,8 @@ impl Config {
|
||||
network: network_requirements,
|
||||
} = config_layer_stack.requirements().clone();
|
||||
|
||||
let (user_instructions, user_instructions_path) =
|
||||
Self::load_instructions(Some(&codex_home))
|
||||
.map(|loaded| (Some(loaded.contents), Some(loaded.path)))
|
||||
.unwrap_or((None, None));
|
||||
let user_instructions = AgentsMdManager::load_global_instructions(Some(&codex_home))
|
||||
.map(|loaded| loaded.contents);
|
||||
let mut startup_warnings = Vec::new();
|
||||
|
||||
// Destructure ConfigOverrides fully to ensure all overrides are applied.
|
||||
@@ -1547,6 +1547,7 @@ impl Config {
|
||||
.collect();
|
||||
let active_project = cfg
|
||||
.get_active_project(resolved_cwd.as_path())
|
||||
.await
|
||||
.unwrap_or(ProjectConfig { trust_level: None });
|
||||
let permission_config_syntax = resolve_permission_config_syntax(
|
||||
&config_layer_stack,
|
||||
@@ -1615,6 +1616,7 @@ impl Config {
|
||||
compile_permission_profile(
|
||||
permissions,
|
||||
default_permissions,
|
||||
resolved_cwd.as_path(),
|
||||
&mut startup_warnings,
|
||||
)?;
|
||||
let mut sandbox_policy = file_system_sandbox_policy
|
||||
@@ -1636,13 +1638,15 @@ impl Config {
|
||||
)
|
||||
} else {
|
||||
let configured_network_proxy_config = NetworkProxyConfig::default();
|
||||
let mut sandbox_policy = cfg.derive_sandbox_policy(
|
||||
sandbox_mode,
|
||||
config_profile.sandbox_mode,
|
||||
windows_sandbox_level,
|
||||
resolved_cwd.as_path(),
|
||||
Some(&constrained_sandbox_policy),
|
||||
);
|
||||
let mut sandbox_policy = cfg
|
||||
.derive_sandbox_policy(
|
||||
sandbox_mode,
|
||||
config_profile.sandbox_mode,
|
||||
windows_sandbox_level,
|
||||
resolved_cwd.as_path(),
|
||||
Some(&constrained_sandbox_policy),
|
||||
)
|
||||
.await;
|
||||
if let SandboxPolicy::WorkspaceWrite { writable_roots, .. } = &mut sandbox_policy {
|
||||
for path in &additional_writable_roots {
|
||||
if !writable_roots.iter().any(|existing| existing == path) {
|
||||
@@ -1997,9 +2001,10 @@ impl Config {
|
||||
if effective_sandbox_policy == original_sandbox_policy {
|
||||
file_system_sandbox_policy
|
||||
} else {
|
||||
FileSystemSandboxPolicy::from_legacy_sandbox_policy(
|
||||
FileSystemSandboxPolicy::from_legacy_sandbox_policy_preserving_deny_entries(
|
||||
&effective_sandbox_policy,
|
||||
resolved_cwd.as_path(),
|
||||
&file_system_sandbox_policy,
|
||||
)
|
||||
};
|
||||
let effective_file_system_sandbox_policy = effective_file_system_sandbox_policy
|
||||
@@ -2035,7 +2040,6 @@ impl Config {
|
||||
enforce_residency: enforce_residency.value,
|
||||
notify: cfg.notify,
|
||||
user_instructions,
|
||||
user_instructions_path,
|
||||
base_instructions,
|
||||
personality,
|
||||
developer_instructions,
|
||||
@@ -2060,7 +2064,7 @@ impl Config {
|
||||
mcp_oauth_callback_port: cfg.mcp_oauth_callback_port,
|
||||
mcp_oauth_callback_url: cfg.mcp_oauth_callback_url.clone(),
|
||||
model_providers,
|
||||
project_doc_max_bytes: cfg.project_doc_max_bytes.unwrap_or(PROJECT_DOC_MAX_BYTES),
|
||||
project_doc_max_bytes: cfg.project_doc_max_bytes.unwrap_or(AGENTS_MD_MAX_BYTES),
|
||||
project_doc_fallback_filenames: cfg
|
||||
.project_doc_fallback_filenames
|
||||
.unwrap_or_default()
|
||||
@@ -2207,23 +2211,8 @@ impl Config {
|
||||
},
|
||||
};
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
fn load_instructions(codex_dir: Option<&AbsolutePathBuf>) -> Option<LoadedUserInstructions> {
|
||||
let base = codex_dir?;
|
||||
for candidate in [LOCAL_PROJECT_DOC_FILENAME, DEFAULT_PROJECT_DOC_FILENAME] {
|
||||
let path = base.join(candidate);
|
||||
if let Ok(contents) = std::fs::read_to_string(&path) {
|
||||
let trimmed = contents.trim();
|
||||
if !trimmed.is_empty() {
|
||||
return Some(LoadedUserInstructions {
|
||||
contents: trimmed.to_string(),
|
||||
path,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
/// If `path` is `Some`, attempts to read the file at the given path and
|
||||
@@ -2282,7 +2271,11 @@ impl Config {
|
||||
}
|
||||
|
||||
pub fn managed_network_requirements_enabled(&self) -> bool {
|
||||
self.config_layer_stack
|
||||
!matches!(
|
||||
self.permissions.sandbox_policy.get(),
|
||||
SandboxPolicy::DangerFullAccess
|
||||
) && self
|
||||
.config_layer_stack
|
||||
.requirements_toml()
|
||||
.network
|
||||
.is_some()
|
||||
@@ -2293,11 +2286,6 @@ impl Config {
|
||||
}
|
||||
}
|
||||
|
||||
struct LoadedUserInstructions {
|
||||
contents: String,
|
||||
path: AbsolutePathBuf,
|
||||
}
|
||||
|
||||
pub(crate) fn uses_deprecated_instructions_file(config_layer_stack: &ConfigLayerStack) -> bool {
|
||||
config_layer_stack
|
||||
.layers_high_to_low()
|
||||
|
||||
@@ -5,12 +5,14 @@ use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use codex_config::permissions_toml::FilesystemPermissionToml;
|
||||
use codex_config::permissions_toml::FilesystemPermissionsToml;
|
||||
use codex_config::permissions_toml::NetworkToml;
|
||||
use codex_config::permissions_toml::PermissionProfileToml;
|
||||
use codex_config::permissions_toml::PermissionsToml;
|
||||
use codex_network_proxy::NetworkProxyConfig;
|
||||
#[cfg(test)]
|
||||
use codex_network_proxy::NetworkUnixSocketPermission as ProxyNetworkUnixSocketPermission;
|
||||
use codex_protocol::permissions::FileSystemAccessMode;
|
||||
use codex_protocol::permissions::FileSystemPath;
|
||||
use codex_protocol::permissions::FileSystemSandboxEntry;
|
||||
use codex_protocol::permissions::FileSystemSandboxPolicy;
|
||||
@@ -42,6 +44,7 @@ pub(crate) fn resolve_permission_profile<'a>(
|
||||
pub(crate) fn compile_permission_profile(
|
||||
permissions: &PermissionsToml,
|
||||
profile_name: &str,
|
||||
policy_cwd: &Path,
|
||||
startup_warnings: &mut Vec<String>,
|
||||
) -> io::Result<(FileSystemSandboxPolicy, NetworkSandboxPolicy)> {
|
||||
let profile = resolve_permission_profile(permissions, profile_name)?;
|
||||
@@ -54,8 +57,23 @@ pub(crate) fn compile_permission_profile(
|
||||
missing_filesystem_entries_warning(profile_name),
|
||||
);
|
||||
} else {
|
||||
if cfg!(not(target_os = "macos")) {
|
||||
for pattern in unsupported_read_write_glob_paths(filesystem) {
|
||||
push_warning(
|
||||
startup_warnings,
|
||||
format!(
|
||||
"Filesystem glob `{pattern}` uses `read` or `write` access, which is not fully supported by this platform's sandboxing. Use an exact path or trailing `/**` subtree rule instead. `none` deny-read globs are supported."
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
for (path, permission) in &filesystem.entries {
|
||||
compile_filesystem_permission(path, permission, &mut entries, startup_warnings)?;
|
||||
entries.extend(compile_filesystem_permission(
|
||||
path,
|
||||
permission,
|
||||
policy_cwd,
|
||||
startup_warnings,
|
||||
)?);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -66,7 +84,6 @@ pub(crate) fn compile_permission_profile(
|
||||
}
|
||||
|
||||
let network_sandbox_policy = compile_network_sandbox_policy(profile.network.as_ref());
|
||||
|
||||
Ok((
|
||||
FileSystemSandboxPolicy::restricted(entries),
|
||||
network_sandbox_policy,
|
||||
@@ -118,24 +135,71 @@ fn compile_network_sandbox_policy(network: Option<&NetworkToml>) -> NetworkSandb
|
||||
fn compile_filesystem_permission(
|
||||
path: &str,
|
||||
permission: &FilesystemPermissionToml,
|
||||
entries: &mut Vec<FileSystemSandboxEntry>,
|
||||
policy_cwd: &Path,
|
||||
startup_warnings: &mut Vec<String>,
|
||||
) -> io::Result<()> {
|
||||
) -> io::Result<Vec<FileSystemSandboxEntry>> {
|
||||
let mut entries = Vec::new();
|
||||
match permission {
|
||||
FilesystemPermissionToml::Access(access) => entries.push(FileSystemSandboxEntry {
|
||||
path: compile_filesystem_path(path, startup_warnings)?,
|
||||
access: *access,
|
||||
}),
|
||||
FilesystemPermissionToml::Access(access) => {
|
||||
entries.push(FileSystemSandboxEntry {
|
||||
path: compile_filesystem_access_path(path, *access, startup_warnings)?,
|
||||
access: *access,
|
||||
});
|
||||
}
|
||||
FilesystemPermissionToml::Scoped(scoped_entries) => {
|
||||
for (subpath, access) in scoped_entries {
|
||||
entries.push(FileSystemSandboxEntry {
|
||||
path: compile_scoped_filesystem_path(path, subpath, startup_warnings)?,
|
||||
access: *access,
|
||||
});
|
||||
let has_glob = contains_glob_chars(subpath);
|
||||
let can_compile_as_pattern = match parse_special_path(path) {
|
||||
Some(FileSystemSpecialPath::ProjectRoots { .. }) | None => true,
|
||||
Some(_) => false,
|
||||
};
|
||||
if has_glob && *access == FileSystemAccessMode::None && can_compile_as_pattern {
|
||||
// Scoped glob syntax is a first-class filesystem policy
|
||||
// pattern entry. Literal scoped paths continue through the
|
||||
// exact-path parser so existing path semantics stay intact.
|
||||
let entry = FileSystemSandboxEntry {
|
||||
path: FileSystemPath::GlobPattern {
|
||||
pattern: compile_scoped_filesystem_pattern(
|
||||
path, subpath, *access, policy_cwd,
|
||||
)?,
|
||||
},
|
||||
access: *access,
|
||||
};
|
||||
entries.push(entry);
|
||||
} else {
|
||||
let subpath = compile_read_write_glob_path(subpath, *access)?;
|
||||
entries.push(FileSystemSandboxEntry {
|
||||
path: compile_scoped_filesystem_path(path, subpath, startup_warnings)?,
|
||||
access: *access,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
Ok(entries)
|
||||
}
|
||||
|
||||
fn compile_filesystem_access_path(
|
||||
path: &str,
|
||||
access: FileSystemAccessMode,
|
||||
startup_warnings: &mut Vec<String>,
|
||||
) -> io::Result<FileSystemPath> {
|
||||
if !contains_glob_chars(path) {
|
||||
return compile_filesystem_path(path, startup_warnings);
|
||||
}
|
||||
|
||||
if access == FileSystemAccessMode::None {
|
||||
// At this point `path` is an unscoped filesystem table key. Top-level
|
||||
// glob deny entries still go through the absolute-path parser before
|
||||
// becoming policy patterns; relative project-root glob syntax is
|
||||
// handled by `compile_scoped_filesystem_pattern`.
|
||||
return Ok(FileSystemPath::GlobPattern {
|
||||
pattern: parse_absolute_path(path)?.to_string_lossy().into_owned(),
|
||||
});
|
||||
}
|
||||
|
||||
let path = compile_read_write_glob_path(path, access)?;
|
||||
compile_filesystem_path(path, startup_warnings)
|
||||
}
|
||||
|
||||
fn compile_filesystem_path(
|
||||
@@ -186,6 +250,97 @@ fn compile_scoped_filesystem_path(
|
||||
Ok(FileSystemPath::Path { path })
|
||||
}
|
||||
|
||||
fn compile_scoped_filesystem_pattern(
|
||||
path: &str,
|
||||
subpath: &str,
|
||||
access: FileSystemAccessMode,
|
||||
policy_cwd: &Path,
|
||||
) -> io::Result<String> {
|
||||
// Pattern entries currently mean deny-read only. Supporting broader access
|
||||
// modes here would imply glob-based read/write allow semantics that the
|
||||
// sandbox policy does not express yet.
|
||||
if access != FileSystemAccessMode::None {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
format!("filesystem glob subpath `{subpath}` only supports `none` access"),
|
||||
));
|
||||
}
|
||||
let subpath = parse_relative_subpath(subpath)?;
|
||||
|
||||
match parse_special_path(path) {
|
||||
Some(FileSystemSpecialPath::ProjectRoots { .. }) => {
|
||||
// `:project_roots` is represented as a special path, but current
|
||||
// filesystem-policy resolution defines it relative to the session
|
||||
// cwd. Use the same policy cwd here so glob entries and exact
|
||||
// scoped entries resolve consistently.
|
||||
Ok(
|
||||
AbsolutePathBuf::resolve_path_against_base(&subpath, policy_cwd)
|
||||
.to_string_lossy()
|
||||
.to_string(),
|
||||
)
|
||||
}
|
||||
Some(_) => Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
format!("filesystem path `{path}` does not support nested entries"),
|
||||
)),
|
||||
None => {
|
||||
let base = parse_absolute_path(path)?;
|
||||
Ok(base.join(&subpath).to_string_lossy().to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn compile_read_write_glob_path(path: &str, access: FileSystemAccessMode) -> io::Result<&str> {
|
||||
if !contains_glob_chars(path) {
|
||||
return Ok(path);
|
||||
}
|
||||
|
||||
let path_without_trailing_glob = remove_trailing_glob_suffix(path);
|
||||
if !contains_glob_chars(path_without_trailing_glob) {
|
||||
return Ok(path_without_trailing_glob);
|
||||
}
|
||||
|
||||
Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
format!(
|
||||
"filesystem glob path `{path}` only supports `none` access; use an exact path or trailing `/**` for `{access}` subtree access"
|
||||
),
|
||||
))
|
||||
}
|
||||
|
||||
fn unsupported_read_write_glob_paths(filesystem: &FilesystemPermissionsToml) -> Vec<String> {
|
||||
let mut patterns = Vec::new();
|
||||
for (path, permission) in &filesystem.entries {
|
||||
match permission {
|
||||
FilesystemPermissionToml::Access(access) => {
|
||||
if *access != FileSystemAccessMode::None
|
||||
&& contains_glob_chars(remove_trailing_glob_suffix(path))
|
||||
{
|
||||
patterns.push(path.clone());
|
||||
}
|
||||
}
|
||||
FilesystemPermissionToml::Scoped(scoped_entries) => {
|
||||
for (subpath, access) in scoped_entries {
|
||||
if *access != FileSystemAccessMode::None
|
||||
&& contains_glob_chars(remove_trailing_glob_suffix(subpath))
|
||||
{
|
||||
patterns.push(format!("{path}/{subpath}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
patterns
|
||||
}
|
||||
|
||||
fn contains_glob_chars(path: &str) -> bool {
|
||||
path.chars().any(|ch| matches!(ch, '*' | '?' | '[' | ']'))
|
||||
}
|
||||
|
||||
fn remove_trailing_glob_suffix(path: &str) -> &str {
|
||||
path.strip_suffix("/**").unwrap_or(path)
|
||||
}
|
||||
|
||||
// WARNING: keep this parser forward-compatible.
|
||||
// Adding a new `:special_path` must not make older Codex versions reject the
|
||||
// config. Unknown values intentionally round-trip through
|
||||
|
||||
@@ -2,6 +2,7 @@ use super::*;
|
||||
use crate::config::Config;
|
||||
use crate::config::ConfigOverrides;
|
||||
use codex_config::config_toml::ConfigToml;
|
||||
use codex_config::permissions_toml::FilesystemPermissionToml;
|
||||
use codex_config::permissions_toml::FilesystemPermissionsToml;
|
||||
use codex_config::permissions_toml::NetworkDomainPermissionToml;
|
||||
use codex_config::permissions_toml::NetworkDomainPermissionsToml;
|
||||
@@ -10,6 +11,11 @@ use codex_config::permissions_toml::NetworkUnixSocketPermissionToml;
|
||||
use codex_config::permissions_toml::NetworkUnixSocketPermissionsToml;
|
||||
use codex_config::permissions_toml::PermissionProfileToml;
|
||||
use codex_config::permissions_toml::PermissionsToml;
|
||||
use codex_protocol::permissions::FileSystemAccessMode;
|
||||
use codex_protocol::permissions::FileSystemPath;
|
||||
use codex_protocol::permissions::FileSystemSandboxEntry;
|
||||
use codex_protocol::permissions::FileSystemSandboxPolicy;
|
||||
use codex_protocol::permissions::FileSystemSpecialPath;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::collections::BTreeMap;
|
||||
@@ -24,8 +30,8 @@ fn normalize_absolute_path_for_platform_simplifies_windows_verbatim_paths() {
|
||||
assert_eq!(parsed, PathBuf::from(r"D:\c\x\worktrees\2508\swift-base"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn restricted_read_implicitly_allows_helper_executables() -> std::io::Result<()> {
|
||||
#[tokio::test]
|
||||
async fn restricted_read_implicitly_allows_helper_executables() -> std::io::Result<()> {
|
||||
let temp_dir = TempDir::new()?;
|
||||
let cwd = temp_dir.path().join("workspace");
|
||||
let codex_home = temp_dir.path().join(".codex");
|
||||
@@ -64,7 +70,8 @@ fn restricted_read_implicitly_allows_helper_executables() -> std::io::Result<()>
|
||||
..Default::default()
|
||||
},
|
||||
AbsolutePathBuf::from_absolute_path(&codex_home)?,
|
||||
)?;
|
||||
)
|
||||
.await?;
|
||||
|
||||
let expected_zsh = AbsolutePathBuf::try_from(zsh_path)?;
|
||||
let expected_allowed_arg0_dir = AbsolutePathBuf::try_from(allowed_arg0_dir)?;
|
||||
@@ -215,3 +222,89 @@ fn network_toml_overlays_unix_socket_permissions_by_path() {
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_write_glob_warnings_skip_supported_deny_read_globs_and_trailing_subpaths() {
|
||||
let filesystem = FilesystemPermissionsToml {
|
||||
entries: BTreeMap::from([
|
||||
(
|
||||
"/tmp/**/*.log".to_string(),
|
||||
FilesystemPermissionToml::Access(FileSystemAccessMode::Read),
|
||||
),
|
||||
(
|
||||
"/tmp/cache/**".to_string(),
|
||||
FilesystemPermissionToml::Access(FileSystemAccessMode::Write),
|
||||
),
|
||||
(
|
||||
":project_roots".to_string(),
|
||||
FilesystemPermissionToml::Scoped(BTreeMap::from([
|
||||
("**/*.env".to_string(), FileSystemAccessMode::None),
|
||||
("docs/**".to_string(), FileSystemAccessMode::Read),
|
||||
("src/**/*.rs".to_string(), FileSystemAccessMode::Write),
|
||||
])),
|
||||
),
|
||||
]),
|
||||
};
|
||||
|
||||
assert_eq!(
|
||||
unsupported_read_write_glob_paths(&filesystem),
|
||||
vec![
|
||||
"/tmp/**/*.log".to_string(),
|
||||
":project_roots/src/**/*.rs".to_string()
|
||||
],
|
||||
"`none` glob patterns are supported as deny-read rules; only `read`/`write` globs should warn"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_write_trailing_glob_suffix_compiles_as_subpath() -> std::io::Result<()> {
|
||||
let cwd = TempDir::new()?;
|
||||
let mut startup_warnings = Vec::new();
|
||||
let (file_system_policy, _) = compile_permission_profile(
|
||||
&PermissionsToml {
|
||||
entries: BTreeMap::from([(
|
||||
"workspace".to_string(),
|
||||
PermissionProfileToml {
|
||||
filesystem: Some(FilesystemPermissionsToml {
|
||||
entries: BTreeMap::from([(
|
||||
":project_roots".to_string(),
|
||||
FilesystemPermissionToml::Scoped(BTreeMap::from([(
|
||||
"docs/**".to_string(),
|
||||
FileSystemAccessMode::Read,
|
||||
)])),
|
||||
)]),
|
||||
}),
|
||||
network: None,
|
||||
},
|
||||
)]),
|
||||
},
|
||||
"workspace",
|
||||
cwd.path(),
|
||||
&mut startup_warnings,
|
||||
)?;
|
||||
|
||||
assert_eq!(
|
||||
file_system_policy,
|
||||
FileSystemSandboxPolicy::restricted(vec![FileSystemSandboxEntry {
|
||||
path: FileSystemPath::Special {
|
||||
value: FileSystemSpecialPath::project_roots(Some("docs".into())),
|
||||
},
|
||||
access: FileSystemAccessMode::Read,
|
||||
}]),
|
||||
"trailing /** should compile as a subtree path instead of a glob pattern"
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_write_glob_patterns_still_reject_non_subpath_globs() {
|
||||
let err = compile_read_write_glob_path("src/**/*.rs", FileSystemAccessMode::Read)
|
||||
.expect_err("non-subpath read/write glob should be rejected");
|
||||
|
||||
assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
|
||||
assert!(
|
||||
err.to_string()
|
||||
.contains("filesystem glob path `src/**/*.rs` only supports `none` access"),
|
||||
"{err}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -164,6 +164,49 @@ async fn write_value_supports_nested_app_paths() -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn write_value_supports_custom_mcp_server_default_tool_approval_mode() -> Result<()> {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
std::fs::write(
|
||||
tmp.path().join(CONFIG_TOML_FILE),
|
||||
"[mcp_servers.docs]\ncommand = \"docs-server\"\n",
|
||||
)?;
|
||||
|
||||
let service = ConfigService::without_managed_config_for_tests(tmp.path().to_path_buf());
|
||||
service
|
||||
.write_value(ConfigValueWriteParams {
|
||||
file_path: Some(tmp.path().join(CONFIG_TOML_FILE).display().to_string()),
|
||||
key_path: "mcp_servers.docs.default_tools_approval_mode".to_string(),
|
||||
value: serde_json::json!("approve"),
|
||||
merge_strategy: MergeStrategy::Replace,
|
||||
expected_version: None,
|
||||
})
|
||||
.await
|
||||
.expect("write mcp server default_tools_approval_mode succeeds");
|
||||
|
||||
let contents = std::fs::read_to_string(tmp.path().join(CONFIG_TOML_FILE))?;
|
||||
assert!(contents.contains("default_tools_approval_mode = \"approve\""));
|
||||
|
||||
let read = service
|
||||
.read(ConfigReadParams {
|
||||
include_layers: false,
|
||||
cwd: None,
|
||||
})
|
||||
.await
|
||||
.expect("config read succeeds");
|
||||
|
||||
assert_eq!(
|
||||
read.config
|
||||
.additional
|
||||
.get("mcp_servers")
|
||||
.and_then(|servers| servers.get("docs"))
|
||||
.and_then(|docs| docs.get("default_tools_approval_mode")),
|
||||
Some(&serde_json::json!("approve"))
|
||||
);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn read_includes_origins_and_layers() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
|
||||
@@ -650,7 +650,7 @@ async fn project_trust_context(
|
||||
let projects = project_trust_config.projects.unwrap_or_default();
|
||||
|
||||
let project_root_key = project_trust_key(project_root.as_path());
|
||||
let repo_root = resolve_root_git_project_for_trust(cwd.as_path());
|
||||
let repo_root = resolve_root_git_project_for_trust(cwd.as_path()).await;
|
||||
let repo_root_key = repo_root.as_ref().map(|root| project_trust_key(root));
|
||||
|
||||
let projects_trust = projects
|
||||
|
||||
+35
-325
@@ -1,8 +1,5 @@
|
||||
use std::collections::BTreeSet;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
use std::env;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use std::sync::LazyLock;
|
||||
use std::sync::Mutex as StdMutex;
|
||||
@@ -28,7 +25,6 @@ use crate::codex::INITIAL_SUBMIT_ID;
|
||||
use crate::config::Config;
|
||||
use crate::config_loader::AppsRequirementsToml;
|
||||
use crate::mcp::McpManager;
|
||||
use crate::plugins::AppConnectorId;
|
||||
use crate::plugins::PluginsManager;
|
||||
use crate::plugins::list_tool_suggest_discoverable_plugins;
|
||||
use codex_config::types::AppToolApproval;
|
||||
@@ -38,18 +34,15 @@ use codex_features::Feature;
|
||||
use codex_login::AuthManager;
|
||||
use codex_login::CodexAuth;
|
||||
use codex_login::default_client::create_client;
|
||||
use codex_login::default_client::is_first_party_chat_originator;
|
||||
use codex_login::default_client::originator;
|
||||
use codex_mcp::CODEX_APPS_MCP_SERVER_NAME;
|
||||
use codex_mcp::McpConnectionManager;
|
||||
use codex_mcp::SandboxState;
|
||||
use codex_mcp::ToolInfo;
|
||||
use codex_mcp::ToolPluginProvenance;
|
||||
use codex_mcp::codex_apps_tools_cache_key;
|
||||
use codex_mcp::compute_auth_statuses;
|
||||
use codex_mcp::with_codex_apps_mcp;
|
||||
|
||||
pub use codex_connectors::CONNECTORS_CACHE_TTL;
|
||||
const CONNECTORS_READY_TIMEOUT_ON_EMPTY_TOOLS: Duration = Duration::from_secs(30);
|
||||
const DIRECTORY_CONNECTORS_TIMEOUT: Duration = Duration::from_secs(60);
|
||||
|
||||
@@ -125,13 +118,15 @@ pub(crate) async fn list_tool_suggest_discoverable_tools_with_auth(
|
||||
let directory_connectors =
|
||||
list_directory_connectors_for_tool_suggest_with_auth(config, auth).await?;
|
||||
let connector_ids = tool_suggest_connector_ids(config).await;
|
||||
let discoverable_connectors = filter_tool_suggest_discoverable_connectors(
|
||||
directory_connectors,
|
||||
accessible_connectors,
|
||||
&connector_ids,
|
||||
)
|
||||
.into_iter()
|
||||
.map(DiscoverableTool::from);
|
||||
let discoverable_connectors =
|
||||
codex_connectors::filter::filter_tool_suggest_discoverable_connectors(
|
||||
directory_connectors,
|
||||
accessible_connectors,
|
||||
&connector_ids,
|
||||
originator().value.as_str(),
|
||||
)
|
||||
.into_iter()
|
||||
.map(DiscoverableTool::from);
|
||||
let discoverable_plugins = list_tool_suggest_discoverable_plugins(config)
|
||||
.await?
|
||||
.into_iter()
|
||||
@@ -154,7 +149,12 @@ pub async fn list_cached_accessible_connectors_from_mcp_tools(
|
||||
return Some(Vec::new());
|
||||
}
|
||||
let cache_key = accessible_connectors_cache_key(config, auth.as_ref());
|
||||
read_cached_accessible_connectors(&cache_key).map(filter_disallowed_connectors)
|
||||
read_cached_accessible_connectors(&cache_key).map(|connectors| {
|
||||
codex_connectors::filter::filter_disallowed_connectors(
|
||||
connectors,
|
||||
originator().value.as_str(),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn refresh_accessible_connectors_cache_from_mcp_tools(
|
||||
@@ -167,8 +167,10 @@ pub(crate) fn refresh_accessible_connectors_cache_from_mcp_tools(
|
||||
}
|
||||
|
||||
let cache_key = accessible_connectors_cache_key(config, auth);
|
||||
let accessible_connectors =
|
||||
filter_disallowed_connectors(accessible_connectors_from_mcp_tools(mcp_tools));
|
||||
let accessible_connectors = codex_connectors::filter::filter_disallowed_connectors(
|
||||
accessible_connectors_from_mcp_tools(mcp_tools),
|
||||
originator().value.as_str(),
|
||||
);
|
||||
write_cached_accessible_connectors(cache_key, &accessible_connectors);
|
||||
}
|
||||
|
||||
@@ -205,7 +207,10 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_options_and_status(
|
||||
let tool_plugin_provenance = mcp_manager.tool_plugin_provenance(config).await;
|
||||
if !force_refetch && let Some(cached_connectors) = read_cached_accessible_connectors(&cache_key)
|
||||
{
|
||||
let cached_connectors = filter_disallowed_connectors(cached_connectors);
|
||||
let cached_connectors = codex_connectors::filter::filter_disallowed_connectors(
|
||||
cached_connectors,
|
||||
originator().value.as_str(),
|
||||
);
|
||||
let cached_connectors = with_app_plugin_sources(cached_connectors, &tool_plugin_provenance);
|
||||
return Ok(AccessibleConnectorsStatus {
|
||||
connectors: cached_connectors,
|
||||
@@ -228,13 +233,6 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_options_and_status(
|
||||
let (tx_event, rx_event) = unbounded();
|
||||
drop(rx_event);
|
||||
|
||||
let sandbox_state = SandboxState {
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
codex_linux_sandbox_exe: config.codex_linux_sandbox_exe.clone(),
|
||||
sandbox_cwd: env::current_dir().unwrap_or_else(|_| PathBuf::from("/")),
|
||||
use_legacy_landlock: config.features.use_legacy_landlock(),
|
||||
};
|
||||
|
||||
let (mcp_connection_manager, cancel_token) = McpConnectionManager::new(
|
||||
&mcp_servers,
|
||||
config.mcp_oauth_credentials_store_mode,
|
||||
@@ -242,7 +240,7 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_options_and_status(
|
||||
&config.permissions.approval_policy,
|
||||
INITIAL_SUBMIT_ID.to_owned(),
|
||||
tx_event,
|
||||
sandbox_state,
|
||||
SandboxPolicy::new_read_only_policy(),
|
||||
config.codex_home.to_path_buf(),
|
||||
codex_apps_tools_cache_key(auth.as_ref()),
|
||||
ToolPluginProvenance::default(),
|
||||
@@ -303,8 +301,10 @@ pub async fn list_accessible_connectors_from_mcp_tools_with_options_and_status(
|
||||
cancel_token.cancel();
|
||||
}
|
||||
|
||||
let accessible_connectors =
|
||||
filter_disallowed_connectors(accessible_connectors_from_mcp_tools(&tools));
|
||||
let accessible_connectors = codex_connectors::filter::filter_disallowed_connectors(
|
||||
accessible_connectors_from_mcp_tools(&tools),
|
||||
originator().value.as_str(),
|
||||
);
|
||||
if codex_apps_ready || !accessible_connectors.is_empty() {
|
||||
write_cached_accessible_connectors(cache_key, &accessible_connectors);
|
||||
}
|
||||
@@ -367,35 +367,11 @@ fn write_cached_accessible_connectors(
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
*cache_guard = Some(CachedAccessibleConnectors {
|
||||
key: cache_key,
|
||||
expires_at: Instant::now() + CONNECTORS_CACHE_TTL,
|
||||
expires_at: Instant::now() + codex_connectors::CONNECTORS_CACHE_TTL,
|
||||
connectors: connectors.to_vec(),
|
||||
});
|
||||
}
|
||||
|
||||
fn filter_tool_suggest_discoverable_connectors(
|
||||
directory_connectors: Vec<AppInfo>,
|
||||
accessible_connectors: &[AppInfo],
|
||||
discoverable_connector_ids: &HashSet<String>,
|
||||
) -> Vec<AppInfo> {
|
||||
let accessible_connector_ids: HashSet<&str> = accessible_connectors
|
||||
.iter()
|
||||
.filter(|connector| connector.is_accessible)
|
||||
.map(|connector| connector.id.as_str())
|
||||
.collect();
|
||||
|
||||
let mut connectors = filter_disallowed_connectors(directory_connectors)
|
||||
.into_iter()
|
||||
.filter(|connector| !accessible_connector_ids.contains(connector.id.as_str()))
|
||||
.filter(|connector| discoverable_connector_ids.contains(connector.id.as_str()))
|
||||
.collect::<Vec<_>>();
|
||||
connectors.sort_by(|left, right| {
|
||||
left.name
|
||||
.cmp(&right.name)
|
||||
.then_with(|| left.id.cmp(&right.id))
|
||||
});
|
||||
connectors
|
||||
}
|
||||
|
||||
async fn tool_suggest_connector_ids(config: &Config) -> HashSet<String> {
|
||||
let mut connector_ids = PluginsManager::new(config.codex_home.to_path_buf())
|
||||
.plugins_for_config(config)
|
||||
@@ -503,14 +479,6 @@ async fn chatgpt_get_request_with_token<T: DeserializeOwned>(
|
||||
}
|
||||
}
|
||||
|
||||
pub fn connector_display_label(connector: &AppInfo) -> String {
|
||||
format_connector_label(&connector.name, &connector.id)
|
||||
}
|
||||
|
||||
pub fn connector_mention_slug(connector: &AppInfo) -> String {
|
||||
sanitize_slug(&connector_display_label(connector))
|
||||
}
|
||||
|
||||
pub(crate) fn accessible_connectors_from_mcp_tools(
|
||||
mcp_tools: &HashMap<String, ToolInfo>,
|
||||
) -> Vec<AppInfo> {
|
||||
@@ -521,114 +489,14 @@ pub(crate) fn accessible_connectors_from_mcp_tools(
|
||||
return None;
|
||||
}
|
||||
let connector_id = tool.connector_id.as_deref()?;
|
||||
Some((
|
||||
connector_id.to_string(),
|
||||
normalize_connector_value(tool.connector_name.as_deref()),
|
||||
normalize_connector_value(tool.connector_description.as_deref()),
|
||||
tool.plugin_display_names.clone(),
|
||||
))
|
||||
});
|
||||
collect_accessible_connectors(tools)
|
||||
}
|
||||
|
||||
pub fn merge_connectors(
|
||||
connectors: Vec<AppInfo>,
|
||||
accessible_connectors: Vec<AppInfo>,
|
||||
) -> Vec<AppInfo> {
|
||||
let mut merged: HashMap<String, AppInfo> = connectors
|
||||
.into_iter()
|
||||
.map(|mut connector| {
|
||||
connector.is_accessible = false;
|
||||
(connector.id.clone(), connector)
|
||||
Some(codex_connectors::accessible::AccessibleConnectorTool {
|
||||
connector_id: connector_id.to_string(),
|
||||
connector_name: tool.connector_name.clone(),
|
||||
connector_description: tool.connector_description.clone(),
|
||||
plugin_display_names: tool.plugin_display_names.clone(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
for mut connector in accessible_connectors {
|
||||
connector.is_accessible = true;
|
||||
let connector_id = connector.id.clone();
|
||||
if let Some(existing) = merged.get_mut(&connector_id) {
|
||||
existing.is_accessible = true;
|
||||
if existing.name == existing.id && connector.name != connector.id {
|
||||
existing.name = connector.name;
|
||||
}
|
||||
if existing.description.is_none() && connector.description.is_some() {
|
||||
existing.description = connector.description;
|
||||
}
|
||||
if existing.logo_url.is_none() && connector.logo_url.is_some() {
|
||||
existing.logo_url = connector.logo_url;
|
||||
}
|
||||
if existing.logo_url_dark.is_none() && connector.logo_url_dark.is_some() {
|
||||
existing.logo_url_dark = connector.logo_url_dark;
|
||||
}
|
||||
if existing.distribution_channel.is_none() && connector.distribution_channel.is_some() {
|
||||
existing.distribution_channel = connector.distribution_channel;
|
||||
}
|
||||
existing
|
||||
.plugin_display_names
|
||||
.extend(connector.plugin_display_names);
|
||||
} else {
|
||||
merged.insert(connector_id, connector);
|
||||
}
|
||||
}
|
||||
|
||||
let mut merged = merged.into_values().collect::<Vec<_>>();
|
||||
for connector in &mut merged {
|
||||
if connector.install_url.is_none() {
|
||||
connector.install_url = Some(connector_install_url(&connector.name, &connector.id));
|
||||
}
|
||||
connector.plugin_display_names.sort_unstable();
|
||||
connector.plugin_display_names.dedup();
|
||||
}
|
||||
merged.sort_by(|left, right| {
|
||||
right
|
||||
.is_accessible
|
||||
.cmp(&left.is_accessible)
|
||||
.then_with(|| left.name.cmp(&right.name))
|
||||
.then_with(|| left.id.cmp(&right.id))
|
||||
});
|
||||
merged
|
||||
}
|
||||
|
||||
pub fn merge_plugin_apps(
|
||||
connectors: Vec<AppInfo>,
|
||||
plugin_apps: Vec<AppConnectorId>,
|
||||
) -> Vec<AppInfo> {
|
||||
let mut merged = connectors;
|
||||
let mut connector_ids = merged
|
||||
.iter()
|
||||
.map(|connector| connector.id.clone())
|
||||
.collect::<HashSet<_>>();
|
||||
|
||||
for connector_id in plugin_apps {
|
||||
if connector_ids.insert(connector_id.0.clone()) {
|
||||
merged.push(plugin_app_to_app_info(connector_id));
|
||||
}
|
||||
}
|
||||
|
||||
merged.sort_by(|left, right| {
|
||||
right
|
||||
.is_accessible
|
||||
.cmp(&left.is_accessible)
|
||||
.then_with(|| left.name.cmp(&right.name))
|
||||
.then_with(|| left.id.cmp(&right.id))
|
||||
});
|
||||
merged
|
||||
}
|
||||
|
||||
pub fn merge_plugin_apps_with_accessible(
|
||||
plugin_apps: Vec<AppConnectorId>,
|
||||
accessible_connectors: Vec<AppInfo>,
|
||||
) -> Vec<AppInfo> {
|
||||
let accessible_connector_ids: HashSet<&str> = accessible_connectors
|
||||
.iter()
|
||||
.map(|connector| connector.id.as_str())
|
||||
.collect();
|
||||
let plugin_connectors = plugin_apps
|
||||
.into_iter()
|
||||
.filter(|connector_id| accessible_connector_ids.contains(connector_id.0.as_str()))
|
||||
.map(plugin_app_to_app_info)
|
||||
.collect::<Vec<_>>();
|
||||
merge_connectors(plugin_connectors, accessible_connectors)
|
||||
codex_connectors::accessible::collect_accessible_connectors(tools)
|
||||
}
|
||||
|
||||
pub fn with_app_enabled_state(mut connectors: Vec<AppInfo>, config: &Config) -> Vec<AppInfo> {
|
||||
@@ -701,45 +569,6 @@ pub(crate) fn codex_app_tool_is_enabled(config: &Config, tool_info: &ToolInfo) -
|
||||
.enabled
|
||||
}
|
||||
|
||||
const DISALLOWED_CONNECTOR_IDS: &[&str] = &[
|
||||
"asdk_app_6938a94a61d881918ef32cb999ff937c",
|
||||
"connector_2b0a9009c9c64bf9933a3dae3f2b1254",
|
||||
"connector_3f8d1a79f27c4c7ba1a897ab13bf37dc",
|
||||
"connector_68de829bf7648191acd70a907364c67c",
|
||||
"connector_68e004f14af881919eb50893d3d9f523",
|
||||
"connector_69272cb413a081919685ec3c88d1744e",
|
||||
];
|
||||
const FIRST_PARTY_CHAT_DISALLOWED_CONNECTOR_IDS: &[&str] =
|
||||
&["connector_0f9c9d4592e54d0a9a12b3f44a1e2010"];
|
||||
const DISALLOWED_CONNECTOR_PREFIX: &str = "connector_openai_";
|
||||
|
||||
pub fn filter_disallowed_connectors(connectors: Vec<AppInfo>) -> Vec<AppInfo> {
|
||||
filter_disallowed_connectors_for_originator(connectors, originator().value.as_str())
|
||||
}
|
||||
|
||||
fn filter_disallowed_connectors_for_originator(
|
||||
connectors: Vec<AppInfo>,
|
||||
originator_value: &str,
|
||||
) -> Vec<AppInfo> {
|
||||
connectors
|
||||
.into_iter()
|
||||
.filter(|connector| {
|
||||
is_connector_id_allowed_for_originator(connector.id.as_str(), originator_value)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn is_connector_id_allowed_for_originator(connector_id: &str, originator_value: &str) -> bool {
|
||||
let disallowed_connector_ids = if is_first_party_chat_originator(originator_value) {
|
||||
FIRST_PARTY_CHAT_DISALLOWED_CONNECTOR_IDS
|
||||
} else {
|
||||
DISALLOWED_CONNECTOR_IDS
|
||||
};
|
||||
|
||||
!connector_id.starts_with(DISALLOWED_CONNECTOR_PREFIX)
|
||||
&& !disallowed_connector_ids.contains(&connector_id)
|
||||
}
|
||||
|
||||
fn read_apps_config(config: &Config) -> Option<AppsConfigToml> {
|
||||
let apps_config = read_user_apps_config(config);
|
||||
let had_apps_config = apps_config.is_some();
|
||||
@@ -861,125 +690,6 @@ fn app_tool_policy_from_apps_config(
|
||||
AppToolPolicy { enabled, approval }
|
||||
}
|
||||
|
||||
fn collect_accessible_connectors<I>(tools: I) -> Vec<AppInfo>
|
||||
where
|
||||
I: IntoIterator<Item = (String, Option<String>, Option<String>, Vec<String>)>,
|
||||
{
|
||||
let mut connectors: HashMap<String, (AppInfo, BTreeSet<String>)> = HashMap::new();
|
||||
for (connector_id, connector_name, connector_description, plugin_display_names) in tools {
|
||||
let connector_name = connector_name.unwrap_or_else(|| connector_id.clone());
|
||||
if let Some((existing, existing_plugin_display_names)) = connectors.get_mut(&connector_id) {
|
||||
if existing.name == connector_id && connector_name != connector_id {
|
||||
existing.name = connector_name;
|
||||
}
|
||||
if existing.description.is_none() && connector_description.is_some() {
|
||||
existing.description = connector_description;
|
||||
}
|
||||
existing_plugin_display_names.extend(plugin_display_names);
|
||||
} else {
|
||||
connectors.insert(
|
||||
connector_id.clone(),
|
||||
(
|
||||
AppInfo {
|
||||
id: connector_id.clone(),
|
||||
name: connector_name,
|
||||
description: connector_description,
|
||||
logo_url: None,
|
||||
logo_url_dark: None,
|
||||
distribution_channel: None,
|
||||
branding: None,
|
||||
app_metadata: None,
|
||||
labels: None,
|
||||
install_url: None,
|
||||
is_accessible: true,
|
||||
is_enabled: true,
|
||||
plugin_display_names: Vec::new(),
|
||||
},
|
||||
plugin_display_names
|
||||
.into_iter()
|
||||
.collect::<BTreeSet<String>>(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
let mut accessible: Vec<AppInfo> = connectors
|
||||
.into_values()
|
||||
.map(|(mut connector, plugin_display_names)| {
|
||||
connector.plugin_display_names = plugin_display_names.into_iter().collect();
|
||||
connector.install_url = Some(connector_install_url(&connector.name, &connector.id));
|
||||
connector
|
||||
})
|
||||
.collect();
|
||||
accessible.sort_by(|left, right| {
|
||||
right
|
||||
.is_accessible
|
||||
.cmp(&left.is_accessible)
|
||||
.then_with(|| left.name.cmp(&right.name))
|
||||
.then_with(|| left.id.cmp(&right.id))
|
||||
});
|
||||
accessible
|
||||
}
|
||||
|
||||
fn plugin_app_to_app_info(connector_id: AppConnectorId) -> AppInfo {
|
||||
// Leave the placeholder name as the connector id so merge_connectors() can
|
||||
// replace it with canonical app metadata from directory fetches or
|
||||
// connector_name values from codex_apps tool discovery.
|
||||
let connector_id = connector_id.0;
|
||||
let name = connector_id.clone();
|
||||
AppInfo {
|
||||
id: connector_id.clone(),
|
||||
name: name.clone(),
|
||||
description: None,
|
||||
logo_url: None,
|
||||
logo_url_dark: None,
|
||||
distribution_channel: None,
|
||||
branding: None,
|
||||
app_metadata: None,
|
||||
labels: None,
|
||||
install_url: Some(connector_install_url(&name, &connector_id)),
|
||||
is_accessible: false,
|
||||
is_enabled: true,
|
||||
plugin_display_names: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize_connector_value(value: Option<&str>) -> Option<String> {
|
||||
value
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(str::to_string)
|
||||
}
|
||||
|
||||
pub fn connector_install_url(name: &str, connector_id: &str) -> String {
|
||||
let slug = sanitize_slug(name);
|
||||
format!("https://chatgpt.com/apps/{slug}/{connector_id}")
|
||||
}
|
||||
|
||||
pub fn sanitize_name(name: &str) -> String {
|
||||
sanitize_slug(name).replace("-", "_")
|
||||
}
|
||||
|
||||
fn sanitize_slug(name: &str) -> String {
|
||||
let mut normalized = String::with_capacity(name.len());
|
||||
for character in name.chars() {
|
||||
if character.is_ascii_alphanumeric() {
|
||||
normalized.push(character.to_ascii_lowercase());
|
||||
} else {
|
||||
normalized.push('-');
|
||||
}
|
||||
}
|
||||
let normalized = normalized.trim_matches('-');
|
||||
if normalized.is_empty() {
|
||||
"app".to_string()
|
||||
} else {
|
||||
normalized.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn format_connector_label(name: &str, _id: &str) -> String {
|
||||
name.to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "connectors_tests.rs"]
|
||||
mod tests;
|
||||
|
||||
@@ -11,6 +11,13 @@ use codex_config::types::AppConfig;
|
||||
use codex_config::types::AppToolConfig;
|
||||
use codex_config::types::AppToolsConfig;
|
||||
use codex_config::types::AppsDefaultConfig;
|
||||
use codex_connectors::filter::filter_disallowed_connectors;
|
||||
use codex_connectors::filter::filter_tool_suggest_discoverable_connectors;
|
||||
use codex_connectors::merge::merge_connectors;
|
||||
use codex_connectors::merge::plugin_connector_to_app_info;
|
||||
use codex_connectors::metadata::connector_install_url;
|
||||
use codex_connectors::metadata::connector_mention_slug;
|
||||
use codex_connectors::metadata::sanitize_name;
|
||||
use codex_features::Feature;
|
||||
use codex_mcp::CODEX_APPS_MCP_SERVER_NAME;
|
||||
use codex_mcp::ToolInfo;
|
||||
@@ -138,7 +145,7 @@ fn with_accessible_connectors_cache_cleared<R>(f: impl FnOnce() -> R) -> R {
|
||||
|
||||
#[test]
|
||||
fn merge_connectors_replaces_plugin_placeholder_name_with_accessible_name() {
|
||||
let plugin = plugin_app_to_app_info(AppConnectorId("calendar".to_string()));
|
||||
let plugin = plugin_connector_to_app_info("calendar".to_string());
|
||||
let accessible = google_calendar_accessible_connector(&[]);
|
||||
|
||||
let merged = merge_connectors(vec![plugin], vec![accessible]);
|
||||
@@ -281,7 +288,7 @@ async fn refresh_accessible_connectors_cache_from_mcp_tools_writes_latest_instal
|
||||
|
||||
#[test]
|
||||
fn merge_connectors_unions_and_dedupes_plugin_display_names() {
|
||||
let mut plugin = plugin_app_to_app_info(AppConnectorId("calendar".to_string()));
|
||||
let mut plugin = plugin_connector_to_app_info("calendar".to_string());
|
||||
plugin.plugin_display_names = plugin_names(&["sample", "alpha", "sample"]);
|
||||
|
||||
let accessible = google_calendar_accessible_connector(&["beta", "alpha"]);
|
||||
@@ -975,33 +982,40 @@ fn app_tool_policy_matches_prefix_stripped_tool_name_for_tool_config() {
|
||||
|
||||
#[test]
|
||||
fn filter_disallowed_connectors_allows_non_disallowed_connectors() {
|
||||
let filtered = filter_disallowed_connectors(vec![app("asdk_app_hidden"), app("alpha")]);
|
||||
let filtered =
|
||||
filter_disallowed_connectors(vec![app("asdk_app_hidden"), app("alpha")], "codex_cli");
|
||||
assert_eq!(filtered, vec![app("asdk_app_hidden"), app("alpha")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_disallowed_connectors_filters_openai_prefix() {
|
||||
let filtered = filter_disallowed_connectors(vec![
|
||||
app("connector_openai_foo"),
|
||||
app("connector_openai_bar"),
|
||||
app("gamma"),
|
||||
]);
|
||||
let filtered = filter_disallowed_connectors(
|
||||
vec![
|
||||
app("connector_openai_foo"),
|
||||
app("connector_openai_bar"),
|
||||
app("gamma"),
|
||||
],
|
||||
"codex_cli",
|
||||
);
|
||||
assert_eq!(filtered, vec![app("gamma")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_disallowed_connectors_filters_disallowed_connector_ids() {
|
||||
let filtered = filter_disallowed_connectors(vec![
|
||||
app("asdk_app_6938a94a61d881918ef32cb999ff937c"),
|
||||
app("connector_3f8d1a79f27c4c7ba1a897ab13bf37dc"),
|
||||
app("delta"),
|
||||
]);
|
||||
let filtered = filter_disallowed_connectors(
|
||||
vec![
|
||||
app("asdk_app_6938a94a61d881918ef32cb999ff937c"),
|
||||
app("connector_3f8d1a79f27c4c7ba1a897ab13bf37dc"),
|
||||
app("delta"),
|
||||
],
|
||||
"codex_cli",
|
||||
);
|
||||
assert_eq!(filtered, vec![app("delta")]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_party_chat_originator_filters_target_and_openai_prefixed_connectors() {
|
||||
let filtered = filter_disallowed_connectors_for_originator(
|
||||
let filtered = filter_disallowed_connectors(
|
||||
vec![
|
||||
app("connector_openai_foo"),
|
||||
app("asdk_app_6938a94a61d881918ef32cb999ff937c"),
|
||||
@@ -1064,6 +1078,7 @@ fn filter_tool_suggest_discoverable_connectors_keeps_only_plugin_backed_uninstal
|
||||
"connector_2128aebfecb84f64a069897515042a44".to_string(),
|
||||
"connector_68df038e0ba48191908c8434991bbac2".to_string(),
|
||||
]),
|
||||
"codex_cli",
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
@@ -1103,6 +1118,7 @@ fn filter_tool_suggest_discoverable_connectors_excludes_accessible_apps_even_whe
|
||||
"connector_2128aebfecb84f64a069897515042a44".to_string(),
|
||||
"connector_68df038e0ba48191908c8434991bbac2".to_string(),
|
||||
]),
|
||||
"codex_cli",
|
||||
);
|
||||
|
||||
assert_eq!(filtered, Vec::<AppInfo>::new());
|
||||
|
||||
@@ -133,6 +133,7 @@ fn reference_context_item() -> TurnContextItem {
|
||||
approval_policy: AskForApproval::OnRequest,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
network: None,
|
||||
file_system_sandbox_policy: None,
|
||||
model: "gpt-test".to_string(),
|
||||
personality: None,
|
||||
collaboration_mode: None,
|
||||
|
||||
+69
-36
@@ -251,8 +251,24 @@ pub fn build_exec_request(
|
||||
codex_linux_sandbox_exe: &Option<PathBuf>,
|
||||
use_legacy_landlock: bool,
|
||||
) -> Result<ExecRequest> {
|
||||
let windows_sandbox_level = params.windows_sandbox_level;
|
||||
let enforce_managed_network = params.network.is_some();
|
||||
let ExecParams {
|
||||
command,
|
||||
cwd,
|
||||
mut env,
|
||||
expiration,
|
||||
capture_policy,
|
||||
network,
|
||||
windows_sandbox_level,
|
||||
windows_sandbox_private_desktop,
|
||||
|
||||
// TODO: Should arg0 be set on the ExecRequest that is returned?
|
||||
arg0: _,
|
||||
// These fields are related to approvals, so can be ignored here.
|
||||
justification: _,
|
||||
sandbox_permissions: _,
|
||||
} = params;
|
||||
|
||||
let enforce_managed_network = network.is_some();
|
||||
let sandbox_type = select_process_exec_tool_sandbox_type(
|
||||
file_system_sandbox_policy,
|
||||
network_sandbox_policy,
|
||||
@@ -261,19 +277,6 @@ pub fn build_exec_request(
|
||||
);
|
||||
tracing::debug!("Sandbox type: {sandbox_type:?}");
|
||||
|
||||
let ExecParams {
|
||||
command,
|
||||
cwd,
|
||||
mut env,
|
||||
expiration,
|
||||
capture_policy,
|
||||
network,
|
||||
sandbox_permissions: _,
|
||||
windows_sandbox_level,
|
||||
windows_sandbox_private_desktop,
|
||||
justification: _,
|
||||
arg0: _,
|
||||
} = params;
|
||||
if let Some(network) = network.as_ref() {
|
||||
network.apply_to_env(&mut env);
|
||||
}
|
||||
@@ -357,7 +360,8 @@ pub(crate) async fn execute_exec_request(
|
||||
windows_sandbox_level,
|
||||
windows_sandbox_private_desktop,
|
||||
sandbox_policy,
|
||||
file_system_sandbox_policy,
|
||||
// TODO(mbolin): Use file_system_sandbox_policy instead of sandbox_policy.
|
||||
file_system_sandbox_policy: _,
|
||||
network_sandbox_policy,
|
||||
windows_sandbox_filesystem_overrides,
|
||||
arg0,
|
||||
@@ -378,21 +382,40 @@ pub(crate) async fn execute_exec_request(
|
||||
};
|
||||
|
||||
let start = Instant::now();
|
||||
let raw_output_result = exec(
|
||||
let raw_output_result = get_raw_output_result(
|
||||
params,
|
||||
sandbox,
|
||||
&sandbox_policy,
|
||||
&file_system_sandbox_policy,
|
||||
windows_sandbox_filesystem_overrides.as_ref(),
|
||||
network_sandbox_policy,
|
||||
stdout_stream,
|
||||
after_spawn,
|
||||
sandbox,
|
||||
&sandbox_policy,
|
||||
windows_sandbox_filesystem_overrides.as_ref(),
|
||||
)
|
||||
.await;
|
||||
let duration = start.elapsed();
|
||||
finalize_exec_result(raw_output_result, sandbox, duration)
|
||||
}
|
||||
|
||||
async fn get_raw_output_result(
|
||||
params: ExecParams,
|
||||
network_sandbox_policy: NetworkSandboxPolicy,
|
||||
stdout_stream: Option<StdoutStream>,
|
||||
after_spawn: Option<Box<dyn FnOnce() + Send>>,
|
||||
#[cfg_attr(not(windows), allow(unused_variables))] sandbox: SandboxType,
|
||||
#[cfg_attr(not(windows), allow(unused_variables))] sandbox_policy: &SandboxPolicy,
|
||||
#[cfg_attr(not(windows), allow(unused_variables))] windows_sandbox_filesystem_overrides: Option<
|
||||
&WindowsSandboxFilesystemOverrides,
|
||||
>,
|
||||
) -> Result<RawExecToolCallOutput> {
|
||||
#[cfg(target_os = "windows")]
|
||||
if sandbox == SandboxType::WindowsRestrictedToken {
|
||||
return exec_windows_sandbox(params, sandbox_policy, windows_sandbox_filesystem_overrides)
|
||||
.await;
|
||||
}
|
||||
|
||||
exec(params, network_sandbox_policy, stdout_stream, after_spawn).await
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn extract_create_process_as_user_error_code(err: &str) -> Option<String> {
|
||||
let marker = "CreateProcessAsUserW failed: ";
|
||||
@@ -799,26 +822,24 @@ fn aggregate_output(
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
/// This is a general-purpose function for executing a command specified by
|
||||
/// [ExecParams]. Events are reported via `stdout_stream`, if specified, and
|
||||
/// `after_spawn` is invoked once the child process has been spawned, before
|
||||
/// output consumption begins.
|
||||
///
|
||||
/// `network_sandbox_policy` is used to determine whether
|
||||
/// CODEX_SANDBOX_NETWORK_DISABLED=1 is added to the environment of the spawned
|
||||
/// process.
|
||||
///
|
||||
/// Note this command does not apply any sandboxing logic. The caller is
|
||||
/// responsible for constructing [ExecParams::command] to include any sandboxing
|
||||
/// wrapper args, as appropriate.
|
||||
async fn exec(
|
||||
params: ExecParams,
|
||||
_sandbox: SandboxType,
|
||||
_sandbox_policy: &SandboxPolicy,
|
||||
_file_system_sandbox_policy: &FileSystemSandboxPolicy,
|
||||
_windows_sandbox_filesystem_overrides: Option<&WindowsSandboxFilesystemOverrides>,
|
||||
network_sandbox_policy: NetworkSandboxPolicy,
|
||||
stdout_stream: Option<StdoutStream>,
|
||||
after_spawn: Option<Box<dyn FnOnce() + Send>>,
|
||||
) -> Result<RawExecToolCallOutput> {
|
||||
#[cfg(target_os = "windows")]
|
||||
if _sandbox == SandboxType::WindowsRestrictedToken {
|
||||
return exec_windows_sandbox(
|
||||
params,
|
||||
_sandbox_policy,
|
||||
_windows_sandbox_filesystem_overrides,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
let ExecParams {
|
||||
command,
|
||||
cwd,
|
||||
@@ -827,8 +848,14 @@ async fn exec(
|
||||
arg0,
|
||||
expiration,
|
||||
capture_policy,
|
||||
|
||||
// If applicable, these fields should have been honored upstream of
|
||||
// this exec call.
|
||||
windows_sandbox_level: _,
|
||||
..
|
||||
windows_sandbox_private_desktop: _,
|
||||
// These fields are related to approvals, so can be ignored here.
|
||||
sandbox_permissions: _,
|
||||
justification: _,
|
||||
} = params;
|
||||
if let Some(network) = network.as_ref() {
|
||||
network.apply_to_env(&mut env);
|
||||
@@ -954,6 +981,9 @@ pub(crate) fn resolve_windows_restricted_token_filesystem_overrides(
|
||||
if !file_system_sandbox_policy
|
||||
.get_unreadable_roots_with_cwd(sandbox_policy_cwd)
|
||||
.is_empty()
|
||||
|| !file_system_sandbox_policy
|
||||
.get_unreadable_globs_with_cwd(sandbox_policy_cwd)
|
||||
.is_empty()
|
||||
{
|
||||
return Err(
|
||||
"windows unelevated restricted-token sandbox cannot enforce unreadable split filesystem carveouts directly; refusing to run unsandboxed"
|
||||
@@ -1069,6 +1099,9 @@ pub(crate) fn resolve_windows_elevated_filesystem_overrides(
|
||||
if !file_system_sandbox_policy
|
||||
.get_unreadable_roots_with_cwd(sandbox_policy_cwd)
|
||||
.is_empty()
|
||||
|| !file_system_sandbox_policy
|
||||
.get_unreadable_globs_with_cwd(sandbox_policy_cwd)
|
||||
.is_empty()
|
||||
{
|
||||
return Err(
|
||||
"windows elevated sandbox cannot enforce unreadable split filesystem carveouts directly; refusing to run unsandboxed"
|
||||
|
||||
@@ -277,10 +277,6 @@ async fn exec_full_buffer_capture_ignores_expiration() -> Result<()> {
|
||||
justification: None,
|
||||
arg0: None,
|
||||
},
|
||||
SandboxType::None,
|
||||
&SandboxPolicy::DangerFullAccess,
|
||||
&FileSystemSandboxPolicy::unrestricted(),
|
||||
/*windows_sandbox_filesystem_overrides*/ None,
|
||||
NetworkSandboxPolicy::Enabled,
|
||||
/*stdout_stream*/ None,
|
||||
/*after_spawn*/ None,
|
||||
@@ -317,10 +313,6 @@ async fn exec_full_buffer_capture_keeps_io_drain_timeout_when_descendant_holds_p
|
||||
justification: None,
|
||||
arg0: None,
|
||||
},
|
||||
SandboxType::None,
|
||||
&SandboxPolicy::DangerFullAccess,
|
||||
&FileSystemSandboxPolicy::unrestricted(),
|
||||
/*windows_sandbox_filesystem_overrides*/ None,
|
||||
NetworkSandboxPolicy::Enabled,
|
||||
/*stdout_stream*/ None,
|
||||
/*after_spawn*/ None,
|
||||
@@ -814,6 +806,53 @@ fn windows_elevated_rejects_unreadable_split_carveouts() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_elevated_rejects_unreadable_globs() {
|
||||
let temp_dir = tempfile::TempDir::new().expect("tempdir");
|
||||
let policy = SandboxPolicy::WorkspaceWrite {
|
||||
writable_roots: vec![],
|
||||
read_only_access: codex_protocol::protocol::ReadOnlyAccess::FullAccess,
|
||||
network_access: false,
|
||||
exclude_tmpdir_env_var: true,
|
||||
exclude_slash_tmp: true,
|
||||
};
|
||||
let file_system_policy = FileSystemSandboxPolicy::restricted(vec![
|
||||
codex_protocol::permissions::FileSystemSandboxEntry {
|
||||
path: codex_protocol::permissions::FileSystemPath::Special {
|
||||
value: codex_protocol::permissions::FileSystemSpecialPath::Root,
|
||||
},
|
||||
access: codex_protocol::permissions::FileSystemAccessMode::Read,
|
||||
},
|
||||
codex_protocol::permissions::FileSystemSandboxEntry {
|
||||
path: codex_protocol::permissions::FileSystemPath::Special {
|
||||
value: codex_protocol::permissions::FileSystemSpecialPath::CurrentWorkingDirectory,
|
||||
},
|
||||
access: codex_protocol::permissions::FileSystemAccessMode::Write,
|
||||
},
|
||||
codex_protocol::permissions::FileSystemSandboxEntry {
|
||||
path: codex_protocol::permissions::FileSystemPath::GlobPattern {
|
||||
pattern: "**/*.env".to_string(),
|
||||
},
|
||||
access: codex_protocol::permissions::FileSystemAccessMode::None,
|
||||
},
|
||||
]);
|
||||
|
||||
assert_eq!(
|
||||
unsupported_windows_restricted_token_sandbox_reason(
|
||||
SandboxType::WindowsRestrictedToken,
|
||||
&policy,
|
||||
&file_system_policy,
|
||||
NetworkSandboxPolicy::Restricted,
|
||||
&temp_dir.path().abs(),
|
||||
WindowsSandboxLevel::Elevated,
|
||||
),
|
||||
Some(
|
||||
"windows elevated sandbox cannot enforce unreadable split filesystem carveouts directly; refusing to run unsandboxed"
|
||||
.to_string()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_elevated_rejects_reopened_writable_descendants() {
|
||||
let temp_dir = tempfile::TempDir::new().expect("tempdir");
|
||||
@@ -931,10 +970,6 @@ async fn kill_child_process_group_kills_grandchildren_on_timeout() -> Result<()>
|
||||
|
||||
let output = exec(
|
||||
params,
|
||||
SandboxType::None,
|
||||
&SandboxPolicy::new_read_only_policy(),
|
||||
&FileSystemSandboxPolicy::from(&SandboxPolicy::new_read_only_policy()),
|
||||
/*windows_sandbox_filesystem_overrides*/ None,
|
||||
NetworkSandboxPolicy::Restricted,
|
||||
/*stdout_stream*/ None,
|
||||
/*after_spawn*/ None,
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
use crate::config::Config;
|
||||
use crate::config::ConfigBuilder;
|
||||
use crate::plugins::MarketplaceAddRequest;
|
||||
use crate::plugins::PluginId;
|
||||
use crate::plugins::PluginInstallRequest;
|
||||
use crate::plugins::PluginsManager;
|
||||
use crate::plugins::add_marketplace;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use crate::plugins::configured_plugins_from_stack;
|
||||
use crate::plugins::find_marketplace_manifest_path;
|
||||
use crate::plugins::parse_marketplace_source;
|
||||
use codex_core_plugins::marketplace::MarketplacePluginInstallPolicy;
|
||||
use codex_protocol::protocol::Product;
|
||||
use serde_json::Value as JsonValue;
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::HashSet;
|
||||
@@ -16,8 +22,8 @@ use toml::Value as TomlValue;
|
||||
|
||||
const EXTERNAL_AGENT_CONFIG_DETECT_METRIC: &str = "codex.external_agent_config.detect";
|
||||
const EXTERNAL_AGENT_CONFIG_IMPORT_METRIC: &str = "codex.external_agent_config.import";
|
||||
// Installed marketplace roots always expose their manifest at this relative path.
|
||||
const INSTALLED_MARKETPLACE_MANIFEST_RELATIVE_PATH: &str = ".agents/plugins/marketplace.json";
|
||||
const EXTERNAL_AGENT_DIR: &str = ".claude";
|
||||
const EXTERNAL_AGENT_CONFIG_MD: &str = "CLAUDE.md";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ExternalAgentConfigDetectOptions {
|
||||
@@ -84,20 +90,21 @@ impl ExternalAgentConfigService {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn detect(
|
||||
pub async fn detect(
|
||||
&self,
|
||||
params: ExternalAgentConfigDetectOptions,
|
||||
) -> io::Result<Vec<ExternalAgentConfigMigrationItem>> {
|
||||
let mut items = Vec::new();
|
||||
if params.include_home {
|
||||
self.detect_migrations(/*repo_root*/ None, &mut items)?;
|
||||
self.detect_migrations(/*repo_root*/ None, &mut items)
|
||||
.await?;
|
||||
}
|
||||
|
||||
for cwd in params.cwds.as_deref().unwrap_or(&[]) {
|
||||
let Some(repo_root) = find_repo_root(Some(cwd))? else {
|
||||
continue;
|
||||
};
|
||||
self.detect_migrations(Some(&repo_root), &mut items)?;
|
||||
self.detect_migrations(Some(&repo_root), &mut items).await?;
|
||||
}
|
||||
|
||||
Ok(items)
|
||||
@@ -138,22 +145,11 @@ impl ExternalAgentConfigService {
|
||||
let cwd = migration_item.cwd;
|
||||
let details = migration_item.details;
|
||||
tokio::spawn(async move {
|
||||
match service.import_plugins(cwd.as_deref(), details).await {
|
||||
Ok(outcome) => {
|
||||
tracing::info!(
|
||||
succeeded_marketplaces = outcome.succeeded_marketplaces.len(),
|
||||
succeeded_plugin_ids = outcome.succeeded_plugin_ids.len(),
|
||||
failed_marketplaces = outcome.failed_marketplaces.len(),
|
||||
failed_plugin_ids = outcome.failed_plugin_ids.len(),
|
||||
"external agent config plugin import completed"
|
||||
);
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::warn!(
|
||||
error = %err,
|
||||
"external agent config plugin import failed"
|
||||
);
|
||||
}
|
||||
if let Err(err) = service.import_plugins(cwd.as_deref(), details).await {
|
||||
tracing::warn!(
|
||||
error = %err,
|
||||
"external agent config plugin import failed"
|
||||
);
|
||||
}
|
||||
});
|
||||
emit_migration_metric(
|
||||
@@ -169,7 +165,7 @@ impl ExternalAgentConfigService {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn detect_migrations(
|
||||
async fn detect_migrations(
|
||||
&self,
|
||||
repo_root: Option<&Path>,
|
||||
items: &mut Vec<ExternalAgentConfigMigrationItem>,
|
||||
@@ -177,7 +173,7 @@ impl ExternalAgentConfigService {
|
||||
let cwd = repo_root.map(Path::to_path_buf);
|
||||
let source_settings = repo_root.map_or_else(
|
||||
|| self.external_agent_home.join("settings.json"),
|
||||
|repo_root| repo_root.join(".claude").join("settings.json"),
|
||||
|repo_root| repo_root.join(EXTERNAL_AGENT_DIR).join("settings.json"),
|
||||
);
|
||||
let settings = read_external_settings(&source_settings)?;
|
||||
let target_config = repo_root.map_or_else(
|
||||
@@ -185,7 +181,7 @@ impl ExternalAgentConfigService {
|
||||
|repo_root| repo_root.join(".codex").join("config.toml"),
|
||||
);
|
||||
if let Some(settings) = settings.as_ref() {
|
||||
let migrated = build_config_from_external(&settings)?;
|
||||
let migrated = build_config_from_external(settings)?;
|
||||
if !is_empty_toml_table(&migrated) {
|
||||
let mut should_include = true;
|
||||
if target_config.exists() {
|
||||
@@ -220,16 +216,9 @@ impl ExternalAgentConfigService {
|
||||
}
|
||||
}
|
||||
|
||||
self.detect_plugin_migration(
|
||||
source_settings.as_path(),
|
||||
cwd.clone(),
|
||||
settings.as_ref(),
|
||||
items,
|
||||
);
|
||||
|
||||
let source_skills = repo_root.map_or_else(
|
||||
|| self.external_agent_home.join("skills"),
|
||||
|repo_root| repo_root.join(".claude").join("skills"),
|
||||
|repo_root| repo_root.join(EXTERNAL_AGENT_DIR).join("skills"),
|
||||
);
|
||||
let target_skills = repo_root.map_or_else(
|
||||
|| self.home_target_skills_dir(),
|
||||
@@ -240,7 +229,7 @@ impl ExternalAgentConfigService {
|
||||
items.push(ExternalAgentConfigMigrationItem {
|
||||
item_type: ExternalAgentConfigMigrationItemType::Skills,
|
||||
description: format!(
|
||||
"Copy skill folders from {} to {}",
|
||||
"Migrate skills from {} to {}",
|
||||
source_skills.display(),
|
||||
target_skills.display()
|
||||
),
|
||||
@@ -257,7 +246,7 @@ impl ExternalAgentConfigService {
|
||||
let source_agents_md = if let Some(repo_root) = repo_root {
|
||||
find_repo_agents_md_source(repo_root)?
|
||||
} else {
|
||||
let path = self.external_agent_home.join("CLAUDE.md");
|
||||
let path = self.external_agent_home.join(EXTERNAL_AGENT_CONFIG_MD);
|
||||
is_non_empty_text_file(&path)?.then_some(path)
|
||||
};
|
||||
let target_agents_md = repo_root.map_or_else(
|
||||
@@ -274,7 +263,7 @@ impl ExternalAgentConfigService {
|
||||
source_agents_md.display(),
|
||||
target_agents_md.display()
|
||||
),
|
||||
cwd,
|
||||
cwd: cwd.clone(),
|
||||
details: None,
|
||||
});
|
||||
emit_migration_metric(
|
||||
@@ -284,6 +273,43 @@ impl ExternalAgentConfigService {
|
||||
);
|
||||
}
|
||||
|
||||
if let Some(settings) = settings.as_ref() {
|
||||
match ConfigBuilder::default()
|
||||
.codex_home(self.codex_home.clone())
|
||||
.fallback_cwd(Some(self.codex_home.clone()))
|
||||
.build()
|
||||
.await
|
||||
{
|
||||
Ok(config) => {
|
||||
let configured_plugin_ids =
|
||||
configured_plugins_from_stack(&config.config_layer_stack)
|
||||
.into_keys()
|
||||
.collect::<HashSet<_>>();
|
||||
let configured_marketplace_plugins = configured_marketplace_plugins(
|
||||
&config,
|
||||
&PluginsManager::new(self.codex_home.clone()),
|
||||
)?;
|
||||
if let Some(item) = self.detect_plugin_migration(
|
||||
source_settings.as_path(),
|
||||
repo_root.unwrap_or(self.external_agent_home.as_path()),
|
||||
cwd.clone(),
|
||||
settings,
|
||||
&configured_plugin_ids,
|
||||
&configured_marketplace_plugins,
|
||||
) {
|
||||
items.push(item);
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::warn!(
|
||||
error = %err,
|
||||
settings_path = %source_settings.display(),
|
||||
"skipping external agent plugin migration detection because config load failed"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -297,25 +323,30 @@ impl ExternalAgentConfigService {
|
||||
fn detect_plugin_migration(
|
||||
&self,
|
||||
source_settings: &Path,
|
||||
source_root: &Path,
|
||||
cwd: Option<PathBuf>,
|
||||
settings: Option<&JsonValue>,
|
||||
items: &mut Vec<ExternalAgentConfigMigrationItem>,
|
||||
) {
|
||||
let Some(plugin_details) = settings.and_then(extract_plugin_migration_details) else {
|
||||
return;
|
||||
};
|
||||
|
||||
items.push(ExternalAgentConfigMigrationItem {
|
||||
item_type: ExternalAgentConfigMigrationItemType::Plugins,
|
||||
description: format!("Import enabled plugins from {}", source_settings.display()),
|
||||
cwd,
|
||||
details: Some(plugin_details),
|
||||
});
|
||||
settings: &JsonValue,
|
||||
configured_plugin_ids: &HashSet<String>,
|
||||
configured_marketplace_plugins: &BTreeMap<String, HashSet<String>>,
|
||||
) -> Option<ExternalAgentConfigMigrationItem> {
|
||||
let plugin_details = extract_plugin_migration_details(
|
||||
settings,
|
||||
source_root,
|
||||
configured_plugin_ids,
|
||||
configured_marketplace_plugins,
|
||||
)?;
|
||||
emit_migration_metric(
|
||||
EXTERNAL_AGENT_CONFIG_DETECT_METRIC,
|
||||
ExternalAgentConfigMigrationItemType::Plugins,
|
||||
/*skills_count*/ None,
|
||||
);
|
||||
|
||||
Some(ExternalAgentConfigMigrationItem {
|
||||
item_type: ExternalAgentConfigMigrationItemType::Plugins,
|
||||
description: format!("Import enabled plugins from {}", source_settings.display()),
|
||||
cwd,
|
||||
details: Some(plugin_details),
|
||||
})
|
||||
}
|
||||
|
||||
async fn import_plugins(
|
||||
@@ -337,8 +368,14 @@ impl ExternalAgentConfigService {
|
||||
.iter()
|
||||
.map(|plugin_name| format!("{plugin_name}@{marketplace_name}"))
|
||||
.collect::<Vec<_>>();
|
||||
let import_source =
|
||||
read_marketplace_import_source(cwd, &self.external_agent_home, &marketplace_name)?;
|
||||
let source_settings = cwd.map_or_else(
|
||||
|| self.external_agent_home.join("settings.json"),
|
||||
|cwd| cwd.join(EXTERNAL_AGENT_DIR).join("settings.json"),
|
||||
);
|
||||
let source_root = cwd.unwrap_or(self.external_agent_home.as_path());
|
||||
let import_source = read_external_settings(&source_settings)?.and_then(|settings| {
|
||||
collect_marketplace_import_sources(&settings, source_root).remove(&marketplace_name)
|
||||
});
|
||||
let Some(import_source) = import_source else {
|
||||
outcome.failed_marketplaces.push(marketplace_name);
|
||||
outcome.failed_plugin_ids.extend(plugin_ids);
|
||||
@@ -352,23 +389,17 @@ impl ExternalAgentConfigService {
|
||||
let add_marketplace_outcome = add_marketplace(self.codex_home.clone(), request).await;
|
||||
let marketplace_path = match add_marketplace_outcome {
|
||||
Ok(add_marketplace_outcome) => {
|
||||
match AbsolutePathBuf::try_from(
|
||||
add_marketplace_outcome
|
||||
.installed_root
|
||||
.join(INSTALLED_MARKETPLACE_MANIFEST_RELATIVE_PATH),
|
||||
) {
|
||||
Ok(path) => {
|
||||
outcome
|
||||
.succeeded_marketplaces
|
||||
.push(marketplace_name.clone());
|
||||
path
|
||||
}
|
||||
Err(_) => {
|
||||
outcome.failed_marketplaces.push(marketplace_name);
|
||||
outcome.failed_plugin_ids.extend(plugin_ids);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let Some(marketplace_path) = find_marketplace_manifest_path(
|
||||
add_marketplace_outcome.installed_root.as_path(),
|
||||
) else {
|
||||
outcome.failed_marketplaces.push(marketplace_name);
|
||||
outcome.failed_plugin_ids.extend(plugin_ids);
|
||||
continue;
|
||||
};
|
||||
outcome
|
||||
.succeeded_marketplaces
|
||||
.push(marketplace_name.clone());
|
||||
marketplace_path
|
||||
}
|
||||
Err(_) => {
|
||||
outcome.failed_marketplaces.push(marketplace_name);
|
||||
@@ -400,7 +431,7 @@ impl ExternalAgentConfigService {
|
||||
fn import_config(&self, cwd: Option<&Path>) -> io::Result<()> {
|
||||
let (source_settings, target_config) = if let Some(repo_root) = find_repo_root(cwd)? {
|
||||
(
|
||||
repo_root.join(".claude").join("settings.json"),
|
||||
repo_root.join(EXTERNAL_AGENT_DIR).join("settings.json"),
|
||||
repo_root.join(".codex").join("config.toml"),
|
||||
)
|
||||
} else if cwd.is_some_and(|cwd| !cwd.as_os_str().is_empty()) {
|
||||
@@ -452,7 +483,7 @@ impl ExternalAgentConfigService {
|
||||
fn import_skills(&self, cwd: Option<&Path>) -> io::Result<usize> {
|
||||
let (source_skills, target_skills) = if let Some(repo_root) = find_repo_root(cwd)? {
|
||||
(
|
||||
repo_root.join(".claude").join("skills"),
|
||||
repo_root.join(EXTERNAL_AGENT_DIR).join("skills"),
|
||||
repo_root.join(".agents").join("skills"),
|
||||
)
|
||||
} else if cwd.is_some_and(|cwd| !cwd.as_os_str().is_empty()) {
|
||||
@@ -499,7 +530,7 @@ impl ExternalAgentConfigService {
|
||||
return Ok(());
|
||||
} else {
|
||||
(
|
||||
self.external_agent_home.join("CLAUDE.md"),
|
||||
self.external_agent_home.join(EXTERNAL_AGENT_CONFIG_MD),
|
||||
self.codex_home.join("AGENTS.md"),
|
||||
)
|
||||
};
|
||||
@@ -520,10 +551,10 @@ impl ExternalAgentConfigService {
|
||||
|
||||
fn default_external_agent_home() -> PathBuf {
|
||||
if let Some(home) = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")) {
|
||||
return PathBuf::from(home).join(".claude");
|
||||
return PathBuf::from(home).join(EXTERNAL_AGENT_DIR);
|
||||
}
|
||||
|
||||
PathBuf::from(".claude")
|
||||
PathBuf::from(EXTERNAL_AGENT_DIR)
|
||||
}
|
||||
|
||||
fn read_external_settings(path: &Path) -> io::Result<Option<JsonValue>> {
|
||||
@@ -537,12 +568,37 @@ fn read_external_settings(path: &Path) -> io::Result<Option<JsonValue>> {
|
||||
Ok(Some(settings))
|
||||
}
|
||||
|
||||
fn extract_plugin_migration_details(settings: &JsonValue) -> Option<MigrationDetails> {
|
||||
fn extract_plugin_migration_details(
|
||||
settings: &JsonValue,
|
||||
source_root: &Path,
|
||||
configured_plugin_ids: &HashSet<String>,
|
||||
configured_marketplace_plugins: &BTreeMap<String, HashSet<String>>,
|
||||
) -> Option<MigrationDetails> {
|
||||
let loadable_marketplaces = collect_marketplace_import_sources(settings, source_root)
|
||||
.into_iter()
|
||||
.filter_map(|(marketplace_name, source)| {
|
||||
parse_marketplace_source(&source.source, source.ref_name)
|
||||
.ok()
|
||||
.map(|_| marketplace_name)
|
||||
})
|
||||
.collect::<HashSet<_>>();
|
||||
let mut plugins = BTreeMap::new();
|
||||
for plugin_id in collect_enabled_plugins(settings) {
|
||||
for plugin_id in collect_enabled_plugins(settings)
|
||||
.into_iter()
|
||||
.filter(|plugin_id| !configured_plugin_ids.contains(plugin_id))
|
||||
{
|
||||
let Ok(plugin_id) = PluginId::parse(&plugin_id) else {
|
||||
continue;
|
||||
};
|
||||
if let Some(installable_plugins) =
|
||||
configured_marketplace_plugins.get(&plugin_id.marketplace_name)
|
||||
{
|
||||
if !installable_plugins.contains(&plugin_id.plugin_name) {
|
||||
continue;
|
||||
}
|
||||
} else if !loadable_marketplaces.contains(&plugin_id.marketplace_name) {
|
||||
continue;
|
||||
}
|
||||
let plugin_group = plugins
|
||||
.entry(plugin_id.marketplace_name.clone())
|
||||
.or_insert_with(|| PluginsMigration {
|
||||
@@ -591,8 +647,40 @@ fn collect_enabled_plugins(settings: &JsonValue) -> Vec<String> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn configured_marketplace_plugins(
|
||||
config: &Config,
|
||||
plugins_manager: &PluginsManager,
|
||||
) -> io::Result<BTreeMap<String, HashSet<String>>> {
|
||||
let marketplaces = plugins_manager
|
||||
.list_marketplaces_for_config(config, &[])
|
||||
.map_err(|err| {
|
||||
invalid_data_error(format!("failed to list configured marketplaces: {err}"))
|
||||
})?;
|
||||
let mut marketplace_plugins = BTreeMap::new();
|
||||
for marketplace in marketplaces.marketplaces {
|
||||
let plugins = marketplace
|
||||
.plugins
|
||||
.into_iter()
|
||||
.filter(|plugin| {
|
||||
plugin.policy.installation != MarketplacePluginInstallPolicy::NotAvailable
|
||||
})
|
||||
.filter(|plugin| {
|
||||
plugin
|
||||
.policy
|
||||
.products
|
||||
.as_deref()
|
||||
.is_none_or(|products| Product::Codex.matches_product_restriction(products))
|
||||
})
|
||||
.map(|plugin| plugin.name)
|
||||
.collect::<HashSet<_>>();
|
||||
marketplace_plugins.insert(marketplace.name, plugins);
|
||||
}
|
||||
Ok(marketplace_plugins)
|
||||
}
|
||||
|
||||
fn collect_marketplace_import_sources(
|
||||
settings: &JsonValue,
|
||||
source_root: &Path,
|
||||
) -> BTreeMap<String, MarketplaceImportSource> {
|
||||
let Some(extra_known_marketplaces) = settings
|
||||
.as_object()
|
||||
@@ -623,6 +711,7 @@ fn collect_marketplace_import_sources(
|
||||
if source.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let source = resolve_external_marketplace_source(&source, source_root);
|
||||
|
||||
let ref_name = source_fields
|
||||
.get("ref")
|
||||
@@ -643,20 +732,16 @@ struct MarketplaceImportSource {
|
||||
ref_name: Option<String>,
|
||||
}
|
||||
|
||||
fn read_marketplace_import_source(
|
||||
cwd: Option<&Path>,
|
||||
external_agent_home: &Path,
|
||||
marketplace_name: &str,
|
||||
) -> io::Result<Option<MarketplaceImportSource>> {
|
||||
let source_settings = cwd.map_or_else(
|
||||
|| external_agent_home.join("settings.json"),
|
||||
|cwd| cwd.join(".claude").join("settings.json"),
|
||||
);
|
||||
let Some(settings) = read_external_settings(&source_settings)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
fn resolve_external_marketplace_source(source: &str, source_root: &Path) -> String {
|
||||
if !looks_like_relative_local_path(source) {
|
||||
return source.to_string();
|
||||
}
|
||||
|
||||
Ok(collect_marketplace_import_sources(&settings).remove(marketplace_name))
|
||||
source_root.join(source).display().to_string()
|
||||
}
|
||||
|
||||
fn looks_like_relative_local_path(source: &str) -> bool {
|
||||
source.starts_with("./") || source.starts_with("../") || source == "." || source == ".."
|
||||
}
|
||||
|
||||
fn find_repo_root(cwd: Option<&Path>) -> io::Result<Option<PathBuf>> {
|
||||
@@ -741,8 +826,10 @@ fn is_non_empty_text_file(path: &Path) -> io::Result<bool> {
|
||||
|
||||
fn find_repo_agents_md_source(repo_root: &Path) -> io::Result<Option<PathBuf>> {
|
||||
for candidate in [
|
||||
repo_root.join("CLAUDE.md"),
|
||||
repo_root.join(".claude").join("CLAUDE.md"),
|
||||
repo_root.join(EXTERNAL_AGENT_CONFIG_MD),
|
||||
repo_root
|
||||
.join(EXTERNAL_AGENT_DIR)
|
||||
.join(EXTERNAL_AGENT_CONFIG_MD),
|
||||
] {
|
||||
if is_non_empty_text_file(&candidate)? {
|
||||
return Ok(Some(candidate));
|
||||
@@ -791,7 +878,11 @@ fn rewrite_and_copy_text_file(source: &Path, target: &Path) -> io::Result<()> {
|
||||
}
|
||||
|
||||
fn rewrite_external_agent_terms(content: &str) -> String {
|
||||
let mut rewritten = replace_case_insensitive_with_boundaries(content, "claude.md", "AGENTS.md");
|
||||
let mut rewritten = replace_case_insensitive_with_boundaries(
|
||||
content,
|
||||
&EXTERNAL_AGENT_CONFIG_MD.to_ascii_lowercase(),
|
||||
"AGENTS.md",
|
||||
);
|
||||
for from in [
|
||||
"claude code",
|
||||
"claude-code",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -426,10 +426,14 @@ async fn test_get_git_working_tree_state_branch_fallback() {
|
||||
assert_eq!(state.sha, GitSha::new(&remote_sha));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_root_git_project_for_trust_returns_none_outside_repo() {
|
||||
#[tokio::test]
|
||||
async fn resolve_root_git_project_for_trust_returns_none_outside_repo() {
|
||||
let tmp = TempDir::new().expect("tempdir");
|
||||
assert!(resolve_root_git_project_for_trust(tmp.path()).is_none());
|
||||
assert!(
|
||||
resolve_root_git_project_for_trust(tmp.path())
|
||||
.await
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -439,12 +443,15 @@ async fn resolve_root_git_project_for_trust_regular_repo_returns_repo_root() {
|
||||
let expected = std::fs::canonicalize(&repo_path).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
resolve_root_git_project_for_trust(&repo_path),
|
||||
resolve_root_git_project_for_trust(&repo_path).await,
|
||||
Some(expected.clone())
|
||||
);
|
||||
let nested = repo_path.join("sub/dir");
|
||||
std::fs::create_dir_all(&nested).unwrap();
|
||||
assert_eq!(resolve_root_git_project_for_trust(&nested), Some(expected));
|
||||
assert_eq!(
|
||||
resolve_root_git_project_for_trust(&nested).await,
|
||||
Some(expected)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -467,18 +474,20 @@ async fn resolve_root_git_project_for_trust_detects_worktree_and_returns_main_ro
|
||||
.expect("git worktree add");
|
||||
|
||||
let expected = std::fs::canonicalize(&repo_path).ok();
|
||||
let got =
|
||||
resolve_root_git_project_for_trust(&wt_root).and_then(|p| std::fs::canonicalize(p).ok());
|
||||
let got = resolve_root_git_project_for_trust(&wt_root)
|
||||
.await
|
||||
.and_then(|p| std::fs::canonicalize(p).ok());
|
||||
assert_eq!(got, expected);
|
||||
let nested = wt_root.join("nested/sub");
|
||||
std::fs::create_dir_all(&nested).unwrap();
|
||||
let got_nested =
|
||||
resolve_root_git_project_for_trust(&nested).and_then(|p| std::fs::canonicalize(p).ok());
|
||||
let got_nested = resolve_root_git_project_for_trust(&nested)
|
||||
.await
|
||||
.and_then(|p| std::fs::canonicalize(p).ok());
|
||||
assert_eq!(got_nested, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_root_git_project_for_trust_detects_worktree_pointer_without_git_command() {
|
||||
#[tokio::test]
|
||||
async fn resolve_root_git_project_for_trust_detects_worktree_pointer_without_git_command() {
|
||||
let tmp = TempDir::new().expect("tempdir");
|
||||
let repo_root = tmp.path().join("repo");
|
||||
let common_dir = repo_root.join(".git");
|
||||
@@ -495,17 +504,17 @@ fn resolve_root_git_project_for_trust_detects_worktree_pointer_without_git_comma
|
||||
|
||||
let expected = std::fs::canonicalize(&repo_root).unwrap();
|
||||
assert_eq!(
|
||||
resolve_root_git_project_for_trust(&worktree_root),
|
||||
resolve_root_git_project_for_trust(&worktree_root).await,
|
||||
Some(expected.clone())
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_root_git_project_for_trust(&worktree_root.join("nested")),
|
||||
resolve_root_git_project_for_trust(&worktree_root.join("nested")).await,
|
||||
Some(expected)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_root_git_project_for_trust_non_worktrees_gitdir_returns_none() {
|
||||
#[tokio::test]
|
||||
async fn resolve_root_git_project_for_trust_non_worktrees_gitdir_returns_none() {
|
||||
let tmp = TempDir::new().expect("tempdir");
|
||||
let proj = tmp.path().join("proj");
|
||||
std::fs::create_dir_all(proj.join("nested")).unwrap();
|
||||
@@ -520,8 +529,12 @@ fn resolve_root_git_project_for_trust_non_worktrees_gitdir_returns_none() {
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert!(resolve_root_git_project_for_trust(&proj).is_none());
|
||||
assert!(resolve_root_git_project_for_trust(&proj.join("nested")).is_none());
|
||||
assert!(resolve_root_git_project_for_trust(&proj).await.is_none());
|
||||
assert!(
|
||||
resolve_root_git_project_for_trust(&proj.join("nested"))
|
||||
.await
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
|
||||
@@ -823,9 +823,9 @@ async fn interrupt_and_drain_turn(codex: &Codex) -> anyhow::Result<()> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn guardian_review_session_config_change_invalidates_cached_session() {
|
||||
let parent_config = crate::config::test_config();
|
||||
#[tokio::test]
|
||||
async fn guardian_review_session_config_change_invalidates_cached_session() {
|
||||
let parent_config = crate::config::test_config().await;
|
||||
let cached_spawn_config = build_guardian_review_session_config(
|
||||
&parent_config,
|
||||
/*live_network_config*/ None,
|
||||
@@ -855,9 +855,9 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guardian_review_session_config_disables_hooks() {
|
||||
let mut parent_config = crate::config::test_config();
|
||||
#[tokio::test]
|
||||
async fn guardian_review_session_config_disables_hooks() {
|
||||
let mut parent_config = crate::config::test_config().await;
|
||||
parent_config
|
||||
.features
|
||||
.enable(Feature::CodexHooks)
|
||||
|
||||
@@ -1332,8 +1332,8 @@ async fn guardian_review_surfaces_responses_api_errors_in_rejection_reason() ->
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guardian_parallel_reviews_fork_from_last_committed_trunk_history() -> anyhow::Result<()> {
|
||||
#[tokio::test]
|
||||
async fn guardian_parallel_reviews_fork_from_last_committed_trunk_history() -> anyhow::Result<()> {
|
||||
const TEST_STACK_SIZE_BYTES: usize = 2 * 1024 * 1024;
|
||||
|
||||
let handle =
|
||||
@@ -1569,9 +1569,9 @@ fn guardian_parallel_reviews_fork_from_last_committed_trunk_history() -> anyhow:
|
||||
)),
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn guardian_review_session_config_preserves_parent_network_proxy() {
|
||||
let mut parent_config = test_config();
|
||||
#[tokio::test]
|
||||
async fn guardian_review_session_config_preserves_parent_network_proxy() {
|
||||
let mut parent_config = test_config().await;
|
||||
let network = NetworkProxySpec::from_config_and_constraints(
|
||||
NetworkProxyConfig::default(),
|
||||
Some(NetworkConstraints {
|
||||
@@ -1616,9 +1616,9 @@ fn guardian_review_session_config_preserves_parent_network_proxy() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guardian_review_session_config_overrides_parent_developer_instructions() {
|
||||
let mut parent_config = test_config();
|
||||
#[tokio::test]
|
||||
async fn guardian_review_session_config_overrides_parent_developer_instructions() {
|
||||
let mut parent_config = test_config().await;
|
||||
parent_config.developer_instructions =
|
||||
Some("parent or managed config should not replace guardian policy".to_string());
|
||||
|
||||
@@ -1636,9 +1636,9 @@ fn guardian_review_session_config_overrides_parent_developer_instructions() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guardian_review_session_config_uses_live_network_proxy_state() {
|
||||
let mut parent_config = test_config();
|
||||
#[tokio::test]
|
||||
async fn guardian_review_session_config_uses_live_network_proxy_state() {
|
||||
let mut parent_config = test_config().await;
|
||||
let mut parent_network = NetworkProxyConfig::default();
|
||||
parent_network.network.enabled = true;
|
||||
parent_network
|
||||
@@ -1680,9 +1680,9 @@ fn guardian_review_session_config_uses_live_network_proxy_state() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guardian_review_session_config_rejects_pinned_collab_feature() {
|
||||
let mut parent_config = test_config();
|
||||
#[tokio::test]
|
||||
async fn guardian_review_session_config_rejects_pinned_collab_feature() {
|
||||
let mut parent_config = test_config().await;
|
||||
parent_config.features = ManagedFeatures::from_configured(
|
||||
parent_config.features.get().clone(),
|
||||
Some(Sourced {
|
||||
@@ -1708,9 +1708,9 @@ fn guardian_review_session_config_rejects_pinned_collab_feature() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guardian_review_session_config_uses_parent_active_model_instead_of_hardcoded_slug() {
|
||||
let mut parent_config = test_config();
|
||||
#[tokio::test]
|
||||
async fn guardian_review_session_config_uses_parent_active_model_instead_of_hardcoded_slug() {
|
||||
let mut parent_config = test_config().await;
|
||||
parent_config.model = Some("configured-model".to_string());
|
||||
|
||||
let guardian_config = build_guardian_review_session_config_for_test(
|
||||
@@ -1724,8 +1724,8 @@ fn guardian_review_session_config_uses_parent_active_model_instead_of_hardcoded_
|
||||
assert_eq!(guardian_config.model, Some("active-model".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guardian_review_session_config_uses_requirements_guardian_policy_config() {
|
||||
#[tokio::test]
|
||||
async fn guardian_review_session_config_uses_requirements_guardian_policy_config() {
|
||||
let codex_home = tempfile::tempdir().expect("create temp dir");
|
||||
let workspace = tempfile::tempdir().expect("create temp dir");
|
||||
let config_layer_stack = ConfigLayerStack::new(
|
||||
@@ -1748,6 +1748,7 @@ fn guardian_review_session_config_uses_requirements_guardian_policy_config() {
|
||||
codex_home.abs(),
|
||||
config_layer_stack,
|
||||
)
|
||||
.await
|
||||
.expect("load config");
|
||||
|
||||
let guardian_config = build_guardian_review_session_config_for_test(
|
||||
@@ -1766,8 +1767,9 @@ fn guardian_review_session_config_uses_requirements_guardian_policy_config() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn guardian_review_session_config_uses_default_guardian_policy_without_requirements_override() {
|
||||
#[tokio::test]
|
||||
async fn guardian_review_session_config_uses_default_guardian_policy_without_requirements_override()
|
||||
{
|
||||
let codex_home = tempfile::tempdir().expect("create temp dir");
|
||||
let workspace = tempfile::tempdir().expect("create temp dir");
|
||||
let config_layer_stack =
|
||||
@@ -1782,6 +1784,7 @@ fn guardian_review_session_config_uses_default_guardian_policy_without_requireme
|
||||
codex_home.abs(),
|
||||
config_layer_stack,
|
||||
)
|
||||
.await
|
||||
.expect("load config");
|
||||
|
||||
let guardian_config = build_guardian_review_session_config_for_test(
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
// the TUI or the tracing stack).
|
||||
#![deny(clippy::print_stdout, clippy::print_stderr)]
|
||||
|
||||
mod agent_identity;
|
||||
mod apply_patch;
|
||||
mod apps;
|
||||
mod arc_monitor;
|
||||
@@ -54,12 +55,11 @@ pub use network_proxy_loader::MtimeConfigReloader;
|
||||
pub use network_proxy_loader::build_network_proxy_state;
|
||||
pub use network_proxy_loader::build_network_proxy_state_and_reloader;
|
||||
mod original_image_detail;
|
||||
pub use codex_mcp::MCP_SANDBOX_STATE_CAPABILITY;
|
||||
pub use codex_mcp::MCP_SANDBOX_STATE_METHOD;
|
||||
pub use codex_mcp::SandboxState;
|
||||
mod mcp_openai_file;
|
||||
mod mcp_tool_call;
|
||||
mod memories;
|
||||
pub use memories::clear_memory_roots_contents;
|
||||
pub(crate) mod mention_syntax;
|
||||
pub(crate) mod message_history;
|
||||
pub(crate) mod utils;
|
||||
@@ -99,9 +99,7 @@ pub(crate) use skills::build_skill_injections;
|
||||
pub(crate) use skills::build_skill_name_counts;
|
||||
pub(crate) use skills::collect_env_var_dependencies;
|
||||
pub(crate) use skills::collect_explicit_skill_mentions;
|
||||
pub(crate) use skills::config_rules;
|
||||
pub(crate) use skills::injection;
|
||||
pub(crate) use skills::loader;
|
||||
pub(crate) use skills::manager;
|
||||
pub(crate) use skills::maybe_emit_implicit_skill_invocation;
|
||||
pub(crate) use skills::render_skills_section;
|
||||
@@ -132,14 +130,12 @@ pub type ConversationManager = ThreadManager;
|
||||
pub type NewConversation = NewThread;
|
||||
#[deprecated(note = "use CodexThread")]
|
||||
pub type CodexConversation = CodexThread;
|
||||
pub(crate) mod project_doc;
|
||||
pub use project_doc::DEFAULT_PROJECT_DOC_FILENAME;
|
||||
pub use project_doc::LOCAL_PROJECT_DOC_FILENAME;
|
||||
pub use project_doc::discover_project_doc_paths;
|
||||
pub use project_doc::read_project_docs;
|
||||
pub(crate) mod agents_md;
|
||||
pub use agents_md::AgentsMdManager;
|
||||
pub use agents_md::DEFAULT_AGENTS_MD_FILENAME;
|
||||
pub use agents_md::LOCAL_AGENTS_MD_FILENAME;
|
||||
mod rollout;
|
||||
pub(crate) mod safety;
|
||||
pub mod seatbelt;
|
||||
mod session_rollout_init_error;
|
||||
pub mod shell;
|
||||
pub(crate) mod shell_snapshot;
|
||||
@@ -152,6 +148,7 @@ mod tools;
|
||||
pub(crate) mod turn_diff_tracker;
|
||||
mod turn_metadata;
|
||||
mod turn_timing;
|
||||
mod unavailable_tool;
|
||||
pub use rollout::ARCHIVED_SESSIONS_SUBDIR;
|
||||
pub use rollout::Cursor;
|
||||
pub use rollout::EventPersistenceMode;
|
||||
|
||||
@@ -115,6 +115,7 @@ async fn build_uploaded_local_argument_value(
|
||||
let upload_auth = CoreAuthProvider {
|
||||
token: Some(token_data.access_token),
|
||||
account_id: token_data.account_id,
|
||||
is_fedramp_account: auth.is_fedramp_account(),
|
||||
};
|
||||
let uploaded = upload_local_file(
|
||||
turn_context.config.chatgpt_base_url.trim_end_matches('/'),
|
||||
|
||||
@@ -364,12 +364,14 @@ fn mcp_dependency_to_server_config(
|
||||
http_headers: None,
|
||||
env_http_headers: None,
|
||||
},
|
||||
experimental_environment: None,
|
||||
enabled: true,
|
||||
required: false,
|
||||
supports_parallel_tool_calls: false,
|
||||
disabled_reason: None,
|
||||
startup_timeout_sec: None,
|
||||
tool_timeout_sec: None,
|
||||
default_tools_approval_mode: None,
|
||||
enabled_tools: None,
|
||||
disabled_tools: None,
|
||||
scopes: None,
|
||||
@@ -391,12 +393,14 @@ fn mcp_dependency_to_server_config(
|
||||
env_vars: Vec::new(),
|
||||
cwd: None,
|
||||
},
|
||||
experimental_environment: None,
|
||||
enabled: true,
|
||||
required: false,
|
||||
supports_parallel_tool_calls: false,
|
||||
disabled_reason: None,
|
||||
startup_timeout_sec: None,
|
||||
tool_timeout_sec: None,
|
||||
default_tools_approval_mode: None,
|
||||
enabled_tools: None,
|
||||
disabled_tools: None,
|
||||
scopes: None,
|
||||
|
||||
@@ -36,6 +36,7 @@ use codex_analytics::build_track_events_context;
|
||||
use codex_config::types::AppToolApproval;
|
||||
use codex_features::Feature;
|
||||
use codex_mcp::CODEX_APPS_MCP_SERVER_NAME;
|
||||
use codex_mcp::SandboxState;
|
||||
use codex_mcp::declared_openai_file_input_param_names;
|
||||
use codex_mcp::mcp_permission_prompt_is_auto_approved;
|
||||
use codex_otel::sanitize_metric_tag_value;
|
||||
@@ -100,6 +101,9 @@ pub(crate) async fn handle_mcp_tool_call(
|
||||
|
||||
let metadata =
|
||||
lookup_mcp_tool_metadata(sess.as_ref(), turn_context.as_ref(), &server, &tool_name).await;
|
||||
let mcp_app_resource_uri = metadata
|
||||
.as_ref()
|
||||
.and_then(|metadata| metadata.mcp_app_resource_uri.clone());
|
||||
let app_tool_policy = if server == CODEX_APPS_MCP_SERVER_NAME {
|
||||
connectors::app_tool_policy(
|
||||
&turn_context.config,
|
||||
@@ -129,6 +133,7 @@ pub(crate) async fn handle_mcp_tool_call(
|
||||
turn_context.as_ref(),
|
||||
&call_id,
|
||||
invocation,
|
||||
mcp_app_resource_uri.clone(),
|
||||
"MCP tool call blocked by app configuration".to_string(),
|
||||
/*already_started*/ false,
|
||||
)
|
||||
@@ -160,6 +165,7 @@ pub(crate) async fn handle_mcp_tool_call(
|
||||
let tool_call_begin_event = EventMsg::McpToolCallBegin(McpToolCallBeginEvent {
|
||||
call_id: call_id.clone(),
|
||||
invocation: invocation.clone(),
|
||||
mcp_app_resource_uri: mcp_app_resource_uri.clone(),
|
||||
});
|
||||
notify_mcp_tool_call_event(sess.as_ref(), turn_context.as_ref(), tool_call_begin_event).await;
|
||||
|
||||
@@ -212,6 +218,7 @@ pub(crate) async fn handle_mcp_tool_call(
|
||||
let tool_call_end_event = EventMsg::McpToolCallEnd(McpToolCallEndEvent {
|
||||
call_id: call_id.clone(),
|
||||
invocation,
|
||||
mcp_app_resource_uri: mcp_app_resource_uri.clone(),
|
||||
duration,
|
||||
result: result.clone(),
|
||||
});
|
||||
@@ -238,6 +245,7 @@ pub(crate) async fn handle_mcp_tool_call(
|
||||
turn_context.as_ref(),
|
||||
&call_id,
|
||||
invocation,
|
||||
mcp_app_resource_uri.clone(),
|
||||
message,
|
||||
/*already_started*/ true,
|
||||
)
|
||||
@@ -253,6 +261,7 @@ pub(crate) async fn handle_mcp_tool_call(
|
||||
turn_context.as_ref(),
|
||||
&call_id,
|
||||
invocation,
|
||||
mcp_app_resource_uri.clone(),
|
||||
message,
|
||||
/*already_started*/ true,
|
||||
)
|
||||
@@ -267,6 +276,7 @@ pub(crate) async fn handle_mcp_tool_call(
|
||||
turn_context.as_ref(),
|
||||
&call_id,
|
||||
invocation,
|
||||
mcp_app_resource_uri.clone(),
|
||||
message,
|
||||
/*already_started*/ true,
|
||||
)
|
||||
@@ -324,6 +334,7 @@ pub(crate) async fn handle_mcp_tool_call(
|
||||
let tool_call_end_event = EventMsg::McpToolCallEnd(McpToolCallEndEvent {
|
||||
call_id: call_id.clone(),
|
||||
invocation,
|
||||
mcp_app_resource_uri,
|
||||
duration,
|
||||
result: result.clone(),
|
||||
});
|
||||
@@ -466,6 +477,10 @@ async fn execute_mcp_tool_call(
|
||||
metadata.and_then(|metadata| metadata.openai_file_input_params.as_deref()),
|
||||
)
|
||||
.await?;
|
||||
let request_meta =
|
||||
augment_mcp_tool_request_meta_with_sandbox_state(sess, turn_context, server, request_meta)
|
||||
.await
|
||||
.map_err(|e| format!("failed to build MCP tool request metadata: {e:#}"))?;
|
||||
let result = sess
|
||||
.call_tool(server, tool_name, rewritten_arguments, request_meta)
|
||||
.await
|
||||
@@ -479,6 +494,52 @@ async fn execute_mcp_tool_call(
|
||||
)
|
||||
}
|
||||
|
||||
async fn augment_mcp_tool_request_meta_with_sandbox_state(
|
||||
sess: &Session,
|
||||
turn_context: &TurnContext,
|
||||
server: &str,
|
||||
mut meta: Option<serde_json::Value>,
|
||||
) -> anyhow::Result<Option<serde_json::Value>> {
|
||||
let supports_sandbox_state_meta = sess
|
||||
.services
|
||||
.mcp_connection_manager
|
||||
.read()
|
||||
.await
|
||||
.server_supports_sandbox_state_meta_capability(server)
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
if !supports_sandbox_state_meta {
|
||||
return Ok(meta);
|
||||
}
|
||||
|
||||
let sandbox_state = serde_json::to_value(SandboxState {
|
||||
sandbox_policy: turn_context.sandbox_policy.get().clone(),
|
||||
codex_linux_sandbox_exe: turn_context.codex_linux_sandbox_exe.clone(),
|
||||
sandbox_cwd: turn_context.cwd.to_path_buf(),
|
||||
use_legacy_landlock: turn_context.features.use_legacy_landlock(),
|
||||
})?;
|
||||
|
||||
match meta.as_mut() {
|
||||
Some(serde_json::Value::Object(map)) => {
|
||||
map.insert(
|
||||
codex_mcp::MCP_SANDBOX_STATE_META_CAPABILITY.to_string(),
|
||||
sandbox_state,
|
||||
);
|
||||
}
|
||||
Some(_) => {}
|
||||
None => {
|
||||
let mut map = serde_json::Map::new();
|
||||
map.insert(
|
||||
codex_mcp::MCP_SANDBOX_STATE_META_CAPABILITY.to_string(),
|
||||
sandbox_state,
|
||||
);
|
||||
meta = Some(serde_json::Value::Object(map));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(meta)
|
||||
}
|
||||
|
||||
async fn maybe_mark_thread_memory_mode_polluted(sess: &Session, turn_context: &TurnContext) {
|
||||
if !turn_context
|
||||
.config
|
||||
@@ -591,11 +652,14 @@ pub(crate) struct McpToolApprovalMetadata {
|
||||
connector_description: Option<String>,
|
||||
tool_title: Option<String>,
|
||||
tool_description: Option<String>,
|
||||
mcp_app_resource_uri: Option<String>,
|
||||
codex_apps_meta: Option<serde_json::Map<String, serde_json::Value>>,
|
||||
openai_file_input_params: Option<Vec<String>>,
|
||||
}
|
||||
|
||||
const MCP_TOOL_CODEX_APPS_META_KEY: &str = "_codex_apps";
|
||||
const MCP_TOOL_OPENAI_OUTPUT_TEMPLATE_META_KEY: &str = "openai/outputTemplate";
|
||||
const MCP_TOOL_UI_RESOURCE_URI_META_KEY: &str = "ui/resourceUri";
|
||||
|
||||
fn custom_mcp_tool_approval_mode(
|
||||
turn_context: &TurnContext,
|
||||
@@ -612,9 +676,14 @@ fn custom_mcp_tool_approval_mode(
|
||||
.and_then(|value| {
|
||||
HashMap::<String, codex_config::types::McpServerConfig>::deserialize(value).ok()
|
||||
})
|
||||
.and_then(|servers| servers.get(server).cloned())
|
||||
.and_then(|server| server.tools.get(tool_name).cloned())
|
||||
.and_then(|tool| tool.approval_mode)
|
||||
.and_then(|servers| {
|
||||
let server_config = servers.get(server)?;
|
||||
server_config
|
||||
.tools
|
||||
.get(tool_name)
|
||||
.and_then(|tool| tool.approval_mode)
|
||||
.or(server_config.default_tools_approval_mode)
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
@@ -1049,6 +1118,7 @@ pub(crate) async fn lookup_mcp_tool_metadata(
|
||||
connector_description,
|
||||
tool_title: tool_info.tool.title,
|
||||
tool_description: tool_info.tool.description.map(std::borrow::Cow::into_owned),
|
||||
mcp_app_resource_uri: get_mcp_app_resource_uri(tool_info.tool.meta.as_deref()),
|
||||
codex_apps_meta: tool_info
|
||||
.tool
|
||||
.meta
|
||||
@@ -1063,6 +1133,26 @@ pub(crate) async fn lookup_mcp_tool_metadata(
|
||||
})
|
||||
}
|
||||
|
||||
fn get_mcp_app_resource_uri(
|
||||
meta: Option<&serde_json::Map<String, serde_json::Value>>,
|
||||
) -> Option<String> {
|
||||
meta.and_then(|meta| {
|
||||
meta.get("ui")
|
||||
.and_then(serde_json::Value::as_object)
|
||||
.and_then(|ui| ui.get("resourceUri"))
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.or_else(|| {
|
||||
meta.get(MCP_TOOL_UI_RESOURCE_URI_META_KEY)
|
||||
.and_then(serde_json::Value::as_str)
|
||||
})
|
||||
.or_else(|| {
|
||||
meta.get(MCP_TOOL_OPENAI_OUTPUT_TEMPLATE_META_KEY)
|
||||
.and_then(serde_json::Value::as_str)
|
||||
})
|
||||
.map(str::to_string)
|
||||
})
|
||||
}
|
||||
|
||||
async fn lookup_mcp_app_usage_metadata(
|
||||
sess: &Session,
|
||||
server: &str,
|
||||
@@ -1615,6 +1705,7 @@ async fn notify_mcp_tool_call_skip(
|
||||
turn_context: &TurnContext,
|
||||
call_id: &str,
|
||||
invocation: McpInvocation,
|
||||
mcp_app_resource_uri: Option<String>,
|
||||
message: String,
|
||||
already_started: bool,
|
||||
) -> Result<CallToolResult, String> {
|
||||
@@ -1622,6 +1713,7 @@ async fn notify_mcp_tool_call_skip(
|
||||
let tool_call_begin_event = EventMsg::McpToolCallBegin(McpToolCallBeginEvent {
|
||||
call_id: call_id.to_string(),
|
||||
invocation: invocation.clone(),
|
||||
mcp_app_resource_uri: mcp_app_resource_uri.clone(),
|
||||
});
|
||||
notify_mcp_tool_call_event(sess, turn_context, tool_call_begin_event).await;
|
||||
}
|
||||
@@ -1629,6 +1721,7 @@ async fn notify_mcp_tool_call_skip(
|
||||
let tool_call_end_event = EventMsg::McpToolCallEnd(McpToolCallEndEvent {
|
||||
call_id: call_id.to_string(),
|
||||
invocation,
|
||||
mcp_app_resource_uri,
|
||||
duration: Duration::ZERO,
|
||||
result: Err(message.clone()),
|
||||
});
|
||||
|
||||
@@ -59,6 +59,7 @@ fn approval_metadata(
|
||||
connector_description: connector_description.map(str::to_string),
|
||||
tool_title: tool_title.map(str::to_string),
|
||||
tool_description: tool_description.map(str::to_string),
|
||||
mcp_app_resource_uri: None,
|
||||
codex_apps_meta: None,
|
||||
openai_file_input_params: None,
|
||||
}
|
||||
@@ -74,6 +75,35 @@ fn prompt_options(
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mcp_app_resource_uri_reads_known_tool_meta_keys() {
|
||||
let nested = serde_json::json!({
|
||||
"ui": {
|
||||
"resourceUri": "ui://widget/nested.html",
|
||||
},
|
||||
});
|
||||
assert_eq!(
|
||||
get_mcp_app_resource_uri(nested.as_object()),
|
||||
Some("ui://widget/nested.html".to_string())
|
||||
);
|
||||
|
||||
let flat = serde_json::json!({
|
||||
"ui/resourceUri": "ui://widget/flat.html",
|
||||
});
|
||||
assert_eq!(
|
||||
get_mcp_app_resource_uri(flat.as_object()),
|
||||
Some("ui://widget/flat.html".to_string())
|
||||
);
|
||||
|
||||
let output_template = serde_json::json!({
|
||||
"openai/outputTemplate": "ui://widget/output-template.html",
|
||||
});
|
||||
assert_eq!(
|
||||
get_mcp_app_resource_uri(output_template.as_object()),
|
||||
Some("ui://widget/output-template.html".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn approval_required_when_read_only_false_and_destructive() {
|
||||
let annotations = annotations(Some(false), Some(true), /*open_world*/ None);
|
||||
@@ -589,6 +619,7 @@ async fn codex_apps_tool_call_request_meta_includes_turn_metadata_and_codex_apps
|
||||
connector_description: Some("Manage events".to_string()),
|
||||
tool_title: Some("Create Event".to_string()),
|
||||
tool_description: Some("Create a calendar event.".to_string()),
|
||||
mcp_app_resource_uri: None,
|
||||
codex_apps_meta: Some(
|
||||
serde_json::json!({
|
||||
"resource_uri": "connector://calendar/tools/calendar_create_event",
|
||||
@@ -746,6 +777,7 @@ fn guardian_mcp_review_request_includes_annotations_when_present() {
|
||||
connector_description: None,
|
||||
tool_title: None,
|
||||
tool_description: None,
|
||||
mcp_app_resource_uri: None,
|
||||
codex_apps_meta: None,
|
||||
openai_file_input_params: None,
|
||||
};
|
||||
@@ -1114,6 +1146,43 @@ async fn persist_custom_mcp_tool_approval_writes_tool_override() {
|
||||
assert!(contents.contains("[mcp_servers.docs.tools.search]"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn custom_mcp_tool_approval_mode_uses_server_default_with_tool_override() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
std::fs::write(
|
||||
tmp.path().join(CONFIG_TOML_FILE),
|
||||
r#"
|
||||
[mcp_servers.docs]
|
||||
command = "docs-server"
|
||||
default_tools_approval_mode = "approve"
|
||||
|
||||
[mcp_servers.docs.tools.search]
|
||||
approval_mode = "prompt"
|
||||
"#,
|
||||
)
|
||||
.expect("seed config");
|
||||
let config = ConfigBuilder::default()
|
||||
.codex_home(tmp.path().to_path_buf())
|
||||
.build()
|
||||
.await
|
||||
.expect("load config");
|
||||
let (_session, mut turn_context) = make_session_and_context().await;
|
||||
turn_context.config = Arc::new(config);
|
||||
|
||||
assert_eq!(
|
||||
custom_mcp_tool_approval_mode(&turn_context, "docs", "read"),
|
||||
AppToolApproval::Approve
|
||||
);
|
||||
assert_eq!(
|
||||
custom_mcp_tool_approval_mode(&turn_context, "docs", "search"),
|
||||
AppToolApproval::Prompt
|
||||
);
|
||||
assert_eq!(
|
||||
custom_mcp_tool_approval_mode(&turn_context, "unknown", "search"),
|
||||
AppToolApproval::Auto
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn maybe_persist_mcp_tool_approval_reloads_session_config() {
|
||||
let (session, turn_context) = make_session_and_context().await;
|
||||
@@ -1272,6 +1341,7 @@ async fn approve_mode_skips_when_annotations_do_not_require_approval() {
|
||||
connector_description: None,
|
||||
tool_title: Some("Read Only Tool".to_string()),
|
||||
tool_description: None,
|
||||
mcp_app_resource_uri: None,
|
||||
codex_apps_meta: None,
|
||||
openai_file_input_params: None,
|
||||
};
|
||||
@@ -1340,6 +1410,7 @@ async fn guardian_mode_skips_auto_when_annotations_do_not_require_approval() {
|
||||
connector_description: None,
|
||||
tool_title: Some("Read Only Tool".to_string()),
|
||||
tool_description: None,
|
||||
mcp_app_resource_uri: None,
|
||||
codex_apps_meta: None,
|
||||
openai_file_input_params: None,
|
||||
};
|
||||
@@ -1411,6 +1482,7 @@ async fn guardian_mode_mcp_denial_returns_rationale_message() {
|
||||
connector_description: None,
|
||||
tool_title: Some("Dangerous Tool".to_string()),
|
||||
tool_description: Some("Reads calendar data.".to_string()),
|
||||
mcp_app_resource_uri: None,
|
||||
codex_apps_meta: None,
|
||||
openai_file_input_params: None,
|
||||
};
|
||||
@@ -1462,6 +1534,7 @@ async fn prompt_mode_waits_for_approval_when_annotations_do_not_require_approval
|
||||
connector_description: None,
|
||||
tool_title: Some("Read Only Tool".to_string()),
|
||||
tool_description: None,
|
||||
mcp_app_resource_uri: None,
|
||||
codex_apps_meta: None,
|
||||
openai_file_input_params: None,
|
||||
};
|
||||
@@ -1539,6 +1612,7 @@ async fn approve_mode_blocks_when_arc_returns_interrupt_for_model() {
|
||||
connector_description: Some("Manage events".to_string()),
|
||||
tool_title: Some("Dangerous Tool".to_string()),
|
||||
tool_description: Some("Performs a risky action.".to_string()),
|
||||
mcp_app_resource_uri: None,
|
||||
codex_apps_meta: None,
|
||||
openai_file_input_params: None,
|
||||
};
|
||||
@@ -1609,6 +1683,7 @@ async fn custom_approve_mode_blocks_when_arc_returns_interrupt_for_model() {
|
||||
connector_description: None,
|
||||
tool_title: Some("Dangerous Tool".to_string()),
|
||||
tool_description: Some("Performs a risky action.".to_string()),
|
||||
mcp_app_resource_uri: None,
|
||||
codex_apps_meta: None,
|
||||
openai_file_input_params: None,
|
||||
};
|
||||
@@ -1679,6 +1754,7 @@ async fn approve_mode_blocks_when_arc_returns_interrupt_without_annotations() {
|
||||
connector_description: Some("Manage events".to_string()),
|
||||
tool_title: Some("Dangerous Tool".to_string()),
|
||||
tool_description: Some("Performs a risky action.".to_string()),
|
||||
mcp_app_resource_uri: None,
|
||||
codex_apps_meta: None,
|
||||
openai_file_input_params: None,
|
||||
};
|
||||
@@ -1757,6 +1833,7 @@ async fn full_access_mode_skips_arc_monitor_for_all_approval_modes() {
|
||||
connector_description: Some("Manage events".to_string()),
|
||||
tool_title: Some("Dangerous Tool".to_string()),
|
||||
tool_description: Some("Performs a risky action.".to_string()),
|
||||
mcp_app_resource_uri: None,
|
||||
codex_apps_meta: None,
|
||||
openai_file_input_params: None,
|
||||
};
|
||||
@@ -1859,6 +1936,7 @@ async fn approve_mode_routes_arc_ask_user_to_guardian_when_guardian_reviewer_is_
|
||||
connector_description: Some("Manage events".to_string()),
|
||||
tool_title: Some("Dangerous Tool".to_string()),
|
||||
tool_description: Some("Performs a risky action.".to_string()),
|
||||
mcp_app_resource_uri: None,
|
||||
codex_apps_meta: None,
|
||||
openai_file_input_params: None,
|
||||
};
|
||||
|
||||
@@ -41,9 +41,13 @@ pub(crate) fn build_mcp_tool_exposure(
|
||||
|
||||
let direct_tools =
|
||||
filter_codex_apps_mcp_tools(all_mcp_tools, explicitly_enabled_connectors, config);
|
||||
for direct_tool_name in direct_tools.keys() {
|
||||
deferred_tools.remove(direct_tool_name);
|
||||
}
|
||||
|
||||
McpToolExposure {
|
||||
direct_tools,
|
||||
deferred_tools: Some(deferred_tools),
|
||||
deferred_tools: (!deferred_tools.is_empty()).then_some(deferred_tools),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -84,7 +84,11 @@ What it does:
|
||||
- `raw_memories.md` (merged raw memories, latest first)
|
||||
- `rollout_summaries/` (one summary file per retained rollout)
|
||||
- prunes stale rollout summaries that are no longer retained
|
||||
- if there are no inputs, marks the job successful and exits
|
||||
- finds old resource files from memory extensions under
|
||||
`memories_extensions/<extension>/resources/` for extension directories that
|
||||
have an `instructions.md`, using the memory module retention window
|
||||
- if there are no Phase 1 inputs or old extension resources, marks the job
|
||||
successful and exits
|
||||
|
||||
If there is input, it then:
|
||||
|
||||
@@ -92,10 +96,13 @@ If there is input, it then:
|
||||
- builds the Phase 2 prompt with a diff of the current Phase 1 input
|
||||
selection versus the last successful Phase 2 selection (`added`,
|
||||
`retained`, `removed`)
|
||||
- includes old extension resource paths in the prompt diff
|
||||
- runs it with no approvals, no network, and local write access only
|
||||
- disables collab for that agent (to prevent recursive delegation)
|
||||
- watches the agent status and heartbeats the global job lease while it runs
|
||||
- marks the phase-2 job success/failure in the state DB when the agent finishes
|
||||
- prunes old extension resource files after the consolidation agent completes
|
||||
and the successful Phase 2 job is recorded
|
||||
|
||||
Selection diff behavior:
|
||||
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
use std::path::Path;
|
||||
|
||||
pub async fn clear_memory_roots_contents(codex_home: &Path) -> std::io::Result<()> {
|
||||
for memory_root in [
|
||||
codex_home.join("memories"),
|
||||
codex_home.join("memories_extensions"),
|
||||
] {
|
||||
clear_memory_root_contents(memory_root.as_path()).await?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn clear_memory_root_contents(memory_root: &Path) -> std::io::Result<()> {
|
||||
match tokio::fs::symlink_metadata(memory_root).await {
|
||||
Ok(metadata) if metadata.file_type().is_symlink() => {
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
use crate::memories::memory_extensions_root;
|
||||
use chrono::DateTime;
|
||||
use chrono::Duration;
|
||||
use chrono::NaiveDateTime;
|
||||
use chrono::Utc;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use tracing::warn;
|
||||
|
||||
const FILENAME_TS_FORMAT: &str = "%Y-%m-%dT%H-%M-%S";
|
||||
pub(super) const EXTENSION_RESOURCE_RETENTION_DAYS: i64 = 7;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(super) struct RemovedExtensionResource {
|
||||
pub(super) extension: String,
|
||||
pub(super) resource_path: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(super) struct PendingExtensionResourceRemoval {
|
||||
pub(super) removed: RemovedExtensionResource,
|
||||
path: PathBuf,
|
||||
}
|
||||
|
||||
pub(super) async fn find_old_extension_resources(
|
||||
memory_root: &Path,
|
||||
) -> Vec<PendingExtensionResourceRemoval> {
|
||||
find_old_extension_resources_with_now(memory_root, Utc::now()).await
|
||||
}
|
||||
|
||||
async fn find_old_extension_resources_with_now(
|
||||
memory_root: &Path,
|
||||
now: DateTime<Utc>,
|
||||
) -> Vec<PendingExtensionResourceRemoval> {
|
||||
let mut pending = Vec::new();
|
||||
let cutoff = now - Duration::days(EXTENSION_RESOURCE_RETENTION_DAYS);
|
||||
let extensions_root = memory_extensions_root(memory_root);
|
||||
let mut extensions = match tokio::fs::read_dir(&extensions_root).await {
|
||||
Ok(extensions) => extensions,
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return pending,
|
||||
Err(err) => {
|
||||
warn!(
|
||||
"failed reading memory extensions root {}: {err}",
|
||||
extensions_root.display()
|
||||
);
|
||||
return pending;
|
||||
}
|
||||
};
|
||||
|
||||
while let Ok(Some(extension_entry)) = extensions.next_entry().await {
|
||||
let extension_path = extension_entry.path();
|
||||
let Ok(file_type) = extension_entry.file_type().await else {
|
||||
continue;
|
||||
};
|
||||
if !file_type.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let Some(extension) = extension_path
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.map(ToOwned::to_owned)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if !tokio::fs::try_exists(extension_path.join("instructions.md"))
|
||||
.await
|
||||
.unwrap_or(false)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
let resources_path = extension_path.join("resources");
|
||||
let mut resources = match tokio::fs::read_dir(&resources_path).await {
|
||||
Ok(resources) => resources,
|
||||
Err(err) if err.kind() == std::io::ErrorKind::NotFound => continue,
|
||||
Err(err) => {
|
||||
warn!(
|
||||
"failed reading memory extension resources {}: {err}",
|
||||
resources_path.display()
|
||||
);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
while let Ok(Some(resource_entry)) = resources.next_entry().await {
|
||||
let resource_file_path = resource_entry.path();
|
||||
let Ok(file_type) = resource_entry.file_type().await else {
|
||||
continue;
|
||||
};
|
||||
if !file_type.is_file() {
|
||||
continue;
|
||||
}
|
||||
let Some(file_name) = resource_file_path
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if !file_name.ends_with(".md") {
|
||||
continue;
|
||||
}
|
||||
let Some(resource_timestamp) = resource_timestamp(file_name) else {
|
||||
continue;
|
||||
};
|
||||
if resource_timestamp > cutoff {
|
||||
continue;
|
||||
}
|
||||
|
||||
pending.push(PendingExtensionResourceRemoval {
|
||||
removed: RemovedExtensionResource {
|
||||
extension: extension.clone(),
|
||||
resource_path: format!("resources/{file_name}"),
|
||||
},
|
||||
path: resource_file_path,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pending.sort_by(|left, right| {
|
||||
left.removed
|
||||
.extension
|
||||
.cmp(&right.removed.extension)
|
||||
.then_with(|| left.removed.resource_path.cmp(&right.removed.resource_path))
|
||||
});
|
||||
pending
|
||||
}
|
||||
|
||||
pub(super) async fn remove_extension_resources(resources: &[PendingExtensionResourceRemoval]) {
|
||||
for resource in resources {
|
||||
if let Err(err) = tokio::fs::remove_file(&resource.path).await
|
||||
&& err.kind() != std::io::ErrorKind::NotFound
|
||||
{
|
||||
warn!(
|
||||
"failed pruning old memory extension resource {}: {err}",
|
||||
resource.path.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn resource_timestamp(file_name: &str) -> Option<DateTime<Utc>> {
|
||||
let timestamp = file_name.get(..19)?;
|
||||
let naive = NaiveDateTime::parse_from_str(timestamp, FILENAME_TS_FORMAT).ok()?;
|
||||
Some(DateTime::from_naive_utc_and_offset(naive, Utc))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
use tempfile::TempDir;
|
||||
|
||||
#[tokio::test]
|
||||
async fn finds_only_old_resources_from_extensions_with_instructions() {
|
||||
let codex_home = TempDir::new().expect("create temp codex home");
|
||||
let memory_root = codex_home.path().join("memories");
|
||||
let extensions_root = memory_extensions_root(&memory_root);
|
||||
let telepathy_resources = extensions_root.join("telepathy/resources");
|
||||
tokio::fs::create_dir_all(&telepathy_resources)
|
||||
.await
|
||||
.expect("create telepathy resources");
|
||||
tokio::fs::write(
|
||||
extensions_root.join("telepathy/instructions.md"),
|
||||
"instructions",
|
||||
)
|
||||
.await
|
||||
.expect("write telepathy instructions");
|
||||
|
||||
let now = DateTime::from_naive_utc_and_offset(
|
||||
NaiveDateTime::parse_from_str("2026-04-14T12-00-00", FILENAME_TS_FORMAT)
|
||||
.expect("parse now"),
|
||||
Utc,
|
||||
);
|
||||
let old_file = telepathy_resources.join("2026-04-06T11-59-59-abcd-10min-old.md");
|
||||
let exact_cutoff_file =
|
||||
telepathy_resources.join("2026-04-07T12-00-00-abcd-10min-cutoff.md");
|
||||
let recent_file = telepathy_resources.join("2026-04-08T12-00-00-abcd-10min-recent.md");
|
||||
let invalid_file = telepathy_resources.join("not-a-timestamp.md");
|
||||
for file in [&old_file, &exact_cutoff_file, &recent_file, &invalid_file] {
|
||||
tokio::fs::write(file, "resource")
|
||||
.await
|
||||
.expect("write telepathy resource");
|
||||
}
|
||||
|
||||
let ignored_resources = extensions_root.join("ignored/resources");
|
||||
tokio::fs::create_dir_all(&ignored_resources)
|
||||
.await
|
||||
.expect("create ignored resources");
|
||||
let ignored_old_file = ignored_resources.join("2026-04-06T11-59-59-abcd-10min-old.md");
|
||||
tokio::fs::write(&ignored_old_file, "ignored")
|
||||
.await
|
||||
.expect("write ignored resource");
|
||||
|
||||
let pending = find_old_extension_resources_with_now(&memory_root, now).await;
|
||||
|
||||
assert_eq!(
|
||||
pending
|
||||
.iter()
|
||||
.map(|resource| resource.removed.clone())
|
||||
.collect::<Vec<_>>(),
|
||||
vec![
|
||||
RemovedExtensionResource {
|
||||
extension: "telepathy".to_string(),
|
||||
resource_path: "resources/2026-04-06T11-59-59-abcd-10min-old.md".to_string(),
|
||||
},
|
||||
RemovedExtensionResource {
|
||||
extension: "telepathy".to_string(),
|
||||
resource_path: "resources/2026-04-07T12-00-00-abcd-10min-cutoff.md".to_string(),
|
||||
},
|
||||
]
|
||||
);
|
||||
assert!(
|
||||
tokio::fs::try_exists(&old_file)
|
||||
.await
|
||||
.expect("check old file before remove")
|
||||
);
|
||||
assert!(
|
||||
tokio::fs::try_exists(&exact_cutoff_file)
|
||||
.await
|
||||
.expect("check cutoff file before remove")
|
||||
);
|
||||
|
||||
remove_extension_resources(&pending).await;
|
||||
|
||||
assert!(
|
||||
!tokio::fs::try_exists(&old_file)
|
||||
.await
|
||||
.expect("check old file")
|
||||
);
|
||||
assert!(
|
||||
!tokio::fs::try_exists(&exact_cutoff_file)
|
||||
.await
|
||||
.expect("check cutoff file")
|
||||
);
|
||||
assert!(
|
||||
tokio::fs::try_exists(&recent_file)
|
||||
.await
|
||||
.expect("check recent file")
|
||||
);
|
||||
assert!(
|
||||
tokio::fs::try_exists(&invalid_file)
|
||||
.await
|
||||
.expect("check invalid file")
|
||||
);
|
||||
assert!(
|
||||
tokio::fs::try_exists(&ignored_old_file)
|
||||
.await
|
||||
.expect("check ignored old file")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,7 @@ pub(crate) mod usage;
|
||||
|
||||
use codex_protocol::openai_models::ReasoningEffort;
|
||||
|
||||
pub(crate) use control::clear_memory_root_contents;
|
||||
pub use control::clear_memory_roots_contents;
|
||||
/// Starts the memory startup pipeline for eligible root sessions.
|
||||
/// This is the single entrypoint that `codex` uses to trigger memory startup.
|
||||
///
|
||||
@@ -30,6 +30,8 @@ mod artifacts {
|
||||
pub(super) const RAW_MEMORIES_FILENAME: &str = "raw_memories.md";
|
||||
}
|
||||
|
||||
mod extensions;
|
||||
|
||||
/// Phase 1 (startup extraction).
|
||||
mod phase_one {
|
||||
/// Default model used for phase 1.
|
||||
|
||||
@@ -477,9 +477,10 @@ mod job {
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
serde_json::to_string(&filtered).map_err(|err| {
|
||||
let serialized = serde_json::to_string(&filtered).map_err(|err| {
|
||||
CodexErr::InvalidRequest(format!("failed to serialize rollout memory: {err}"))
|
||||
})
|
||||
})?;
|
||||
Ok(redact_secrets(serialized))
|
||||
}
|
||||
|
||||
fn sanitize_response_item_for_memories(item: &ResponseItem) -> Option<ResponseItem> {
|
||||
|
||||
@@ -3,6 +3,8 @@ use super::JobResult;
|
||||
use super::aggregate_stats;
|
||||
use super::job::serialize_filtered_rollout_response_items;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::FunctionCallOutputBody;
|
||||
use codex_protocol::models::FunctionCallOutputPayload;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_protocol::protocol::RolloutItem;
|
||||
use codex_protocol::protocol::TokenUsage;
|
||||
@@ -72,6 +74,25 @@ fn serializes_memory_rollout_with_agents_removed_but_environment_kept() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serializes_memory_rollout_redacts_secrets_before_prompt_upload() {
|
||||
let serialized = serialize_filtered_rollout_response_items(&[RolloutItem::ResponseItem(
|
||||
ResponseItem::FunctionCallOutput {
|
||||
call_id: "call_123".to_string(),
|
||||
output: FunctionCallOutputPayload {
|
||||
body: FunctionCallOutputBody::Text(
|
||||
r#"{"token":"sk-abcdefghijklmnopqrstuvwxyz123456"}"#.to_string(),
|
||||
),
|
||||
success: Some(true),
|
||||
},
|
||||
},
|
||||
)])
|
||||
.expect("serialize");
|
||||
|
||||
assert!(!serialized.contains("sk-abcdefghijklmnopqrstuvwxyz123456"));
|
||||
assert!(serialized.contains("[REDACTED_SECRET]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn count_outcomes_sums_token_usage_across_all_jobs() {
|
||||
let counts = aggregate_stats(vec![
|
||||
|
||||
@@ -3,6 +3,9 @@ use crate::agent::status::is_final as is_final_agent_status;
|
||||
use crate::codex::Session;
|
||||
use crate::codex::emit_subagent_session_started;
|
||||
use crate::config::Config;
|
||||
use crate::memories::extensions::PendingExtensionResourceRemoval;
|
||||
use crate::memories::extensions::find_old_extension_resources;
|
||||
use crate::memories::extensions::remove_extension_resources;
|
||||
use crate::memories::memory_root;
|
||||
use crate::memories::metrics;
|
||||
use crate::memories::phase_two;
|
||||
@@ -112,7 +115,12 @@ pub(super) async fn run(session: &Arc<Session>, config: Arc<Config>) {
|
||||
job::failed(session, db, &claim, "failed_rebuild_raw_memories").await;
|
||||
return;
|
||||
}
|
||||
if raw_memories.is_empty() {
|
||||
let pending_extension_resource_removals = find_old_extension_resources(&root).await;
|
||||
let removed_extension_resources = pending_extension_resource_removals
|
||||
.iter()
|
||||
.map(|resource| resource.removed.clone())
|
||||
.collect::<Vec<_>>();
|
||||
if raw_memories.is_empty() && pending_extension_resource_removals.is_empty() {
|
||||
// We check only after sync of the file system.
|
||||
job::succeed(
|
||||
session,
|
||||
@@ -127,7 +135,7 @@ pub(super) async fn run(session: &Arc<Session>, config: Arc<Config>) {
|
||||
}
|
||||
|
||||
// 5. Spawn the agent
|
||||
let prompt = agent::get_prompt(config, &selection);
|
||||
let prompt = agent::get_prompt(config, &selection, &removed_extension_resources);
|
||||
let source = SessionSource::SubAgent(SubAgentSource::MemoryConsolidation);
|
||||
let thread_id = match session
|
||||
.services
|
||||
@@ -170,6 +178,7 @@ pub(super) async fn run(session: &Arc<Session>, config: Arc<Config>) {
|
||||
claim,
|
||||
new_watermark,
|
||||
raw_memories.clone(),
|
||||
pending_extension_resource_removals,
|
||||
thread_id,
|
||||
phase_two_e2e_timer,
|
||||
);
|
||||
@@ -268,15 +277,15 @@ mod job {
|
||||
completion_watermark: i64,
|
||||
selected_outputs: &[codex_state::Stage1Output],
|
||||
reason: &'static str,
|
||||
) {
|
||||
) -> bool {
|
||||
session.services.session_telemetry.counter(
|
||||
metrics::MEMORY_PHASE_TWO_JOBS,
|
||||
/*inc*/ 1,
|
||||
&[("status", reason)],
|
||||
);
|
||||
let _ = db
|
||||
.mark_global_phase2_job_succeeded(&claim.token, completion_watermark, selected_outputs)
|
||||
.await;
|
||||
db.mark_global_phase2_job_succeeded(&claim.token, completion_watermark, selected_outputs)
|
||||
.await
|
||||
.unwrap_or(false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -328,9 +337,10 @@ mod agent {
|
||||
pub(super) fn get_prompt(
|
||||
config: Arc<Config>,
|
||||
selection: &codex_state::Phase2InputSelection,
|
||||
removed_extension_resources: &[crate::memories::extensions::RemovedExtensionResource],
|
||||
) -> Vec<UserInput> {
|
||||
let root = memory_root(&config.codex_home);
|
||||
let prompt = build_consolidation_prompt(&root, selection);
|
||||
let prompt = build_consolidation_prompt(&root, selection, removed_extension_resources);
|
||||
vec![UserInput::Text {
|
||||
text: prompt,
|
||||
text_elements: vec![],
|
||||
@@ -343,6 +353,7 @@ mod agent {
|
||||
claim: Claim,
|
||||
new_watermark: i64,
|
||||
selected_outputs: Vec<codex_state::Stage1Output>,
|
||||
pending_extension_resource_removals: Vec<PendingExtensionResourceRemoval>,
|
||||
thread_id: ThreadId,
|
||||
phase_two_e2e_timer: Option<codex_otel::Timer>,
|
||||
) {
|
||||
@@ -379,7 +390,7 @@ mod agent {
|
||||
if let Some(token_usage) = agent_control.get_total_token_usage(thread_id).await {
|
||||
emit_token_usage_metrics(&session, &token_usage);
|
||||
}
|
||||
job::succeed(
|
||||
if job::succeed(
|
||||
&session,
|
||||
&db,
|
||||
&claim,
|
||||
@@ -387,7 +398,10 @@ mod agent {
|
||||
&selected_outputs,
|
||||
"succeeded",
|
||||
)
|
||||
.await;
|
||||
.await
|
||||
{
|
||||
remove_extension_resources(&pending_extension_resource_removals).await;
|
||||
}
|
||||
} else {
|
||||
job::failed(&session, &db, &claim, "failed_agent").await;
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use crate::memories::extensions::EXTENSION_RESOURCE_RETENTION_DAYS;
|
||||
use crate::memories::extensions::RemovedExtensionResource;
|
||||
use crate::memories::memory_extensions_root;
|
||||
use crate::memories::memory_root;
|
||||
use crate::memories::phase_one;
|
||||
@@ -10,6 +12,7 @@ use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use codex_utils_output_truncation::TruncationPolicy;
|
||||
use codex_utils_output_truncation::truncate_text;
|
||||
use codex_utils_template::Template;
|
||||
use std::fmt::Write as _;
|
||||
use std::path::Path;
|
||||
use std::sync::LazyLock;
|
||||
use tokio::fs;
|
||||
@@ -62,8 +65,9 @@ Memory extensions (under {{ memory_extensions_root }}/):
|
||||
source.
|
||||
|
||||
If the user has any memory extensions, you MUST read the instructions for each extension to
|
||||
determine how to use the memory source. If it has no extension folders, continue with the standard
|
||||
memory inputs only.
|
||||
determine how to use the memory source. If the Phase 2 diff lists removed memory extension
|
||||
resources, use that extension-specific deletion diff to remove stale memories derived only from
|
||||
those resources. If it has no extension folders, continue with the standard memory inputs only.
|
||||
"#;
|
||||
|
||||
const MEMORY_EXTENSIONS_PRIMARY_INPUTS: &str = r#"
|
||||
@@ -73,12 +77,16 @@ Under `{{ memory_extensions_root }}/`:
|
||||
- `<extension_name>/instructions.md`
|
||||
- If extension folders exist, read each instructions.md first and follow it when interpreting
|
||||
that extension's memory source.
|
||||
|
||||
If the Phase 2 diff lists removed memory extension resources, use that extension-specific deletion
|
||||
diff to remove stale memories derived only from those resources.
|
||||
"#;
|
||||
|
||||
/// Builds the consolidation subagent prompt for a specific memory root.
|
||||
pub(super) fn build_consolidation_prompt(
|
||||
memory_root: &Path,
|
||||
selection: &Phase2InputSelection,
|
||||
removed_extension_resources: &[RemovedExtensionResource],
|
||||
) -> String {
|
||||
let memory_extensions_root = memory_extensions_root(memory_root);
|
||||
let memory_extensions_exist = memory_extensions_root.is_dir();
|
||||
@@ -100,7 +108,8 @@ pub(super) fn build_consolidation_prompt(
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
let phase2_input_selection = render_phase2_input_selection(selection);
|
||||
let phase2_input_selection =
|
||||
render_phase2_input_selection(selection, removed_extension_resources);
|
||||
CONSOLIDATION_PROMPT_TEMPLATE
|
||||
.render([
|
||||
("memory_root", memory_root.as_str()),
|
||||
@@ -131,7 +140,10 @@ fn render_memory_extensions_block(template: &Template, memory_extensions_root: &
|
||||
})
|
||||
}
|
||||
|
||||
fn render_phase2_input_selection(selection: &Phase2InputSelection) -> String {
|
||||
fn render_phase2_input_selection(
|
||||
selection: &Phase2InputSelection,
|
||||
removed_extension_resources: &[RemovedExtensionResource],
|
||||
) -> String {
|
||||
let retained = selection.retained_thread_ids.len();
|
||||
let added = selection.selected.len().saturating_sub(retained);
|
||||
let selected = if selection.selected.is_empty() {
|
||||
@@ -160,11 +172,29 @@ fn render_phase2_input_selection(selection: &Phase2InputSelection) -> String {
|
||||
.join("\n")
|
||||
};
|
||||
|
||||
format!(
|
||||
let mut rendered = format!(
|
||||
"- selected inputs this run: {}\n- newly added since the last successful Phase 2 run: {added}\n- retained from the last successful Phase 2 run: {retained}\n- removed from the last successful Phase 2 run: {}\n\nCurrent selected Phase 1 inputs:\n{selected}\n\nRemoved from the last successful Phase 2 selection:\n{removed}\n",
|
||||
selection.selected.len(),
|
||||
selection.removed.len(),
|
||||
)
|
||||
);
|
||||
|
||||
if !removed_extension_resources.is_empty() {
|
||||
rendered.push_str("\nMemory extension resources removed by retention pruning:\n");
|
||||
let _ = writeln!(
|
||||
rendered,
|
||||
"- retention window: {EXTENSION_RESOURCE_RETENTION_DAYS} days"
|
||||
);
|
||||
let mut current_extension = "";
|
||||
for removed_resource in removed_extension_resources {
|
||||
if removed_resource.extension != current_extension {
|
||||
current_extension = &removed_resource.extension;
|
||||
let _ = writeln!(rendered, "- extension: {current_extension}");
|
||||
}
|
||||
let _ = writeln!(rendered, " - {}", removed_resource.resource_path);
|
||||
}
|
||||
}
|
||||
|
||||
rendered
|
||||
}
|
||||
|
||||
fn render_selected_input_line(item: &Stage1Output, retained: bool) -> String {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
use super::*;
|
||||
use crate::memories::extensions::RemovedExtensionResource;
|
||||
use codex_models_manager::model_info::model_info_from_slug;
|
||||
use codex_state::Phase2InputSelection;
|
||||
use core_test_support::PathExt;
|
||||
use pretty_assertions::assert_eq;
|
||||
use tempfile::tempdir;
|
||||
@@ -54,6 +56,36 @@ fn build_stage_one_input_message_uses_default_limit_when_model_context_window_mi
|
||||
assert!(message.contains(&expected_truncated));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_consolidation_prompt_includes_removed_extension_resources() {
|
||||
let temp = tempdir().unwrap();
|
||||
let memory_root = temp.path().join("memories");
|
||||
std::fs::create_dir_all(temp.path().join("memories_extensions")).unwrap();
|
||||
let removed_extension_resources = vec![
|
||||
RemovedExtensionResource {
|
||||
extension: "telepathy".to_string(),
|
||||
resource_path: "resources/2026-04-06T11-59-59-abcd-10min-old.md".to_string(),
|
||||
},
|
||||
RemovedExtensionResource {
|
||||
extension: "telepathy".to_string(),
|
||||
resource_path: "resources/2026-04-07T12-00-00-abcd-10min-cutoff.md".to_string(),
|
||||
},
|
||||
];
|
||||
|
||||
let prompt = build_consolidation_prompt(
|
||||
&memory_root,
|
||||
&Phase2InputSelection::default(),
|
||||
&removed_extension_resources,
|
||||
);
|
||||
|
||||
assert!(prompt.contains("Memory extension resources removed by retention pruning:"));
|
||||
assert!(prompt.contains("- retention window: 7 days"));
|
||||
assert!(prompt.contains("- extension: telepathy"));
|
||||
assert!(prompt.contains(" - resources/2026-04-06T11-59-59-abcd-10min-old.md"));
|
||||
assert!(prompt.contains(" - resources/2026-04-07T12-00-00-abcd-10min-cutoff.md"));
|
||||
assert!(prompt.contains("extension-specific deletion diff"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn build_memory_tool_developer_instructions_renders_embedded_template() {
|
||||
let temp = tempdir().unwrap();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use super::control::clear_memory_root_contents;
|
||||
use super::storage::rebuild_raw_memories_file_from_memories;
|
||||
use super::storage::sync_rollout_summaries_from_memories;
|
||||
use crate::memories::clear_memory_root_contents;
|
||||
use crate::memories::ensure_layout;
|
||||
use crate::memories::memory_root;
|
||||
use crate::memories::raw_memories_file;
|
||||
@@ -424,6 +424,7 @@ mod phase2 {
|
||||
use crate::memories::phase2;
|
||||
use crate::memories::raw_memories_file;
|
||||
use crate::memories::rollout_summaries_dir;
|
||||
use chrono::Duration as ChronoDuration;
|
||||
use chrono::Utc;
|
||||
use codex_config::Constrained;
|
||||
use codex_login::CodexAuth;
|
||||
@@ -467,7 +468,7 @@ mod phase2 {
|
||||
impl DispatchHarness {
|
||||
async fn new() -> Self {
|
||||
let codex_home = tempfile::tempdir().expect("create temp codex home");
|
||||
let mut config = test_config();
|
||||
let mut config = test_config().await;
|
||||
config.codex_home =
|
||||
codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path(codex_home.path())
|
||||
.expect("codex home is absolute");
|
||||
@@ -894,7 +895,7 @@ mod phase2 {
|
||||
#[tokio::test]
|
||||
async fn dispatch_marks_job_for_retry_when_spawn_agent_fails() {
|
||||
let codex_home = tempfile::tempdir().expect("create temp codex home");
|
||||
let mut config = test_config();
|
||||
let mut config = test_config().await;
|
||||
config.codex_home =
|
||||
codex_utils_absolute_path::AbsolutePathBuf::from_absolute_path(codex_home.path())
|
||||
.expect("codex home is absolute");
|
||||
@@ -960,6 +961,28 @@ mod phase2 {
|
||||
"stage-1 success should enqueue global consolidation"
|
||||
);
|
||||
|
||||
let telepathy_resources = config
|
||||
.codex_home
|
||||
.join("memories_extensions/telepathy/resources");
|
||||
tokio::fs::create_dir_all(&telepathy_resources)
|
||||
.await
|
||||
.expect("create telepathy resources");
|
||||
tokio::fs::write(
|
||||
config
|
||||
.codex_home
|
||||
.join("memories_extensions/telepathy/instructions.md"),
|
||||
"instructions",
|
||||
)
|
||||
.await
|
||||
.expect("write telepathy instructions");
|
||||
let old_file = telepathy_resources.join(format!(
|
||||
"{}-abcd-10min-old.md",
|
||||
(Utc::now() - ChronoDuration::days(8)).format("%Y-%m-%dT%H-%M-%S")
|
||||
));
|
||||
tokio::fs::write(&old_file, "old resource")
|
||||
.await
|
||||
.expect("write old extension resource");
|
||||
|
||||
phase2::run(&session, Arc::clone(&config)).await;
|
||||
|
||||
let retry_claim = state_db
|
||||
@@ -971,5 +994,11 @@ mod phase2 {
|
||||
Phase2JobClaimOutcome::SkippedNotDirty,
|
||||
"spawn failures should leave the job in retry backoff instead of running"
|
||||
);
|
||||
assert!(
|
||||
tokio::fs::try_exists(&old_file)
|
||||
.await
|
||||
.expect("check old extension resource"),
|
||||
"spawn failures should not prune extension resources before retry"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
pub(crate) use codex_tools::can_request_original_image_detail;
|
||||
pub(crate) use codex_tools::normalize_output_image_detail;
|
||||
pub(crate) use codex_tools::sanitize_original_image_detail;
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
use crate::config::edit::ConfigEditsBuilder;
|
||||
use crate::rollout::ARCHIVED_SESSIONS_SUBDIR;
|
||||
use crate::rollout::SESSIONS_SUBDIR;
|
||||
use crate::rollout::list::ThreadListConfig;
|
||||
use crate::rollout::list::ThreadListLayout;
|
||||
use crate::rollout::list::ThreadSortKey;
|
||||
use crate::rollout::list::get_threads_in_root;
|
||||
use codex_config::config_toml::ConfigToml;
|
||||
use codex_protocol::config_types::Personality;
|
||||
use codex_protocol::protocol::SessionSource;
|
||||
use codex_rollout::state_db;
|
||||
use codex_thread_store::ListThreadsParams;
|
||||
use codex_thread_store::LocalThreadStore;
|
||||
use codex_thread_store::ThreadSortKey;
|
||||
use codex_thread_store::ThreadStore;
|
||||
use std::io;
|
||||
use std::path::Path;
|
||||
use tokio::fs::OpenOptions;
|
||||
@@ -64,57 +60,33 @@ pub async fn maybe_migrate_personality(
|
||||
}
|
||||
|
||||
async fn has_recorded_sessions(codex_home: &Path, default_provider: &str) -> io::Result<bool> {
|
||||
let allowed_sources: &[SessionSource] = &[];
|
||||
let store = LocalThreadStore::new(codex_rollout::RolloutConfig {
|
||||
codex_home: codex_home.to_path_buf(),
|
||||
sqlite_home: codex_home.to_path_buf(),
|
||||
cwd: codex_home.to_path_buf(),
|
||||
model_provider_id: default_provider.to_string(),
|
||||
generate_memories: false,
|
||||
});
|
||||
if has_threads(&store, /*archived*/ false).await? {
|
||||
return Ok(true);
|
||||
}
|
||||
has_threads(&store, /*archived*/ true).await
|
||||
}
|
||||
|
||||
if let Some(state_db_ctx) = state_db::open_if_present(codex_home, default_provider).await
|
||||
&& let Some(ids) = state_db::list_thread_ids_db(
|
||||
Some(state_db_ctx.as_ref()),
|
||||
codex_home,
|
||||
/*page_size*/ 1,
|
||||
/*cursor*/ None,
|
||||
ThreadSortKey::CreatedAt,
|
||||
allowed_sources,
|
||||
/*model_providers*/ None,
|
||||
/*archived_only*/ false,
|
||||
"personality_migration",
|
||||
)
|
||||
async fn has_threads(store: &LocalThreadStore, archived: bool) -> io::Result<bool> {
|
||||
store
|
||||
.list_threads(ListThreadsParams {
|
||||
page_size: 1,
|
||||
cursor: None,
|
||||
sort_key: ThreadSortKey::CreatedAt,
|
||||
allowed_sources: Vec::new(),
|
||||
model_providers: None,
|
||||
archived,
|
||||
search_term: None,
|
||||
})
|
||||
.await
|
||||
&& !ids.is_empty()
|
||||
{
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
let sessions = get_threads_in_root(
|
||||
codex_home.join(SESSIONS_SUBDIR),
|
||||
/*page_size*/ 1,
|
||||
/*cursor*/ None,
|
||||
ThreadSortKey::CreatedAt,
|
||||
ThreadListConfig {
|
||||
allowed_sources,
|
||||
model_providers: None,
|
||||
default_provider,
|
||||
layout: ThreadListLayout::NestedByDate,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
if !sessions.items.is_empty() {
|
||||
return Ok(true);
|
||||
}
|
||||
|
||||
let archived_sessions = get_threads_in_root(
|
||||
codex_home.join(ARCHIVED_SESSIONS_SUBDIR),
|
||||
/*page_size*/ 1,
|
||||
/*cursor*/ None,
|
||||
ThreadSortKey::CreatedAt,
|
||||
ThreadListConfig {
|
||||
allowed_sources,
|
||||
model_providers: None,
|
||||
default_provider,
|
||||
layout: ThreadListLayout::Flat,
|
||||
},
|
||||
)
|
||||
.await?;
|
||||
Ok(!archived_sessions.items.is_empty())
|
||||
.map(|page| !page.items.is_empty())
|
||||
.map_err(io::Error::other)
|
||||
}
|
||||
|
||||
async fn create_marker(marker_path: &Path) -> io::Result<()> {
|
||||
|
||||
@@ -7,6 +7,8 @@ use codex_protocol::protocol::SessionMeta;
|
||||
use codex_protocol::protocol::SessionMetaLine;
|
||||
use codex_protocol::protocol::SessionSource;
|
||||
use codex_protocol::protocol::UserMessageEvent;
|
||||
use codex_rollout::ARCHIVED_SESSIONS_SUBDIR;
|
||||
use codex_rollout::SESSIONS_SUBDIR;
|
||||
use pretty_assertions::assert_eq;
|
||||
use tempfile::TempDir;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
@@ -25,6 +27,16 @@ async fn write_session_with_user_event(codex_home: &Path) -> io::Result<()> {
|
||||
.join("2025")
|
||||
.join("01")
|
||||
.join("01");
|
||||
write_rollout_with_user_event(&dir, thread_id).await
|
||||
}
|
||||
|
||||
async fn write_archived_session_with_user_event(codex_home: &Path) -> io::Result<()> {
|
||||
let thread_id = ThreadId::new();
|
||||
let dir = codex_home.join(ARCHIVED_SESSIONS_SUBDIR);
|
||||
write_rollout_with_user_event(&dir, thread_id).await
|
||||
}
|
||||
|
||||
async fn write_rollout_with_user_event(dir: &Path, thread_id: ThreadId) -> io::Result<()> {
|
||||
tokio::fs::create_dir_all(&dir).await?;
|
||||
let file_path = dir.join(format!("rollout-{TEST_TIMESTAMP}-{thread_id}.jsonl"));
|
||||
let mut file = tokio::fs::File::create(&file_path).await?;
|
||||
@@ -85,6 +97,22 @@ async fn applies_when_sessions_exist_and_no_personality() -> io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn applies_when_only_archived_sessions_exist_and_no_personality() -> io::Result<()> {
|
||||
let temp = TempDir::new()?;
|
||||
write_archived_session_with_user_event(temp.path()).await?;
|
||||
|
||||
let config_toml = ConfigToml::default();
|
||||
let status = maybe_migrate_personality(temp.path(), &config_toml).await?;
|
||||
|
||||
assert_eq!(status, PersonalityMigrationStatus::Applied);
|
||||
assert!(temp.path().join(PERSONALITY_MIGRATION_FILENAME).exists());
|
||||
|
||||
let persisted = read_config_toml(temp.path()).await?;
|
||||
assert_eq!(persisted.personality, Some(Personality::Pragmatic));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn skips_when_marker_exists() -> io::Result<()> {
|
||||
let temp = TempDir::new()?;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use std::collections::BTreeSet;
|
||||
use std::collections::HashMap;
|
||||
|
||||
use codex_connectors::metadata::connector_display_label;
|
||||
use codex_protocol::models::DeveloperInstructions;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
|
||||
@@ -46,7 +47,7 @@ pub(crate) fn build_plugin_injections(
|
||||
.iter()
|
||||
.any(|plugin_name| plugin_name == &plugin.display_name)
|
||||
})
|
||||
.map(connectors::connector_display_label)
|
||||
.map(connector_display_label)
|
||||
.collect::<BTreeSet<String>>()
|
||||
.into_iter()
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use crate::config::Config;
|
||||
use codex_core_plugins::marketplace::find_marketplace_manifest_path;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
@@ -50,9 +51,7 @@ pub(crate) fn installed_marketplace_roots_from_config(
|
||||
marketplace,
|
||||
&default_install_root,
|
||||
)?;
|
||||
path.join(".agents/plugins/marketplace.json")
|
||||
.is_file()
|
||||
.then_some(path)
|
||||
find_marketplace_manifest_path(&path).map(|_| path)
|
||||
})
|
||||
.filter_map(|path| AbsolutePathBuf::try_from(path).ok())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,6 @@ use crate::config_loader::ConfigLayerStack;
|
||||
use crate::config_loader::ConfigRequirements;
|
||||
use crate::config_loader::ConfigRequirementsToml;
|
||||
use crate::plugins::LoadedPlugin;
|
||||
use crate::plugins::MarketplacePluginInstallPolicy;
|
||||
use crate::plugins::PluginLoadOutcome;
|
||||
use crate::plugins::marketplace_install_root;
|
||||
use crate::plugins::test_support::TEST_CURATED_PLUGIN_SHA;
|
||||
@@ -14,12 +13,17 @@ use crate::plugins::test_support::write_curated_plugin_sha_with as write_curated
|
||||
use crate::plugins::test_support::write_file;
|
||||
use crate::plugins::test_support::write_openai_curated_marketplace;
|
||||
use codex_app_server_protocol::ConfigLayerSource;
|
||||
use codex_config::McpServerConfig;
|
||||
use codex_config::types::McpServerTransportConfig;
|
||||
use codex_core_plugins::loader::refresh_non_curated_plugin_cache;
|
||||
use codex_core_plugins::loader::refresh_non_curated_plugin_cache_force_reinstall;
|
||||
use codex_core_plugins::marketplace::MarketplacePluginInstallPolicy;
|
||||
use codex_login::CodexAuth;
|
||||
use codex_protocol::protocol::Product;
|
||||
use codex_utils_absolute_path::test_support::PathBufExt;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use tempfile::TempDir;
|
||||
use toml::Value;
|
||||
use wiremock::Mock;
|
||||
@@ -172,12 +176,14 @@ async fn load_plugins_loads_default_skills_and_mcp_servers() {
|
||||
http_headers: None,
|
||||
env_http_headers: None,
|
||||
},
|
||||
experimental_environment: None,
|
||||
enabled: true,
|
||||
required: false,
|
||||
supports_parallel_tool_calls: false,
|
||||
disabled_reason: None,
|
||||
startup_timeout_sec: None,
|
||||
tool_timeout_sec: None,
|
||||
default_tools_approval_mode: None,
|
||||
enabled_tools: None,
|
||||
disabled_tools: None,
|
||||
scopes: None,
|
||||
@@ -508,12 +514,14 @@ async fn load_plugins_uses_manifest_configured_component_paths() {
|
||||
http_headers: None,
|
||||
env_http_headers: None,
|
||||
},
|
||||
experimental_environment: None,
|
||||
enabled: true,
|
||||
required: false,
|
||||
supports_parallel_tool_calls: false,
|
||||
disabled_reason: None,
|
||||
startup_timeout_sec: None,
|
||||
tool_timeout_sec: None,
|
||||
default_tools_approval_mode: None,
|
||||
enabled_tools: None,
|
||||
disabled_tools: None,
|
||||
scopes: None,
|
||||
@@ -617,12 +625,14 @@ async fn load_plugins_ignores_manifest_component_paths_without_dot_slash() {
|
||||
http_headers: None,
|
||||
env_http_headers: None,
|
||||
},
|
||||
experimental_environment: None,
|
||||
enabled: true,
|
||||
required: false,
|
||||
supports_parallel_tool_calls: false,
|
||||
disabled_reason: None,
|
||||
startup_timeout_sec: None,
|
||||
tool_timeout_sec: None,
|
||||
default_tools_approval_mode: None,
|
||||
enabled_tools: None,
|
||||
disabled_tools: None,
|
||||
scopes: None,
|
||||
@@ -774,12 +784,14 @@ fn capability_index_filters_inactive_and_zero_capability_plugins() {
|
||||
http_headers: None,
|
||||
env_http_headers: None,
|
||||
},
|
||||
experimental_environment: None,
|
||||
enabled: true,
|
||||
required: false,
|
||||
supports_parallel_tool_calls: false,
|
||||
disabled_reason: None,
|
||||
startup_timeout_sec: None,
|
||||
tool_timeout_sec: None,
|
||||
default_tools_approval_mode: None,
|
||||
enabled_tools: None,
|
||||
disabled_tools: None,
|
||||
scopes: None,
|
||||
@@ -2477,14 +2489,10 @@ enabled = true
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
curated_plugin_ids_from_config_keys(configured_plugins_from_codex_home(
|
||||
tmp.path(),
|
||||
"failed to read user config while refreshing curated plugin cache",
|
||||
"failed to parse user config while refreshing curated plugin cache",
|
||||
))
|
||||
.into_iter()
|
||||
.map(|plugin_id| plugin_id.as_key())
|
||||
.collect::<Vec<_>>(),
|
||||
configured_curated_plugin_ids_from_codex_home(tmp.path())
|
||||
.into_iter()
|
||||
.map(|plugin_id| plugin_id.as_key())
|
||||
.collect::<Vec<_>>(),
|
||||
vec!["slack@openai-curated".to_string()]
|
||||
);
|
||||
|
||||
@@ -2496,11 +2504,7 @@ plugins = true
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
curated_plugin_ids_from_config_keys(configured_plugins_from_codex_home(
|
||||
tmp.path(),
|
||||
"failed to read user config while refreshing curated plugin cache",
|
||||
"failed to parse user config while refreshing curated plugin cache",
|
||||
)),
|
||||
configured_curated_plugin_ids_from_codex_home(tmp.path()),
|
||||
Vec::<PluginId>::new()
|
||||
);
|
||||
}
|
||||
@@ -2678,6 +2682,68 @@ enabled = true
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refresh_non_curated_plugin_cache_force_reinstalls_current_local_version() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let repo_root = tmp.path().join("repo");
|
||||
fs::create_dir_all(repo_root.join(".git")).unwrap();
|
||||
fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap();
|
||||
write_plugin(&repo_root, "sample-plugin", "sample-plugin");
|
||||
fs::write(repo_root.join("sample-plugin/skills/SKILL.md"), "new skill").unwrap();
|
||||
write_file(
|
||||
&repo_root.join(".agents/plugins/marketplace.json"),
|
||||
r#"{
|
||||
"name": "debug",
|
||||
"plugins": [
|
||||
{
|
||||
"name": "sample-plugin",
|
||||
"source": {
|
||||
"source": "local",
|
||||
"path": "./sample-plugin"
|
||||
}
|
||||
}
|
||||
]
|
||||
}"#,
|
||||
);
|
||||
write_plugin(
|
||||
&tmp.path().join("plugins/cache/debug"),
|
||||
"sample-plugin/local",
|
||||
"sample-plugin",
|
||||
);
|
||||
fs::write(
|
||||
tmp.path()
|
||||
.join("plugins/cache/debug/sample-plugin/local/skills/SKILL.md"),
|
||||
"old skill",
|
||||
)
|
||||
.unwrap();
|
||||
write_file(
|
||||
&tmp.path().join(CONFIG_TOML_FILE),
|
||||
r#"[features]
|
||||
plugins = true
|
||||
|
||||
[plugins."sample-plugin@debug"]
|
||||
enabled = true
|
||||
"#,
|
||||
);
|
||||
|
||||
assert!(
|
||||
refresh_non_curated_plugin_cache_force_reinstall(
|
||||
tmp.path(),
|
||||
&[AbsolutePathBuf::try_from(repo_root).unwrap()],
|
||||
)
|
||||
.expect("cache refresh should reinstall unchanged local version")
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
fs::read_to_string(
|
||||
tmp.path()
|
||||
.join("plugins/cache/debug/sample-plugin/local/skills/SKILL.md")
|
||||
)
|
||||
.unwrap(),
|
||||
"new skill"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refresh_non_curated_plugin_cache_ignores_invalid_unconfigured_plugin_versions() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -1,509 +0,0 @@
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use codex_utils_plugins::PLUGIN_MANIFEST_PATH;
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value as JsonValue;
|
||||
use std::fs;
|
||||
use std::path::Component;
|
||||
use std::path::Path;
|
||||
const MAX_DEFAULT_PROMPT_COUNT: usize = 3;
|
||||
const MAX_DEFAULT_PROMPT_LEN: usize = 128;
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct RawPluginManifest {
|
||||
#[serde(default)]
|
||||
name: String,
|
||||
#[serde(default)]
|
||||
version: Option<String>,
|
||||
#[serde(default)]
|
||||
description: Option<String>,
|
||||
// Keep manifest paths as raw strings so we can validate the required `./...` syntax before
|
||||
// resolving them under the plugin root.
|
||||
#[serde(default)]
|
||||
skills: Option<String>,
|
||||
#[serde(default)]
|
||||
mcp_servers: Option<String>,
|
||||
#[serde(default)]
|
||||
apps: Option<String>,
|
||||
#[serde(default)]
|
||||
interface: Option<RawPluginManifestInterface>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct PluginManifest {
|
||||
pub(crate) name: String,
|
||||
pub(crate) version: Option<String>,
|
||||
pub(crate) description: Option<String>,
|
||||
pub(crate) paths: PluginManifestPaths,
|
||||
pub(crate) interface: Option<PluginManifestInterface>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PluginManifestPaths {
|
||||
pub skills: Option<AbsolutePathBuf>,
|
||||
pub mcp_servers: Option<AbsolutePathBuf>,
|
||||
pub apps: Option<AbsolutePathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct PluginManifestInterface {
|
||||
pub display_name: Option<String>,
|
||||
pub short_description: Option<String>,
|
||||
pub long_description: Option<String>,
|
||||
pub developer_name: Option<String>,
|
||||
pub category: Option<String>,
|
||||
pub capabilities: Vec<String>,
|
||||
pub website_url: Option<String>,
|
||||
pub privacy_policy_url: Option<String>,
|
||||
pub terms_of_service_url: Option<String>,
|
||||
pub default_prompt: Option<Vec<String>>,
|
||||
pub brand_color: Option<String>,
|
||||
pub composer_icon: Option<AbsolutePathBuf>,
|
||||
pub logo: Option<AbsolutePathBuf>,
|
||||
pub screenshots: Vec<AbsolutePathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct RawPluginManifestInterface {
|
||||
#[serde(default)]
|
||||
display_name: Option<String>,
|
||||
#[serde(default)]
|
||||
short_description: Option<String>,
|
||||
#[serde(default)]
|
||||
long_description: Option<String>,
|
||||
#[serde(default)]
|
||||
developer_name: Option<String>,
|
||||
#[serde(default)]
|
||||
category: Option<String>,
|
||||
#[serde(default)]
|
||||
capabilities: Vec<String>,
|
||||
#[serde(default)]
|
||||
#[serde(alias = "websiteURL")]
|
||||
website_url: Option<String>,
|
||||
#[serde(default)]
|
||||
#[serde(alias = "privacyPolicyURL")]
|
||||
privacy_policy_url: Option<String>,
|
||||
#[serde(default)]
|
||||
#[serde(alias = "termsOfServiceURL")]
|
||||
terms_of_service_url: Option<String>,
|
||||
#[serde(default)]
|
||||
default_prompt: Option<RawPluginManifestDefaultPrompt>,
|
||||
#[serde(default)]
|
||||
brand_color: Option<String>,
|
||||
#[serde(default)]
|
||||
composer_icon: Option<String>,
|
||||
#[serde(default)]
|
||||
logo: Option<String>,
|
||||
#[serde(default)]
|
||||
screenshots: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum RawPluginManifestDefaultPrompt {
|
||||
String(String),
|
||||
List(Vec<RawPluginManifestDefaultPromptEntry>),
|
||||
Invalid(JsonValue),
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum RawPluginManifestDefaultPromptEntry {
|
||||
String(String),
|
||||
Invalid(JsonValue),
|
||||
}
|
||||
|
||||
pub(crate) fn load_plugin_manifest(plugin_root: &Path) -> Option<PluginManifest> {
|
||||
let manifest_path = plugin_root.join(PLUGIN_MANIFEST_PATH);
|
||||
if !manifest_path.is_file() {
|
||||
return None;
|
||||
}
|
||||
let contents = fs::read_to_string(&manifest_path).ok()?;
|
||||
match serde_json::from_str::<RawPluginManifest>(&contents) {
|
||||
Ok(manifest) => {
|
||||
let RawPluginManifest {
|
||||
name: raw_name,
|
||||
version,
|
||||
description,
|
||||
skills,
|
||||
mcp_servers,
|
||||
apps,
|
||||
interface,
|
||||
} = manifest;
|
||||
let name = plugin_root
|
||||
.file_name()
|
||||
.and_then(|entry| entry.to_str())
|
||||
.filter(|_| raw_name.trim().is_empty())
|
||||
.unwrap_or(&raw_name)
|
||||
.to_string();
|
||||
let version = version.and_then(|version| {
|
||||
let version = version.trim();
|
||||
(!version.is_empty()).then(|| version.to_string())
|
||||
});
|
||||
let interface = interface.and_then(|interface| {
|
||||
let RawPluginManifestInterface {
|
||||
display_name,
|
||||
short_description,
|
||||
long_description,
|
||||
developer_name,
|
||||
category,
|
||||
capabilities,
|
||||
website_url,
|
||||
privacy_policy_url,
|
||||
terms_of_service_url,
|
||||
default_prompt,
|
||||
brand_color,
|
||||
composer_icon,
|
||||
logo,
|
||||
screenshots,
|
||||
} = interface;
|
||||
|
||||
let interface = PluginManifestInterface {
|
||||
display_name,
|
||||
short_description,
|
||||
long_description,
|
||||
developer_name,
|
||||
category,
|
||||
capabilities,
|
||||
website_url,
|
||||
privacy_policy_url,
|
||||
terms_of_service_url,
|
||||
default_prompt: resolve_default_prompts(plugin_root, default_prompt.as_ref()),
|
||||
brand_color,
|
||||
composer_icon: resolve_interface_asset_path(
|
||||
plugin_root,
|
||||
"interface.composerIcon",
|
||||
composer_icon.as_deref(),
|
||||
),
|
||||
logo: resolve_interface_asset_path(
|
||||
plugin_root,
|
||||
"interface.logo",
|
||||
logo.as_deref(),
|
||||
),
|
||||
screenshots: screenshots
|
||||
.iter()
|
||||
.filter_map(|screenshot| {
|
||||
resolve_interface_asset_path(
|
||||
plugin_root,
|
||||
"interface.screenshots",
|
||||
Some(screenshot),
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
};
|
||||
|
||||
let has_fields = interface.display_name.is_some()
|
||||
|| interface.short_description.is_some()
|
||||
|| interface.long_description.is_some()
|
||||
|| interface.developer_name.is_some()
|
||||
|| interface.category.is_some()
|
||||
|| !interface.capabilities.is_empty()
|
||||
|| interface.website_url.is_some()
|
||||
|| interface.privacy_policy_url.is_some()
|
||||
|| interface.terms_of_service_url.is_some()
|
||||
|| interface.default_prompt.is_some()
|
||||
|| interface.brand_color.is_some()
|
||||
|| interface.composer_icon.is_some()
|
||||
|| interface.logo.is_some()
|
||||
|| !interface.screenshots.is_empty();
|
||||
|
||||
has_fields.then_some(interface)
|
||||
});
|
||||
Some(PluginManifest {
|
||||
name,
|
||||
version,
|
||||
description,
|
||||
paths: PluginManifestPaths {
|
||||
skills: resolve_manifest_path(plugin_root, "skills", skills.as_deref()),
|
||||
mcp_servers: resolve_manifest_path(
|
||||
plugin_root,
|
||||
"mcpServers",
|
||||
mcp_servers.as_deref(),
|
||||
),
|
||||
apps: resolve_manifest_path(plugin_root, "apps", apps.as_deref()),
|
||||
},
|
||||
interface,
|
||||
})
|
||||
}
|
||||
Err(err) => {
|
||||
tracing::warn!(
|
||||
path = %manifest_path.display(),
|
||||
"failed to parse plugin manifest: {err}"
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_interface_asset_path(
|
||||
plugin_root: &Path,
|
||||
field: &'static str,
|
||||
path: Option<&str>,
|
||||
) -> Option<AbsolutePathBuf> {
|
||||
resolve_manifest_path(plugin_root, field, path)
|
||||
}
|
||||
|
||||
fn resolve_default_prompts(
|
||||
plugin_root: &Path,
|
||||
value: Option<&RawPluginManifestDefaultPrompt>,
|
||||
) -> Option<Vec<String>> {
|
||||
match value? {
|
||||
RawPluginManifestDefaultPrompt::String(prompt) => {
|
||||
resolve_default_prompt_str(plugin_root, "interface.defaultPrompt", prompt)
|
||||
.map(|prompt| vec![prompt])
|
||||
}
|
||||
RawPluginManifestDefaultPrompt::List(values) => {
|
||||
let mut prompts = Vec::new();
|
||||
for (index, item) in values.iter().enumerate() {
|
||||
if prompts.len() >= MAX_DEFAULT_PROMPT_COUNT {
|
||||
warn_invalid_default_prompt(
|
||||
plugin_root,
|
||||
"interface.defaultPrompt",
|
||||
&format!("maximum of {MAX_DEFAULT_PROMPT_COUNT} prompts is supported"),
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
match item {
|
||||
RawPluginManifestDefaultPromptEntry::String(prompt) => {
|
||||
let field = format!("interface.defaultPrompt[{index}]");
|
||||
if let Some(prompt) =
|
||||
resolve_default_prompt_str(plugin_root, &field, prompt)
|
||||
{
|
||||
prompts.push(prompt);
|
||||
}
|
||||
}
|
||||
RawPluginManifestDefaultPromptEntry::Invalid(value) => {
|
||||
let field = format!("interface.defaultPrompt[{index}]");
|
||||
warn_invalid_default_prompt(
|
||||
plugin_root,
|
||||
&field,
|
||||
&format!("expected a string, found {}", json_value_type(value)),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
(!prompts.is_empty()).then_some(prompts)
|
||||
}
|
||||
RawPluginManifestDefaultPrompt::Invalid(value) => {
|
||||
warn_invalid_default_prompt(
|
||||
plugin_root,
|
||||
"interface.defaultPrompt",
|
||||
&format!(
|
||||
"expected a string or array of strings, found {}",
|
||||
json_value_type(value)
|
||||
),
|
||||
);
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_default_prompt_str(plugin_root: &Path, field: &str, prompt: &str) -> Option<String> {
|
||||
let prompt = prompt.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||
if prompt.is_empty() {
|
||||
warn_invalid_default_prompt(plugin_root, field, "prompt must not be empty");
|
||||
return None;
|
||||
}
|
||||
if prompt.chars().count() > MAX_DEFAULT_PROMPT_LEN {
|
||||
warn_invalid_default_prompt(
|
||||
plugin_root,
|
||||
field,
|
||||
&format!("prompt must be at most {MAX_DEFAULT_PROMPT_LEN} characters"),
|
||||
);
|
||||
return None;
|
||||
}
|
||||
Some(prompt)
|
||||
}
|
||||
|
||||
fn warn_invalid_default_prompt(plugin_root: &Path, field: &str, message: &str) {
|
||||
let manifest_path = plugin_root.join(PLUGIN_MANIFEST_PATH);
|
||||
tracing::warn!(
|
||||
path = %manifest_path.display(),
|
||||
"ignoring {field}: {message}"
|
||||
);
|
||||
}
|
||||
|
||||
fn json_value_type(value: &JsonValue) -> &'static str {
|
||||
match value {
|
||||
JsonValue::Null => "null",
|
||||
JsonValue::Bool(_) => "boolean",
|
||||
JsonValue::Number(_) => "number",
|
||||
JsonValue::String(_) => "string",
|
||||
JsonValue::Array(_) => "array",
|
||||
JsonValue::Object(_) => "object",
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_manifest_path(
|
||||
plugin_root: &Path,
|
||||
field: &'static str,
|
||||
path: Option<&str>,
|
||||
) -> Option<AbsolutePathBuf> {
|
||||
// `plugin.json` paths are required to be relative to the plugin root and we return the
|
||||
// normalized absolute path to the rest of the system.
|
||||
let path = path?;
|
||||
if path.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let Some(relative_path) = path.strip_prefix("./") else {
|
||||
tracing::warn!("ignoring {field}: path must start with `./` relative to plugin root");
|
||||
return None;
|
||||
};
|
||||
if relative_path.is_empty() {
|
||||
tracing::warn!("ignoring {field}: path must not be `./`");
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut normalized = std::path::PathBuf::new();
|
||||
for component in Path::new(relative_path).components() {
|
||||
match component {
|
||||
Component::Normal(component) => normalized.push(component),
|
||||
Component::ParentDir => {
|
||||
tracing::warn!("ignoring {field}: path must not contain '..'");
|
||||
return None;
|
||||
}
|
||||
_ => {
|
||||
tracing::warn!("ignoring {field}: path must stay within the plugin root");
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AbsolutePathBuf::try_from(plugin_root.join(normalized))
|
||||
.map_err(|err| {
|
||||
tracing::warn!("ignoring {field}: path must resolve to an absolute path: {err}");
|
||||
err
|
||||
})
|
||||
.ok()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::MAX_DEFAULT_PROMPT_LEN;
|
||||
use super::PluginManifest;
|
||||
use super::load_plugin_manifest;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use tempfile::tempdir;
|
||||
|
||||
fn write_manifest(plugin_root: &Path, version: Option<&str>, interface: &str) {
|
||||
fs::create_dir_all(plugin_root.join(".codex-plugin")).expect("create manifest dir");
|
||||
let version = version
|
||||
.map(|version| format!(" \"version\": \"{version}\",\n"))
|
||||
.unwrap_or_default();
|
||||
fs::write(
|
||||
plugin_root.join(".codex-plugin/plugin.json"),
|
||||
format!(
|
||||
r#"{{
|
||||
"name": "demo-plugin",
|
||||
{version}
|
||||
"interface": {interface}
|
||||
}}"#
|
||||
),
|
||||
)
|
||||
.expect("write manifest");
|
||||
}
|
||||
|
||||
fn load_manifest(plugin_root: &Path) -> PluginManifest {
|
||||
load_plugin_manifest(plugin_root).expect("load plugin manifest")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_interface_accepts_legacy_default_prompt_string() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let plugin_root = tmp.path().join("demo-plugin");
|
||||
write_manifest(
|
||||
&plugin_root,
|
||||
/*version*/ None,
|
||||
r#"{
|
||||
"displayName": "Demo Plugin",
|
||||
"defaultPrompt": " Summarize my inbox "
|
||||
}"#,
|
||||
);
|
||||
|
||||
let manifest = load_manifest(&plugin_root);
|
||||
let interface = manifest.interface.expect("plugin interface");
|
||||
|
||||
assert_eq!(
|
||||
interface.default_prompt,
|
||||
Some(vec!["Summarize my inbox".to_string()])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_interface_normalizes_default_prompt_array() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let plugin_root = tmp.path().join("demo-plugin");
|
||||
let too_long = "x".repeat(MAX_DEFAULT_PROMPT_LEN + 1);
|
||||
write_manifest(
|
||||
&plugin_root,
|
||||
/*version*/ None,
|
||||
&format!(
|
||||
r#"{{
|
||||
"displayName": "Demo Plugin",
|
||||
"defaultPrompt": [
|
||||
" Summarize my inbox ",
|
||||
123,
|
||||
"{too_long}",
|
||||
" ",
|
||||
"Draft the reply ",
|
||||
"Find my next action",
|
||||
"Archive old mail"
|
||||
]
|
||||
}}"#
|
||||
),
|
||||
);
|
||||
|
||||
let manifest = load_manifest(&plugin_root);
|
||||
let interface = manifest.interface.expect("plugin interface");
|
||||
|
||||
assert_eq!(
|
||||
interface.default_prompt,
|
||||
Some(vec![
|
||||
"Summarize my inbox".to_string(),
|
||||
"Draft the reply".to_string(),
|
||||
"Find my next action".to_string(),
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_interface_ignores_invalid_default_prompt_shape() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let plugin_root = tmp.path().join("demo-plugin");
|
||||
write_manifest(
|
||||
&plugin_root,
|
||||
/*version*/ None,
|
||||
r#"{
|
||||
"displayName": "Demo Plugin",
|
||||
"defaultPrompt": { "text": "Summarize my inbox" }
|
||||
}"#,
|
||||
);
|
||||
|
||||
let manifest = load_manifest(&plugin_root);
|
||||
let interface = manifest.interface.expect("plugin interface");
|
||||
|
||||
assert_eq!(interface.default_prompt, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_manifest_reads_trimmed_version() {
|
||||
let tmp = tempdir().expect("tempdir");
|
||||
let plugin_root = tmp.path().join("demo-plugin");
|
||||
write_manifest(
|
||||
&plugin_root,
|
||||
Some(" 1.2.3-beta+7 "),
|
||||
r#"{
|
||||
"displayName": "Demo Plugin"
|
||||
}"#,
|
||||
);
|
||||
|
||||
let manifest = load_manifest(&plugin_root);
|
||||
|
||||
assert_eq!(manifest.version, Some("1.2.3-beta+7".to_string()));
|
||||
}
|
||||
}
|
||||
@@ -1,483 +0,0 @@
|
||||
use super::PluginManifestInterface;
|
||||
use super::load_plugin_manifest;
|
||||
use codex_app_server_protocol::PluginAuthPolicy;
|
||||
use codex_app_server_protocol::PluginInstallPolicy;
|
||||
use codex_git_utils::get_git_repo_root;
|
||||
use codex_plugin::PluginId;
|
||||
use codex_plugin::PluginIdError;
|
||||
use codex_protocol::protocol::Product;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use dirs::home_dir;
|
||||
use serde::Deserialize;
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::path::Component;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use tracing::warn;
|
||||
|
||||
const MARKETPLACE_RELATIVE_PATH: &str = ".agents/plugins/marketplace.json";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ResolvedMarketplacePlugin {
|
||||
pub plugin_id: PluginId,
|
||||
pub source_path: AbsolutePathBuf,
|
||||
pub auth_policy: MarketplacePluginAuthPolicy,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Marketplace {
|
||||
pub name: String,
|
||||
pub path: AbsolutePathBuf,
|
||||
pub interface: Option<MarketplaceInterface>,
|
||||
pub plugins: Vec<MarketplacePlugin>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct MarketplaceListError {
|
||||
pub path: AbsolutePathBuf,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub struct MarketplaceListOutcome {
|
||||
pub marketplaces: Vec<Marketplace>,
|
||||
pub errors: Vec<MarketplaceListError>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct MarketplaceInterface {
|
||||
pub display_name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct MarketplacePlugin {
|
||||
pub name: String,
|
||||
pub source: MarketplacePluginSource,
|
||||
pub policy: MarketplacePluginPolicy,
|
||||
pub interface: Option<PluginManifestInterface>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum MarketplacePluginSource {
|
||||
Local { path: AbsolutePathBuf },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct MarketplacePluginPolicy {
|
||||
pub installation: MarketplacePluginInstallPolicy,
|
||||
pub authentication: MarketplacePluginAuthPolicy,
|
||||
// TODO: Surface or enforce product gating at the Codex/plugin consumer boundary instead of
|
||||
// only carrying it through core marketplace metadata.
|
||||
pub products: Option<Vec<Product>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
|
||||
pub enum MarketplacePluginInstallPolicy {
|
||||
#[serde(rename = "NOT_AVAILABLE")]
|
||||
NotAvailable,
|
||||
#[default]
|
||||
#[serde(rename = "AVAILABLE")]
|
||||
Available,
|
||||
#[serde(rename = "INSTALLED_BY_DEFAULT")]
|
||||
InstalledByDefault,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
|
||||
pub enum MarketplacePluginAuthPolicy {
|
||||
#[default]
|
||||
#[serde(rename = "ON_INSTALL")]
|
||||
OnInstall,
|
||||
#[serde(rename = "ON_USE")]
|
||||
OnUse,
|
||||
}
|
||||
|
||||
impl From<MarketplacePluginInstallPolicy> for PluginInstallPolicy {
|
||||
fn from(value: MarketplacePluginInstallPolicy) -> Self {
|
||||
match value {
|
||||
MarketplacePluginInstallPolicy::NotAvailable => Self::NotAvailable,
|
||||
MarketplacePluginInstallPolicy::Available => Self::Available,
|
||||
MarketplacePluginInstallPolicy::InstalledByDefault => Self::InstalledByDefault,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<MarketplacePluginAuthPolicy> for PluginAuthPolicy {
|
||||
fn from(value: MarketplacePluginAuthPolicy) -> Self {
|
||||
match value {
|
||||
MarketplacePluginAuthPolicy::OnInstall => Self::OnInstall,
|
||||
MarketplacePluginAuthPolicy::OnUse => Self::OnUse,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum MarketplaceError {
|
||||
#[error("{context}: {source}")]
|
||||
Io {
|
||||
context: &'static str,
|
||||
#[source]
|
||||
source: io::Error,
|
||||
},
|
||||
|
||||
#[error("marketplace file `{path}` does not exist")]
|
||||
MarketplaceNotFound { path: PathBuf },
|
||||
|
||||
#[error("invalid marketplace file `{path}`: {message}")]
|
||||
InvalidMarketplaceFile { path: PathBuf, message: String },
|
||||
|
||||
#[error("plugin `{plugin_name}` was not found in marketplace `{marketplace_name}`")]
|
||||
PluginNotFound {
|
||||
plugin_name: String,
|
||||
marketplace_name: String,
|
||||
},
|
||||
|
||||
#[error(
|
||||
"plugin `{plugin_name}` is not available for install in marketplace `{marketplace_name}`"
|
||||
)]
|
||||
PluginNotAvailable {
|
||||
plugin_name: String,
|
||||
marketplace_name: String,
|
||||
},
|
||||
|
||||
#[error("plugins feature is disabled")]
|
||||
PluginsDisabled,
|
||||
|
||||
#[error("{0}")]
|
||||
InvalidPlugin(String),
|
||||
}
|
||||
|
||||
impl MarketplaceError {
|
||||
fn io(context: &'static str, source: io::Error) -> Self {
|
||||
Self::Io { context, source }
|
||||
}
|
||||
}
|
||||
|
||||
// Always read the specified marketplace file from disk so installs see the
|
||||
// latest marketplace.json contents without any in-memory cache invalidation.
|
||||
pub fn resolve_marketplace_plugin(
|
||||
marketplace_path: &AbsolutePathBuf,
|
||||
plugin_name: &str,
|
||||
restriction_product: Option<Product>,
|
||||
) -> Result<ResolvedMarketplacePlugin, MarketplaceError> {
|
||||
let marketplace = load_raw_marketplace_manifest(marketplace_path)?;
|
||||
let marketplace_name = marketplace.name;
|
||||
let plugin = marketplace
|
||||
.plugins
|
||||
.into_iter()
|
||||
.find(|plugin| plugin.name == plugin_name);
|
||||
|
||||
let Some(plugin) = plugin else {
|
||||
return Err(MarketplaceError::PluginNotFound {
|
||||
plugin_name: plugin_name.to_string(),
|
||||
marketplace_name,
|
||||
});
|
||||
};
|
||||
|
||||
let RawMarketplaceManifestPlugin {
|
||||
name,
|
||||
source,
|
||||
policy,
|
||||
..
|
||||
} = plugin;
|
||||
let install_policy = policy.installation;
|
||||
let product_allowed = match policy.products.as_deref() {
|
||||
None => true,
|
||||
Some([]) => false,
|
||||
Some(products) => {
|
||||
restriction_product.is_some_and(|product| product.matches_product_restriction(products))
|
||||
}
|
||||
};
|
||||
if install_policy == MarketplacePluginInstallPolicy::NotAvailable || !product_allowed {
|
||||
return Err(MarketplaceError::PluginNotAvailable {
|
||||
plugin_name: name,
|
||||
marketplace_name,
|
||||
});
|
||||
}
|
||||
|
||||
let plugin_id = PluginId::new(name, marketplace_name).map_err(|err| match err {
|
||||
PluginIdError::Invalid(message) => MarketplaceError::InvalidPlugin(message),
|
||||
})?;
|
||||
Ok(ResolvedMarketplacePlugin {
|
||||
plugin_id,
|
||||
source_path: resolve_plugin_source_path(marketplace_path, source)?,
|
||||
auth_policy: policy.authentication,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn list_marketplaces(
|
||||
additional_roots: &[AbsolutePathBuf],
|
||||
) -> Result<MarketplaceListOutcome, MarketplaceError> {
|
||||
list_marketplaces_with_home(additional_roots, home_dir().as_deref())
|
||||
}
|
||||
|
||||
pub fn validate_marketplace_root(root: &Path) -> Result<String, MarketplaceError> {
|
||||
let path = AbsolutePathBuf::try_from(root.join(MARKETPLACE_RELATIVE_PATH)).map_err(|err| {
|
||||
MarketplaceError::InvalidMarketplaceFile {
|
||||
path: root.join(MARKETPLACE_RELATIVE_PATH),
|
||||
message: format!("marketplace path must resolve to an absolute path: {err}"),
|
||||
}
|
||||
})?;
|
||||
let marketplace = load_marketplace(&path)?;
|
||||
Ok(marketplace.name)
|
||||
}
|
||||
|
||||
pub(crate) fn load_marketplace(path: &AbsolutePathBuf) -> Result<Marketplace, MarketplaceError> {
|
||||
let marketplace = load_raw_marketplace_manifest(path)?;
|
||||
let mut plugins = Vec::new();
|
||||
|
||||
for plugin in marketplace.plugins {
|
||||
let RawMarketplaceManifestPlugin {
|
||||
name,
|
||||
source,
|
||||
policy,
|
||||
category,
|
||||
} = plugin;
|
||||
let source_path = resolve_plugin_source_path(path, source)?;
|
||||
let source = MarketplacePluginSource::Local {
|
||||
path: source_path.clone(),
|
||||
};
|
||||
let mut interface =
|
||||
load_plugin_manifest(source_path.as_path()).and_then(|manifest| manifest.interface);
|
||||
if let Some(category) = category {
|
||||
// Marketplace taxonomy wins when both sources provide a category.
|
||||
interface
|
||||
.get_or_insert_with(PluginManifestInterface::default)
|
||||
.category = Some(category);
|
||||
}
|
||||
|
||||
plugins.push(MarketplacePlugin {
|
||||
name,
|
||||
source,
|
||||
policy: MarketplacePluginPolicy {
|
||||
installation: policy.installation,
|
||||
authentication: policy.authentication,
|
||||
products: policy.products,
|
||||
},
|
||||
interface,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(Marketplace {
|
||||
name: marketplace.name,
|
||||
path: path.clone(),
|
||||
interface: resolve_marketplace_interface(marketplace.interface),
|
||||
plugins,
|
||||
})
|
||||
}
|
||||
|
||||
fn list_marketplaces_with_home(
|
||||
additional_roots: &[AbsolutePathBuf],
|
||||
home_dir: Option<&Path>,
|
||||
) -> Result<MarketplaceListOutcome, MarketplaceError> {
|
||||
let mut outcome = MarketplaceListOutcome::default();
|
||||
|
||||
for marketplace_path in discover_marketplace_paths_from_roots(additional_roots, home_dir) {
|
||||
match load_marketplace(&marketplace_path) {
|
||||
Ok(marketplace) => outcome.marketplaces.push(marketplace),
|
||||
Err(err) => {
|
||||
warn!(
|
||||
path = %marketplace_path.display(),
|
||||
error = %err,
|
||||
"skipping marketplace that failed to load"
|
||||
);
|
||||
outcome.errors.push(MarketplaceListError {
|
||||
path: marketplace_path,
|
||||
message: err.to_string(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(outcome)
|
||||
}
|
||||
|
||||
fn discover_marketplace_paths_from_roots(
|
||||
additional_roots: &[AbsolutePathBuf],
|
||||
home_dir: Option<&Path>,
|
||||
) -> Vec<AbsolutePathBuf> {
|
||||
let mut paths = Vec::new();
|
||||
|
||||
if let Some(home) = home_dir {
|
||||
let path = home.join(MARKETPLACE_RELATIVE_PATH);
|
||||
if path.is_file()
|
||||
&& let Ok(path) = AbsolutePathBuf::try_from(path)
|
||||
{
|
||||
paths.push(path);
|
||||
}
|
||||
}
|
||||
|
||||
for root in additional_roots {
|
||||
// Curated marketplaces can now come from an HTTP-downloaded directory that is not a git
|
||||
// checkout, so check the root directly before falling back to repo-root discovery.
|
||||
let path = root.join(MARKETPLACE_RELATIVE_PATH);
|
||||
if path.as_path().is_file() && !paths.contains(&path) {
|
||||
paths.push(path);
|
||||
continue;
|
||||
}
|
||||
if let Some(repo_root) = get_git_repo_root(root.as_path())
|
||||
&& let Ok(repo_root) = AbsolutePathBuf::try_from(repo_root)
|
||||
{
|
||||
let path = repo_root.join(MARKETPLACE_RELATIVE_PATH);
|
||||
if path.as_path().is_file() && !paths.contains(&path) {
|
||||
paths.push(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
paths
|
||||
}
|
||||
|
||||
fn load_raw_marketplace_manifest(
|
||||
path: &AbsolutePathBuf,
|
||||
) -> Result<RawMarketplaceManifest, MarketplaceError> {
|
||||
let contents = fs::read_to_string(path.as_path()).map_err(|err| {
|
||||
if err.kind() == io::ErrorKind::NotFound {
|
||||
MarketplaceError::MarketplaceNotFound {
|
||||
path: path.to_path_buf(),
|
||||
}
|
||||
} else {
|
||||
MarketplaceError::io("failed to read marketplace file", err)
|
||||
}
|
||||
})?;
|
||||
serde_json::from_str(&contents).map_err(|err| MarketplaceError::InvalidMarketplaceFile {
|
||||
path: path.to_path_buf(),
|
||||
message: err.to_string(),
|
||||
})
|
||||
}
|
||||
|
||||
fn resolve_plugin_source_path(
|
||||
marketplace_path: &AbsolutePathBuf,
|
||||
source: RawMarketplaceManifestPluginSource,
|
||||
) -> Result<AbsolutePathBuf, MarketplaceError> {
|
||||
match source {
|
||||
RawMarketplaceManifestPluginSource::Local { path } => {
|
||||
let Some(path) = path.strip_prefix("./") else {
|
||||
return Err(MarketplaceError::InvalidMarketplaceFile {
|
||||
path: marketplace_path.to_path_buf(),
|
||||
message: "local plugin source path must start with `./`".to_string(),
|
||||
});
|
||||
};
|
||||
if path.is_empty() {
|
||||
return Err(MarketplaceError::InvalidMarketplaceFile {
|
||||
path: marketplace_path.to_path_buf(),
|
||||
message: "local plugin source path must not be empty".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
let relative_source_path = Path::new(path);
|
||||
if relative_source_path
|
||||
.components()
|
||||
.any(|component| !matches!(component, Component::Normal(_)))
|
||||
{
|
||||
return Err(MarketplaceError::InvalidMarketplaceFile {
|
||||
path: marketplace_path.to_path_buf(),
|
||||
message: "local plugin source path must stay within the marketplace root"
|
||||
.to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
// `marketplace.json` lives under `<root>/.agents/plugins/`, but local plugin paths
|
||||
// are resolved relative to `<root>`, not relative to the `plugins/` directory.
|
||||
Ok(marketplace_root_dir(marketplace_path)?.join(relative_source_path))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn marketplace_root_dir(
|
||||
marketplace_path: &AbsolutePathBuf,
|
||||
) -> Result<AbsolutePathBuf, MarketplaceError> {
|
||||
let Some(plugins_dir) = marketplace_path.parent() else {
|
||||
return Err(MarketplaceError::InvalidMarketplaceFile {
|
||||
path: marketplace_path.to_path_buf(),
|
||||
message: "marketplace file must live under `<root>/.agents/plugins/`".to_string(),
|
||||
});
|
||||
};
|
||||
let Some(dot_agents_dir) = plugins_dir.parent() else {
|
||||
return Err(MarketplaceError::InvalidMarketplaceFile {
|
||||
path: marketplace_path.to_path_buf(),
|
||||
message: "marketplace file must live under `<root>/.agents/plugins/`".to_string(),
|
||||
});
|
||||
};
|
||||
let Some(marketplace_root) = dot_agents_dir.parent() else {
|
||||
return Err(MarketplaceError::InvalidMarketplaceFile {
|
||||
path: marketplace_path.to_path_buf(),
|
||||
message: "marketplace file must live under `<root>/.agents/plugins/`".to_string(),
|
||||
});
|
||||
};
|
||||
|
||||
if plugins_dir.as_path().file_name().and_then(|s| s.to_str()) != Some("plugins")
|
||||
|| dot_agents_dir
|
||||
.as_path()
|
||||
.file_name()
|
||||
.and_then(|s| s.to_str())
|
||||
!= Some(".agents")
|
||||
{
|
||||
return Err(MarketplaceError::InvalidMarketplaceFile {
|
||||
path: marketplace_path.to_path_buf(),
|
||||
message: "marketplace file must live under `<root>/.agents/plugins/`".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(marketplace_root)
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct RawMarketplaceManifest {
|
||||
name: String,
|
||||
#[serde(default)]
|
||||
interface: Option<RawMarketplaceManifestInterface>,
|
||||
plugins: Vec<RawMarketplaceManifestPlugin>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct RawMarketplaceManifestInterface {
|
||||
#[serde(default)]
|
||||
display_name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct RawMarketplaceManifestPlugin {
|
||||
name: String,
|
||||
source: RawMarketplaceManifestPluginSource,
|
||||
#[serde(default)]
|
||||
policy: RawMarketplaceManifestPluginPolicy,
|
||||
#[serde(default)]
|
||||
category: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct RawMarketplaceManifestPluginPolicy {
|
||||
#[serde(default)]
|
||||
installation: MarketplacePluginInstallPolicy,
|
||||
#[serde(default)]
|
||||
authentication: MarketplacePluginAuthPolicy,
|
||||
products: Option<Vec<Product>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(tag = "source", rename_all = "lowercase")]
|
||||
enum RawMarketplaceManifestPluginSource {
|
||||
Local { path: String },
|
||||
}
|
||||
|
||||
fn resolve_marketplace_interface(
|
||||
interface: Option<RawMarketplaceManifestInterface>,
|
||||
) -> Option<MarketplaceInterface> {
|
||||
let interface = interface?;
|
||||
if interface.display_name.is_some() {
|
||||
Some(MarketplaceInterface {
|
||||
display_name: interface.display_name,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "marketplace_tests.rs"]
|
||||
mod tests;
|
||||
@@ -20,7 +20,7 @@ use metadata::find_marketplace_root_by_name;
|
||||
use metadata::installed_marketplace_root_for_source;
|
||||
use metadata::record_added_marketplace_entry;
|
||||
use source::MarketplaceSource;
|
||||
use source::parse_marketplace_source;
|
||||
pub(crate) use source::parse_marketplace_source;
|
||||
use source::stage_marketplace_source;
|
||||
use source::validate_marketplace_source_root;
|
||||
|
||||
@@ -275,9 +275,15 @@ mod tests {
|
||||
);
|
||||
|
||||
let config = fs::read_to_string(codex_home.path().join(codex_config::CONFIG_TOML_FILE))?;
|
||||
assert!(config.contains("[marketplaces.debug]"));
|
||||
assert!(config.contains("source_type = \"local\""));
|
||||
assert!(config.contains(&format!("source = \"{expected_source}\"")));
|
||||
let config: toml::Value = toml::from_str(&config)?;
|
||||
assert_eq!(
|
||||
config["marketplaces"]["debug"]["source_type"].as_str(),
|
||||
Some("local")
|
||||
);
|
||||
assert_eq!(
|
||||
config["marketplaces"]["debug"]["source"].as_str(),
|
||||
Some(expected_source.as_str())
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
use super::MarketplaceAddError;
|
||||
use super::MarketplaceSource;
|
||||
use crate::plugins::installed_marketplaces::resolve_configured_marketplace_root;
|
||||
use crate::plugins::validate_marketplace_root;
|
||||
use codex_config::CONFIG_TOML_FILE;
|
||||
use codex_config::MarketplaceConfigUpdate;
|
||||
use codex_config::record_user_marketplace;
|
||||
use codex_core_plugins::marketplace::validate_marketplace_root;
|
||||
use std::fs;
|
||||
use std::io::ErrorKind;
|
||||
use std::path::Path;
|
||||
@@ -38,6 +38,7 @@ pub(super) fn record_added_marketplace_entry(
|
||||
let timestamp = utc_timestamp_now()?;
|
||||
let update = MarketplaceConfigUpdate {
|
||||
last_updated: ×tamp,
|
||||
last_revision: None,
|
||||
source_type: install_metadata.config_source_type(),
|
||||
source: &source,
|
||||
ref_name: install_metadata.ref_name(),
|
||||
@@ -296,19 +297,11 @@ mod tests {
|
||||
r#"{"name":"debug","plugins":[]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
codex_home.path().join(CONFIG_TOML_FILE),
|
||||
format!(
|
||||
"[marketplaces.debug]\nsource_type = \"local\"\nsource = \"{}\"\n",
|
||||
source_root.display()
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let source = MarketplaceSource::Local {
|
||||
path: source_root.clone(),
|
||||
};
|
||||
let install_metadata = MarketplaceInstallMetadata::from_source(&source, &[]);
|
||||
record_added_marketplace_entry(codex_home.path(), "debug", &install_metadata).unwrap();
|
||||
|
||||
let root = installed_marketplace_root_for_source(
|
||||
codex_home.path(),
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
use super::MarketplaceAddError;
|
||||
use crate::plugins::validate_marketplace_root;
|
||||
use crate::plugins::validate_plugin_segment;
|
||||
use codex_core_plugins::marketplace::validate_marketplace_root;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(super) enum MarketplaceSource {
|
||||
pub(crate) enum MarketplaceSource {
|
||||
Git {
|
||||
url: String,
|
||||
ref_name: Option<String>,
|
||||
@@ -15,7 +15,7 @@ pub(super) enum MarketplaceSource {
|
||||
},
|
||||
}
|
||||
|
||||
pub(super) fn parse_marketplace_source(
|
||||
pub(crate) fn parse_marketplace_source(
|
||||
source: &str,
|
||||
explicit_ref: Option<String>,
|
||||
) -> Result<MarketplaceSource, MarketplaceAddError> {
|
||||
@@ -124,14 +124,26 @@ fn normalize_git_url(url: &str) -> String {
|
||||
}
|
||||
|
||||
fn looks_like_local_path(source: &str) -> bool {
|
||||
source.starts_with("./")
|
||||
Path::new(source).is_absolute()
|
||||
|| looks_like_windows_absolute_path(source)
|
||||
|| source.starts_with("./")
|
||||
|| source.starts_with(".\\")
|
||||
|| source.starts_with("../")
|
||||
|| source.starts_with('/')
|
||||
|| source.starts_with("..\\")
|
||||
|| source.starts_with("~/")
|
||||
|| source == "."
|
||||
|| source == ".."
|
||||
}
|
||||
|
||||
fn looks_like_windows_absolute_path(source: &str) -> bool {
|
||||
let bytes = source.as_bytes();
|
||||
bytes.len() >= 3
|
||||
&& bytes[0].is_ascii_alphabetic()
|
||||
&& bytes[1] == b':'
|
||||
&& matches!(bytes[2], b'\\' | b'/')
|
||||
|| source.starts_with(r"\\")
|
||||
}
|
||||
|
||||
fn resolve_local_source_path(source: &str) -> Result<PathBuf, MarketplaceAddError> {
|
||||
let path = expand_tilde_path(source);
|
||||
let path = if path.is_absolute() {
|
||||
@@ -310,6 +322,14 @@ mod tests {
|
||||
assert!(path.is_absolute());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn windows_absolute_paths_look_like_local_paths_on_every_host() {
|
||||
assert!(looks_like_local_path(r"C:\Users\alice\marketplace"));
|
||||
assert!(looks_like_local_path("C:/Users/alice/marketplace"));
|
||||
assert!(looks_like_local_path(r"\\server\share\marketplace"));
|
||||
assert!(!looks_like_local_path(r"C:relative\path"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_file_source_is_rejected() {
|
||||
let tempdir = TempDir::new().unwrap();
|
||||
|
||||
@@ -1,926 +0,0 @@
|
||||
use super::*;
|
||||
use codex_protocol::protocol::Product;
|
||||
use pretty_assertions::assert_eq;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn resolve_marketplace_plugin_finds_repo_marketplace_plugin() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let repo_root = tmp.path().join("repo");
|
||||
fs::create_dir_all(repo_root.join(".git")).unwrap();
|
||||
fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap();
|
||||
fs::create_dir_all(repo_root.join("nested")).unwrap();
|
||||
fs::write(
|
||||
repo_root.join(".agents/plugins/marketplace.json"),
|
||||
r#"{
|
||||
"name": "codex-curated",
|
||||
"plugins": [
|
||||
{
|
||||
"name": "local-plugin",
|
||||
"source": {
|
||||
"source": "local",
|
||||
"path": "./plugin-1"
|
||||
}
|
||||
}
|
||||
]
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let resolved = resolve_marketplace_plugin(
|
||||
&AbsolutePathBuf::try_from(repo_root.join(".agents/plugins/marketplace.json")).unwrap(),
|
||||
"local-plugin",
|
||||
Some(Product::Codex),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
resolved,
|
||||
ResolvedMarketplacePlugin {
|
||||
plugin_id: PluginId::new("local-plugin".to_string(), "codex-curated".to_string())
|
||||
.unwrap(),
|
||||
source_path: AbsolutePathBuf::try_from(repo_root.join("plugin-1")).unwrap(),
|
||||
auth_policy: MarketplacePluginAuthPolicy::OnInstall,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_marketplace_plugin_reports_missing_plugin() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let repo_root = tmp.path().join("repo");
|
||||
fs::create_dir_all(repo_root.join(".git")).unwrap();
|
||||
fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap();
|
||||
fs::write(
|
||||
repo_root.join(".agents/plugins/marketplace.json"),
|
||||
r#"{"name":"codex-curated","plugins":[]}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let err = resolve_marketplace_plugin(
|
||||
&AbsolutePathBuf::try_from(repo_root.join(".agents/plugins/marketplace.json")).unwrap(),
|
||||
"missing",
|
||||
Some(Product::Codex),
|
||||
)
|
||||
.unwrap_err();
|
||||
|
||||
assert_eq!(
|
||||
err.to_string(),
|
||||
"plugin `missing` was not found in marketplace `codex-curated`"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_marketplaces_returns_home_and_repo_marketplaces() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let home_root = tmp.path().join("home");
|
||||
let repo_root = tmp.path().join("repo");
|
||||
|
||||
fs::create_dir_all(repo_root.join(".git")).unwrap();
|
||||
fs::create_dir_all(home_root.join(".agents/plugins")).unwrap();
|
||||
fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap();
|
||||
fs::write(
|
||||
home_root.join(".agents/plugins/marketplace.json"),
|
||||
r#"{
|
||||
"name": "codex-curated",
|
||||
"plugins": [
|
||||
{
|
||||
"name": "shared-plugin",
|
||||
"source": {
|
||||
"source": "local",
|
||||
"path": "./home-shared"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "home-only",
|
||||
"source": {
|
||||
"source": "local",
|
||||
"path": "./home-only"
|
||||
}
|
||||
}
|
||||
]
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
repo_root.join(".agents/plugins/marketplace.json"),
|
||||
r#"{
|
||||
"name": "codex-curated",
|
||||
"plugins": [
|
||||
{
|
||||
"name": "shared-plugin",
|
||||
"source": {
|
||||
"source": "local",
|
||||
"path": "./repo-shared"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "repo-only",
|
||||
"source": {
|
||||
"source": "local",
|
||||
"path": "./repo-only"
|
||||
}
|
||||
}
|
||||
]
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let marketplaces = list_marketplaces_with_home(
|
||||
&[AbsolutePathBuf::try_from(repo_root.clone()).unwrap()],
|
||||
Some(&home_root),
|
||||
)
|
||||
.unwrap()
|
||||
.marketplaces;
|
||||
|
||||
assert_eq!(
|
||||
marketplaces,
|
||||
vec![
|
||||
Marketplace {
|
||||
name: "codex-curated".to_string(),
|
||||
path:
|
||||
AbsolutePathBuf::try_from(home_root.join(".agents/plugins/marketplace.json"),)
|
||||
.unwrap(),
|
||||
interface: None,
|
||||
plugins: vec![
|
||||
MarketplacePlugin {
|
||||
name: "shared-plugin".to_string(),
|
||||
source: MarketplacePluginSource::Local {
|
||||
path: AbsolutePathBuf::try_from(home_root.join("home-shared")).unwrap(),
|
||||
},
|
||||
policy: MarketplacePluginPolicy {
|
||||
installation: MarketplacePluginInstallPolicy::Available,
|
||||
authentication: MarketplacePluginAuthPolicy::OnInstall,
|
||||
products: None,
|
||||
},
|
||||
interface: None,
|
||||
},
|
||||
MarketplacePlugin {
|
||||
name: "home-only".to_string(),
|
||||
source: MarketplacePluginSource::Local {
|
||||
path: AbsolutePathBuf::try_from(home_root.join("home-only")).unwrap(),
|
||||
},
|
||||
policy: MarketplacePluginPolicy {
|
||||
installation: MarketplacePluginInstallPolicy::Available,
|
||||
authentication: MarketplacePluginAuthPolicy::OnInstall,
|
||||
products: None,
|
||||
},
|
||||
interface: None,
|
||||
},
|
||||
],
|
||||
},
|
||||
Marketplace {
|
||||
name: "codex-curated".to_string(),
|
||||
path:
|
||||
AbsolutePathBuf::try_from(repo_root.join(".agents/plugins/marketplace.json"),)
|
||||
.unwrap(),
|
||||
interface: None,
|
||||
plugins: vec![
|
||||
MarketplacePlugin {
|
||||
name: "shared-plugin".to_string(),
|
||||
source: MarketplacePluginSource::Local {
|
||||
path: AbsolutePathBuf::try_from(repo_root.join("repo-shared")).unwrap(),
|
||||
},
|
||||
policy: MarketplacePluginPolicy {
|
||||
installation: MarketplacePluginInstallPolicy::Available,
|
||||
authentication: MarketplacePluginAuthPolicy::OnInstall,
|
||||
products: None,
|
||||
},
|
||||
interface: None,
|
||||
},
|
||||
MarketplacePlugin {
|
||||
name: "repo-only".to_string(),
|
||||
source: MarketplacePluginSource::Local {
|
||||
path: AbsolutePathBuf::try_from(repo_root.join("repo-only")).unwrap(),
|
||||
},
|
||||
policy: MarketplacePluginPolicy {
|
||||
installation: MarketplacePluginInstallPolicy::Available,
|
||||
authentication: MarketplacePluginAuthPolicy::OnInstall,
|
||||
products: None,
|
||||
},
|
||||
interface: None,
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_marketplaces_keeps_distinct_entries_for_same_name() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let home_root = tmp.path().join("home");
|
||||
let repo_root = tmp.path().join("repo");
|
||||
let home_marketplace = home_root.join(".agents/plugins/marketplace.json");
|
||||
let repo_marketplace = repo_root.join(".agents/plugins/marketplace.json");
|
||||
|
||||
fs::create_dir_all(repo_root.join(".git")).unwrap();
|
||||
fs::create_dir_all(home_root.join(".agents/plugins")).unwrap();
|
||||
fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap();
|
||||
|
||||
fs::write(
|
||||
home_marketplace.clone(),
|
||||
r#"{
|
||||
"name": "codex-curated",
|
||||
"plugins": [
|
||||
{
|
||||
"name": "local-plugin",
|
||||
"source": {
|
||||
"source": "local",
|
||||
"path": "./home-plugin"
|
||||
}
|
||||
}
|
||||
]
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
repo_marketplace.clone(),
|
||||
r#"{
|
||||
"name": "codex-curated",
|
||||
"plugins": [
|
||||
{
|
||||
"name": "local-plugin",
|
||||
"source": {
|
||||
"source": "local",
|
||||
"path": "./repo-plugin"
|
||||
}
|
||||
}
|
||||
]
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let marketplaces = list_marketplaces_with_home(
|
||||
&[AbsolutePathBuf::try_from(repo_root.clone()).unwrap()],
|
||||
Some(&home_root),
|
||||
)
|
||||
.unwrap()
|
||||
.marketplaces;
|
||||
|
||||
assert_eq!(
|
||||
marketplaces,
|
||||
vec![
|
||||
Marketplace {
|
||||
name: "codex-curated".to_string(),
|
||||
path: AbsolutePathBuf::try_from(home_marketplace).unwrap(),
|
||||
interface: None,
|
||||
plugins: vec![MarketplacePlugin {
|
||||
name: "local-plugin".to_string(),
|
||||
source: MarketplacePluginSource::Local {
|
||||
path: AbsolutePathBuf::try_from(home_root.join("home-plugin")).unwrap(),
|
||||
},
|
||||
policy: MarketplacePluginPolicy {
|
||||
installation: MarketplacePluginInstallPolicy::Available,
|
||||
authentication: MarketplacePluginAuthPolicy::OnInstall,
|
||||
products: None,
|
||||
},
|
||||
interface: None,
|
||||
}],
|
||||
},
|
||||
Marketplace {
|
||||
name: "codex-curated".to_string(),
|
||||
path: AbsolutePathBuf::try_from(repo_marketplace.clone()).unwrap(),
|
||||
interface: None,
|
||||
plugins: vec![MarketplacePlugin {
|
||||
name: "local-plugin".to_string(),
|
||||
source: MarketplacePluginSource::Local {
|
||||
path: AbsolutePathBuf::try_from(repo_root.join("repo-plugin")).unwrap(),
|
||||
},
|
||||
policy: MarketplacePluginPolicy {
|
||||
installation: MarketplacePluginInstallPolicy::Available,
|
||||
authentication: MarketplacePluginAuthPolicy::OnInstall,
|
||||
products: None,
|
||||
},
|
||||
interface: None,
|
||||
}],
|
||||
},
|
||||
]
|
||||
);
|
||||
|
||||
let resolved = resolve_marketplace_plugin(
|
||||
&AbsolutePathBuf::try_from(repo_marketplace).unwrap(),
|
||||
"local-plugin",
|
||||
Some(Product::Codex),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
resolved.source_path,
|
||||
AbsolutePathBuf::try_from(repo_root.join("repo-plugin")).unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_marketplaces_dedupes_multiple_roots_in_same_repo() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let repo_root = tmp.path().join("repo");
|
||||
let nested_root = repo_root.join("nested/project");
|
||||
|
||||
fs::create_dir_all(repo_root.join(".git")).unwrap();
|
||||
fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap();
|
||||
fs::create_dir_all(&nested_root).unwrap();
|
||||
fs::write(
|
||||
repo_root.join(".agents/plugins/marketplace.json"),
|
||||
r#"{
|
||||
"name": "codex-curated",
|
||||
"plugins": [
|
||||
{
|
||||
"name": "local-plugin",
|
||||
"source": {
|
||||
"source": "local",
|
||||
"path": "./plugin"
|
||||
}
|
||||
}
|
||||
]
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let marketplaces = list_marketplaces_with_home(
|
||||
&[
|
||||
AbsolutePathBuf::try_from(repo_root.clone()).unwrap(),
|
||||
AbsolutePathBuf::try_from(nested_root).unwrap(),
|
||||
],
|
||||
/*home_dir*/ None,
|
||||
)
|
||||
.unwrap()
|
||||
.marketplaces;
|
||||
|
||||
assert_eq!(
|
||||
marketplaces,
|
||||
vec![Marketplace {
|
||||
name: "codex-curated".to_string(),
|
||||
path: AbsolutePathBuf::try_from(repo_root.join(".agents/plugins/marketplace.json"))
|
||||
.unwrap(),
|
||||
interface: None,
|
||||
plugins: vec![MarketplacePlugin {
|
||||
name: "local-plugin".to_string(),
|
||||
source: MarketplacePluginSource::Local {
|
||||
path: AbsolutePathBuf::try_from(repo_root.join("plugin")).unwrap(),
|
||||
},
|
||||
policy: MarketplacePluginPolicy {
|
||||
installation: MarketplacePluginInstallPolicy::Available,
|
||||
authentication: MarketplacePluginAuthPolicy::OnInstall,
|
||||
products: None,
|
||||
},
|
||||
interface: None,
|
||||
}],
|
||||
}]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_marketplaces_reads_marketplace_display_name() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let repo_root = tmp.path().join("repo");
|
||||
|
||||
fs::create_dir_all(repo_root.join(".git")).unwrap();
|
||||
fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap();
|
||||
fs::write(
|
||||
repo_root.join(".agents/plugins/marketplace.json"),
|
||||
r#"{
|
||||
"name": "openai-curated",
|
||||
"interface": {
|
||||
"displayName": "ChatGPT Official"
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
"name": "local-plugin",
|
||||
"source": {
|
||||
"source": "local",
|
||||
"path": "./plugin"
|
||||
}
|
||||
}
|
||||
]
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let marketplaces = list_marketplaces_with_home(
|
||||
&[AbsolutePathBuf::try_from(repo_root).unwrap()],
|
||||
/*home_dir*/ None,
|
||||
)
|
||||
.unwrap()
|
||||
.marketplaces;
|
||||
|
||||
assert_eq!(
|
||||
marketplaces[0].interface,
|
||||
Some(MarketplaceInterface {
|
||||
display_name: Some("ChatGPT Official".to_string()),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_marketplaces_skips_marketplaces_that_fail_to_load() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let valid_repo_root = tmp.path().join("valid-repo");
|
||||
let invalid_repo_root = tmp.path().join("invalid-repo");
|
||||
|
||||
fs::create_dir_all(valid_repo_root.join(".git")).unwrap();
|
||||
fs::create_dir_all(valid_repo_root.join(".agents/plugins")).unwrap();
|
||||
fs::create_dir_all(invalid_repo_root.join(".git")).unwrap();
|
||||
fs::create_dir_all(invalid_repo_root.join(".agents/plugins")).unwrap();
|
||||
fs::write(
|
||||
valid_repo_root.join(".agents/plugins/marketplace.json"),
|
||||
r#"{
|
||||
"name": "valid-marketplace",
|
||||
"plugins": [
|
||||
{
|
||||
"name": "valid-plugin",
|
||||
"source": {
|
||||
"source": "local",
|
||||
"path": "./plugin"
|
||||
}
|
||||
}
|
||||
]
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
invalid_repo_root.join(".agents/plugins/marketplace.json"),
|
||||
r#"{
|
||||
"name": "invalid-marketplace",
|
||||
"plugins": [
|
||||
{
|
||||
"name": "broken-plugin",
|
||||
"source": {
|
||||
"source": "local",
|
||||
"path": "plugin-without-dot-slash"
|
||||
}
|
||||
}
|
||||
]
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let marketplaces = list_marketplaces_with_home(
|
||||
&[
|
||||
AbsolutePathBuf::try_from(valid_repo_root).unwrap(),
|
||||
AbsolutePathBuf::try_from(invalid_repo_root).unwrap(),
|
||||
],
|
||||
/*home_dir*/ None,
|
||||
)
|
||||
.unwrap()
|
||||
.marketplaces;
|
||||
|
||||
assert_eq!(marketplaces.len(), 1);
|
||||
assert_eq!(marketplaces[0].name, "valid-marketplace");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_marketplaces_reports_marketplace_load_errors() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let valid_repo_root = tmp.path().join("valid-repo");
|
||||
let invalid_repo_root = tmp.path().join("invalid-repo");
|
||||
|
||||
fs::create_dir_all(valid_repo_root.join(".git")).unwrap();
|
||||
fs::create_dir_all(valid_repo_root.join(".agents/plugins")).unwrap();
|
||||
fs::create_dir_all(invalid_repo_root.join(".git")).unwrap();
|
||||
fs::create_dir_all(invalid_repo_root.join(".agents/plugins")).unwrap();
|
||||
fs::write(
|
||||
valid_repo_root.join(".agents/plugins/marketplace.json"),
|
||||
r#"{
|
||||
"name": "valid-marketplace",
|
||||
"plugins": [
|
||||
{
|
||||
"name": "valid-plugin",
|
||||
"source": {
|
||||
"source": "local",
|
||||
"path": "./plugin"
|
||||
}
|
||||
}
|
||||
]
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let invalid_marketplace_path =
|
||||
AbsolutePathBuf::try_from(invalid_repo_root.join(".agents/plugins/marketplace.json"))
|
||||
.unwrap();
|
||||
fs::write(invalid_marketplace_path.as_path(), "{not json").unwrap();
|
||||
|
||||
let outcome = list_marketplaces_with_home(
|
||||
&[
|
||||
AbsolutePathBuf::try_from(valid_repo_root).unwrap(),
|
||||
AbsolutePathBuf::try_from(invalid_repo_root).unwrap(),
|
||||
],
|
||||
/*home_dir*/ None,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(outcome.marketplaces.len(), 1);
|
||||
assert_eq!(outcome.marketplaces[0].name, "valid-marketplace");
|
||||
assert_eq!(outcome.errors.len(), 1);
|
||||
assert_eq!(outcome.errors[0].path, invalid_marketplace_path);
|
||||
assert!(
|
||||
outcome.errors[0]
|
||||
.message
|
||||
.contains("invalid marketplace file"),
|
||||
"unexpected errors: {:?}",
|
||||
outcome.errors
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_marketplaces_resolves_plugin_interface_paths_to_absolute() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let repo_root = tmp.path().join("repo");
|
||||
let plugin_root = repo_root.join("plugins/demo-plugin");
|
||||
fs::create_dir_all(repo_root.join(".git")).unwrap();
|
||||
fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap();
|
||||
fs::create_dir_all(plugin_root.join(".codex-plugin")).unwrap();
|
||||
fs::write(
|
||||
repo_root.join(".agents/plugins/marketplace.json"),
|
||||
r#"{
|
||||
"name": "codex-curated",
|
||||
"plugins": [
|
||||
{
|
||||
"name": "demo-plugin",
|
||||
"source": {
|
||||
"source": "local",
|
||||
"path": "./plugins/demo-plugin"
|
||||
},
|
||||
"policy": {
|
||||
"installation": "AVAILABLE",
|
||||
"authentication": "ON_INSTALL",
|
||||
"products": ["CODEX", "CHATGPT", "ATLAS"]
|
||||
},
|
||||
"category": "Design"
|
||||
}
|
||||
]
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
plugin_root.join(".codex-plugin/plugin.json"),
|
||||
r#"{
|
||||
"name": "demo-plugin",
|
||||
"interface": {
|
||||
"displayName": "Demo",
|
||||
"category": "Productivity",
|
||||
"capabilities": ["Interactive", "Write"],
|
||||
"composerIcon": "./assets/icon.png",
|
||||
"logo": "./assets/logo.png",
|
||||
"screenshots": ["./assets/shot1.png"]
|
||||
}
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let marketplaces = list_marketplaces_with_home(
|
||||
&[AbsolutePathBuf::try_from(repo_root).unwrap()],
|
||||
/*home_dir*/ None,
|
||||
)
|
||||
.unwrap()
|
||||
.marketplaces;
|
||||
|
||||
assert_eq!(
|
||||
marketplaces[0].plugins[0].policy.installation,
|
||||
MarketplacePluginInstallPolicy::Available
|
||||
);
|
||||
assert_eq!(
|
||||
marketplaces[0].plugins[0].policy.authentication,
|
||||
MarketplacePluginAuthPolicy::OnInstall
|
||||
);
|
||||
assert_eq!(
|
||||
marketplaces[0].plugins[0].policy.products,
|
||||
Some(vec![Product::Codex, Product::Chatgpt, Product::Atlas])
|
||||
);
|
||||
assert_eq!(
|
||||
marketplaces[0].plugins[0].interface,
|
||||
Some(PluginManifestInterface {
|
||||
display_name: Some("Demo".to_string()),
|
||||
short_description: None,
|
||||
long_description: None,
|
||||
developer_name: None,
|
||||
category: Some("Design".to_string()),
|
||||
capabilities: vec!["Interactive".to_string(), "Write".to_string()],
|
||||
website_url: None,
|
||||
privacy_policy_url: None,
|
||||
terms_of_service_url: None,
|
||||
default_prompt: None,
|
||||
brand_color: None,
|
||||
composer_icon: Some(
|
||||
AbsolutePathBuf::try_from(plugin_root.join("assets/icon.png")).unwrap(),
|
||||
),
|
||||
logo: Some(AbsolutePathBuf::try_from(plugin_root.join("assets/logo.png")).unwrap()),
|
||||
screenshots: vec![
|
||||
AbsolutePathBuf::try_from(plugin_root.join("assets/shot1.png")).unwrap(),
|
||||
],
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_marketplaces_ignores_legacy_top_level_policy_fields() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let repo_root = tmp.path().join("repo");
|
||||
|
||||
fs::create_dir_all(repo_root.join(".git")).unwrap();
|
||||
fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap();
|
||||
fs::write(
|
||||
repo_root.join(".agents/plugins/marketplace.json"),
|
||||
r#"{
|
||||
"name": "codex-curated",
|
||||
"plugins": [
|
||||
{
|
||||
"name": "demo-plugin",
|
||||
"source": {
|
||||
"source": "local",
|
||||
"path": "./plugins/demo-plugin"
|
||||
},
|
||||
"installPolicy": "NOT_AVAILABLE",
|
||||
"authPolicy": "ON_USE"
|
||||
}
|
||||
]
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let marketplaces = list_marketplaces_with_home(
|
||||
&[AbsolutePathBuf::try_from(repo_root).unwrap()],
|
||||
/*home_dir*/ None,
|
||||
)
|
||||
.unwrap()
|
||||
.marketplaces;
|
||||
|
||||
assert_eq!(
|
||||
marketplaces[0].plugins[0].policy.installation,
|
||||
MarketplacePluginInstallPolicy::Available
|
||||
);
|
||||
assert_eq!(
|
||||
marketplaces[0].plugins[0].policy.authentication,
|
||||
MarketplacePluginAuthPolicy::OnInstall
|
||||
);
|
||||
assert_eq!(marketplaces[0].plugins[0].policy.products, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_marketplaces_ignores_plugin_interface_assets_without_dot_slash() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let repo_root = tmp.path().join("repo");
|
||||
let plugin_root = repo_root.join("plugins/demo-plugin");
|
||||
|
||||
fs::create_dir_all(repo_root.join(".git")).unwrap();
|
||||
fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap();
|
||||
fs::create_dir_all(plugin_root.join(".codex-plugin")).unwrap();
|
||||
fs::write(
|
||||
repo_root.join(".agents/plugins/marketplace.json"),
|
||||
r#"{
|
||||
"name": "codex-curated",
|
||||
"plugins": [
|
||||
{
|
||||
"name": "demo-plugin",
|
||||
"source": {
|
||||
"source": "local",
|
||||
"path": "./plugins/demo-plugin"
|
||||
}
|
||||
}
|
||||
]
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
plugin_root.join(".codex-plugin/plugin.json"),
|
||||
r#"{
|
||||
"name": "demo-plugin",
|
||||
"interface": {
|
||||
"displayName": "Demo",
|
||||
"capabilities": ["Interactive"],
|
||||
"composerIcon": "assets/icon.png",
|
||||
"logo": "/tmp/logo.png",
|
||||
"screenshots": ["assets/shot1.png"]
|
||||
}
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let marketplaces = list_marketplaces_with_home(
|
||||
&[AbsolutePathBuf::try_from(repo_root).unwrap()],
|
||||
/*home_dir*/ None,
|
||||
)
|
||||
.unwrap()
|
||||
.marketplaces;
|
||||
|
||||
assert_eq!(
|
||||
marketplaces[0].plugins[0].interface,
|
||||
Some(PluginManifestInterface {
|
||||
display_name: Some("Demo".to_string()),
|
||||
short_description: None,
|
||||
long_description: None,
|
||||
developer_name: None,
|
||||
category: None,
|
||||
capabilities: vec!["Interactive".to_string()],
|
||||
website_url: None,
|
||||
privacy_policy_url: None,
|
||||
terms_of_service_url: None,
|
||||
default_prompt: None,
|
||||
brand_color: None,
|
||||
composer_icon: None,
|
||||
logo: None,
|
||||
screenshots: Vec::new(),
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
marketplaces[0].plugins[0].policy.installation,
|
||||
MarketplacePluginInstallPolicy::Available
|
||||
);
|
||||
assert_eq!(
|
||||
marketplaces[0].plugins[0].policy.authentication,
|
||||
MarketplacePluginAuthPolicy::OnInstall
|
||||
);
|
||||
assert_eq!(marketplaces[0].plugins[0].policy.products, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_marketplace_plugin_rejects_non_relative_local_paths() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let repo_root = tmp.path().join("repo");
|
||||
fs::create_dir_all(repo_root.join(".git")).unwrap();
|
||||
fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap();
|
||||
fs::write(
|
||||
repo_root.join(".agents/plugins/marketplace.json"),
|
||||
r#"{
|
||||
"name": "codex-curated",
|
||||
"plugins": [
|
||||
{
|
||||
"name": "local-plugin",
|
||||
"source": {
|
||||
"source": "local",
|
||||
"path": "../plugin-1"
|
||||
}
|
||||
}
|
||||
]
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let marketplace_path =
|
||||
AbsolutePathBuf::try_from(repo_root.join(".agents/plugins/marketplace.json")).unwrap();
|
||||
let err = resolve_marketplace_plugin(&marketplace_path, "local-plugin", Some(Product::Codex))
|
||||
.unwrap_err();
|
||||
|
||||
assert_eq!(
|
||||
err.to_string(),
|
||||
format!(
|
||||
"invalid marketplace file `{}`: local plugin source path must start with `./`",
|
||||
marketplace_path.display()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_marketplace_plugin_uses_first_duplicate_entry() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let repo_root = tmp.path().join("repo");
|
||||
fs::create_dir_all(repo_root.join(".git")).unwrap();
|
||||
fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap();
|
||||
fs::write(
|
||||
repo_root.join(".agents/plugins/marketplace.json"),
|
||||
r#"{
|
||||
"name": "codex-curated",
|
||||
"plugins": [
|
||||
{
|
||||
"name": "local-plugin",
|
||||
"source": {
|
||||
"source": "local",
|
||||
"path": "./first"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "local-plugin",
|
||||
"source": {
|
||||
"source": "local",
|
||||
"path": "./second"
|
||||
}
|
||||
}
|
||||
]
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let resolved = resolve_marketplace_plugin(
|
||||
&AbsolutePathBuf::try_from(repo_root.join(".agents/plugins/marketplace.json")).unwrap(),
|
||||
"local-plugin",
|
||||
Some(Product::Codex),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
resolved.source_path,
|
||||
AbsolutePathBuf::try_from(repo_root.join("first")).unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_marketplace_plugin_rejects_disallowed_product() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let repo_root = tmp.path().join("repo");
|
||||
fs::create_dir_all(repo_root.join(".git")).unwrap();
|
||||
fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap();
|
||||
fs::write(
|
||||
repo_root.join(".agents/plugins/marketplace.json"),
|
||||
r#"{
|
||||
"name": "codex-curated",
|
||||
"plugins": [
|
||||
{
|
||||
"name": "chatgpt-plugin",
|
||||
"source": {
|
||||
"source": "local",
|
||||
"path": "./plugin"
|
||||
},
|
||||
"policy": {
|
||||
"products": ["CHATGPT"]
|
||||
}
|
||||
}
|
||||
]
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let err = resolve_marketplace_plugin(
|
||||
&AbsolutePathBuf::try_from(repo_root.join(".agents/plugins/marketplace.json")).unwrap(),
|
||||
"chatgpt-plugin",
|
||||
Some(Product::Atlas),
|
||||
)
|
||||
.unwrap_err();
|
||||
|
||||
assert_eq!(
|
||||
err.to_string(),
|
||||
"plugin `chatgpt-plugin` is not available for install in marketplace `codex-curated`"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_marketplace_plugin_allows_missing_products_field() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let repo_root = tmp.path().join("repo");
|
||||
fs::create_dir_all(repo_root.join(".git")).unwrap();
|
||||
fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap();
|
||||
fs::write(
|
||||
repo_root.join(".agents/plugins/marketplace.json"),
|
||||
r#"{
|
||||
"name": "codex-curated",
|
||||
"plugins": [
|
||||
{
|
||||
"name": "default-plugin",
|
||||
"source": {
|
||||
"source": "local",
|
||||
"path": "./plugin"
|
||||
},
|
||||
"policy": {}
|
||||
}
|
||||
]
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let resolved = resolve_marketplace_plugin(
|
||||
&AbsolutePathBuf::try_from(repo_root.join(".agents/plugins/marketplace.json")).unwrap(),
|
||||
"default-plugin",
|
||||
Some(Product::Codex),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(resolved.plugin_id.as_key(), "default-plugin@codex-curated");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_marketplace_plugin_rejects_explicit_empty_products() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let repo_root = tmp.path().join("repo");
|
||||
fs::create_dir_all(repo_root.join(".git")).unwrap();
|
||||
fs::create_dir_all(repo_root.join(".agents/plugins")).unwrap();
|
||||
fs::write(
|
||||
repo_root.join(".agents/plugins/marketplace.json"),
|
||||
r#"{
|
||||
"name": "codex-curated",
|
||||
"plugins": [
|
||||
{
|
||||
"name": "disabled-plugin",
|
||||
"source": {
|
||||
"source": "local",
|
||||
"path": "./plugin"
|
||||
},
|
||||
"policy": {
|
||||
"products": []
|
||||
}
|
||||
}
|
||||
]
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let err = resolve_marketplace_plugin(
|
||||
&AbsolutePathBuf::try_from(repo_root.join(".agents/plugins/marketplace.json")).unwrap(),
|
||||
"disabled-plugin",
|
||||
Some(Product::Codex),
|
||||
)
|
||||
.unwrap_err();
|
||||
|
||||
assert_eq!(
|
||||
err.to_string(),
|
||||
"plugin `disabled-plugin` is not available for install in marketplace `codex-curated`"
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
|
||||
use codex_connectors::metadata::connector_mention_slug;
|
||||
use codex_protocol::user_input::UserInput;
|
||||
|
||||
use crate::connectors;
|
||||
@@ -108,7 +109,7 @@ pub(crate) fn build_connector_slug_counts(
|
||||
) -> HashMap<String, usize> {
|
||||
let mut counts: HashMap<String, usize> = HashMap::new();
|
||||
for connector in connectors {
|
||||
let slug = connectors::connector_mention_slug(connector);
|
||||
let slug = connector_mention_slug(connector);
|
||||
*counts.entry(slug).or_insert(0) += 1;
|
||||
}
|
||||
counts
|
||||
|
||||
@@ -4,18 +4,15 @@ mod discoverable;
|
||||
mod injection;
|
||||
mod installed_marketplaces;
|
||||
mod manager;
|
||||
mod manifest;
|
||||
mod marketplace;
|
||||
mod marketplace_add;
|
||||
mod mentions;
|
||||
mod remote;
|
||||
mod render;
|
||||
mod startup_sync;
|
||||
mod store;
|
||||
#[cfg(test)]
|
||||
pub(crate) mod test_support;
|
||||
mod toggles;
|
||||
|
||||
pub use codex_core_plugins::marketplace_upgrade::ConfiguredMarketplaceUpgradeError as PluginMarketplaceUpgradeError;
|
||||
pub use codex_core_plugins::marketplace_upgrade::ConfiguredMarketplaceUpgradeOutcome as PluginMarketplaceUpgradeOutcome;
|
||||
pub use codex_plugin::AppConnectorId;
|
||||
pub use codex_plugin::EffectiveSkillRoots;
|
||||
pub use codex_plugin::PluginCapabilitySummary;
|
||||
@@ -27,6 +24,7 @@ pub use codex_plugin::validate_plugin_segment;
|
||||
pub type LoadedPlugin = codex_plugin::LoadedPlugin<McpServerConfig>;
|
||||
pub type PluginLoadOutcome = codex_plugin::PluginLoadOutcome<McpServerConfig>;
|
||||
|
||||
pub(crate) use codex_core_plugins::marketplace::find_marketplace_manifest_path;
|
||||
pub(crate) use discoverable::list_tool_suggest_discoverable_plugins;
|
||||
pub(crate) use injection::build_plugin_injections;
|
||||
pub use installed_marketplaces::INSTALLED_MARKETPLACES_DIR;
|
||||
@@ -45,32 +43,17 @@ pub use manager::PluginRemoteSyncError;
|
||||
pub use manager::PluginUninstallError;
|
||||
pub use manager::PluginsManager;
|
||||
pub use manager::RemotePluginSyncResult;
|
||||
pub use manager::installed_plugin_telemetry_metadata;
|
||||
pub use manager::load_plugin_apps;
|
||||
pub use manager::load_plugin_mcp_servers;
|
||||
pub use manager::plugin_telemetry_metadata_from_root;
|
||||
pub use manifest::PluginManifestInterface;
|
||||
pub(crate) use manifest::PluginManifestPaths;
|
||||
pub(crate) use manifest::load_plugin_manifest;
|
||||
pub use marketplace::MarketplaceError;
|
||||
pub use marketplace::MarketplaceListError;
|
||||
pub use marketplace::MarketplacePluginAuthPolicy;
|
||||
pub use marketplace::MarketplacePluginInstallPolicy;
|
||||
pub use marketplace::MarketplacePluginPolicy;
|
||||
pub use marketplace::MarketplacePluginSource;
|
||||
pub use marketplace::validate_marketplace_root;
|
||||
pub(crate) use manager::configured_plugins_from_stack;
|
||||
pub use marketplace_add::MarketplaceAddError;
|
||||
pub use marketplace_add::MarketplaceAddOutcome;
|
||||
pub use marketplace_add::MarketplaceAddRequest;
|
||||
pub use marketplace_add::add_marketplace;
|
||||
pub use remote::RemotePluginFetchError;
|
||||
pub use remote::fetch_remote_featured_plugin_ids;
|
||||
pub(crate) use marketplace_add::parse_marketplace_source;
|
||||
pub(crate) use render::render_explicit_plugin_instructions;
|
||||
pub(crate) use render::render_plugins_section;
|
||||
pub(crate) use startup_sync::curated_plugins_repo_path;
|
||||
pub(crate) use startup_sync::read_curated_plugins_sha;
|
||||
pub(crate) use startup_sync::sync_openai_plugins_repo;
|
||||
pub use toggles::collect_plugin_enabled_candidates;
|
||||
|
||||
pub(crate) use mentions::build_connector_slug_counts;
|
||||
pub(crate) use mentions::build_skill_name_counts;
|
||||
|
||||
@@ -1,313 +0,0 @@
|
||||
use crate::config::Config;
|
||||
use codex_login::CodexAuth;
|
||||
use codex_login::default_client::build_reqwest_client;
|
||||
use codex_protocol::protocol::Product;
|
||||
use serde::Deserialize;
|
||||
use std::time::Duration;
|
||||
use url::Url;
|
||||
|
||||
const DEFAULT_REMOTE_MARKETPLACE_NAME: &str = "openai-curated";
|
||||
const REMOTE_PLUGIN_FETCH_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
const REMOTE_FEATURED_PLUGIN_FETCH_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
const REMOTE_PLUGIN_MUTATION_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
|
||||
pub(crate) struct RemotePluginStatusSummary {
|
||||
pub(crate) name: String,
|
||||
#[serde(default = "default_remote_marketplace_name")]
|
||||
pub(crate) marketplace_name: String,
|
||||
pub(crate) enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct RemotePluginMutationResponse {
|
||||
pub id: String,
|
||||
pub enabled: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum RemotePluginMutationError {
|
||||
#[error("chatgpt authentication required for remote plugin mutation")]
|
||||
AuthRequired,
|
||||
|
||||
#[error(
|
||||
"chatgpt authentication required for remote plugin mutation; api key auth is not supported"
|
||||
)]
|
||||
UnsupportedAuthMode,
|
||||
|
||||
#[error("failed to read auth token for remote plugin mutation: {0}")]
|
||||
AuthToken(#[source] std::io::Error),
|
||||
|
||||
#[error("invalid chatgpt base url for remote plugin mutation: {0}")]
|
||||
InvalidBaseUrl(#[source] url::ParseError),
|
||||
|
||||
#[error("chatgpt base url cannot be used for plugin mutation")]
|
||||
InvalidBaseUrlPath,
|
||||
|
||||
#[error("failed to send remote plugin mutation request to {url}: {source}")]
|
||||
Request {
|
||||
url: String,
|
||||
#[source]
|
||||
source: reqwest::Error,
|
||||
},
|
||||
|
||||
#[error("remote plugin mutation failed with status {status} from {url}: {body}")]
|
||||
UnexpectedStatus {
|
||||
url: String,
|
||||
status: reqwest::StatusCode,
|
||||
body: String,
|
||||
},
|
||||
|
||||
#[error("failed to parse remote plugin mutation response from {url}: {source}")]
|
||||
Decode {
|
||||
url: String,
|
||||
#[source]
|
||||
source: serde_json::Error,
|
||||
},
|
||||
|
||||
#[error(
|
||||
"remote plugin mutation returned unexpected plugin id: expected `{expected}`, got `{actual}`"
|
||||
)]
|
||||
UnexpectedPluginId { expected: String, actual: String },
|
||||
|
||||
#[error(
|
||||
"remote plugin mutation returned unexpected enabled state for `{plugin_id}`: expected {expected_enabled}, got {actual_enabled}"
|
||||
)]
|
||||
UnexpectedEnabledState {
|
||||
plugin_id: String,
|
||||
expected_enabled: bool,
|
||||
actual_enabled: bool,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum RemotePluginFetchError {
|
||||
#[error("chatgpt authentication required to sync remote plugins")]
|
||||
AuthRequired,
|
||||
|
||||
#[error(
|
||||
"chatgpt authentication required to sync remote plugins; api key auth is not supported"
|
||||
)]
|
||||
UnsupportedAuthMode,
|
||||
|
||||
#[error("failed to read auth token for remote plugin sync: {0}")]
|
||||
AuthToken(#[source] std::io::Error),
|
||||
|
||||
#[error("failed to send remote plugin sync request to {url}: {source}")]
|
||||
Request {
|
||||
url: String,
|
||||
#[source]
|
||||
source: reqwest::Error,
|
||||
},
|
||||
|
||||
#[error("remote plugin sync request to {url} failed with status {status}: {body}")]
|
||||
UnexpectedStatus {
|
||||
url: String,
|
||||
status: reqwest::StatusCode,
|
||||
body: String,
|
||||
},
|
||||
|
||||
#[error("failed to parse remote plugin sync response from {url}: {source}")]
|
||||
Decode {
|
||||
url: String,
|
||||
#[source]
|
||||
source: serde_json::Error,
|
||||
},
|
||||
}
|
||||
|
||||
pub(crate) async fn fetch_remote_plugin_status(
|
||||
config: &Config,
|
||||
auth: Option<&CodexAuth>,
|
||||
) -> Result<Vec<RemotePluginStatusSummary>, RemotePluginFetchError> {
|
||||
let Some(auth) = auth else {
|
||||
return Err(RemotePluginFetchError::AuthRequired);
|
||||
};
|
||||
if !auth.is_chatgpt_auth() {
|
||||
return Err(RemotePluginFetchError::UnsupportedAuthMode);
|
||||
}
|
||||
|
||||
let base_url = config.chatgpt_base_url.trim_end_matches('/');
|
||||
let url = format!("{base_url}/plugins/list");
|
||||
let client = build_reqwest_client();
|
||||
let token = auth
|
||||
.get_token()
|
||||
.map_err(RemotePluginFetchError::AuthToken)?;
|
||||
let mut request = client
|
||||
.get(&url)
|
||||
.timeout(REMOTE_PLUGIN_FETCH_TIMEOUT)
|
||||
.bearer_auth(token);
|
||||
if let Some(account_id) = auth.get_account_id() {
|
||||
request = request.header("chatgpt-account-id", account_id);
|
||||
}
|
||||
|
||||
let response = request
|
||||
.send()
|
||||
.await
|
||||
.map_err(|source| RemotePluginFetchError::Request {
|
||||
url: url.clone(),
|
||||
source,
|
||||
})?;
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
if !status.is_success() {
|
||||
return Err(RemotePluginFetchError::UnexpectedStatus { url, status, body });
|
||||
}
|
||||
|
||||
serde_json::from_str(&body).map_err(|source| RemotePluginFetchError::Decode {
|
||||
url: url.clone(),
|
||||
source,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn fetch_remote_featured_plugin_ids(
|
||||
config: &Config,
|
||||
auth: Option<&CodexAuth>,
|
||||
product: Option<Product>,
|
||||
) -> Result<Vec<String>, RemotePluginFetchError> {
|
||||
let base_url = config.chatgpt_base_url.trim_end_matches('/');
|
||||
let url = format!("{base_url}/plugins/featured");
|
||||
let client = build_reqwest_client();
|
||||
let mut request = client
|
||||
.get(&url)
|
||||
.query(&[(
|
||||
"platform",
|
||||
product.unwrap_or(Product::Codex).to_app_platform(),
|
||||
)])
|
||||
.timeout(REMOTE_FEATURED_PLUGIN_FETCH_TIMEOUT);
|
||||
|
||||
if let Some(auth) = auth.filter(|auth| auth.is_chatgpt_auth()) {
|
||||
let token = auth
|
||||
.get_token()
|
||||
.map_err(RemotePluginFetchError::AuthToken)?;
|
||||
request = request.bearer_auth(token);
|
||||
if let Some(account_id) = auth.get_account_id() {
|
||||
request = request.header("chatgpt-account-id", account_id);
|
||||
}
|
||||
}
|
||||
|
||||
let response = request
|
||||
.send()
|
||||
.await
|
||||
.map_err(|source| RemotePluginFetchError::Request {
|
||||
url: url.clone(),
|
||||
source,
|
||||
})?;
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
if !status.is_success() {
|
||||
return Err(RemotePluginFetchError::UnexpectedStatus { url, status, body });
|
||||
}
|
||||
|
||||
serde_json::from_str(&body).map_err(|source| RemotePluginFetchError::Decode {
|
||||
url: url.clone(),
|
||||
source,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) async fn enable_remote_plugin(
|
||||
config: &Config,
|
||||
auth: Option<&CodexAuth>,
|
||||
plugin_id: &str,
|
||||
) -> Result<(), RemotePluginMutationError> {
|
||||
post_remote_plugin_mutation(config, auth, plugin_id, "enable").await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(crate) async fn uninstall_remote_plugin(
|
||||
config: &Config,
|
||||
auth: Option<&CodexAuth>,
|
||||
plugin_id: &str,
|
||||
) -> Result<(), RemotePluginMutationError> {
|
||||
post_remote_plugin_mutation(config, auth, plugin_id, "uninstall").await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn ensure_chatgpt_auth(auth: Option<&CodexAuth>) -> Result<&CodexAuth, RemotePluginMutationError> {
|
||||
let Some(auth) = auth else {
|
||||
return Err(RemotePluginMutationError::AuthRequired);
|
||||
};
|
||||
if !auth.is_chatgpt_auth() {
|
||||
return Err(RemotePluginMutationError::UnsupportedAuthMode);
|
||||
}
|
||||
Ok(auth)
|
||||
}
|
||||
|
||||
fn default_remote_marketplace_name() -> String {
|
||||
DEFAULT_REMOTE_MARKETPLACE_NAME.to_string()
|
||||
}
|
||||
|
||||
async fn post_remote_plugin_mutation(
|
||||
config: &Config,
|
||||
auth: Option<&CodexAuth>,
|
||||
plugin_id: &str,
|
||||
action: &str,
|
||||
) -> Result<RemotePluginMutationResponse, RemotePluginMutationError> {
|
||||
let auth = ensure_chatgpt_auth(auth)?;
|
||||
let url = remote_plugin_mutation_url(config, plugin_id, action)?;
|
||||
let client = build_reqwest_client();
|
||||
let token = auth
|
||||
.get_token()
|
||||
.map_err(RemotePluginMutationError::AuthToken)?;
|
||||
let mut request = client
|
||||
.post(url.clone())
|
||||
.timeout(REMOTE_PLUGIN_MUTATION_TIMEOUT)
|
||||
.bearer_auth(token);
|
||||
if let Some(account_id) = auth.get_account_id() {
|
||||
request = request.header("chatgpt-account-id", account_id);
|
||||
}
|
||||
|
||||
let response = request
|
||||
.send()
|
||||
.await
|
||||
.map_err(|source| RemotePluginMutationError::Request {
|
||||
url: url.clone(),
|
||||
source,
|
||||
})?;
|
||||
let status = response.status();
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
if !status.is_success() {
|
||||
return Err(RemotePluginMutationError::UnexpectedStatus { url, status, body });
|
||||
}
|
||||
|
||||
let parsed: RemotePluginMutationResponse =
|
||||
serde_json::from_str(&body).map_err(|source| RemotePluginMutationError::Decode {
|
||||
url: url.clone(),
|
||||
source,
|
||||
})?;
|
||||
let expected_enabled = action == "enable";
|
||||
if parsed.id != plugin_id {
|
||||
return Err(RemotePluginMutationError::UnexpectedPluginId {
|
||||
expected: plugin_id.to_string(),
|
||||
actual: parsed.id,
|
||||
});
|
||||
}
|
||||
if parsed.enabled != expected_enabled {
|
||||
return Err(RemotePluginMutationError::UnexpectedEnabledState {
|
||||
plugin_id: plugin_id.to_string(),
|
||||
expected_enabled,
|
||||
actual_enabled: parsed.enabled,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(parsed)
|
||||
}
|
||||
|
||||
fn remote_plugin_mutation_url(
|
||||
config: &Config,
|
||||
plugin_id: &str,
|
||||
action: &str,
|
||||
) -> Result<String, RemotePluginMutationError> {
|
||||
let mut url = Url::parse(config.chatgpt_base_url.trim_end_matches('/'))
|
||||
.map_err(RemotePluginMutationError::InvalidBaseUrl)?;
|
||||
{
|
||||
let mut segments = url
|
||||
.path_segments_mut()
|
||||
.map_err(|()| RemotePluginMutationError::InvalidBaseUrlPath)?;
|
||||
segments.pop_if_empty();
|
||||
segments.push("plugins");
|
||||
segments.push(plugin_id);
|
||||
segments.push(action);
|
||||
}
|
||||
Ok(url.to_string())
|
||||
}
|
||||
@@ -1,368 +0,0 @@
|
||||
use super::load_plugin_manifest;
|
||||
use super::manifest::PluginManifest;
|
||||
use codex_plugin::PluginId;
|
||||
use codex_plugin::validate_plugin_segment;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use codex_utils_plugins::PLUGIN_MANIFEST_PATH;
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value as JsonValue;
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub(crate) const DEFAULT_PLUGIN_VERSION: &str = "local";
|
||||
pub(crate) const PLUGINS_CACHE_DIR: &str = "plugins/cache";
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct PluginInstallResult {
|
||||
pub plugin_id: PluginId,
|
||||
pub plugin_version: String,
|
||||
pub installed_path: AbsolutePathBuf,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PluginStore {
|
||||
root: AbsolutePathBuf,
|
||||
}
|
||||
|
||||
impl PluginStore {
|
||||
pub fn new(codex_home: PathBuf) -> Self {
|
||||
Self {
|
||||
root: AbsolutePathBuf::try_from(codex_home.join(PLUGINS_CACHE_DIR))
|
||||
.unwrap_or_else(|err| panic!("plugin cache root should be absolute: {err}")),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn root(&self) -> &AbsolutePathBuf {
|
||||
&self.root
|
||||
}
|
||||
|
||||
pub fn plugin_base_root(&self, plugin_id: &PluginId) -> AbsolutePathBuf {
|
||||
AbsolutePathBuf::try_from(
|
||||
self.root
|
||||
.as_path()
|
||||
.join(&plugin_id.marketplace_name)
|
||||
.join(&plugin_id.plugin_name),
|
||||
)
|
||||
.unwrap_or_else(|err| panic!("plugin cache path should resolve to an absolute path: {err}"))
|
||||
}
|
||||
|
||||
pub fn plugin_root(&self, plugin_id: &PluginId, plugin_version: &str) -> AbsolutePathBuf {
|
||||
AbsolutePathBuf::try_from(
|
||||
self.plugin_base_root(plugin_id)
|
||||
.as_path()
|
||||
.join(plugin_version),
|
||||
)
|
||||
.unwrap_or_else(|err| panic!("plugin cache path should resolve to an absolute path: {err}"))
|
||||
}
|
||||
|
||||
pub fn active_plugin_version(&self, plugin_id: &PluginId) -> Option<String> {
|
||||
let mut discovered_versions = fs::read_dir(self.plugin_base_root(plugin_id).as_path())
|
||||
.ok()?
|
||||
.filter_map(Result::ok)
|
||||
.filter_map(|entry| {
|
||||
entry.file_type().ok().filter(std::fs::FileType::is_dir)?;
|
||||
entry.file_name().into_string().ok()
|
||||
})
|
||||
.filter(|version| validate_plugin_version_segment(version).is_ok())
|
||||
.collect::<Vec<_>>();
|
||||
discovered_versions.sort_unstable();
|
||||
if discovered_versions.is_empty() {
|
||||
None
|
||||
} else if discovered_versions
|
||||
.iter()
|
||||
.any(|version| version == DEFAULT_PLUGIN_VERSION)
|
||||
{
|
||||
Some(DEFAULT_PLUGIN_VERSION.to_string())
|
||||
} else {
|
||||
discovered_versions.pop()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn active_plugin_root(&self, plugin_id: &PluginId) -> Option<AbsolutePathBuf> {
|
||||
self.active_plugin_version(plugin_id)
|
||||
.map(|plugin_version| self.plugin_root(plugin_id, &plugin_version))
|
||||
}
|
||||
|
||||
pub fn is_installed(&self, plugin_id: &PluginId) -> bool {
|
||||
self.active_plugin_version(plugin_id).is_some()
|
||||
}
|
||||
|
||||
pub fn install(
|
||||
&self,
|
||||
source_path: AbsolutePathBuf,
|
||||
plugin_id: PluginId,
|
||||
) -> Result<PluginInstallResult, PluginStoreError> {
|
||||
let plugin_version = plugin_version_for_source(source_path.as_path())?;
|
||||
self.install_with_version(source_path, plugin_id, plugin_version)
|
||||
}
|
||||
|
||||
pub fn install_with_version(
|
||||
&self,
|
||||
source_path: AbsolutePathBuf,
|
||||
plugin_id: PluginId,
|
||||
plugin_version: String,
|
||||
) -> Result<PluginInstallResult, PluginStoreError> {
|
||||
if !source_path.as_path().is_dir() {
|
||||
return Err(PluginStoreError::Invalid(format!(
|
||||
"plugin source path is not a directory: {}",
|
||||
source_path.display()
|
||||
)));
|
||||
}
|
||||
|
||||
let plugin_name = plugin_name_for_source(source_path.as_path())?;
|
||||
if plugin_name != plugin_id.plugin_name {
|
||||
return Err(PluginStoreError::Invalid(format!(
|
||||
"plugin manifest name `{plugin_name}` does not match marketplace plugin name `{}`",
|
||||
plugin_id.plugin_name
|
||||
)));
|
||||
}
|
||||
validate_plugin_version_segment(&plugin_version).map_err(PluginStoreError::Invalid)?;
|
||||
let installed_path = self.plugin_root(&plugin_id, &plugin_version);
|
||||
replace_plugin_root_atomically(
|
||||
source_path.as_path(),
|
||||
self.plugin_base_root(&plugin_id).as_path(),
|
||||
&plugin_version,
|
||||
)?;
|
||||
|
||||
Ok(PluginInstallResult {
|
||||
plugin_id,
|
||||
plugin_version,
|
||||
installed_path,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn uninstall(&self, plugin_id: &PluginId) -> Result<(), PluginStoreError> {
|
||||
remove_existing_target(self.plugin_base_root(plugin_id).as_path())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum PluginStoreError {
|
||||
#[error("{context}: {source}")]
|
||||
Io {
|
||||
context: &'static str,
|
||||
#[source]
|
||||
source: io::Error,
|
||||
},
|
||||
|
||||
#[error("{0}")]
|
||||
Invalid(String),
|
||||
}
|
||||
|
||||
impl PluginStoreError {
|
||||
fn io(context: &'static str, source: io::Error) -> Self {
|
||||
Self::Io { context, source }
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn plugin_version_for_source(source_path: &Path) -> Result<String, PluginStoreError> {
|
||||
let plugin_version = plugin_manifest_version_for_source(source_path)?
|
||||
.unwrap_or_else(|| DEFAULT_PLUGIN_VERSION.to_string());
|
||||
validate_plugin_version_segment(&plugin_version).map_err(PluginStoreError::Invalid)?;
|
||||
Ok(plugin_version)
|
||||
}
|
||||
|
||||
fn validate_plugin_version_segment(plugin_version: &str) -> Result<(), String> {
|
||||
if plugin_version.is_empty() {
|
||||
return Err("invalid plugin version: must not be empty".to_string());
|
||||
}
|
||||
if matches!(plugin_version, "." | "..") {
|
||||
return Err("invalid plugin version: path traversal is not allowed".to_string());
|
||||
}
|
||||
if !plugin_version
|
||||
.chars()
|
||||
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.' | '+'))
|
||||
{
|
||||
return Err(
|
||||
"invalid plugin version: only ASCII letters, digits, `.`, `+`, `_`, and `-` are allowed"
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn plugin_manifest_for_source(source_path: &Path) -> Result<PluginManifest, PluginStoreError> {
|
||||
let manifest_path = source_path.join(PLUGIN_MANIFEST_PATH);
|
||||
if !manifest_path.is_file() {
|
||||
return Err(PluginStoreError::Invalid(format!(
|
||||
"missing plugin manifest: {}",
|
||||
manifest_path.display()
|
||||
)));
|
||||
}
|
||||
|
||||
load_plugin_manifest(source_path).ok_or_else(|| {
|
||||
PluginStoreError::Invalid(format!(
|
||||
"missing or invalid plugin manifest: {}",
|
||||
manifest_path.display()
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct RawPluginManifestVersion {
|
||||
#[serde(default)]
|
||||
version: Option<JsonValue>,
|
||||
}
|
||||
|
||||
fn plugin_manifest_version_for_source(
|
||||
source_path: &Path,
|
||||
) -> Result<Option<String>, PluginStoreError> {
|
||||
let manifest_path = source_path.join(PLUGIN_MANIFEST_PATH);
|
||||
if !manifest_path.is_file() {
|
||||
return Err(PluginStoreError::Invalid(format!(
|
||||
"missing plugin manifest: {}",
|
||||
manifest_path.display()
|
||||
)));
|
||||
}
|
||||
|
||||
let contents = fs::read_to_string(&manifest_path)
|
||||
.map_err(|err| PluginStoreError::io("failed to read plugin manifest", err))?;
|
||||
let manifest: RawPluginManifestVersion = serde_json::from_str(&contents).map_err(|err| {
|
||||
PluginStoreError::Invalid(format!(
|
||||
"failed to parse plugin manifest {}: {err}",
|
||||
manifest_path.display()
|
||||
))
|
||||
})?;
|
||||
let Some(version) = manifest.version else {
|
||||
return Ok(None);
|
||||
};
|
||||
let Some(version) = version.as_str() else {
|
||||
return Err(PluginStoreError::Invalid(format!(
|
||||
"invalid plugin version in manifest {}: expected string",
|
||||
manifest_path.display()
|
||||
)));
|
||||
};
|
||||
let version = version.trim();
|
||||
if version.is_empty() {
|
||||
return Err(PluginStoreError::Invalid(format!(
|
||||
"invalid plugin version in manifest {}: must not be blank",
|
||||
manifest_path.display()
|
||||
)));
|
||||
}
|
||||
Ok(Some(version.to_string()))
|
||||
}
|
||||
|
||||
fn plugin_name_for_source(source_path: &Path) -> Result<String, PluginStoreError> {
|
||||
let manifest = plugin_manifest_for_source(source_path)?;
|
||||
|
||||
let plugin_name = manifest.name;
|
||||
validate_plugin_segment(&plugin_name, "plugin name")
|
||||
.map_err(PluginStoreError::Invalid)
|
||||
.map(|_| plugin_name)
|
||||
}
|
||||
|
||||
fn remove_existing_target(path: &Path) -> Result<(), PluginStoreError> {
|
||||
if !path.exists() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if path.is_dir() {
|
||||
fs::remove_dir_all(path).map_err(|err| {
|
||||
PluginStoreError::io("failed to remove existing plugin cache entry", err)
|
||||
})
|
||||
} else {
|
||||
fs::remove_file(path).map_err(|err| {
|
||||
PluginStoreError::io("failed to remove existing plugin cache entry", err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
fn replace_plugin_root_atomically(
|
||||
source: &Path,
|
||||
target_root: &Path,
|
||||
plugin_version: &str,
|
||||
) -> Result<(), PluginStoreError> {
|
||||
let Some(parent) = target_root.parent() else {
|
||||
return Err(PluginStoreError::Invalid(format!(
|
||||
"plugin cache path has no parent: {}",
|
||||
target_root.display()
|
||||
)));
|
||||
};
|
||||
|
||||
fs::create_dir_all(parent)
|
||||
.map_err(|err| PluginStoreError::io("failed to create plugin cache directory", err))?;
|
||||
|
||||
let Some(plugin_dir_name) = target_root.file_name() else {
|
||||
return Err(PluginStoreError::Invalid(format!(
|
||||
"plugin cache path has no directory name: {}",
|
||||
target_root.display()
|
||||
)));
|
||||
};
|
||||
let staged_dir = tempfile::Builder::new()
|
||||
.prefix("plugin-install-")
|
||||
.tempdir_in(parent)
|
||||
.map_err(|err| {
|
||||
PluginStoreError::io("failed to create temporary plugin cache directory", err)
|
||||
})?;
|
||||
let staged_root = staged_dir.path().join(plugin_dir_name);
|
||||
let staged_version_root = staged_root.join(plugin_version);
|
||||
copy_dir_recursive(source, &staged_version_root)?;
|
||||
|
||||
if target_root.exists() {
|
||||
let backup_dir = tempfile::Builder::new()
|
||||
.prefix("plugin-backup-")
|
||||
.tempdir_in(parent)
|
||||
.map_err(|err| {
|
||||
PluginStoreError::io("failed to create plugin cache backup directory", err)
|
||||
})?;
|
||||
let backup_root = backup_dir.path().join(plugin_dir_name);
|
||||
fs::rename(target_root, &backup_root)
|
||||
.map_err(|err| PluginStoreError::io("failed to back up plugin cache entry", err))?;
|
||||
|
||||
if let Err(err) = fs::rename(&staged_root, target_root) {
|
||||
let rollback_result = fs::rename(&backup_root, target_root);
|
||||
return match rollback_result {
|
||||
Ok(()) => Err(PluginStoreError::io(
|
||||
"failed to activate updated plugin cache entry",
|
||||
err,
|
||||
)),
|
||||
Err(rollback_err) => {
|
||||
let backup_path = backup_dir.keep().join(plugin_dir_name);
|
||||
Err(PluginStoreError::Invalid(format!(
|
||||
"failed to activate updated plugin cache entry at {}: {err}; failed to restore previous cache entry (left at {}): {rollback_err}",
|
||||
target_root.display(),
|
||||
backup_path.display()
|
||||
)))
|
||||
}
|
||||
};
|
||||
}
|
||||
} else {
|
||||
fs::rename(&staged_root, target_root)
|
||||
.map_err(|err| PluginStoreError::io("failed to activate plugin cache entry", err))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn copy_dir_recursive(source: &Path, target: &Path) -> Result<(), PluginStoreError> {
|
||||
fs::create_dir_all(target)
|
||||
.map_err(|err| PluginStoreError::io("failed to create plugin target directory", err))?;
|
||||
|
||||
for entry in fs::read_dir(source)
|
||||
.map_err(|err| PluginStoreError::io("failed to read plugin source directory", err))?
|
||||
{
|
||||
let entry =
|
||||
entry.map_err(|err| PluginStoreError::io("failed to enumerate plugin source", err))?;
|
||||
let source_path = entry.path();
|
||||
let target_path = target.join(entry.file_name());
|
||||
let file_type = entry
|
||||
.file_type()
|
||||
.map_err(|err| PluginStoreError::io("failed to inspect plugin source entry", err))?;
|
||||
|
||||
if file_type.is_dir() {
|
||||
copy_dir_recursive(&source_path, &target_path)?;
|
||||
} else if file_type.is_file() {
|
||||
fs::copy(&source_path, &target_path)
|
||||
.map_err(|err| PluginStoreError::io("failed to copy plugin file", err))?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "store_tests.rs"]
|
||||
mod tests;
|
||||
@@ -1,310 +0,0 @@
|
||||
use super::*;
|
||||
use codex_plugin::PluginId;
|
||||
use pretty_assertions::assert_eq;
|
||||
use tempfile::tempdir;
|
||||
|
||||
fn write_plugin_with_version(
|
||||
root: &Path,
|
||||
dir_name: &str,
|
||||
manifest_name: &str,
|
||||
manifest_version: Option<&str>,
|
||||
) {
|
||||
let plugin_root = root.join(dir_name);
|
||||
fs::create_dir_all(plugin_root.join(".codex-plugin")).unwrap();
|
||||
fs::create_dir_all(plugin_root.join("skills")).unwrap();
|
||||
let version = manifest_version
|
||||
.map(|manifest_version| format!(r#","version":"{manifest_version}""#))
|
||||
.unwrap_or_default();
|
||||
fs::write(
|
||||
plugin_root.join(".codex-plugin/plugin.json"),
|
||||
format!(r#"{{"name":"{manifest_name}"{version}}}"#),
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(plugin_root.join("skills/SKILL.md"), "skill").unwrap();
|
||||
fs::write(plugin_root.join(".mcp.json"), r#"{"mcpServers":{}}"#).unwrap();
|
||||
}
|
||||
|
||||
fn write_plugin(root: &Path, dir_name: &str, manifest_name: &str) {
|
||||
write_plugin_with_version(
|
||||
root,
|
||||
dir_name,
|
||||
manifest_name,
|
||||
/*manifest_version*/ None,
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_copies_plugin_into_default_marketplace() {
|
||||
let tmp = tempdir().unwrap();
|
||||
write_plugin(tmp.path(), "sample-plugin", "sample-plugin");
|
||||
let plugin_id = PluginId::new("sample-plugin".to_string(), "debug".to_string()).unwrap();
|
||||
|
||||
let result = PluginStore::new(tmp.path().to_path_buf())
|
||||
.install(
|
||||
AbsolutePathBuf::try_from(tmp.path().join("sample-plugin")).unwrap(),
|
||||
plugin_id.clone(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let installed_path = tmp.path().join("plugins/cache/debug/sample-plugin/local");
|
||||
assert_eq!(
|
||||
result,
|
||||
PluginInstallResult {
|
||||
plugin_id,
|
||||
plugin_version: "local".to_string(),
|
||||
installed_path: AbsolutePathBuf::try_from(installed_path.clone()).unwrap(),
|
||||
}
|
||||
);
|
||||
assert!(installed_path.join(".codex-plugin/plugin.json").is_file());
|
||||
assert!(installed_path.join("skills/SKILL.md").is_file());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_uses_manifest_name_for_destination_and_key() {
|
||||
let tmp = tempdir().unwrap();
|
||||
write_plugin(tmp.path(), "source-dir", "manifest-name");
|
||||
let plugin_id = PluginId::new("manifest-name".to_string(), "market".to_string()).unwrap();
|
||||
|
||||
let result = PluginStore::new(tmp.path().to_path_buf())
|
||||
.install(
|
||||
AbsolutePathBuf::try_from(tmp.path().join("source-dir")).unwrap(),
|
||||
plugin_id.clone(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
result,
|
||||
PluginInstallResult {
|
||||
plugin_id,
|
||||
plugin_version: "local".to_string(),
|
||||
installed_path: AbsolutePathBuf::try_from(
|
||||
tmp.path().join("plugins/cache/market/manifest-name/local"),
|
||||
)
|
||||
.unwrap(),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_root_derives_path_from_key_and_version() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let store = PluginStore::new(tmp.path().to_path_buf());
|
||||
let plugin_id = PluginId::new("sample".to_string(), "debug".to_string()).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
store.plugin_root(&plugin_id, "local").as_path(),
|
||||
tmp.path().join("plugins/cache/debug/sample/local")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_with_version_uses_requested_cache_version() {
|
||||
let tmp = tempdir().unwrap();
|
||||
write_plugin(tmp.path(), "sample-plugin", "sample-plugin");
|
||||
let plugin_id =
|
||||
PluginId::new("sample-plugin".to_string(), "openai-curated".to_string()).unwrap();
|
||||
let plugin_version = "0123456789abcdef".to_string();
|
||||
|
||||
let result = PluginStore::new(tmp.path().to_path_buf())
|
||||
.install_with_version(
|
||||
AbsolutePathBuf::try_from(tmp.path().join("sample-plugin")).unwrap(),
|
||||
plugin_id.clone(),
|
||||
plugin_version.clone(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let installed_path = tmp.path().join(format!(
|
||||
"plugins/cache/openai-curated/sample-plugin/{plugin_version}"
|
||||
));
|
||||
assert_eq!(
|
||||
result,
|
||||
PluginInstallResult {
|
||||
plugin_id,
|
||||
plugin_version,
|
||||
installed_path: AbsolutePathBuf::try_from(installed_path.clone()).unwrap(),
|
||||
}
|
||||
);
|
||||
assert!(installed_path.join(".codex-plugin/plugin.json").is_file());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_uses_manifest_version_when_present() {
|
||||
let tmp = tempdir().unwrap();
|
||||
write_plugin_with_version(
|
||||
tmp.path(),
|
||||
"sample-plugin",
|
||||
"sample-plugin",
|
||||
Some("1.2.3-beta+7"),
|
||||
);
|
||||
let plugin_id = PluginId::new("sample-plugin".to_string(), "debug".to_string()).unwrap();
|
||||
|
||||
let result = PluginStore::new(tmp.path().to_path_buf())
|
||||
.install(
|
||||
AbsolutePathBuf::try_from(tmp.path().join("sample-plugin")).unwrap(),
|
||||
plugin_id.clone(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let installed_path = tmp
|
||||
.path()
|
||||
.join("plugins/cache/debug/sample-plugin/1.2.3-beta+7");
|
||||
assert_eq!(
|
||||
result,
|
||||
PluginInstallResult {
|
||||
plugin_id,
|
||||
plugin_version: "1.2.3-beta+7".to_string(),
|
||||
installed_path: AbsolutePathBuf::try_from(installed_path.clone()).unwrap(),
|
||||
}
|
||||
);
|
||||
assert!(installed_path.join(".codex-plugin/plugin.json").is_file());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_rejects_blank_manifest_version() {
|
||||
let tmp = tempdir().unwrap();
|
||||
write_plugin_with_version(tmp.path(), "sample-plugin", "sample-plugin", Some(" "));
|
||||
let plugin_id = PluginId::new("sample-plugin".to_string(), "debug".to_string()).unwrap();
|
||||
|
||||
let err = PluginStore::new(tmp.path().to_path_buf())
|
||||
.install(
|
||||
AbsolutePathBuf::try_from(tmp.path().join("sample-plugin")).unwrap(),
|
||||
plugin_id,
|
||||
)
|
||||
.expect_err("blank manifest version should be rejected");
|
||||
let err = err.to_string().replace('\\', "/");
|
||||
|
||||
assert!(
|
||||
err.starts_with("invalid plugin version in manifest "),
|
||||
"unexpected error: {err}"
|
||||
);
|
||||
assert!(
|
||||
err.ends_with("sample-plugin/.codex-plugin/plugin.json: must not be blank"),
|
||||
"unexpected error: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn active_plugin_version_reads_version_directory_name() {
|
||||
let tmp = tempdir().unwrap();
|
||||
write_plugin(
|
||||
&tmp.path().join("plugins/cache/debug"),
|
||||
"sample-plugin/local",
|
||||
"sample-plugin",
|
||||
);
|
||||
let store = PluginStore::new(tmp.path().to_path_buf());
|
||||
let plugin_id = PluginId::new("sample-plugin".to_string(), "debug".to_string()).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
store.active_plugin_version(&plugin_id),
|
||||
Some("local".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
store.active_plugin_root(&plugin_id).unwrap().as_path(),
|
||||
tmp.path().join("plugins/cache/debug/sample-plugin/local")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn active_plugin_version_prefers_default_local_version_when_multiple_versions_exist() {
|
||||
let tmp = tempdir().unwrap();
|
||||
write_plugin(
|
||||
&tmp.path().join("plugins/cache/debug"),
|
||||
"sample-plugin/0123456789abcdef",
|
||||
"sample-plugin",
|
||||
);
|
||||
write_plugin(
|
||||
&tmp.path().join("plugins/cache/debug"),
|
||||
"sample-plugin/local",
|
||||
"sample-plugin",
|
||||
);
|
||||
let store = PluginStore::new(tmp.path().to_path_buf());
|
||||
let plugin_id = PluginId::new("sample-plugin".to_string(), "debug".to_string()).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
store.active_plugin_version(&plugin_id),
|
||||
Some("local".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn active_plugin_version_returns_last_sorted_version_when_default_is_missing() {
|
||||
let tmp = tempdir().unwrap();
|
||||
write_plugin(
|
||||
&tmp.path().join("plugins/cache/debug"),
|
||||
"sample-plugin/0123456789abcdef",
|
||||
"sample-plugin",
|
||||
);
|
||||
write_plugin(
|
||||
&tmp.path().join("plugins/cache/debug"),
|
||||
"sample-plugin/fedcba9876543210",
|
||||
"sample-plugin",
|
||||
);
|
||||
let store = PluginStore::new(tmp.path().to_path_buf());
|
||||
let plugin_id = PluginId::new("sample-plugin".to_string(), "debug".to_string()).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
store.active_plugin_version(&plugin_id),
|
||||
Some("fedcba9876543210".to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_root_rejects_path_separators_in_key_segments() {
|
||||
let err = PluginId::parse("../../etc@debug").unwrap_err();
|
||||
assert_eq!(
|
||||
err.to_string(),
|
||||
"invalid plugin name: only ASCII letters, digits, `_`, and `-` are allowed in `../../etc@debug`"
|
||||
);
|
||||
|
||||
let err = PluginId::parse("sample@../../etc").unwrap_err();
|
||||
assert_eq!(
|
||||
err.to_string(),
|
||||
"invalid marketplace name: only ASCII letters, digits, `_`, and `-` are allowed in `sample@../../etc`"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_rejects_manifest_names_with_path_separators() {
|
||||
let tmp = tempdir().unwrap();
|
||||
write_plugin(tmp.path(), "source-dir", "../../etc");
|
||||
|
||||
let err = PluginStore::new(tmp.path().to_path_buf())
|
||||
.install(
|
||||
AbsolutePathBuf::try_from(tmp.path().join("source-dir")).unwrap(),
|
||||
PluginId::new("source-dir".to_string(), "debug".to_string()).unwrap(),
|
||||
)
|
||||
.unwrap_err();
|
||||
|
||||
assert_eq!(
|
||||
err.to_string(),
|
||||
"invalid plugin name: only ASCII letters, digits, `_`, and `-` are allowed"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_rejects_marketplace_names_with_path_separators() {
|
||||
let err = PluginId::new("sample-plugin".to_string(), "../../etc".to_string()).unwrap_err();
|
||||
|
||||
assert_eq!(
|
||||
err.to_string(),
|
||||
"invalid marketplace name: only ASCII letters, digits, `_`, and `-` are allowed"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn install_rejects_manifest_names_that_do_not_match_marketplace_plugin_name() {
|
||||
let tmp = tempdir().unwrap();
|
||||
write_plugin(tmp.path(), "source-dir", "manifest-name");
|
||||
|
||||
let err = PluginStore::new(tmp.path().to_path_buf())
|
||||
.install(
|
||||
AbsolutePathBuf::try_from(tmp.path().join("source-dir")).unwrap(),
|
||||
PluginId::new("different-name".to_string(), "debug".to_string()).unwrap(),
|
||||
)
|
||||
.unwrap_err();
|
||||
|
||||
assert_eq!(
|
||||
err.to_string(),
|
||||
"plugin manifest name `manifest-name` does not match marketplace plugin name `different-name`"
|
||||
);
|
||||
}
|
||||
@@ -1,100 +0,0 @@
|
||||
use serde_json::Value as JsonValue;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
pub fn collect_plugin_enabled_candidates<'a>(
|
||||
edits: impl Iterator<Item = (&'a String, &'a JsonValue)>,
|
||||
) -> BTreeMap<String, bool> {
|
||||
let mut pending_changes = BTreeMap::new();
|
||||
for (key_path, value) in edits {
|
||||
let segments = key_path
|
||||
.split('.')
|
||||
.map(str::to_string)
|
||||
.collect::<Vec<String>>();
|
||||
match segments.as_slice() {
|
||||
[plugins, plugin_id, enabled]
|
||||
if plugins == "plugins" && enabled == "enabled" && value.is_boolean() =>
|
||||
{
|
||||
if let Some(enabled) = value.as_bool() {
|
||||
pending_changes.insert(plugin_id.clone(), enabled);
|
||||
}
|
||||
}
|
||||
[plugins, plugin_id] if plugins == "plugins" => {
|
||||
if let Some(enabled) = value.get("enabled").and_then(JsonValue::as_bool) {
|
||||
pending_changes.insert(plugin_id.clone(), enabled);
|
||||
}
|
||||
}
|
||||
[plugins] if plugins == "plugins" => {
|
||||
let Some(entries) = value.as_object() else {
|
||||
continue;
|
||||
};
|
||||
for (plugin_id, plugin_value) in entries {
|
||||
let Some(enabled) = plugin_value.get("enabled").and_then(JsonValue::as_bool)
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
pending_changes.insert(plugin_id.clone(), enabled);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
pending_changes
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::collect_plugin_enabled_candidates;
|
||||
use pretty_assertions::assert_eq;
|
||||
use serde_json::json;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
#[test]
|
||||
fn collect_plugin_enabled_candidates_tracks_direct_and_table_writes() {
|
||||
let candidates = collect_plugin_enabled_candidates(
|
||||
[
|
||||
(&"plugins.sample@test.enabled".to_string(), &json!(true)),
|
||||
(
|
||||
&"plugins.other@test".to_string(),
|
||||
&json!({ "enabled": false, "ignored": true }),
|
||||
),
|
||||
(
|
||||
&"plugins".to_string(),
|
||||
&json!({
|
||||
"nested@test": { "enabled": true },
|
||||
"skip@test": { "name": "skip" },
|
||||
}),
|
||||
),
|
||||
]
|
||||
.into_iter(),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
candidates,
|
||||
BTreeMap::from([
|
||||
("nested@test".to_string(), true),
|
||||
("other@test".to_string(), false),
|
||||
("sample@test".to_string(), true),
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_plugin_enabled_candidates_uses_last_write_for_same_plugin() {
|
||||
let candidates = collect_plugin_enabled_candidates(
|
||||
[
|
||||
(&"plugins.sample@test.enabled".to_string(), &json!(true)),
|
||||
(
|
||||
&"plugins.sample@test".to_string(),
|
||||
&json!({ "enabled": false }),
|
||||
),
|
||||
]
|
||||
.into_iter(),
|
||||
);
|
||||
|
||||
assert_eq!(
|
||||
candidates,
|
||||
BTreeMap::from([("sample@test".to_string(), false)])
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,325 +0,0 @@
|
||||
//! Project-level documentation discovery.
|
||||
//!
|
||||
//! Project-level documentation is primarily stored in files named `AGENTS.md`.
|
||||
//! Additional fallback filenames can be configured via `project_doc_fallback_filenames`.
|
||||
//! We include the concatenation of all files found along the path from the
|
||||
//! project root to the current working directory as follows:
|
||||
//!
|
||||
//! 1. Determine the project root by walking upwards from the current working
|
||||
//! directory until a configured `project_root_markers` entry is found.
|
||||
//! When `project_root_markers` is unset, the default marker list is used
|
||||
//! (`.git`). If no marker is found, only the current working directory is
|
||||
//! considered. An empty marker list disables parent traversal.
|
||||
//! 2. Collect every `AGENTS.md` found from the project root down to the
|
||||
//! current working directory (inclusive) and concatenate their contents in
|
||||
//! that order.
|
||||
//! 3. We do **not** walk past the project root.
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::config_loader::ConfigLayerStackOrdering;
|
||||
use crate::config_loader::default_project_root_markers;
|
||||
use crate::config_loader::merge_toml_values;
|
||||
use crate::config_loader::project_root_markers_from_config;
|
||||
use codex_app_server_protocol::ConfigLayerSource;
|
||||
use codex_exec_server::Environment;
|
||||
use codex_exec_server::ExecutorFileSystem;
|
||||
use codex_features::Feature;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use dunce::canonicalize as normalize_path;
|
||||
use std::io;
|
||||
use toml::Value as TomlValue;
|
||||
use tracing::error;
|
||||
|
||||
pub(crate) const HIERARCHICAL_AGENTS_MESSAGE: &str =
|
||||
include_str!("../hierarchical_agents_message.md");
|
||||
|
||||
/// Default filename scanned for project-level docs.
|
||||
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";
|
||||
|
||||
/// When both `Config::instructions` and the project doc are present, they will
|
||||
/// be concatenated with the following separator.
|
||||
const PROJECT_DOC_SEPARATOR: &str = "\n\n--- project-doc ---\n\n";
|
||||
|
||||
fn render_js_repl_instructions(config: &Config) -> Option<String> {
|
||||
if !config.features.enabled(Feature::JsRepl) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut section = String::from("## JavaScript REPL (Node)\n");
|
||||
section.push_str(
|
||||
"- Use `js_repl` for Node-backed JavaScript with top-level await in a persistent kernel.\n",
|
||||
);
|
||||
section.push_str("- `js_repl` is a freeform/custom tool. Direct `js_repl` calls must send raw JavaScript tool input (optionally with first-line `// codex-js-repl: timeout_ms=15000`). Do not wrap code in JSON (for example `{\"code\":\"...\"}`), quotes, or markdown code fences.\n");
|
||||
section.push_str(
|
||||
"- Helpers: `codex.cwd`, `codex.homeDir`, `codex.tmpDir`, `codex.tool(name, args?)`, and `codex.emitImage(imageLike)`.\n",
|
||||
);
|
||||
section.push_str("- `codex.tool` executes a normal tool call and resolves to the raw tool output object. Use it for shell and non-shell tools alike. Nested tool outputs stay inside JavaScript unless you emit them explicitly.\n");
|
||||
section.push_str("- `codex.emitImage(...)` adds one image to the outer `js_repl` function output each time you call it, so you can call it multiple times to emit multiple images. It accepts a data URL, a single `input_image` item, an object like `{ bytes, mimeType }`, or a raw tool response object with exactly one image and no text. It rejects mixed text-and-image content.\n");
|
||||
section.push_str("- `codex.tool(...)` and `codex.emitImage(...)` keep stable helper identities across cells. Saved references and persisted objects can reuse them in later cells, but async callbacks that fire after a cell finishes still fail because no exec is active.\n");
|
||||
section.push_str("- Request full-resolution image processing with `detail: \"original\"` only when the `view_image` tool schema includes a `detail` argument. The same availability applies to `codex.emitImage(...)`: if `view_image.detail` is present, you may also pass `detail: \"original\"` there. Use this when high-fidelity image perception or precise localization is needed, especially for CUA agents.\n");
|
||||
section.push_str("- Example of sharing an in-memory Playwright screenshot: `await codex.emitImage({ bytes: await page.screenshot({ type: \"jpeg\", quality: 85 }), mimeType: \"image/jpeg\", detail: \"original\" })`.\n");
|
||||
section.push_str("- Example of sharing a local image tool result: `await codex.emitImage(codex.tool(\"view_image\", { path: \"/absolute/path\", detail: \"original\" }))`.\n");
|
||||
section.push_str("- When encoding an image to send with `codex.emitImage(...)` or `view_image`, prefer JPEG at about 85 quality when lossy compression is acceptable; use PNG when transparency or lossless detail matters. Smaller uploads are faster and less likely to hit size limits.\n");
|
||||
section.push_str("- Top-level bindings persist across cells. If a cell throws, prior bindings remain available and bindings that finished initializing before the throw often remain usable in later cells. For code you plan to reuse across cells, prefer declaring or assigning it in direct top-level statements before operations that might throw. If you hit `SyntaxError: Identifier 'x' has already been declared`, first reuse the existing binding, reassign a previously declared `let`, or pick a new descriptive name. Use `{ ... }` only for a short temporary block when you specifically need local scratch names; do not wrap an entire cell in block scope if you want those names reusable later. Reset the kernel with `js_repl_reset` only when you need a clean state.\n");
|
||||
section.push_str("- Top-level static import declarations (for example `import x from \"./file.js\"`) are currently unsupported in `js_repl`; use dynamic imports with `await import(\"pkg\")`, `await import(\"./file.js\")`, or `await import(\"/abs/path/file.mjs\")` instead. Imported local files must be ESM `.js`/`.mjs` files and run in the same REPL VM context. Bare package imports always resolve from REPL-global search roots (`CODEX_JS_REPL_NODE_MODULE_DIRS`, then cwd), not relative to the imported file location. Local files may statically import only other local relative/absolute/`file://` `.js`/`.mjs` files; package and builtin imports from local files must stay dynamic. `import.meta.resolve()` returns importable strings such as `file://...`, bare package names, and `node:...` specifiers. Local file modules reload between execs, while top-level bindings persist until `js_repl_reset`.\n");
|
||||
|
||||
if config.features.enabled(Feature::JsReplToolsOnly) {
|
||||
section.push_str("- Do not call tools directly; use `js_repl` + `codex.tool(...)` for all tool calls, including shell commands.\n");
|
||||
section
|
||||
.push_str("- MCP tools (if any) can also be called by name via `codex.tool(...)`.\n");
|
||||
}
|
||||
|
||||
section.push_str("- Avoid direct access to `process.stdout` / `process.stderr` / `process.stdin`; it can corrupt the JSON line protocol. Use `console.log`, `codex.tool(...)`, and `codex.emitImage(...)`.");
|
||||
|
||||
Some(section)
|
||||
}
|
||||
|
||||
/// Combines `Config::instructions` and `AGENTS.md` (if present) into a single
|
||||
/// string of instructions.
|
||||
pub(crate) async fn get_user_instructions(
|
||||
config: &Config,
|
||||
environment: Option<&Environment>,
|
||||
) -> Option<String> {
|
||||
let fs = environment?.get_filesystem();
|
||||
get_user_instructions_with_fs(config, fs.as_ref()).await
|
||||
}
|
||||
|
||||
pub(crate) async fn get_user_instructions_with_fs(
|
||||
config: &Config,
|
||||
fs: &dyn ExecutorFileSystem,
|
||||
) -> Option<String> {
|
||||
let project_docs = read_project_docs_with_fs(config, fs).await;
|
||||
|
||||
let mut output = String::new();
|
||||
|
||||
if let Some(instructions) = config.user_instructions.clone() {
|
||||
output.push_str(&instructions);
|
||||
}
|
||||
|
||||
match project_docs {
|
||||
Ok(Some(docs)) => {
|
||||
if !output.is_empty() {
|
||||
output.push_str(PROJECT_DOC_SEPARATOR);
|
||||
}
|
||||
output.push_str(&docs);
|
||||
}
|
||||
Ok(None) => {}
|
||||
Err(e) => {
|
||||
error!("error trying to find project doc: {e:#}");
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(js_repl_section) = render_js_repl_instructions(config) {
|
||||
if !output.is_empty() {
|
||||
output.push_str("\n\n");
|
||||
}
|
||||
output.push_str(&js_repl_section);
|
||||
}
|
||||
|
||||
if config.features.enabled(Feature::ChildAgentsMd) {
|
||||
if !output.is_empty() {
|
||||
output.push_str("\n\n");
|
||||
}
|
||||
output.push_str(HIERARCHICAL_AGENTS_MESSAGE);
|
||||
}
|
||||
|
||||
if !output.is_empty() {
|
||||
Some(output)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Attempt to locate and load the project documentation.
|
||||
///
|
||||
/// On success returns `Ok(Some(contents))` where `contents` is the
|
||||
/// concatenation of all discovered docs. If no documentation file is found the
|
||||
/// function returns `Ok(None)`. Unexpected I/O failures bubble up as `Err` so
|
||||
/// callers can decide how to handle them.
|
||||
pub async fn read_project_docs(
|
||||
config: &Config,
|
||||
environment: &Environment,
|
||||
) -> io::Result<Option<String>> {
|
||||
let fs = environment.get_filesystem();
|
||||
read_project_docs_with_fs(config, fs.as_ref()).await
|
||||
}
|
||||
|
||||
async fn read_project_docs_with_fs(
|
||||
config: &Config,
|
||||
fs: &dyn ExecutorFileSystem,
|
||||
) -> io::Result<Option<String>> {
|
||||
let max_total = config.project_doc_max_bytes;
|
||||
|
||||
if max_total == 0 {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let paths = discover_project_doc_paths(config, fs).await?;
|
||||
if paths.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let mut remaining: u64 = max_total as u64;
|
||||
let mut parts: Vec<String> = Vec::new();
|
||||
|
||||
for p in paths {
|
||||
if remaining == 0 {
|
||||
break;
|
||||
}
|
||||
|
||||
match fs.get_metadata(&p, /*sandbox*/ None).await {
|
||||
Ok(metadata) if !metadata.is_file => continue,
|
||||
Ok(_) => {}
|
||||
Err(err) if err.kind() == io::ErrorKind::NotFound => continue,
|
||||
Err(err) => return Err(err),
|
||||
}
|
||||
|
||||
let mut data = match fs.read_file(&p, /*sandbox*/ None).await {
|
||||
Ok(data) => data,
|
||||
Err(err) if err.kind() == io::ErrorKind::NotFound => continue,
|
||||
Err(err) => return Err(err),
|
||||
};
|
||||
let size = data.len() as u64;
|
||||
if size > remaining {
|
||||
data.truncate(remaining as usize);
|
||||
}
|
||||
|
||||
if size > remaining {
|
||||
tracing::warn!(
|
||||
"Project doc `{}` exceeds remaining budget ({} bytes) - truncating.",
|
||||
p.display(),
|
||||
remaining,
|
||||
);
|
||||
}
|
||||
|
||||
let text = String::from_utf8_lossy(&data).to_string();
|
||||
if !text.trim().is_empty() {
|
||||
parts.push(text);
|
||||
remaining = remaining.saturating_sub(data.len() as u64);
|
||||
}
|
||||
}
|
||||
|
||||
if parts.is_empty() {
|
||||
Ok(None)
|
||||
} else {
|
||||
Ok(Some(parts.join("\n\n")))
|
||||
}
|
||||
}
|
||||
|
||||
/// Discover the list of AGENTS.md files using the same search rules as
|
||||
/// `read_project_docs`, but return the file paths instead of concatenated
|
||||
/// contents. The list is ordered from project root to the current working
|
||||
/// directory (inclusive). Symlinks are allowed. When `project_doc_max_bytes`
|
||||
/// is zero, returns an empty list.
|
||||
pub async fn discover_project_doc_paths(
|
||||
config: &Config,
|
||||
fs: &dyn ExecutorFileSystem,
|
||||
) -> io::Result<Vec<AbsolutePathBuf>> {
|
||||
if config.project_doc_max_bytes == 0 {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let mut dir = config.cwd.clone();
|
||||
if let Ok(canon) = normalize_path(&dir) {
|
||||
dir = AbsolutePathBuf::try_from(canon)?;
|
||||
}
|
||||
|
||||
let mut merged = TomlValue::Table(toml::map::Map::new());
|
||||
for layer in config.config_layer_stack.get_layers(
|
||||
ConfigLayerStackOrdering::LowestPrecedenceFirst,
|
||||
/*include_disabled*/ false,
|
||||
) {
|
||||
if matches!(layer.name, ConfigLayerSource::Project { .. }) {
|
||||
continue;
|
||||
}
|
||||
merge_toml_values(&mut merged, &layer.config);
|
||||
}
|
||||
let project_root_markers = match project_root_markers_from_config(&merged) {
|
||||
Ok(Some(markers)) => markers,
|
||||
Ok(None) => default_project_root_markers(),
|
||||
Err(err) => {
|
||||
tracing::warn!("invalid project_root_markers: {err}");
|
||||
default_project_root_markers()
|
||||
}
|
||||
};
|
||||
let mut project_root = None;
|
||||
if !project_root_markers.is_empty() {
|
||||
for ancestor in dir.ancestors() {
|
||||
for marker in &project_root_markers {
|
||||
let marker_path = ancestor.join(marker);
|
||||
let marker_exists = match fs.get_metadata(&marker_path, /*sandbox*/ None).await {
|
||||
Ok(_) => true,
|
||||
Err(err) if err.kind() == io::ErrorKind::NotFound => false,
|
||||
Err(err) => return Err(err),
|
||||
};
|
||||
if marker_exists {
|
||||
project_root = Some(ancestor.clone());
|
||||
break;
|
||||
}
|
||||
}
|
||||
if project_root.is_some() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let search_dirs: Vec<AbsolutePathBuf> = if let Some(root) = project_root {
|
||||
let mut dirs = Vec::new();
|
||||
let mut cursor = dir.clone();
|
||||
loop {
|
||||
dirs.push(cursor.clone());
|
||||
if cursor == root {
|
||||
break;
|
||||
}
|
||||
let Some(parent) = cursor.parent() else {
|
||||
break;
|
||||
};
|
||||
cursor = parent;
|
||||
}
|
||||
dirs.reverse();
|
||||
dirs
|
||||
} else {
|
||||
vec![dir]
|
||||
};
|
||||
|
||||
let mut found: Vec<AbsolutePathBuf> = Vec::new();
|
||||
let candidate_filenames = candidate_filenames(config);
|
||||
for d in search_dirs {
|
||||
for name in &candidate_filenames {
|
||||
let candidate = d.join(name);
|
||||
match fs.get_metadata(&candidate, /*sandbox*/ None).await {
|
||||
Ok(md) if md.is_file => {
|
||||
found.push(candidate);
|
||||
break;
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(err) if err.kind() == io::ErrorKind::NotFound => continue,
|
||||
Err(err) => return Err(err),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(found)
|
||||
}
|
||||
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());
|
||||
names.push(LOCAL_PROJECT_DOC_FILENAME);
|
||||
names.push(DEFAULT_PROJECT_DOC_FILENAME);
|
||||
for candidate in &config.project_doc_fallback_filenames {
|
||||
let candidate = candidate.as_str();
|
||||
if candidate.is_empty() {
|
||||
continue;
|
||||
}
|
||||
if !names.contains(&candidate) {
|
||||
names.push(candidate);
|
||||
}
|
||||
}
|
||||
names
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "project_doc_tests.rs"]
|
||||
mod tests;
|
||||
@@ -106,7 +106,7 @@ mod tests {
|
||||
async fn build_prompt_input_includes_context_and_user_message() {
|
||||
let codex_home = tempfile::tempdir().expect("create codex home");
|
||||
let cwd = tempfile::tempdir().expect("create cwd");
|
||||
let mut config = test_config();
|
||||
let mut config = test_config().await;
|
||||
config.codex_home =
|
||||
AbsolutePathBuf::from_absolute_path(codex_home.path()).expect("codex home is absolute");
|
||||
config.cwd = AbsolutePathBuf::try_from(cwd.path().to_path_buf()).expect("absolute cwd");
|
||||
|
||||
@@ -4,8 +4,10 @@ use crate::event_mapping::is_contextual_user_message_content;
|
||||
use chrono::Utc;
|
||||
use codex_git_utils::resolve_root_git_project_for_trust;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_state::SortKey;
|
||||
use codex_state::ThreadMetadata;
|
||||
use codex_thread_store::ListThreadsParams;
|
||||
use codex_thread_store::StoredThread;
|
||||
use codex_thread_store::ThreadSortKey;
|
||||
use codex_thread_store::ThreadStore;
|
||||
use codex_utils_output_truncation::TruncationPolicy;
|
||||
use codex_utils_output_truncation::truncate_text;
|
||||
use dirs::home_dir;
|
||||
@@ -23,6 +25,8 @@ use tracing::info;
|
||||
use tracing::warn;
|
||||
|
||||
const STARTUP_CONTEXT_HEADER: &str = "Startup context from Codex.\nThis is background context about recent work and machine/workspace layout. It may be incomplete or stale. Use it to inform responses, and do not repeat it back unless relevant.";
|
||||
const STARTUP_CONTEXT_OPEN_TAG: &str = "<startup_context>";
|
||||
const STARTUP_CONTEXT_CLOSE_TAG: &str = "</startup_context>";
|
||||
const CURRENT_THREAD_SECTION_TOKEN_BUDGET: usize = 1_200;
|
||||
const RECENT_WORK_SECTION_TOKEN_BUDGET: usize = 2_200;
|
||||
const WORKSPACE_SECTION_TOKEN_BUDGET: usize = 1_600;
|
||||
@@ -58,8 +62,8 @@ pub(crate) async fn build_realtime_startup_context(
|
||||
let history = sess.clone_history().await;
|
||||
let current_thread_section = build_current_thread_section(history.raw_items());
|
||||
let recent_threads = load_recent_threads(sess).await;
|
||||
let recent_work_section = build_recent_work_section(&cwd, &recent_threads);
|
||||
let workspace_section = build_workspace_section_with_user_root(&cwd, home_dir());
|
||||
let recent_work_section = build_recent_work_section(&cwd, &recent_threads).await;
|
||||
let workspace_section = build_workspace_section_with_user_root(&cwd, home_dir()).await;
|
||||
|
||||
if current_thread_section.is_none()
|
||||
&& recent_work_section.is_none()
|
||||
@@ -98,13 +102,13 @@ pub(crate) async fn build_realtime_startup_context(
|
||||
}
|
||||
if let Some(section) = format_section(
|
||||
"Notes",
|
||||
Some("Built at realtime startup from the current thread history, persisted thread metadata in the state DB, and a bounded local workspace scan. This excludes repo memory instructions, AGENTS files, project-doc prompt blends, and memory summaries.".to_string()),
|
||||
Some("Built at realtime startup from the current thread history, local thread metadata, and a bounded local workspace scan. This excludes repo memory instructions, AGENTS files, project-doc prompt blends, and memory summaries.".to_string()),
|
||||
NOTES_SECTION_TOKEN_BUDGET,
|
||||
) {
|
||||
parts.push(section);
|
||||
}
|
||||
|
||||
let context = truncate_text(&parts.join("\n\n"), TruncationPolicy::Tokens(budget_tokens));
|
||||
let context = format_startup_context_blob(&parts.join("\n\n"), budget_tokens);
|
||||
debug!(
|
||||
approx_tokens = approx_token_count(&context),
|
||||
bytes = context.len(),
|
||||
@@ -117,41 +121,41 @@ pub(crate) async fn build_realtime_startup_context(
|
||||
Some(context)
|
||||
}
|
||||
|
||||
async fn load_recent_threads(sess: &Session) -> Vec<ThreadMetadata> {
|
||||
let Some(state_db) = sess.services.state_db.as_ref() else {
|
||||
return Vec::new();
|
||||
};
|
||||
|
||||
match state_db
|
||||
.list_threads(
|
||||
MAX_RECENT_THREADS,
|
||||
/*anchor*/ None,
|
||||
SortKey::UpdatedAt,
|
||||
&[],
|
||||
/*model_providers*/ None,
|
||||
/*archived_only*/ false,
|
||||
/*search_term*/ None,
|
||||
)
|
||||
async fn load_recent_threads(sess: &Session) -> Vec<StoredThread> {
|
||||
match sess
|
||||
.services
|
||||
.thread_store
|
||||
.list_threads(ListThreadsParams {
|
||||
page_size: MAX_RECENT_THREADS,
|
||||
cursor: None,
|
||||
sort_key: ThreadSortKey::UpdatedAt,
|
||||
allowed_sources: Vec::new(),
|
||||
model_providers: None,
|
||||
archived: false,
|
||||
search_term: None,
|
||||
})
|
||||
.await
|
||||
{
|
||||
Ok(page) => page.items,
|
||||
Err(err) => {
|
||||
warn!("failed to load realtime startup threads from state db: {err}");
|
||||
warn!("failed to load realtime startup threads from thread store: {err}");
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn build_recent_work_section(cwd: &Path, recent_threads: &[ThreadMetadata]) -> Option<String> {
|
||||
let mut groups: HashMap<PathBuf, Vec<&ThreadMetadata>> = HashMap::new();
|
||||
async fn build_recent_work_section(cwd: &Path, recent_threads: &[StoredThread]) -> Option<String> {
|
||||
let mut groups: HashMap<PathBuf, Vec<&StoredThread>> = HashMap::new();
|
||||
for entry in recent_threads {
|
||||
let group =
|
||||
resolve_root_git_project_for_trust(&entry.cwd).unwrap_or_else(|| entry.cwd.clone());
|
||||
let group = resolve_root_git_project_for_trust(&entry.cwd)
|
||||
.await
|
||||
.unwrap_or_else(|| entry.cwd.clone());
|
||||
groups.entry(group).or_default().push(entry);
|
||||
}
|
||||
|
||||
let current_group =
|
||||
resolve_root_git_project_for_trust(cwd).unwrap_or_else(|| cwd.to_path_buf());
|
||||
let current_group = resolve_root_git_project_for_trust(cwd)
|
||||
.await
|
||||
.unwrap_or_else(|| cwd.to_path_buf());
|
||||
let mut groups = groups.into_iter().collect::<Vec<_>>();
|
||||
groups.sort_by(|(left_group, left_entries), (right_group, right_entries)| {
|
||||
let left_latest = left_entries
|
||||
@@ -176,14 +180,13 @@ fn build_recent_work_section(cwd: &Path, recent_threads: &[ThreadMetadata]) -> O
|
||||
))
|
||||
});
|
||||
|
||||
let sections = groups
|
||||
.into_iter()
|
||||
.take(MAX_RECENT_WORK_GROUPS)
|
||||
.filter_map(|(group, mut entries)| {
|
||||
entries.sort_by_key(|entry| Reverse(entry.updated_at));
|
||||
format_thread_group(¤t_group, &group, entries)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let mut sections = Vec::new();
|
||||
for (group, mut entries) in groups.into_iter().take(MAX_RECENT_WORK_GROUPS) {
|
||||
entries.sort_by_key(|entry| Reverse(entry.updated_at));
|
||||
if let Some(section) = format_thread_group(¤t_group, &group, entries).await {
|
||||
sections.push(section);
|
||||
}
|
||||
}
|
||||
(!sections.is_empty()).then(|| sections.join("\n\n"))
|
||||
}
|
||||
|
||||
@@ -304,11 +307,11 @@ pub(crate) fn truncate_realtime_text_to_token_budget(text: &str, budget_tokens:
|
||||
}
|
||||
}
|
||||
|
||||
fn build_workspace_section_with_user_root(
|
||||
async fn build_workspace_section_with_user_root(
|
||||
cwd: &Path,
|
||||
user_root: Option<PathBuf>,
|
||||
) -> Option<String> {
|
||||
let git_root = resolve_root_git_project_for_trust(cwd);
|
||||
let git_root = resolve_root_git_project_for_trust(cwd).await;
|
||||
let cwd_tree = render_tree(cwd);
|
||||
let git_root_tree = git_root
|
||||
.as_ref()
|
||||
@@ -443,13 +446,37 @@ fn format_section(title: &str, body: Option<String>, budget_tokens: usize) -> Op
|
||||
))
|
||||
}
|
||||
|
||||
fn format_thread_group(
|
||||
fn format_startup_context_blob(body: &str, budget_tokens: usize) -> String {
|
||||
let wrapper = format!("{STARTUP_CONTEXT_OPEN_TAG}\n\n{STARTUP_CONTEXT_CLOSE_TAG}");
|
||||
let mut body_budget = budget_tokens.saturating_sub(approx_token_count(&wrapper));
|
||||
|
||||
loop {
|
||||
let body = truncate_text(body, TruncationPolicy::Tokens(body_budget));
|
||||
let wrapped = format!("{STARTUP_CONTEXT_OPEN_TAG}\n{body}\n{STARTUP_CONTEXT_CLOSE_TAG}");
|
||||
let wrapped_tokens = approx_token_count(&wrapped);
|
||||
if wrapped_tokens <= budget_tokens || body_budget == 0 {
|
||||
return wrapped;
|
||||
}
|
||||
|
||||
let excess_tokens = wrapped_tokens.saturating_sub(budget_tokens);
|
||||
let next_budget = body_budget.saturating_sub(excess_tokens.max(1));
|
||||
if next_budget == body_budget {
|
||||
return wrapped;
|
||||
}
|
||||
body_budget = next_budget;
|
||||
}
|
||||
}
|
||||
|
||||
async fn format_thread_group(
|
||||
current_group: &Path,
|
||||
group: &Path,
|
||||
entries: Vec<&ThreadMetadata>,
|
||||
entries: Vec<&StoredThread>,
|
||||
) -> Option<String> {
|
||||
let latest = entries.first()?;
|
||||
let group_label = if resolve_root_git_project_for_trust(latest.cwd.as_path()).is_some() {
|
||||
let group_label = if resolve_root_git_project_for_trust(latest.cwd.as_path())
|
||||
.await
|
||||
.is_some()
|
||||
{
|
||||
format!("### Git repo: {}", group.display())
|
||||
} else {
|
||||
format!("### Directory: {}", group.display())
|
||||
@@ -461,8 +488,9 @@ fn format_thread_group(
|
||||
];
|
||||
|
||||
if let Some(git_branch) = latest
|
||||
.git_branch
|
||||
.as_deref()
|
||||
.git_info
|
||||
.as_ref()
|
||||
.and_then(|git| git.branch.as_deref())
|
||||
.filter(|git_branch| !git_branch.is_empty())
|
||||
{
|
||||
lines.push(format!("Latest branch: {git_branch}"));
|
||||
|
||||
@@ -1,22 +1,34 @@
|
||||
use super::build_current_thread_section;
|
||||
use super::build_recent_work_section;
|
||||
use super::build_workspace_section_with_user_root;
|
||||
use super::format_startup_context_blob;
|
||||
use chrono::TimeZone;
|
||||
use chrono::Utc;
|
||||
use codex_git_utils::GitSha;
|
||||
use codex_protocol::ThreadId;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_state::ThreadMetadata;
|
||||
use codex_protocol::protocol::AskForApproval;
|
||||
use codex_protocol::protocol::GitInfo;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use codex_protocol::protocol::SessionSource;
|
||||
use codex_thread_store::StoredThread;
|
||||
use pretty_assertions::assert_eq;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
use tempfile::TempDir;
|
||||
|
||||
fn thread_metadata(cwd: &str, title: &str, first_user_message: &str) -> ThreadMetadata {
|
||||
ThreadMetadata {
|
||||
id: ThreadId::new(),
|
||||
rollout_path: PathBuf::from("/tmp/rollout.jsonl"),
|
||||
fn stored_thread(cwd: &str, title: &str, first_user_message: &str) -> StoredThread {
|
||||
StoredThread {
|
||||
thread_id: ThreadId::new(),
|
||||
rollout_path: Some(PathBuf::from("/tmp/rollout.jsonl")),
|
||||
forked_from_id: None,
|
||||
preview: first_user_message.to_string(),
|
||||
name: (!title.is_empty()).then(|| title.to_string()),
|
||||
model_provider: "test-provider".to_string(),
|
||||
model: Some("gpt-5".to_string()),
|
||||
reasoning_effort: None,
|
||||
created_at: Utc
|
||||
.timestamp_opt(1_709_251_100, 0)
|
||||
.single()
|
||||
@@ -25,24 +37,23 @@ fn thread_metadata(cwd: &str, title: &str, first_user_message: &str) -> ThreadMe
|
||||
.timestamp_opt(1_709_251_200, 0)
|
||||
.single()
|
||||
.expect("valid timestamp"),
|
||||
source: "cli".to_string(),
|
||||
agent_path: None,
|
||||
agent_nickname: None,
|
||||
agent_role: None,
|
||||
model_provider: "test-provider".to_string(),
|
||||
model: Some("gpt-5".to_string()),
|
||||
reasoning_effort: None,
|
||||
archived_at: None,
|
||||
cwd: PathBuf::from(cwd),
|
||||
cli_version: "test".to_string(),
|
||||
title: title.to_string(),
|
||||
sandbox_policy: "workspace-write".to_string(),
|
||||
approval_mode: "never".to_string(),
|
||||
tokens_used: 0,
|
||||
source: SessionSource::Cli,
|
||||
agent_nickname: None,
|
||||
agent_role: None,
|
||||
agent_path: None,
|
||||
git_info: Some(GitInfo {
|
||||
commit_hash: Some(GitSha::new("abcdef")),
|
||||
branch: Some("main".to_string()),
|
||||
repository_url: None,
|
||||
}),
|
||||
approval_mode: AskForApproval::Never,
|
||||
sandbox_policy: SandboxPolicy::new_read_only_policy(),
|
||||
token_usage: None,
|
||||
first_user_message: Some(first_user_message.to_string()),
|
||||
archived_at: None,
|
||||
git_sha: None,
|
||||
git_branch: Some("main".to_string()),
|
||||
git_origin_url: None,
|
||||
history: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -161,29 +172,47 @@ fn current_thread_section_keeps_latest_turns_when_history_exceeds_budget() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_section_requires_meaningful_structure() {
|
||||
fn startup_context_blob_is_wrapped_in_tags_and_fits_budget() {
|
||||
let body = format!(
|
||||
"Startup context from Codex.\n{}\n{}",
|
||||
"recent work ".repeat(1_200),
|
||||
"workspace tree ".repeat(800),
|
||||
);
|
||||
|
||||
let wrapped = format_startup_context_blob(&body, /*budget_tokens*/ 200);
|
||||
|
||||
assert!(wrapped.starts_with("<startup_context>\n"));
|
||||
assert!(wrapped.ends_with("\n</startup_context>"));
|
||||
assert!(wrapped.contains("Startup context from Codex."));
|
||||
assert!(wrapped.contains("tokens truncated"));
|
||||
assert!(wrapped.len().div_ceil(4) <= 200);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn workspace_section_requires_meaningful_structure() {
|
||||
let cwd = TempDir::new().expect("tempdir");
|
||||
assert_eq!(
|
||||
build_workspace_section_with_user_root(cwd.path(), /*user_root*/ None),
|
||||
build_workspace_section_with_user_root(cwd.path(), /*user_root*/ None).await,
|
||||
None
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_section_includes_tree_when_entries_exist() {
|
||||
#[tokio::test]
|
||||
async fn workspace_section_includes_tree_when_entries_exist() {
|
||||
let cwd = TempDir::new().expect("tempdir");
|
||||
fs::create_dir(cwd.path().join("docs")).expect("create docs dir");
|
||||
fs::write(cwd.path().join("README.md"), "hello").expect("write readme");
|
||||
|
||||
let section = build_workspace_section_with_user_root(cwd.path(), /*user_root*/ None)
|
||||
.await
|
||||
.expect("workspace section");
|
||||
assert!(section.contains("Working directory tree:"));
|
||||
assert!(section.contains("- docs/"));
|
||||
assert!(section.contains("- README.md"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn workspace_section_includes_user_root_tree_when_distinct() {
|
||||
#[tokio::test]
|
||||
async fn workspace_section_includes_user_root_tree_when_distinct() {
|
||||
let root = TempDir::new().expect("tempdir");
|
||||
let cwd = root.path().join("cwd");
|
||||
let git_root = root.path().join("git");
|
||||
@@ -197,14 +226,15 @@ fn workspace_section_includes_user_root_tree_when_distinct() {
|
||||
fs::write(user_root.join(".zshrc"), "export TEST=1").expect("write home file");
|
||||
|
||||
let section = build_workspace_section_with_user_root(cwd.as_path(), Some(user_root))
|
||||
.await
|
||||
.expect("workspace section");
|
||||
assert!(section.contains("User root tree:"));
|
||||
assert!(section.contains("- code/"));
|
||||
assert!(!section.contains("- .zshrc"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recent_work_section_groups_threads_by_cwd() {
|
||||
#[tokio::test]
|
||||
async fn recent_work_section_groups_threads_by_cwd() {
|
||||
let root = TempDir::new().expect("tempdir");
|
||||
let repo = root.path().join("repo");
|
||||
let workspace_a = repo.join("workspace-a");
|
||||
@@ -224,22 +254,23 @@ fn recent_work_section_groups_threads_by_cwd() {
|
||||
fs::create_dir_all(&outside).expect("create outside dir");
|
||||
|
||||
let recent_threads = vec![
|
||||
thread_metadata(
|
||||
stored_thread(
|
||||
workspace_a.to_string_lossy().as_ref(),
|
||||
"Investigate realtime startup context",
|
||||
"Log the startup context before sending it",
|
||||
),
|
||||
thread_metadata(
|
||||
stored_thread(
|
||||
workspace_b.to_string_lossy().as_ref(),
|
||||
"Trim websocket startup payload",
|
||||
"Remove memories from the realtime startup context",
|
||||
),
|
||||
thread_metadata(outside.to_string_lossy().as_ref(), "", "Inspect flaky test"),
|
||||
stored_thread(outside.to_string_lossy().as_ref(), "", "Inspect flaky test"),
|
||||
];
|
||||
let current_cwd = workspace_a;
|
||||
let repo = fs::canonicalize(repo).expect("canonicalize repo");
|
||||
|
||||
let section = build_recent_work_section(current_cwd.as_path(), &recent_threads)
|
||||
.await
|
||||
.expect("recent work section");
|
||||
assert!(section.contains(&format!("### Git repo: {}", repo.display())));
|
||||
assert!(section.contains("Recent sessions: 2"));
|
||||
|
||||
@@ -65,8 +65,10 @@ const HANDOFF_OUT_QUEUE_CAPACITY: usize = 64;
|
||||
const OUTPUT_EVENTS_QUEUE_CAPACITY: usize = 256;
|
||||
const REALTIME_STARTUP_CONTEXT_TOKEN_BUDGET: usize = 5_000;
|
||||
const DEFAULT_REALTIME_MODEL: &str = "gpt-realtime-1.5";
|
||||
const REALTIME_V2_PROGRESS_UPDATE_SUFFIX: &str =
|
||||
"\n\nUpdate from background agent (task hasn't finished yet):";
|
||||
pub(crate) const REALTIME_USER_TEXT_PREFIX: &str = "[USER] ";
|
||||
pub(crate) const REALTIME_BACKEND_TEXT_PREFIX: &str = "[BACKEND] ";
|
||||
const REALTIME_V2_HANDOFF_COMPLETE_ACKNOWLEDGEMENT: &str =
|
||||
"Background agent finished. Use the preceding [BACKEND] messages as the result.";
|
||||
const REALTIME_V2_STEER_ACKNOWLEDGEMENT: &str =
|
||||
"This was sent to steer the previous background agent task.";
|
||||
const REALTIME_ACTIVE_RESPONSE_ERROR_PREFIX: &str =
|
||||
@@ -209,6 +211,7 @@ impl RealtimeHandoffState {
|
||||
struct ConversationState {
|
||||
audio_tx: Sender<RealtimeAudioFrame>,
|
||||
user_text_tx: Sender<String>,
|
||||
session_kind: RealtimeSessionKind,
|
||||
writer: RealtimeWebsocketWriter,
|
||||
handoff: RealtimeHandoffState,
|
||||
input_task: JoinHandle<()>,
|
||||
@@ -245,6 +248,16 @@ impl RealtimeConversationManager {
|
||||
.and_then(|state| state.realtime_active.load(Ordering::Relaxed).then_some(()))
|
||||
}
|
||||
|
||||
pub(crate) async fn is_running_v2(&self) -> bool {
|
||||
let state = self.state.lock().await;
|
||||
matches!(
|
||||
state.as_ref(),
|
||||
Some(state)
|
||||
if state.realtime_active.load(Ordering::Relaxed)
|
||||
&& state.session_kind == RealtimeSessionKind::V2
|
||||
)
|
||||
}
|
||||
|
||||
async fn start(&self, start: RealtimeStart) -> CodexResult<RealtimeStartOutput> {
|
||||
let previous_state = {
|
||||
let mut guard = self.state.lock().await;
|
||||
@@ -331,6 +344,7 @@ impl RealtimeConversationManager {
|
||||
*guard = Some(ConversationState {
|
||||
audio_tx,
|
||||
user_text_tx,
|
||||
session_kind,
|
||||
writer,
|
||||
handoff,
|
||||
input_task: task,
|
||||
@@ -406,15 +420,18 @@ impl RealtimeConversationManager {
|
||||
pub(crate) async fn text_in(&self, text: String) -> CodexResult<()> {
|
||||
let sender = {
|
||||
let guard = self.state.lock().await;
|
||||
guard.as_ref().map(|state| state.user_text_tx.clone())
|
||||
guard
|
||||
.as_ref()
|
||||
.map(|state| (state.user_text_tx.clone(), state.session_kind))
|
||||
};
|
||||
|
||||
let Some(sender) = sender else {
|
||||
let Some((sender, session_kind)) = sender else {
|
||||
return Err(CodexErr::InvalidRequest(
|
||||
"conversation is not running".to_string(),
|
||||
));
|
||||
};
|
||||
|
||||
let text = prefix_realtime_text(text, REALTIME_USER_TEXT_PREFIX, session_kind);
|
||||
sender
|
||||
.send(text)
|
||||
.await
|
||||
@@ -437,6 +454,11 @@ impl RealtimeConversationManager {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let output_text = prefix_realtime_text(
|
||||
output_text,
|
||||
REALTIME_BACKEND_TEXT_PREFIX,
|
||||
handoff.session_kind,
|
||||
);
|
||||
*handoff.last_output_text.lock().await = Some(output_text.clone());
|
||||
handoff
|
||||
.output_tx
|
||||
@@ -695,6 +717,17 @@ fn default_realtime_voice(version: RealtimeWsVersion) -> RealtimeVoice {
|
||||
}
|
||||
}
|
||||
|
||||
fn prefix_realtime_text(text: String, prefix: &str, session_kind: RealtimeSessionKind) -> String {
|
||||
if session_kind != RealtimeSessionKind::V2 || text.is_empty() || text.starts_with(prefix) {
|
||||
return text;
|
||||
}
|
||||
format!("{prefix}{text}")
|
||||
}
|
||||
|
||||
pub(crate) fn prefix_realtime_v2_text(text: String, prefix: &str) -> String {
|
||||
prefix_realtime_text(text, prefix, RealtimeSessionKind::V2)
|
||||
}
|
||||
|
||||
fn validate_realtime_voice(version: RealtimeWsVersion, voice: RealtimeVoice) -> CodexResult<()> {
|
||||
let voices = RealtimeVoicesList::builtin();
|
||||
let allowed = match version {
|
||||
@@ -1074,18 +1107,17 @@ async fn handle_handoff_output(
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
writer
|
||||
.send_conversation_item_create(format!(
|
||||
"{output_text}{REALTIME_V2_PROGRESS_UPDATE_SUFFIX}"
|
||||
))
|
||||
.await
|
||||
writer.send_conversation_item_create(output_text).await
|
||||
}
|
||||
HandoffOutput::FinalUpdate {
|
||||
handoff_id,
|
||||
output_text,
|
||||
output_text: _,
|
||||
} => {
|
||||
if let Err(err) = writer
|
||||
.send_conversation_handoff_append(handoff_id, output_text)
|
||||
.send_conversation_handoff_append(
|
||||
handoff_id,
|
||||
REALTIME_V2_HANDOFF_COMPLETE_ACKNOWLEDGEMENT.to_string(),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Err(err)
|
||||
|
||||
@@ -46,11 +46,7 @@ impl codex_rollout::RolloutConfigView for Config {
|
||||
}
|
||||
|
||||
pub(crate) mod list {
|
||||
pub use codex_rollout::ThreadListConfig;
|
||||
pub use codex_rollout::ThreadListLayout;
|
||||
pub use codex_rollout::ThreadSortKey;
|
||||
pub use codex_rollout::find_thread_path_by_id_str;
|
||||
pub use codex_rollout::get_threads_in_root;
|
||||
}
|
||||
|
||||
pub(crate) mod metadata {
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
#![cfg(target_os = "macos")]
|
||||
|
||||
use crate::spawn::CODEX_SANDBOX_ENV_VAR;
|
||||
use crate::spawn::SpawnChildRequest;
|
||||
use crate::spawn::StdioPolicy;
|
||||
use crate::spawn::spawn_child_async;
|
||||
use codex_network_proxy::NetworkProxy;
|
||||
use codex_protocol::permissions::FileSystemSandboxPolicy;
|
||||
use codex_protocol::permissions::NetworkSandboxPolicy;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use codex_sandboxing::seatbelt::MACOS_PATH_TO_SEATBELT_EXECUTABLE;
|
||||
use codex_sandboxing::seatbelt::create_seatbelt_command_args_for_policies;
|
||||
use codex_utils_absolute_path::AbsolutePathBuf;
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use tokio::process::Child;
|
||||
|
||||
pub async fn spawn_command_under_seatbelt(
|
||||
command: Vec<String>,
|
||||
command_cwd: AbsolutePathBuf,
|
||||
sandbox_policy: &SandboxPolicy,
|
||||
sandbox_policy_cwd: &AbsolutePathBuf,
|
||||
stdio_policy: StdioPolicy,
|
||||
network: Option<&NetworkProxy>,
|
||||
mut env: HashMap<String, String>,
|
||||
) -> std::io::Result<Child> {
|
||||
let args = create_seatbelt_command_args_for_policies(
|
||||
command,
|
||||
&FileSystemSandboxPolicy::from_legacy_sandbox_policy(sandbox_policy, sandbox_policy_cwd),
|
||||
NetworkSandboxPolicy::from(sandbox_policy),
|
||||
sandbox_policy_cwd,
|
||||
/*enforce_managed_network*/ false,
|
||||
network,
|
||||
);
|
||||
let arg0 = None;
|
||||
env.insert(CODEX_SANDBOX_ENV_VAR.to_string(), "seatbelt".to_string());
|
||||
spawn_child_async(SpawnChildRequest {
|
||||
program: PathBuf::from(MACOS_PATH_TO_SEATBELT_EXECUTABLE),
|
||||
args,
|
||||
arg0,
|
||||
cwd: command_cwd,
|
||||
network_sandbox_policy: NetworkSandboxPolicy::from(sandbox_policy),
|
||||
network,
|
||||
stdio_policy,
|
||||
env,
|
||||
})
|
||||
.await
|
||||
}
|
||||
@@ -4,6 +4,7 @@ use std::sync::Arc;
|
||||
use crate::RolloutRecorder;
|
||||
use crate::SkillsManager;
|
||||
use crate::agent::AgentControl;
|
||||
use crate::agent_identity::AgentIdentityManager;
|
||||
use crate::client::ModelClient;
|
||||
use crate::config::StartedNetworkProxy;
|
||||
use crate::exec_policy::ExecPolicyManager;
|
||||
@@ -23,6 +24,7 @@ use codex_mcp::McpConnectionManager;
|
||||
use codex_models_manager::manager::ModelsManager;
|
||||
use codex_otel::SessionTelemetry;
|
||||
use codex_rollout::state_db::StateDbHandle;
|
||||
use codex_thread_store::LocalThreadStore;
|
||||
use std::path::PathBuf;
|
||||
use tokio::sync::Mutex;
|
||||
use tokio::sync::RwLock;
|
||||
@@ -41,6 +43,7 @@ pub(crate) struct SessionServices {
|
||||
pub(crate) hooks: Hooks,
|
||||
pub(crate) rollout: Mutex<Option<RolloutRecorder>>,
|
||||
pub(crate) user_shell: Arc<crate::shell::Shell>,
|
||||
pub(crate) agent_identity_manager: Arc<AgentIdentityManager>,
|
||||
pub(crate) shell_snapshot_tx: watch::Sender<Option<Arc<crate::shell_snapshot::ShellSnapshot>>>,
|
||||
pub(crate) show_raw_agent_reasoning: bool,
|
||||
pub(crate) exec_policy: Arc<ExecPolicyManager>,
|
||||
@@ -57,6 +60,7 @@ pub(crate) struct SessionServices {
|
||||
pub(crate) network_proxy: Option<StartedNetworkProxy>,
|
||||
pub(crate) network_approval: Arc<NetworkApprovalService>,
|
||||
pub(crate) state_db: Option<StateDbHandle>,
|
||||
pub(crate) thread_store: LocalThreadStore,
|
||||
/// Session-scoped model client shared across turns.
|
||||
pub(crate) model_client: ModelClient,
|
||||
pub(crate) code_mode_service: CodeModeService,
|
||||
|
||||
@@ -136,11 +136,20 @@ pub(crate) async fn record_completed_response_item(
|
||||
sess.defer_mailbox_delivery_to_next_turn(&turn_context.sub_id)
|
||||
.await;
|
||||
}
|
||||
maybe_mark_thread_memory_mode_polluted_from_web_search(sess, turn_context, item).await;
|
||||
mark_thread_memory_mode_polluted_if_external_context(sess, turn_context, item).await;
|
||||
record_stage1_output_usage_for_completed_item(turn_context, item).await;
|
||||
}
|
||||
|
||||
async fn maybe_mark_thread_memory_mode_polluted_from_web_search(
|
||||
fn response_item_may_include_external_context(item: &ResponseItem) -> bool {
|
||||
matches!(
|
||||
item,
|
||||
ResponseItem::ToolSearchCall { .. }
|
||||
| ResponseItem::ToolSearchOutput { .. }
|
||||
| ResponseItem::WebSearchCall { .. }
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) async fn mark_thread_memory_mode_polluted_if_external_context(
|
||||
sess: &Session,
|
||||
turn_context: &TurnContext,
|
||||
item: &ResponseItem,
|
||||
@@ -149,7 +158,7 @@ async fn maybe_mark_thread_memory_mode_polluted_from_web_search(
|
||||
.config
|
||||
.memories
|
||||
.no_memories_if_mcp_or_web_search
|
||||
|| !matches!(item, ResponseItem::WebSearchCall { .. })
|
||||
|| !response_item_may_include_external_context(item)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2,11 +2,16 @@ use super::completed_item_defers_mailbox_delivery_to_next_turn;
|
||||
use super::handle_non_tool_response_item;
|
||||
use super::image_generation_artifact_path;
|
||||
use super::last_assistant_message_from_item;
|
||||
use super::response_item_may_include_external_context;
|
||||
use super::save_image_generation_result;
|
||||
use crate::codex::make_session_and_context;
|
||||
use codex_protocol::error::CodexErr;
|
||||
use codex_protocol::items::TurnItem;
|
||||
use codex_protocol::models::ContentItem;
|
||||
use codex_protocol::models::FunctionCallOutputPayload;
|
||||
use codex_protocol::models::LocalShellAction;
|
||||
use codex_protocol::models::LocalShellExecAction;
|
||||
use codex_protocol::models::LocalShellStatus;
|
||||
use codex_protocol::models::MessagePhase;
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_utils_absolute_path::test_support::PathExt;
|
||||
@@ -28,6 +33,84 @@ fn assistant_output_text_with_phase(text: &str, phase: Option<MessagePhase>) ->
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_context_pollution_items_include_web_search_and_tool_search() {
|
||||
let polluting_items = [
|
||||
ResponseItem::WebSearchCall {
|
||||
id: None,
|
||||
status: Some("completed".to_string()),
|
||||
action: None,
|
||||
},
|
||||
ResponseItem::ToolSearchCall {
|
||||
id: None,
|
||||
call_id: Some("search-1".to_string()),
|
||||
status: None,
|
||||
execution: "client".to_string(),
|
||||
arguments: serde_json::json!({"query": "calendar"}),
|
||||
},
|
||||
ResponseItem::ToolSearchOutput {
|
||||
call_id: Some("search-1".to_string()),
|
||||
status: "completed".to_string(),
|
||||
execution: "client".to_string(),
|
||||
tools: Vec::new(),
|
||||
},
|
||||
];
|
||||
|
||||
assert!(
|
||||
polluting_items
|
||||
.iter()
|
||||
.all(response_item_may_include_external_context)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn external_context_pollution_items_exclude_local_tool_calls() {
|
||||
let non_polluting_items = [
|
||||
ResponseItem::LocalShellCall {
|
||||
id: None,
|
||||
call_id: Some("shell-1".to_string()),
|
||||
status: LocalShellStatus::Completed,
|
||||
action: LocalShellAction::Exec(LocalShellExecAction {
|
||||
command: vec!["cat".to_string(), "README.md".to_string()],
|
||||
timeout_ms: None,
|
||||
working_directory: None,
|
||||
env: None,
|
||||
user: None,
|
||||
}),
|
||||
},
|
||||
ResponseItem::FunctionCall {
|
||||
id: None,
|
||||
name: "shell".to_string(),
|
||||
namespace: None,
|
||||
arguments: "{}".to_string(),
|
||||
call_id: "call-1".to_string(),
|
||||
},
|
||||
ResponseItem::FunctionCallOutput {
|
||||
call_id: "call-1".to_string(),
|
||||
output: FunctionCallOutputPayload::from_text("ok".to_string()),
|
||||
},
|
||||
ResponseItem::CustomToolCall {
|
||||
id: None,
|
||||
status: None,
|
||||
call_id: "custom-1".to_string(),
|
||||
name: "apply_patch".to_string(),
|
||||
input: "*** Begin Patch\n*** End Patch\n".to_string(),
|
||||
},
|
||||
ResponseItem::CustomToolCallOutput {
|
||||
call_id: "custom-1".to_string(),
|
||||
name: Some("apply_patch".to_string()),
|
||||
output: FunctionCallOutputPayload::from_text("ok".to_string()),
|
||||
},
|
||||
assistant_output_text("plain assistant text"),
|
||||
];
|
||||
|
||||
assert!(
|
||||
!non_polluting_items
|
||||
.iter()
|
||||
.any(response_item_may_include_external_context)
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn handle_non_tool_response_item_strips_citations_from_assistant_message() {
|
||||
let (session, turn_context) = make_session_and_context().await;
|
||||
|
||||
@@ -163,7 +163,9 @@ pub(crate) async fn execute_user_shell_command(
|
||||
cwd: cwd.clone(),
|
||||
env: exec_env_map,
|
||||
exec_server_env_config: None,
|
||||
network: turn_context.network.clone(),
|
||||
// `/shell` is the explicit full-access escape hatch, so it must not
|
||||
// inherit a managed proxy from the surrounding session or turn.
|
||||
network: None,
|
||||
// TODO(zhao-oai): Now that we have ExecExpiration::Cancellation, we
|
||||
// should use that instead of an "arbitrarily large" timeout here.
|
||||
expiration: USER_SHELL_TIMEOUT_MS.into(),
|
||||
|
||||
@@ -237,7 +237,7 @@ async fn ignores_session_prefix_messages_when_truncating() {
|
||||
#[tokio::test]
|
||||
async fn shutdown_all_threads_bounded_submits_shutdown_to_every_thread() {
|
||||
let temp_dir = tempdir().expect("tempdir");
|
||||
let mut config = test_config();
|
||||
let mut config = test_config().await;
|
||||
config.codex_home = temp_dir.path().join("codex-home").abs();
|
||||
config.cwd = config.codex_home.abs();
|
||||
std::fs::create_dir_all(&config.codex_home).expect("create codex home");
|
||||
@@ -279,7 +279,7 @@ async fn new_uses_configured_openai_provider_for_model_refresh() {
|
||||
let models_mock = mount_models_once(&server, ModelsResponse { models: vec![] }).await;
|
||||
|
||||
let temp_dir = tempdir().expect("tempdir");
|
||||
let mut config = test_config();
|
||||
let mut config = test_config().await;
|
||||
config.codex_home = temp_dir.path().join("codex-home").abs();
|
||||
config.cwd = config.codex_home.abs();
|
||||
std::fs::create_dir_all(&config.codex_home).expect("create codex home");
|
||||
@@ -422,7 +422,7 @@ fn mixed_response_and_legacy_user_event_history_is_mid_turn() {
|
||||
#[tokio::test]
|
||||
async fn interrupted_fork_snapshot_does_not_synthesize_turn_id_for_legacy_history() {
|
||||
let temp_dir = tempdir().expect("tempdir");
|
||||
let mut config = test_config();
|
||||
let mut config = test_config().await;
|
||||
config.codex_home = temp_dir.path().join("codex-home").abs();
|
||||
config.cwd = config.codex_home.abs();
|
||||
std::fs::create_dir_all(&config.codex_home).expect("create codex home");
|
||||
@@ -525,7 +525,7 @@ async fn interrupted_fork_snapshot_does_not_synthesize_turn_id_for_legacy_histor
|
||||
#[tokio::test]
|
||||
async fn interrupted_fork_snapshot_preserves_explicit_turn_id() {
|
||||
let temp_dir = tempdir().expect("tempdir");
|
||||
let mut config = test_config();
|
||||
let mut config = test_config().await;
|
||||
config.codex_home = temp_dir.path().join("codex-home").abs();
|
||||
config.cwd = config.codex_home.abs();
|
||||
std::fs::create_dir_all(&config.codex_home).expect("create codex home");
|
||||
@@ -618,7 +618,7 @@ async fn interrupted_fork_snapshot_preserves_explicit_turn_id() {
|
||||
#[tokio::test]
|
||||
async fn interrupted_fork_snapshot_uses_persisted_mid_turn_history_without_live_source() {
|
||||
let temp_dir = tempdir().expect("tempdir");
|
||||
let mut config = test_config();
|
||||
let mut config = test_config().await;
|
||||
config.codex_home = temp_dir.path().join("codex-home").abs();
|
||||
config.cwd = config.codex_home.abs();
|
||||
std::fs::create_dir_all(&config.codex_home).expect("create codex home");
|
||||
|
||||
@@ -18,6 +18,8 @@ use tokio_util::sync::CancellationToken;
|
||||
use crate::codex::Session;
|
||||
use crate::codex::TurnContext;
|
||||
use crate::function_tool::FunctionCallError;
|
||||
use crate::original_image_detail::can_request_original_image_detail;
|
||||
use crate::original_image_detail::sanitize_original_image_detail as sanitize_image_detail_items;
|
||||
use crate::tools::ToolRouter;
|
||||
use crate::tools::context::FunctionToolOutput;
|
||||
use crate::tools::context::SharedTurnDiffTracker;
|
||||
@@ -116,7 +118,7 @@ struct CoreTurnHost {
|
||||
impl CodeModeTurnHost for CoreTurnHost {
|
||||
async fn invoke_tool(
|
||||
&self,
|
||||
tool_name: String,
|
||||
tool_name: ToolName,
|
||||
input: Option<JsonValue>,
|
||||
cancellation_token: CancellationToken,
|
||||
) -> Result<JsonValue, String> {
|
||||
@@ -160,12 +162,14 @@ pub(super) async fn handle_runtime_response(
|
||||
match response {
|
||||
RuntimeResponse::Yielded { content_items, .. } => {
|
||||
let mut content_items = into_function_call_output_content_items(content_items);
|
||||
sanitize_runtime_image_detail(exec.turn.as_ref(), &mut content_items);
|
||||
content_items = truncate_code_mode_result(content_items, max_output_tokens);
|
||||
prepend_script_status(&mut content_items, &script_status, started_at.elapsed());
|
||||
Ok(FunctionToolOutput::from_content(content_items, Some(true)))
|
||||
}
|
||||
RuntimeResponse::Terminated { content_items, .. } => {
|
||||
let mut content_items = into_function_call_output_content_items(content_items);
|
||||
sanitize_runtime_image_detail(exec.turn.as_ref(), &mut content_items);
|
||||
content_items = truncate_code_mode_result(content_items, max_output_tokens);
|
||||
prepend_script_status(&mut content_items, &script_status, started_at.elapsed());
|
||||
Ok(FunctionToolOutput::from_content(content_items, Some(true)))
|
||||
@@ -177,6 +181,7 @@ pub(super) async fn handle_runtime_response(
|
||||
..
|
||||
} => {
|
||||
let mut content_items = into_function_call_output_content_items(content_items);
|
||||
sanitize_runtime_image_detail(exec.turn.as_ref(), &mut content_items);
|
||||
exec.session
|
||||
.services
|
||||
.code_mode_service
|
||||
@@ -198,6 +203,10 @@ pub(super) async fn handle_runtime_response(
|
||||
}
|
||||
}
|
||||
|
||||
fn sanitize_runtime_image_detail(turn: &TurnContext, items: &mut [FunctionCallOutputContentItem]) {
|
||||
sanitize_image_detail_items(can_request_original_image_detail(&turn.model_info), items);
|
||||
}
|
||||
|
||||
fn format_script_status(response: &RuntimeResponse) -> String {
|
||||
match response {
|
||||
RuntimeResponse::Yielded { cell_id, .. } => {
|
||||
@@ -278,6 +287,7 @@ async fn build_nested_router(exec: &ExecContext) -> ToolRouter {
|
||||
ToolRouterParams {
|
||||
deferred_mcp_tools: None,
|
||||
mcp_tools: Some(listed_mcp_tools),
|
||||
unavailable_called_tools: Vec::new(),
|
||||
parallel_mcp_server_names,
|
||||
discoverable_tools: None,
|
||||
dynamic_tools: exec.turn.dynamic_tools.as_slice(),
|
||||
@@ -288,38 +298,39 @@ async fn build_nested_router(exec: &ExecContext) -> ToolRouter {
|
||||
async fn call_nested_tool(
|
||||
exec: ExecContext,
|
||||
tool_runtime: ToolCallRuntime,
|
||||
tool_name: String,
|
||||
tool_name: ToolName,
|
||||
input: Option<JsonValue>,
|
||||
cancellation_token: CancellationToken,
|
||||
) -> Result<JsonValue, FunctionCallError> {
|
||||
if tool_name == PUBLIC_TOOL_NAME {
|
||||
if tool_name.namespace.is_none() && tool_name.name == PUBLIC_TOOL_NAME {
|
||||
return Err(FunctionCallError::RespondToModel(format!(
|
||||
"{PUBLIC_TOOL_NAME} cannot invoke itself"
|
||||
)));
|
||||
}
|
||||
|
||||
let payload = if let Some(tool_info) = exec
|
||||
.session
|
||||
.resolve_mcp_tool_info(&tool_name, /*namespace*/ None)
|
||||
.await
|
||||
{
|
||||
match serialize_function_tool_arguments(&tool_name, input) {
|
||||
Ok(raw_arguments) => ToolPayload::Mcp {
|
||||
server: tool_info.server_name,
|
||||
tool: tool_info.tool.name.to_string(),
|
||||
raw_arguments,
|
||||
},
|
||||
Err(error) => return Err(FunctionCallError::RespondToModel(error)),
|
||||
}
|
||||
} else {
|
||||
match build_nested_tool_payload(tool_runtime.find_spec(&tool_name), &tool_name, input) {
|
||||
Ok(payload) => payload,
|
||||
Err(error) => return Err(FunctionCallError::RespondToModel(error)),
|
||||
}
|
||||
};
|
||||
let (tool_call_name, payload) =
|
||||
if let Some(tool_info) = exec.session.resolve_mcp_tool_info(&tool_name).await {
|
||||
let raw_arguments = match serialize_function_tool_arguments(&tool_name, input) {
|
||||
Ok(raw_arguments) => raw_arguments,
|
||||
Err(error) => return Err(FunctionCallError::RespondToModel(error)),
|
||||
};
|
||||
(
|
||||
tool_info.canonical_tool_name(),
|
||||
ToolPayload::Mcp {
|
||||
server: tool_info.server_name,
|
||||
tool: tool_info.tool.name.to_string(),
|
||||
raw_arguments,
|
||||
},
|
||||
)
|
||||
} else {
|
||||
match build_nested_tool_payload(tool_runtime.find_spec(&tool_name), &tool_name, input) {
|
||||
Ok(payload) => (tool_name, payload),
|
||||
Err(error) => return Err(FunctionCallError::RespondToModel(error)),
|
||||
}
|
||||
};
|
||||
|
||||
let call = ToolCall {
|
||||
tool_name: ToolName::plain(tool_name.clone()),
|
||||
tool_name: tool_call_name,
|
||||
call_id: format!("{PUBLIC_TOOL_NAME}-{}", uuid::Uuid::new_v4()),
|
||||
payload,
|
||||
};
|
||||
@@ -339,7 +350,7 @@ fn tool_kind_for_spec(spec: &ToolSpec) -> codex_code_mode::CodeModeToolKind {
|
||||
|
||||
fn tool_kind_for_name(
|
||||
spec: Option<ToolSpec>,
|
||||
tool_name: &str,
|
||||
tool_name: &ToolName,
|
||||
) -> Result<codex_code_mode::CodeModeToolKind, String> {
|
||||
spec.as_ref()
|
||||
.map(tool_kind_for_spec)
|
||||
@@ -348,7 +359,7 @@ fn tool_kind_for_name(
|
||||
|
||||
fn build_nested_tool_payload(
|
||||
spec: Option<ToolSpec>,
|
||||
tool_name: &str,
|
||||
tool_name: &ToolName,
|
||||
input: Option<JsonValue>,
|
||||
) -> Result<ToolPayload, String> {
|
||||
let actual_kind = tool_kind_for_name(spec, tool_name)?;
|
||||
@@ -363,7 +374,7 @@ fn build_nested_tool_payload(
|
||||
}
|
||||
|
||||
fn build_function_tool_payload(
|
||||
tool_name: &str,
|
||||
tool_name: &ToolName,
|
||||
input: Option<JsonValue>,
|
||||
) -> Result<ToolPayload, String> {
|
||||
let arguments = serialize_function_tool_arguments(tool_name, input)?;
|
||||
@@ -371,7 +382,7 @@ fn build_function_tool_payload(
|
||||
}
|
||||
|
||||
fn serialize_function_tool_arguments(
|
||||
tool_name: &str,
|
||||
tool_name: &ToolName,
|
||||
input: Option<JsonValue>,
|
||||
) -> Result<String, String> {
|
||||
match input {
|
||||
@@ -385,7 +396,7 @@ fn serialize_function_tool_arguments(
|
||||
}
|
||||
|
||||
fn build_freeform_tool_payload(
|
||||
tool_name: &str,
|
||||
tool_name: &ToolName,
|
||||
input: Option<JsonValue>,
|
||||
) -> Result<ToolPayload, String> {
|
||||
match input {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
use crate::codex::Session;
|
||||
use crate::codex::TurnContext;
|
||||
use crate::original_image_detail::sanitize_original_image_detail;
|
||||
use crate::tools::TELEMETRY_PREVIEW_MAX_BYTES;
|
||||
use crate::tools::TELEMETRY_PREVIEW_MAX_LINES;
|
||||
use crate::tools::TELEMETRY_PREVIEW_TRUNCATION_NOTICE;
|
||||
@@ -122,6 +123,7 @@ impl ToolOutput for CallToolResult {
|
||||
pub struct McpToolOutput {
|
||||
pub result: CallToolResult,
|
||||
pub wall_time: Duration,
|
||||
pub original_image_detail_supported: bool,
|
||||
}
|
||||
|
||||
impl ToolOutput for McpToolOutput {
|
||||
@@ -155,6 +157,10 @@ impl ToolOutput for McpToolOutput {
|
||||
impl McpToolOutput {
|
||||
fn response_payload(&self) -> FunctionCallOutputPayload {
|
||||
let mut payload = self.result.as_function_call_output_payload();
|
||||
if let Some(items) = payload.content_items_mut() {
|
||||
sanitize_original_image_detail(self.original_image_detail_supported, items);
|
||||
}
|
||||
|
||||
let wall_time_seconds = self.wall_time.as_secs_f64();
|
||||
let header = format!("Wall time: {wall_time_seconds:.4} seconds\nOutput:");
|
||||
|
||||
|
||||
@@ -98,6 +98,7 @@ fn mcp_tool_output_response_item_includes_wall_time() {
|
||||
meta: None,
|
||||
},
|
||||
wall_time: std::time::Duration::from_millis(1250),
|
||||
original_image_detail_supported: false,
|
||||
};
|
||||
|
||||
let response = output.to_response_item(
|
||||
@@ -149,6 +150,7 @@ fn mcp_tool_output_response_item_preserves_content_items() {
|
||||
meta: None,
|
||||
},
|
||||
wall_time: std::time::Duration::from_millis(500),
|
||||
original_image_detail_supported: false,
|
||||
};
|
||||
|
||||
let response = output.to_response_item(
|
||||
@@ -201,6 +203,7 @@ fn mcp_tool_output_code_mode_result_stays_raw_call_tool_result() {
|
||||
meta: None,
|
||||
},
|
||||
wall_time: std::time::Duration::from_millis(1250),
|
||||
original_image_detail_supported: false,
|
||||
};
|
||||
|
||||
let result = output.code_mode_result(&ToolPayload::Mcp {
|
||||
|
||||
@@ -4,6 +4,7 @@ use std::fs::FileType;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use codex_protocol::permissions::ReadDenyMatcher;
|
||||
use codex_utils_string::take_bytes_at_char_boundary;
|
||||
use serde::Deserialize;
|
||||
use tokio::fs;
|
||||
@@ -18,6 +19,8 @@ use crate::tools::registry::ToolKind;
|
||||
|
||||
pub struct ListDirHandler;
|
||||
|
||||
const DENY_READ_POLICY_MESSAGE: &str =
|
||||
"access denied: reading this path is blocked by filesystem deny_read policy";
|
||||
const MAX_ENTRY_LENGTH: usize = 500;
|
||||
const INDENTATION_SPACES: usize = 2;
|
||||
|
||||
@@ -52,7 +55,7 @@ impl ToolHandler for ListDirHandler {
|
||||
}
|
||||
|
||||
async fn handle(&self, invocation: ToolInvocation) -> Result<Self::Output, FunctionCallError> {
|
||||
let ToolInvocation { payload, .. } = invocation;
|
||||
let ToolInvocation { payload, turn, .. } = invocation;
|
||||
|
||||
let arguments = match payload {
|
||||
ToolPayload::Function { arguments } => arguments,
|
||||
@@ -96,8 +99,20 @@ impl ToolHandler for ListDirHandler {
|
||||
"dir_path must be an absolute path".to_string(),
|
||||
));
|
||||
}
|
||||
let read_deny_matcher = ReadDenyMatcher::new(&turn.file_system_sandbox_policy, &turn.cwd);
|
||||
if read_deny_matcher
|
||||
.as_ref()
|
||||
.is_some_and(|matcher| matcher.is_read_denied(&path))
|
||||
{
|
||||
return Err(FunctionCallError::RespondToModel(format!(
|
||||
"{DENY_READ_POLICY_MESSAGE}: `{}`",
|
||||
path.display()
|
||||
)));
|
||||
}
|
||||
|
||||
let entries = list_dir_slice(&path, offset, limit, depth).await?;
|
||||
let entries =
|
||||
list_dir_slice_with_policy(&path, offset, limit, depth, read_deny_matcher.as_ref())
|
||||
.await?;
|
||||
let mut output = Vec::with_capacity(entries.len() + 1);
|
||||
output.push(format!("Absolute path: {}", path.display()));
|
||||
output.extend(entries);
|
||||
@@ -105,14 +120,15 @@ impl ToolHandler for ListDirHandler {
|
||||
}
|
||||
}
|
||||
|
||||
async fn list_dir_slice(
|
||||
async fn list_dir_slice_with_policy(
|
||||
path: &Path,
|
||||
offset: usize,
|
||||
limit: usize,
|
||||
depth: usize,
|
||||
read_deny_matcher: Option<&ReadDenyMatcher>,
|
||||
) -> Result<Vec<String>, FunctionCallError> {
|
||||
let mut entries = Vec::new();
|
||||
collect_entries(path, Path::new(""), depth, &mut entries).await?;
|
||||
collect_entries(path, Path::new(""), depth, read_deny_matcher, &mut entries).await?;
|
||||
|
||||
if entries.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
@@ -148,6 +164,7 @@ async fn collect_entries(
|
||||
dir_path: &Path,
|
||||
relative_prefix: &Path,
|
||||
depth: usize,
|
||||
read_deny_matcher: Option<&ReadDenyMatcher>,
|
||||
entries: &mut Vec<DirEntry>,
|
||||
) -> Result<(), FunctionCallError> {
|
||||
let mut queue = VecDeque::new();
|
||||
@@ -163,6 +180,13 @@ async fn collect_entries(
|
||||
while let Some(entry) = read_dir.next_entry().await.map_err(|err| {
|
||||
FunctionCallError::RespondToModel(format!("failed to read directory: {err}"))
|
||||
})? {
|
||||
let entry_path = entry.path();
|
||||
if let Some(read_deny_matcher) = read_deny_matcher
|
||||
&& read_deny_matcher.is_read_denied(&entry_path)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
let file_type = entry.file_type().await.map_err(|err| {
|
||||
FunctionCallError::RespondToModel(format!("failed to inspect entry: {err}"))
|
||||
})?;
|
||||
@@ -179,7 +203,7 @@ async fn collect_entries(
|
||||
let sort_key = format_entry_name(&relative_path);
|
||||
let kind = DirEntryKind::from(&file_type);
|
||||
dir_entries.push((
|
||||
entry.path(),
|
||||
entry_path,
|
||||
relative_path,
|
||||
kind,
|
||||
DirEntry {
|
||||
|
||||
@@ -1,7 +1,21 @@
|
||||
use super::*;
|
||||
use codex_protocol::permissions::FileSystemAccessMode;
|
||||
use codex_protocol::permissions::FileSystemPath;
|
||||
use codex_protocol::permissions::FileSystemSandboxEntry;
|
||||
use codex_protocol::permissions::FileSystemSandboxPolicy;
|
||||
use codex_protocol::permissions::ReadDenyMatcher;
|
||||
use pretty_assertions::assert_eq;
|
||||
use tempfile::tempdir;
|
||||
|
||||
async fn list_dir_slice(
|
||||
path: &Path,
|
||||
offset: usize,
|
||||
limit: usize,
|
||||
depth: usize,
|
||||
) -> Result<Vec<String>, FunctionCallError> {
|
||||
list_dir_slice_with_policy(path, offset, limit, depth, /*read_deny_matcher*/ None).await
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn lists_directory_entries() {
|
||||
let temp = tempdir().expect("create tempdir");
|
||||
@@ -258,3 +272,60 @@ async fn truncation_respects_sorted_order() -> anyhow::Result<()> {
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hides_denied_entries_and_prunes_denied_subtrees() {
|
||||
let temp = tempdir().expect("create tempdir");
|
||||
let dir_path = temp.path();
|
||||
let visible_dir = dir_path.join("visible");
|
||||
let denied_dir = dir_path.join("private");
|
||||
tokio::fs::create_dir(&visible_dir)
|
||||
.await
|
||||
.expect("create visible dir");
|
||||
tokio::fs::create_dir(&denied_dir)
|
||||
.await
|
||||
.expect("create denied dir");
|
||||
tokio::fs::write(visible_dir.join("ok.txt"), b"ok")
|
||||
.await
|
||||
.expect("write visible file");
|
||||
tokio::fs::write(denied_dir.join("secret.txt"), b"secret")
|
||||
.await
|
||||
.expect("write denied file");
|
||||
tokio::fs::write(dir_path.join("top_secret.txt"), b"secret")
|
||||
.await
|
||||
.expect("write denied top-level file");
|
||||
|
||||
let policy = FileSystemSandboxPolicy::restricted(vec![
|
||||
FileSystemSandboxEntry {
|
||||
path: FileSystemPath::Path {
|
||||
path: denied_dir.try_into().expect("absolute denied dir"),
|
||||
},
|
||||
access: FileSystemAccessMode::None,
|
||||
},
|
||||
FileSystemSandboxEntry {
|
||||
path: FileSystemPath::Path {
|
||||
path: dir_path
|
||||
.join("top_secret.txt")
|
||||
.try_into()
|
||||
.expect("absolute denied file"),
|
||||
},
|
||||
access: FileSystemAccessMode::None,
|
||||
},
|
||||
]);
|
||||
|
||||
let read_deny_matcher = ReadDenyMatcher::new(&policy, dir_path);
|
||||
let entries = list_dir_slice_with_policy(
|
||||
dir_path,
|
||||
/*offset*/ 1,
|
||||
/*limit*/ 20,
|
||||
/*depth*/ 3,
|
||||
read_deny_matcher.as_ref(),
|
||||
)
|
||||
.await
|
||||
.expect("list directory");
|
||||
|
||||
assert_eq!(
|
||||
entries,
|
||||
vec!["visible/".to_string(), " ok.txt".to_string(),]
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ use std::time::Instant;
|
||||
|
||||
use crate::function_tool::FunctionCallError;
|
||||
use crate::mcp_tool_call::handle_mcp_tool_call;
|
||||
use crate::original_image_detail::can_request_original_image_detail;
|
||||
use crate::tools::context::McpToolOutput;
|
||||
use crate::tools::context::ToolInvocation;
|
||||
use crate::tools::context::ToolPayload;
|
||||
@@ -56,6 +57,7 @@ impl ToolHandler for McpHandler {
|
||||
Ok(McpToolOutput {
|
||||
result,
|
||||
wall_time: started.elapsed(),
|
||||
original_image_detail_supported: can_request_original_image_detail(&turn.model_info),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -562,6 +562,7 @@ async fn emit_tool_call_begin(
|
||||
EventMsg::McpToolCallBegin(McpToolCallBeginEvent {
|
||||
call_id: call_id.to_string(),
|
||||
invocation,
|
||||
mcp_app_resource_uri: None,
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
@@ -581,6 +582,7 @@ async fn emit_tool_call_end(
|
||||
EventMsg::McpToolCallEnd(McpToolCallEndEvent {
|
||||
call_id: call_id.to_string(),
|
||||
invocation,
|
||||
mcp_app_resource_uri: None,
|
||||
duration,
|
||||
result,
|
||||
}),
|
||||
|
||||
@@ -15,6 +15,7 @@ mod shell;
|
||||
mod test_sync;
|
||||
mod tool_search;
|
||||
mod tool_suggest;
|
||||
mod unavailable_tool;
|
||||
pub(crate) mod unified_exec;
|
||||
mod view_image;
|
||||
|
||||
@@ -49,6 +50,8 @@ pub use shell::ShellHandler;
|
||||
pub use test_sync::TestSyncHandler;
|
||||
pub use tool_search::ToolSearchHandler;
|
||||
pub use tool_suggest::ToolSuggestHandler;
|
||||
pub use unavailable_tool::UnavailableToolHandler;
|
||||
pub(crate) use unavailable_tool::unavailable_tool_message;
|
||||
pub use unified_exec::UnifiedExecHandler;
|
||||
pub use view_image::ViewImageHandler;
|
||||
|
||||
|
||||
@@ -1492,24 +1492,25 @@ async fn multi_agent_v2_followup_task_interrupts_busy_child_without_losing_messa
|
||||
let session = Arc::new(session);
|
||||
let turn = Arc::new(turn);
|
||||
|
||||
SpawnAgentHandlerV2
|
||||
.handle(invocation(
|
||||
session.clone(),
|
||||
turn.clone(),
|
||||
"spawn_agent",
|
||||
function_payload(json!({
|
||||
"message": "boot worker",
|
||||
"task_name": "worker"
|
||||
})),
|
||||
))
|
||||
.await
|
||||
.expect("spawn worker");
|
||||
let worker_path = AgentPath::try_from("/root/worker").expect("worker path");
|
||||
let agent_id = session
|
||||
.services
|
||||
.agent_control
|
||||
.resolve_agent_reference(session.conversation_id, &turn.session_source, "worker")
|
||||
.spawn_agent_with_metadata(
|
||||
(*turn.config).clone(),
|
||||
Op::CleanBackgroundTerminals,
|
||||
Some(SessionSource::SubAgent(SubAgentSource::ThreadSpawn {
|
||||
parent_thread_id: root.thread_id,
|
||||
depth: 1,
|
||||
agent_path: Some(worker_path.clone()),
|
||||
agent_nickname: None,
|
||||
agent_role: None,
|
||||
})),
|
||||
crate::agent::control::SpawnAgentOptions::default(),
|
||||
)
|
||||
.await
|
||||
.expect("worker should resolve");
|
||||
.expect("worker spawn should succeed")
|
||||
.thread_id;
|
||||
let thread = manager
|
||||
.get_thread(agent_id)
|
||||
.await
|
||||
@@ -1566,7 +1567,7 @@ async fn multi_agent_v2_followup_task_interrupts_busy_child_without_losing_messa
|
||||
&thread,
|
||||
&InterAgentCommunication::new(
|
||||
AgentPath::root(),
|
||||
AgentPath::try_from("/root/worker").expect("agent path"),
|
||||
worker_path,
|
||||
Vec::new(),
|
||||
"continue".to_string(),
|
||||
/*trigger_turn*/ true,
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
use crate::function_tool::FunctionCallError;
|
||||
use crate::tools::context::FunctionToolOutput;
|
||||
use crate::tools::context::ToolInvocation;
|
||||
use crate::tools::context::ToolPayload;
|
||||
use crate::tools::registry::ToolHandler;
|
||||
use crate::tools::registry::ToolKind;
|
||||
|
||||
pub struct UnavailableToolHandler;
|
||||
|
||||
pub(crate) fn unavailable_tool_message(
|
||||
tool_name: impl std::fmt::Display,
|
||||
next_step: &str,
|
||||
) -> String {
|
||||
format!(
|
||||
"Tool `{tool_name}` is not currently available. It appeared in earlier tool calls in this conversation, but its implementation is not available in the current request. {next_step}"
|
||||
)
|
||||
}
|
||||
|
||||
impl ToolHandler for UnavailableToolHandler {
|
||||
type Output = FunctionToolOutput;
|
||||
|
||||
fn kind(&self) -> ToolKind {
|
||||
ToolKind::Function
|
||||
}
|
||||
|
||||
async fn handle(&self, invocation: ToolInvocation) -> Result<Self::Output, FunctionCallError> {
|
||||
let ToolInvocation {
|
||||
tool_name, payload, ..
|
||||
} = invocation;
|
||||
|
||||
match payload {
|
||||
ToolPayload::Function { .. } => Ok(FunctionToolOutput::from_text(
|
||||
unavailable_tool_message(
|
||||
tool_name.display(),
|
||||
"Retry after the tool becomes available or ask the user to re-enable it.",
|
||||
),
|
||||
Some(false),
|
||||
)),
|
||||
_ => Err(FunctionCallError::RespondToModel(
|
||||
"unavailable tool handler received unsupported payload".to_string(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1330,6 +1330,13 @@ function normalizeMcpImageData(data, mimeType) {
|
||||
return `data:${normalizedMimeType};base64,${data}`;
|
||||
}
|
||||
|
||||
function parseMcpImageDetail(meta) {
|
||||
if (!isPlainObject(meta) || meta["codex/imageDetail"] !== "original") {
|
||||
return undefined;
|
||||
}
|
||||
return "original";
|
||||
}
|
||||
|
||||
function parseMcpToolResult(result) {
|
||||
if (typeof result === "string") {
|
||||
return { images: [], textCount: result.length > 0 ? 1 : 0 };
|
||||
@@ -1362,6 +1369,7 @@ function parseMcpToolResult(result) {
|
||||
if (item.type === "image") {
|
||||
images.push({
|
||||
image_url: normalizeMcpImageData(item.data, item.mimeType ?? item.mime_type),
|
||||
detail: parseMcpImageDetail(item._meta),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -45,6 +45,8 @@ use codex_sandboxing::SandboxCommand;
|
||||
use codex_sandboxing::SandboxManager;
|
||||
use codex_sandboxing::SandboxTransformRequest;
|
||||
use codex_sandboxing::SandboxablePreference;
|
||||
use codex_tools::ResponsesApiNamespaceTool;
|
||||
use codex_tools::ToolName;
|
||||
use codex_tools::ToolSpec;
|
||||
use codex_utils_output_truncation::TruncationPolicy;
|
||||
use codex_utils_output_truncation::truncate_text;
|
||||
@@ -1031,18 +1033,13 @@ impl JsReplManager {
|
||||
}
|
||||
|
||||
let sandbox = SandboxManager::new();
|
||||
let has_managed_network_requirements = turn
|
||||
.config
|
||||
.config_layer_stack
|
||||
.requirements_toml()
|
||||
.network
|
||||
.is_some();
|
||||
let managed_network_active = turn.network.is_some();
|
||||
let sandbox_type = sandbox.select_initial(
|
||||
&turn.file_system_sandbox_policy,
|
||||
turn.network_sandbox_policy,
|
||||
SandboxablePreference::Auto,
|
||||
turn.windows_sandbox_level,
|
||||
has_managed_network_requirements,
|
||||
managed_network_active,
|
||||
);
|
||||
let command = SandboxCommand {
|
||||
program: node_path.into_os_string(),
|
||||
@@ -1065,7 +1062,7 @@ impl JsReplManager {
|
||||
file_system_policy: &turn.file_system_sandbox_policy,
|
||||
network_policy: turn.network_sandbox_policy,
|
||||
sandbox: sandbox_type,
|
||||
enforce_managed_network: has_managed_network_requirements,
|
||||
enforce_managed_network: managed_network_active,
|
||||
network: None,
|
||||
sandbox_policy_cwd: &turn.cwd,
|
||||
codex_linux_sandbox_exe: turn.codex_linux_sandbox_exe.as_deref(),
|
||||
@@ -1566,6 +1563,7 @@ impl JsReplManager {
|
||||
crate::tools::router::ToolRouterParams {
|
||||
deferred_mcp_tools: None,
|
||||
mcp_tools: Some(mcp_tools),
|
||||
unavailable_called_tools: Vec::new(),
|
||||
// JS REPL dispatches nested tool calls directly, not through
|
||||
// `ToolCallRuntime`'s parallel scheduling lock.
|
||||
parallel_mcp_server_names: std::collections::HashSet::new(),
|
||||
@@ -1574,29 +1572,67 @@ impl JsReplManager {
|
||||
},
|
||||
);
|
||||
|
||||
let payload = if let Some(tool_info) = exec
|
||||
let specs = router.specs();
|
||||
let requested_tool_name = specs
|
||||
.iter()
|
||||
.find_map(|spec| match spec {
|
||||
ToolSpec::Function(tool) if tool.name == req.tool_name => {
|
||||
Some(ToolName::plain(req.tool_name.clone()))
|
||||
}
|
||||
ToolSpec::Freeform(tool) if tool.name == req.tool_name => {
|
||||
Some(ToolName::plain(req.tool_name.clone()))
|
||||
}
|
||||
ToolSpec::Namespace(namespace) => {
|
||||
namespace.tools.iter().find_map(|tool| match tool {
|
||||
ResponsesApiNamespaceTool::Function(tool) => {
|
||||
let tool_name =
|
||||
ToolName::namespaced(namespace.name.clone(), tool.name.clone());
|
||||
(tool_name.display() == req.tool_name).then_some(tool_name)
|
||||
}
|
||||
})
|
||||
}
|
||||
ToolSpec::LocalShell {}
|
||||
| ToolSpec::ImageGeneration { .. }
|
||||
| ToolSpec::ToolSearch { .. }
|
||||
| ToolSpec::WebSearch { .. }
|
||||
| ToolSpec::Function(_)
|
||||
| ToolSpec::Freeform(_) => None,
|
||||
})
|
||||
.unwrap_or_else(|| ToolName::plain(req.tool_name.clone()));
|
||||
let (tool_call_name, payload) = if let Some(tool_info) = exec
|
||||
.session
|
||||
.resolve_mcp_tool_info(&req.tool_name, /*namespace*/ None)
|
||||
.resolve_mcp_tool_info(&requested_tool_name)
|
||||
.await
|
||||
{
|
||||
crate::tools::context::ToolPayload::Mcp {
|
||||
server: tool_info.server_name,
|
||||
tool: tool_info.tool.name.to_string(),
|
||||
raw_arguments: req.arguments.clone(),
|
||||
}
|
||||
} else if is_freeform_tool(&router.specs(), &req.tool_name) {
|
||||
crate::tools::context::ToolPayload::Custom {
|
||||
input: req.arguments.clone(),
|
||||
}
|
||||
(
|
||||
tool_info.canonical_tool_name(),
|
||||
crate::tools::context::ToolPayload::Mcp {
|
||||
server: tool_info.server_name,
|
||||
tool: tool_info.tool.name.to_string(),
|
||||
raw_arguments: req.arguments.clone(),
|
||||
},
|
||||
)
|
||||
} else if matches!(
|
||||
router.find_spec(&requested_tool_name),
|
||||
Some(ToolSpec::Freeform(_))
|
||||
) {
|
||||
(
|
||||
requested_tool_name,
|
||||
crate::tools::context::ToolPayload::Custom {
|
||||
input: req.arguments.clone(),
|
||||
},
|
||||
)
|
||||
} else {
|
||||
crate::tools::context::ToolPayload::Function {
|
||||
arguments: req.arguments.clone(),
|
||||
}
|
||||
(
|
||||
requested_tool_name,
|
||||
crate::tools::context::ToolPayload::Function {
|
||||
arguments: req.arguments.clone(),
|
||||
},
|
||||
)
|
||||
};
|
||||
|
||||
let tool_name = req.tool_name.clone();
|
||||
let call = crate::tools::router::ToolCall {
|
||||
tool_name: codex_tools::ToolName::plain(tool_name.clone()),
|
||||
tool_name: tool_call_name,
|
||||
call_id: req.id.clone(),
|
||||
payload,
|
||||
};
|
||||
@@ -1755,12 +1791,6 @@ fn split_exec_result_content_items(
|
||||
}
|
||||
}
|
||||
|
||||
fn is_freeform_tool(specs: &[ToolSpec], name: &str) -> bool {
|
||||
specs
|
||||
.iter()
|
||||
.any(|spec| spec.name() == name && matches!(spec, ToolSpec::Freeform(_)))
|
||||
}
|
||||
|
||||
fn is_js_repl_internal_tool(name: &str) -> bool {
|
||||
matches!(name, "js_repl" | "js_repl_reset")
|
||||
}
|
||||
|
||||
@@ -578,11 +578,11 @@ pub(crate) fn build_network_policy_decider(
|
||||
pub(crate) async fn begin_network_approval(
|
||||
session: &Session,
|
||||
turn_id: &str,
|
||||
has_managed_network_requirements: bool,
|
||||
managed_network_active: bool,
|
||||
spec: Option<NetworkApprovalSpec>,
|
||||
) -> Option<ActiveNetworkApproval> {
|
||||
let spec = spec?;
|
||||
if !has_managed_network_requirements || spec.network.is_none() {
|
||||
if !managed_network_active || spec.network.is_none() {
|
||||
return None;
|
||||
}
|
||||
|
||||
|
||||
@@ -55,7 +55,7 @@ impl ToolOrchestrator {
|
||||
req: &Rq,
|
||||
tool_ctx: &ToolCtx,
|
||||
attempt: &SandboxAttempt<'_>,
|
||||
has_managed_network_requirements: bool,
|
||||
managed_network_active: bool,
|
||||
) -> (Result<Out, ToolError>, Option<DeferredNetworkApproval>)
|
||||
where
|
||||
T: ToolRuntime<Rq, Out>,
|
||||
@@ -63,7 +63,7 @@ impl ToolOrchestrator {
|
||||
let network_approval = begin_network_approval(
|
||||
&tool_ctx.session,
|
||||
&tool_ctx.turn.sub_id,
|
||||
has_managed_network_requirements,
|
||||
managed_network_active,
|
||||
tool.network_approval_spec(req, tool_ctx),
|
||||
)
|
||||
.await;
|
||||
@@ -180,12 +180,7 @@ impl ToolOrchestrator {
|
||||
}
|
||||
|
||||
// 2) First attempt under the selected sandbox.
|
||||
let has_managed_network_requirements = turn_ctx
|
||||
.config
|
||||
.config_layer_stack
|
||||
.requirements_toml()
|
||||
.network
|
||||
.is_some();
|
||||
let managed_network_active = turn_ctx.network.is_some();
|
||||
let initial_sandbox = match tool.sandbox_mode_for_first_attempt(req) {
|
||||
SandboxOverride::BypassSandboxFirstAttempt => SandboxType::None,
|
||||
SandboxOverride::NoOverride => self.sandbox.select_initial(
|
||||
@@ -193,7 +188,7 @@ impl ToolOrchestrator {
|
||||
turn_ctx.network_sandbox_policy,
|
||||
tool.sandbox_preference(),
|
||||
turn_ctx.windows_sandbox_level,
|
||||
has_managed_network_requirements,
|
||||
managed_network_active,
|
||||
),
|
||||
};
|
||||
|
||||
@@ -204,7 +199,7 @@ impl ToolOrchestrator {
|
||||
policy: &turn_ctx.sandbox_policy,
|
||||
file_system_policy: &turn_ctx.file_system_sandbox_policy,
|
||||
network_policy: turn_ctx.network_sandbox_policy,
|
||||
enforce_managed_network: has_managed_network_requirements,
|
||||
enforce_managed_network: managed_network_active,
|
||||
manager: &self.sandbox,
|
||||
sandbox_cwd: &turn_ctx.cwd,
|
||||
codex_linux_sandbox_exe: turn_ctx.codex_linux_sandbox_exe.as_ref(),
|
||||
@@ -221,7 +216,7 @@ impl ToolOrchestrator {
|
||||
req,
|
||||
tool_ctx,
|
||||
&initial_attempt,
|
||||
has_managed_network_requirements,
|
||||
managed_network_active,
|
||||
)
|
||||
.await;
|
||||
match first_result {
|
||||
@@ -236,7 +231,7 @@ impl ToolOrchestrator {
|
||||
output,
|
||||
network_policy_decision,
|
||||
}))) => {
|
||||
let network_approval_context = if has_managed_network_requirements {
|
||||
let network_approval_context = if managed_network_active {
|
||||
network_policy_decision
|
||||
.as_ref()
|
||||
.and_then(network_approval_context_from_payload)
|
||||
@@ -341,7 +336,7 @@ impl ToolOrchestrator {
|
||||
policy: &turn_ctx.sandbox_policy,
|
||||
file_system_policy: &turn_ctx.file_system_sandbox_policy,
|
||||
network_policy: turn_ctx.network_sandbox_policy,
|
||||
enforce_managed_network: has_managed_network_requirements,
|
||||
enforce_managed_network: managed_network_active,
|
||||
manager: &self.sandbox,
|
||||
sandbox_cwd: &turn_ctx.cwd,
|
||||
codex_linux_sandbox_exe: None,
|
||||
@@ -359,7 +354,7 @@ impl ToolOrchestrator {
|
||||
req,
|
||||
tool_ctx,
|
||||
&escalated_attempt,
|
||||
has_managed_network_requirements,
|
||||
managed_network_active,
|
||||
)
|
||||
.await;
|
||||
retry_result.map(|output| OrchestratorRunResult {
|
||||
|
||||
@@ -48,7 +48,7 @@ impl ToolCallRuntime {
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn find_spec(&self, tool_name: &str) -> Option<ToolSpec> {
|
||||
pub(crate) fn find_spec(&self, tool_name: &codex_tools::ToolName) -> Option<ToolSpec> {
|
||||
self.router.find_spec(tool_name)
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ use codex_protocol::models::SearchToolCallParams;
|
||||
use codex_protocol::models::ShellToolCallParams;
|
||||
use codex_tools::ConfiguredToolSpec;
|
||||
use codex_tools::DiscoverableTool;
|
||||
use codex_tools::ResponsesApiNamespaceTool;
|
||||
use codex_tools::ToolName;
|
||||
use codex_tools::ToolSpec;
|
||||
use codex_tools::ToolsConfig;
|
||||
@@ -43,6 +44,7 @@ pub struct ToolRouter {
|
||||
pub(crate) struct ToolRouterParams<'a> {
|
||||
pub(crate) mcp_tools: Option<HashMap<String, ToolInfo>>,
|
||||
pub(crate) deferred_mcp_tools: Option<HashMap<String, ToolInfo>>,
|
||||
pub(crate) unavailable_called_tools: Vec<ToolName>,
|
||||
pub(crate) parallel_mcp_server_names: HashSet<String>,
|
||||
pub(crate) discoverable_tools: Option<Vec<DiscoverableTool>>,
|
||||
pub(crate) dynamic_tools: &'a [DynamicToolSpec],
|
||||
@@ -53,6 +55,7 @@ impl ToolRouter {
|
||||
let ToolRouterParams {
|
||||
mcp_tools,
|
||||
deferred_mcp_tools,
|
||||
unavailable_called_tools,
|
||||
parallel_mcp_server_names,
|
||||
discoverable_tools,
|
||||
dynamic_tools,
|
||||
@@ -61,6 +64,7 @@ impl ToolRouter {
|
||||
config,
|
||||
mcp_tools,
|
||||
deferred_mcp_tools,
|
||||
unavailable_called_tools,
|
||||
discoverable_tools,
|
||||
dynamic_tools,
|
||||
);
|
||||
@@ -102,20 +106,48 @@ impl ToolRouter {
|
||||
self.model_visible_specs.clone()
|
||||
}
|
||||
|
||||
pub fn find_spec(&self, tool_name: &str) -> Option<ToolSpec> {
|
||||
self.specs
|
||||
.iter()
|
||||
.find(|config| config.name() == tool_name)
|
||||
.map(|config| config.spec.clone())
|
||||
pub fn find_spec(&self, tool_name: &ToolName) -> Option<ToolSpec> {
|
||||
self.specs.iter().find_map(|config| match &config.spec {
|
||||
ToolSpec::Function(tool)
|
||||
if tool_name.namespace.is_none() && tool.name == tool_name.name =>
|
||||
{
|
||||
Some(config.spec.clone())
|
||||
}
|
||||
ToolSpec::Freeform(tool)
|
||||
if tool_name.namespace.is_none() && tool.name == tool_name.name =>
|
||||
{
|
||||
Some(config.spec.clone())
|
||||
}
|
||||
ToolSpec::Namespace(namespace) => namespace.tools.iter().find_map(|tool| match tool {
|
||||
ResponsesApiNamespaceTool::Function(tool)
|
||||
if tool_name.namespace.as_deref() == Some(namespace.name.as_str())
|
||||
&& tool.name == tool_name.name =>
|
||||
{
|
||||
Some(ToolSpec::Function(tool.clone()))
|
||||
}
|
||||
_ => None,
|
||||
}),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
fn configured_tool_supports_parallel(&self, tool_name: &ToolName) -> bool {
|
||||
tool_name.namespace.is_none()
|
||||
&& self
|
||||
.specs
|
||||
.iter()
|
||||
.filter(|config| config.supports_parallel_tool_calls)
|
||||
.any(|config| config.name() == tool_name.name.as_str())
|
||||
if tool_name.namespace.is_some() {
|
||||
return false;
|
||||
}
|
||||
|
||||
self.specs
|
||||
.iter()
|
||||
.filter(|config| config.supports_parallel_tool_calls)
|
||||
.any(|config| match &config.spec {
|
||||
ToolSpec::Function(tool) => tool.name == tool_name.name.as_str(),
|
||||
ToolSpec::Freeform(tool) => tool.name == tool_name.name.as_str(),
|
||||
ToolSpec::Namespace(_)
|
||||
| ToolSpec::ToolSearch { .. }
|
||||
| ToolSpec::LocalShell {}
|
||||
| ToolSpec::ImageGeneration { .. }
|
||||
| ToolSpec::WebSearch { .. } => false,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn tool_supports_parallel(&self, call: &ToolCall) -> bool {
|
||||
@@ -141,16 +173,10 @@ impl ToolRouter {
|
||||
call_id,
|
||||
..
|
||||
} => {
|
||||
let mcp_tool = session
|
||||
.resolve_mcp_tool_info(&name, namespace.as_deref())
|
||||
.await;
|
||||
let tool_name = match namespace {
|
||||
Some(namespace) => ToolName::namespaced(namespace, name),
|
||||
None => ToolName::plain(name),
|
||||
};
|
||||
if let Some(tool_info) = mcp_tool {
|
||||
let tool_name = ToolName::new(namespace, name);
|
||||
if let Some(tool_info) = session.resolve_mcp_tool_info(&tool_name).await {
|
||||
Ok(Some(ToolCall {
|
||||
tool_name,
|
||||
tool_name: tool_info.canonical_tool_name(),
|
||||
call_id,
|
||||
payload: ToolPayload::Mcp {
|
||||
server: tool_info.server_name,
|
||||
|
||||
@@ -33,6 +33,7 @@ async fn js_repl_tools_only_blocks_direct_tool_calls() -> anyhow::Result<()> {
|
||||
ToolRouterParams {
|
||||
deferred_mcp_tools,
|
||||
mcp_tools: Some(mcp_tools),
|
||||
unavailable_called_tools: Vec::new(),
|
||||
parallel_mcp_server_names: HashSet::new(),
|
||||
discoverable_tools: None,
|
||||
dynamic_tools: turn.dynamic_tools.as_slice(),
|
||||
@@ -86,6 +87,7 @@ async fn js_repl_tools_only_allows_js_repl_source_calls() -> anyhow::Result<()>
|
||||
ToolRouterParams {
|
||||
deferred_mcp_tools,
|
||||
mcp_tools: Some(mcp_tools),
|
||||
unavailable_called_tools: Vec::new(),
|
||||
parallel_mcp_server_names: HashSet::new(),
|
||||
discoverable_tools: None,
|
||||
dynamic_tools: turn.dynamic_tools.as_slice(),
|
||||
@@ -132,6 +134,7 @@ async fn js_repl_tools_only_blocks_namespaced_js_repl_tool() -> anyhow::Result<(
|
||||
ToolRouterParams {
|
||||
deferred_mcp_tools: None,
|
||||
mcp_tools: None,
|
||||
unavailable_called_tools: Vec::new(),
|
||||
parallel_mcp_server_names: HashSet::new(),
|
||||
discoverable_tools: None,
|
||||
dynamic_tools: turn.dynamic_tools.as_slice(),
|
||||
@@ -182,6 +185,7 @@ async fn parallel_support_does_not_match_namespaced_local_tool_names() -> anyhow
|
||||
ToolRouterParams {
|
||||
deferred_mcp_tools: None,
|
||||
mcp_tools: Some(mcp_tools),
|
||||
unavailable_called_tools: Vec::new(),
|
||||
parallel_mcp_server_names: HashSet::new(),
|
||||
discoverable_tools: None,
|
||||
dynamic_tools: turn.dynamic_tools.as_slice(),
|
||||
@@ -254,6 +258,7 @@ async fn mcp_parallel_support_uses_exact_payload_server() -> anyhow::Result<()>
|
||||
ToolRouterParams {
|
||||
deferred_mcp_tools: None,
|
||||
mcp_tools: None,
|
||||
unavailable_called_tools: Vec::new(),
|
||||
parallel_mcp_server_names: HashSet::from(["echo".to_string()]),
|
||||
discoverable_tools: None,
|
||||
dynamic_tools: turn.dynamic_tools.as_slice(),
|
||||
|
||||
@@ -7,16 +7,23 @@ use crate::tools::handlers::multi_agents_common::MIN_WAIT_TIMEOUT_MS;
|
||||
use crate::tools::registry::ToolRegistryBuilder;
|
||||
use codex_mcp::ToolInfo;
|
||||
use codex_protocol::dynamic_tools::DynamicToolSpec;
|
||||
use codex_tools::AdditionalProperties;
|
||||
use codex_tools::DiscoverableTool;
|
||||
use codex_tools::JsonSchema;
|
||||
use codex_tools::ResponsesApiTool;
|
||||
use codex_tools::ToolHandlerKind;
|
||||
use codex_tools::ToolName;
|
||||
use codex_tools::ToolNamespace;
|
||||
use codex_tools::ToolRegistryPlanDeferredTool;
|
||||
use codex_tools::ToolRegistryPlanMcpTool;
|
||||
use codex_tools::ToolRegistryPlanParams;
|
||||
use codex_tools::ToolUserShellType;
|
||||
use codex_tools::ToolsConfig;
|
||||
use codex_tools::WaitAgentTimeoutOptions;
|
||||
use codex_tools::augment_tool_spec_for_code_mode;
|
||||
use codex_tools::build_tool_registry_plan;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
pub(crate) fn tool_user_shell_type(user_shell: &Shell) -> ToolUserShellType {
|
||||
@@ -29,25 +36,31 @@ pub(crate) fn tool_user_shell_type(user_shell: &Shell) -> ToolUserShellType {
|
||||
}
|
||||
}
|
||||
|
||||
struct McpToolPlanInputs {
|
||||
mcp_tools: HashMap<String, rmcp::model::Tool>,
|
||||
struct McpToolPlanInputs<'a> {
|
||||
mcp_tools: Vec<ToolRegistryPlanMcpTool<'a>>,
|
||||
tool_namespaces: HashMap<String, ToolNamespace>,
|
||||
}
|
||||
|
||||
fn map_mcp_tools_for_plan(mcp_tools: &HashMap<String, ToolInfo>) -> McpToolPlanInputs {
|
||||
fn map_mcp_tools_for_plan(mcp_tools: &HashMap<String, ToolInfo>) -> McpToolPlanInputs<'_> {
|
||||
McpToolPlanInputs {
|
||||
mcp_tools: mcp_tools
|
||||
.iter()
|
||||
.map(|(name, tool)| (name.clone(), tool.tool.clone()))
|
||||
.values()
|
||||
.map(|tool| ToolRegistryPlanMcpTool {
|
||||
name: tool.canonical_tool_name(),
|
||||
tool: &tool.tool,
|
||||
})
|
||||
.collect(),
|
||||
tool_namespaces: mcp_tools
|
||||
.iter()
|
||||
.map(|(name, tool)| {
|
||||
.values()
|
||||
.map(|tool| {
|
||||
(
|
||||
name.clone(),
|
||||
tool.callable_namespace.clone(),
|
||||
ToolNamespace {
|
||||
name: tool.callable_namespace.clone(),
|
||||
description: tool.server_instructions.clone(),
|
||||
description: tool
|
||||
.connector_description
|
||||
.clone()
|
||||
.or_else(|| tool.server_instructions.clone()),
|
||||
},
|
||||
)
|
||||
})
|
||||
@@ -59,6 +72,7 @@ pub(crate) fn build_specs_with_discoverable_tools(
|
||||
config: &ToolsConfig,
|
||||
mcp_tools: Option<HashMap<String, ToolInfo>>,
|
||||
deferred_mcp_tools: Option<HashMap<String, ToolInfo>>,
|
||||
unavailable_called_tools: Vec<ToolName>,
|
||||
discoverable_tools: Option<Vec<DiscoverableTool>>,
|
||||
dynamic_tools: &[DynamicToolSpec],
|
||||
) -> ToolRegistryBuilder {
|
||||
@@ -79,6 +93,7 @@ pub(crate) fn build_specs_with_discoverable_tools(
|
||||
use crate::tools::handlers::TestSyncHandler;
|
||||
use crate::tools::handlers::ToolSearchHandler;
|
||||
use crate::tools::handlers::ToolSuggestHandler;
|
||||
use crate::tools::handlers::UnavailableToolHandler;
|
||||
use crate::tools::handlers::UnifiedExecHandler;
|
||||
use crate::tools::handlers::ViewImageHandler;
|
||||
use crate::tools::handlers::multi_agents::CloseAgentHandler;
|
||||
@@ -92,6 +107,7 @@ pub(crate) fn build_specs_with_discoverable_tools(
|
||||
use crate::tools::handlers::multi_agents_v2::SendMessageHandler as SendMessageHandlerV2;
|
||||
use crate::tools::handlers::multi_agents_v2::SpawnAgentHandler as SpawnAgentHandlerV2;
|
||||
use crate::tools::handlers::multi_agents_v2::WaitAgentHandler as WaitAgentHandlerV2;
|
||||
use crate::tools::handlers::unavailable_tool_message;
|
||||
|
||||
let mut builder = ToolRegistryBuilder::new();
|
||||
let mcp_tool_plan_inputs = mcp_tools.as_ref().map(map_mcp_tools_for_plan);
|
||||
@@ -99,8 +115,7 @@ pub(crate) fn build_specs_with_discoverable_tools(
|
||||
tools
|
||||
.values()
|
||||
.map(|tool| ToolRegistryPlanDeferredTool {
|
||||
tool_name: tool.callable_name.as_str(),
|
||||
tool_namespace: tool.callable_namespace.as_str(),
|
||||
name: tool.canonical_tool_name(),
|
||||
server_name: tool.server_name.as_str(),
|
||||
connector_name: tool.connector_name.as_deref(),
|
||||
connector_description: tool.connector_description.as_deref(),
|
||||
@@ -114,7 +129,7 @@ pub(crate) fn build_specs_with_discoverable_tools(
|
||||
ToolRegistryPlanParams {
|
||||
mcp_tools: mcp_tool_plan_inputs
|
||||
.as_ref()
|
||||
.map(|inputs| &inputs.mcp_tools),
|
||||
.map(|inputs| inputs.mcp_tools.as_slice()),
|
||||
deferred_mcp_tools: deferred_mcp_tool_sources.as_deref(),
|
||||
tool_namespaces: mcp_tool_plan_inputs
|
||||
.as_ref()
|
||||
@@ -148,6 +163,12 @@ pub(crate) fn build_specs_with_discoverable_tools(
|
||||
let code_mode_wait_handler = Arc::new(CodeModeWaitHandler);
|
||||
let js_repl_handler = Arc::new(JsReplHandler);
|
||||
let js_repl_reset_handler = Arc::new(JsReplResetHandler);
|
||||
let unavailable_tool_handler = Arc::new(UnavailableToolHandler);
|
||||
let mut existing_spec_names = plan
|
||||
.specs
|
||||
.iter()
|
||||
.map(|configured_tool| configured_tool.name().to_string())
|
||||
.collect::<HashSet<_>>();
|
||||
|
||||
for spec in plan.specs {
|
||||
if spec.supports_parallel_tool_calls {
|
||||
@@ -272,6 +293,34 @@ pub(crate) fn build_specs_with_discoverable_tools(
|
||||
builder.register_handler(name.clone(), mcp_handler.clone());
|
||||
}
|
||||
}
|
||||
|
||||
for unavailable_tool in unavailable_called_tools {
|
||||
let tool_name = unavailable_tool.display();
|
||||
if existing_spec_names.insert(tool_name.clone()) {
|
||||
let spec = codex_tools::ToolSpec::Function(ResponsesApiTool {
|
||||
name: tool_name.clone(),
|
||||
description: unavailable_tool_message(
|
||||
&tool_name,
|
||||
"Calling this placeholder returns an error explaining that the tool is unavailable.",
|
||||
),
|
||||
strict: false,
|
||||
parameters: JsonSchema::object(
|
||||
Default::default(),
|
||||
/*required*/ None,
|
||||
Some(AdditionalProperties::Boolean(false)),
|
||||
),
|
||||
output_schema: None,
|
||||
defer_loading: None,
|
||||
});
|
||||
let spec = if config.code_mode_enabled {
|
||||
augment_tool_spec_for_code_mode(spec)
|
||||
} else {
|
||||
spec
|
||||
};
|
||||
builder.push_spec(spec);
|
||||
}
|
||||
builder.register_handler(unavailable_tool, unavailable_tool_handler.clone());
|
||||
}
|
||||
builder
|
||||
}
|
||||
|
||||
|
||||
@@ -16,9 +16,11 @@ use codex_protocol::openai_models::ConfigShellToolType;
|
||||
use codex_protocol::openai_models::ModelInfo;
|
||||
use codex_protocol::protocol::SandboxPolicy;
|
||||
use codex_protocol::protocol::SessionSource;
|
||||
use codex_tools::AdditionalProperties;
|
||||
use codex_tools::ConfiguredToolSpec;
|
||||
use codex_tools::DiscoverableTool;
|
||||
use codex_tools::JsonSchema;
|
||||
use codex_tools::ResponsesApiNamespaceTool;
|
||||
use codex_tools::ResponsesApiTool;
|
||||
use codex_tools::ShellCommandBackendConfig;
|
||||
use codex_tools::TOOL_SEARCH_TOOL_NAME;
|
||||
@@ -67,6 +69,25 @@ fn mcp_tool_info(tool: rmcp::model::Tool) -> ToolInfo {
|
||||
}
|
||||
}
|
||||
|
||||
fn mcp_tool_info_with_display_name(display_name: &str, tool: rmcp::model::Tool) -> ToolInfo {
|
||||
let (callable_namespace, callable_name) = display_name
|
||||
.rsplit_once('/')
|
||||
.map(|(namespace, callable_name)| (format!("{namespace}/"), callable_name.to_string()))
|
||||
.unwrap_or_else(|| ("".to_string(), display_name.to_string()));
|
||||
|
||||
ToolInfo {
|
||||
server_name: "test_server".to_string(),
|
||||
callable_name,
|
||||
callable_namespace,
|
||||
server_instructions: None,
|
||||
tool,
|
||||
connector_id: None,
|
||||
connector_name: None,
|
||||
plugin_display_names: Vec::new(),
|
||||
connector_description: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn discoverable_connector(id: &str, name: &str, description: &str) -> DiscoverableTool {
|
||||
let slug = name.replace(' ', "-").to_lowercase();
|
||||
DiscoverableTool::Connector(Box::new(AppInfo {
|
||||
@@ -86,8 +107,8 @@ fn discoverable_connector(id: &str, name: &str, description: &str) -> Discoverab
|
||||
}))
|
||||
}
|
||||
|
||||
fn search_capable_model_info() -> ModelInfo {
|
||||
let config = test_config();
|
||||
async fn search_capable_model_info() -> ModelInfo {
|
||||
let config = test_config().await;
|
||||
let mut model_info = construct_model_info_offline("gpt-5-codex", &config);
|
||||
model_info.supports_search_tool = true;
|
||||
model_info
|
||||
@@ -109,8 +130,11 @@ fn deferred_responses_api_tool_serializes_with_defer_loading() {
|
||||
);
|
||||
|
||||
let serialized = serde_json::to_value(ToolSpec::Function(
|
||||
mcp_tool_to_deferred_responses_api_tool("mcp__codex_apps__lookup_order".to_string(), &tool)
|
||||
.expect("convert deferred tool"),
|
||||
mcp_tool_to_deferred_responses_api_tool(
|
||||
&ToolName::namespaced("mcp__codex_apps__", "lookup_order"),
|
||||
&tool,
|
||||
)
|
||||
.expect("convert deferred tool"),
|
||||
))
|
||||
.expect("serialize deferred tool");
|
||||
|
||||
@@ -118,7 +142,7 @@ fn deferred_responses_api_tool_serializes_with_defer_loading() {
|
||||
serialized,
|
||||
serde_json::json!({
|
||||
"type": "function",
|
||||
"name": "mcp__codex_apps__lookup_order",
|
||||
"name": "lookup_order",
|
||||
"description": "Look up an order",
|
||||
"strict": false,
|
||||
"defer_loading": true,
|
||||
@@ -173,8 +197,27 @@ fn find_tool<'a>(tools: &'a [ConfiguredToolSpec], expected_name: &str) -> &'a Co
|
||||
.unwrap_or_else(|| panic!("expected tool {expected_name}"))
|
||||
}
|
||||
|
||||
fn multi_agent_v2_tools_config() -> ToolsConfig {
|
||||
let config = test_config();
|
||||
fn find_namespace_function_tool<'a>(
|
||||
tools: &'a [ConfiguredToolSpec],
|
||||
expected_namespace: &str,
|
||||
expected_name: &str,
|
||||
) -> &'a ResponsesApiTool {
|
||||
let namespace_tool = find_tool(tools, expected_namespace);
|
||||
let ToolSpec::Namespace(namespace) = &namespace_tool.spec else {
|
||||
panic!("expected namespace tool {expected_namespace}");
|
||||
};
|
||||
namespace
|
||||
.tools
|
||||
.iter()
|
||||
.find_map(|tool| match tool {
|
||||
ResponsesApiNamespaceTool::Function(tool) if tool.name == expected_name => Some(tool),
|
||||
_ => None,
|
||||
})
|
||||
.unwrap_or_else(|| panic!("expected tool {expected_namespace}{expected_name} in namespace"))
|
||||
}
|
||||
|
||||
async fn multi_agent_v2_tools_config() -> ToolsConfig {
|
||||
let config = test_config().await;
|
||||
let model_info = construct_model_info_offline("gpt-5-codex", &config);
|
||||
let mut features = Features::with_defaults();
|
||||
features.enable(Feature::Collab);
|
||||
@@ -207,8 +250,8 @@ fn multi_agent_v2_spawn_agent_description(tools_config: &ToolsConfig) -> String
|
||||
description.clone()
|
||||
}
|
||||
|
||||
fn model_info_from_models_json(slug: &str) -> ModelInfo {
|
||||
let config = test_config();
|
||||
async fn model_info_from_models_json(slug: &str) -> ModelInfo {
|
||||
let config = test_config().await;
|
||||
let response = bundled_models_response()
|
||||
.unwrap_or_else(|err| panic!("bundled models.json should parse: {err}"));
|
||||
let model = response
|
||||
@@ -225,19 +268,36 @@ fn build_specs(
|
||||
mcp_tools: Option<HashMap<String, ToolInfo>>,
|
||||
deferred_mcp_tools: Option<HashMap<String, ToolInfo>>,
|
||||
dynamic_tools: &[DynamicToolSpec],
|
||||
) -> ToolRegistryBuilder {
|
||||
build_specs_with_unavailable_tools(
|
||||
config,
|
||||
mcp_tools,
|
||||
deferred_mcp_tools,
|
||||
Vec::new(),
|
||||
dynamic_tools,
|
||||
)
|
||||
}
|
||||
|
||||
fn build_specs_with_unavailable_tools(
|
||||
config: &ToolsConfig,
|
||||
mcp_tools: Option<HashMap<String, ToolInfo>>,
|
||||
deferred_mcp_tools: Option<HashMap<String, ToolInfo>>,
|
||||
unavailable_called_tools: Vec<ToolName>,
|
||||
dynamic_tools: &[DynamicToolSpec],
|
||||
) -> ToolRegistryBuilder {
|
||||
build_specs_with_discoverable_tools(
|
||||
config,
|
||||
mcp_tools,
|
||||
deferred_mcp_tools,
|
||||
unavailable_called_tools,
|
||||
/*discoverable_tools*/ None,
|
||||
dynamic_tools,
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_provided_unified_exec_is_blocked_for_windows_sandboxed_policies() {
|
||||
let mut model_info = model_info_from_models_json("gpt-5-codex");
|
||||
#[tokio::test]
|
||||
async fn model_provided_unified_exec_is_blocked_for_windows_sandboxed_policies() {
|
||||
let mut model_info = model_info_from_models_json("gpt-5-codex").await;
|
||||
model_info.shell_type = ConfigShellToolType::UnifiedExec;
|
||||
let features = Features::with_defaults();
|
||||
let available_models = Vec::new();
|
||||
@@ -260,9 +320,9 @@ fn model_provided_unified_exec_is_blocked_for_windows_sandboxed_policies() {
|
||||
assert_eq!(config.shell_type, expected_shell_type);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn get_memory_requires_feature_flag() {
|
||||
let config = test_config();
|
||||
#[tokio::test]
|
||||
async fn get_memory_requires_feature_flag() {
|
||||
let config = test_config().await;
|
||||
let model_info = construct_model_info_offline("gpt-5-codex", &config);
|
||||
let mut features = Features::with_defaults();
|
||||
features.disable(Feature::MemoryTool);
|
||||
@@ -290,14 +350,14 @@ fn get_memory_requires_feature_flag() {
|
||||
);
|
||||
}
|
||||
|
||||
fn assert_model_tools(
|
||||
async fn assert_model_tools(
|
||||
model_slug: &str,
|
||||
features: &Features,
|
||||
web_search_mode: Option<WebSearchMode>,
|
||||
expected_tools: &[&str],
|
||||
) {
|
||||
let _config = test_config();
|
||||
let model_info = model_info_from_models_json(model_slug);
|
||||
let _config = test_config().await;
|
||||
let model_info = model_info_from_models_json(model_slug).await;
|
||||
let available_models = Vec::new();
|
||||
let tools_config = ToolsConfig::new(&ToolsConfigParams {
|
||||
model_info: &model_info,
|
||||
@@ -314,6 +374,7 @@ fn assert_model_tools(
|
||||
ToolRouterParams {
|
||||
mcp_tools: None,
|
||||
deferred_mcp_tools: None,
|
||||
unavailable_called_tools: Vec::new(),
|
||||
parallel_mcp_server_names: std::collections::HashSet::new(),
|
||||
discoverable_tools: None,
|
||||
dynamic_tools: &[],
|
||||
@@ -327,7 +388,7 @@ fn assert_model_tools(
|
||||
assert_eq!(&tool_names, &expected_tools,);
|
||||
}
|
||||
|
||||
fn assert_default_model_tools(
|
||||
async fn assert_default_model_tools(
|
||||
model_slug: &str,
|
||||
features: &Features,
|
||||
web_search_mode: Option<WebSearchMode>,
|
||||
@@ -340,11 +401,11 @@ fn assert_default_model_tools(
|
||||
vec![shell_tool]
|
||||
};
|
||||
expected.extend(expected_tail);
|
||||
assert_model_tools(model_slug, features, web_search_mode, &expected);
|
||||
assert_model_tools(model_slug, features, web_search_mode, &expected).await;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_specs_gpt5_codex_default() {
|
||||
#[tokio::test]
|
||||
async fn test_build_specs_gpt5_codex_default() {
|
||||
let features = Features::with_defaults();
|
||||
assert_default_model_tools(
|
||||
"gpt-5-codex",
|
||||
@@ -356,6 +417,7 @@ fn test_build_specs_gpt5_codex_default() {
|
||||
"request_user_input",
|
||||
"apply_patch",
|
||||
"web_search",
|
||||
"image_generation",
|
||||
"view_image",
|
||||
"spawn_agent",
|
||||
"send_input",
|
||||
@@ -363,11 +425,12 @@ fn test_build_specs_gpt5_codex_default() {
|
||||
"wait_agent",
|
||||
"close_agent",
|
||||
],
|
||||
);
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_specs_gpt51_codex_default() {
|
||||
#[tokio::test]
|
||||
async fn test_build_specs_gpt51_codex_default() {
|
||||
let features = Features::with_defaults();
|
||||
assert_default_model_tools(
|
||||
"gpt-5.1-codex",
|
||||
@@ -379,6 +442,7 @@ fn test_build_specs_gpt51_codex_default() {
|
||||
"request_user_input",
|
||||
"apply_patch",
|
||||
"web_search",
|
||||
"image_generation",
|
||||
"view_image",
|
||||
"spawn_agent",
|
||||
"send_input",
|
||||
@@ -386,11 +450,12 @@ fn test_build_specs_gpt51_codex_default() {
|
||||
"wait_agent",
|
||||
"close_agent",
|
||||
],
|
||||
);
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_specs_gpt5_codex_unified_exec_web_search() {
|
||||
#[tokio::test]
|
||||
async fn test_build_specs_gpt5_codex_unified_exec_web_search() {
|
||||
let mut features = Features::with_defaults();
|
||||
features.enable(Feature::UnifiedExec);
|
||||
assert_model_tools(
|
||||
@@ -404,6 +469,7 @@ fn test_build_specs_gpt5_codex_unified_exec_web_search() {
|
||||
"request_user_input",
|
||||
"apply_patch",
|
||||
"web_search",
|
||||
"image_generation",
|
||||
"view_image",
|
||||
"spawn_agent",
|
||||
"send_input",
|
||||
@@ -411,11 +477,12 @@ fn test_build_specs_gpt5_codex_unified_exec_web_search() {
|
||||
"wait_agent",
|
||||
"close_agent",
|
||||
],
|
||||
);
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_specs_gpt51_codex_unified_exec_web_search() {
|
||||
#[tokio::test]
|
||||
async fn test_build_specs_gpt51_codex_unified_exec_web_search() {
|
||||
let mut features = Features::with_defaults();
|
||||
features.enable(Feature::UnifiedExec);
|
||||
assert_model_tools(
|
||||
@@ -429,6 +496,7 @@ fn test_build_specs_gpt51_codex_unified_exec_web_search() {
|
||||
"request_user_input",
|
||||
"apply_patch",
|
||||
"web_search",
|
||||
"image_generation",
|
||||
"view_image",
|
||||
"spawn_agent",
|
||||
"send_input",
|
||||
@@ -436,11 +504,12 @@ fn test_build_specs_gpt51_codex_unified_exec_web_search() {
|
||||
"wait_agent",
|
||||
"close_agent",
|
||||
],
|
||||
);
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_gpt_5_1_codex_max_defaults() {
|
||||
#[tokio::test]
|
||||
async fn test_gpt_5_1_codex_max_defaults() {
|
||||
let features = Features::with_defaults();
|
||||
assert_default_model_tools(
|
||||
"gpt-5.1-codex-max",
|
||||
@@ -452,6 +521,7 @@ fn test_gpt_5_1_codex_max_defaults() {
|
||||
"request_user_input",
|
||||
"apply_patch",
|
||||
"web_search",
|
||||
"image_generation",
|
||||
"view_image",
|
||||
"spawn_agent",
|
||||
"send_input",
|
||||
@@ -459,11 +529,12 @@ fn test_gpt_5_1_codex_max_defaults() {
|
||||
"wait_agent",
|
||||
"close_agent",
|
||||
],
|
||||
);
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_codex_5_1_mini_defaults() {
|
||||
#[tokio::test]
|
||||
async fn test_codex_5_1_mini_defaults() {
|
||||
let features = Features::with_defaults();
|
||||
assert_default_model_tools(
|
||||
"gpt-5.1-codex-mini",
|
||||
@@ -475,6 +546,7 @@ fn test_codex_5_1_mini_defaults() {
|
||||
"request_user_input",
|
||||
"apply_patch",
|
||||
"web_search",
|
||||
"image_generation",
|
||||
"view_image",
|
||||
"spawn_agent",
|
||||
"send_input",
|
||||
@@ -482,11 +554,12 @@ fn test_codex_5_1_mini_defaults() {
|
||||
"wait_agent",
|
||||
"close_agent",
|
||||
],
|
||||
);
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_gpt_5_defaults() {
|
||||
#[tokio::test]
|
||||
async fn test_gpt_5_defaults() {
|
||||
let features = Features::with_defaults();
|
||||
assert_default_model_tools(
|
||||
"gpt-5",
|
||||
@@ -497,6 +570,7 @@ fn test_gpt_5_defaults() {
|
||||
"update_plan",
|
||||
"request_user_input",
|
||||
"web_search",
|
||||
"image_generation",
|
||||
"view_image",
|
||||
"spawn_agent",
|
||||
"send_input",
|
||||
@@ -504,11 +578,12 @@ fn test_gpt_5_defaults() {
|
||||
"wait_agent",
|
||||
"close_agent",
|
||||
],
|
||||
);
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_gpt_5_1_defaults() {
|
||||
#[tokio::test]
|
||||
async fn test_gpt_5_1_defaults() {
|
||||
let features = Features::with_defaults();
|
||||
assert_default_model_tools(
|
||||
"gpt-5.1",
|
||||
@@ -520,6 +595,7 @@ fn test_gpt_5_1_defaults() {
|
||||
"request_user_input",
|
||||
"apply_patch",
|
||||
"web_search",
|
||||
"image_generation",
|
||||
"view_image",
|
||||
"spawn_agent",
|
||||
"send_input",
|
||||
@@ -527,11 +603,12 @@ fn test_gpt_5_1_defaults() {
|
||||
"wait_agent",
|
||||
"close_agent",
|
||||
],
|
||||
);
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_gpt_5_1_codex_max_unified_exec_web_search() {
|
||||
#[tokio::test]
|
||||
async fn test_gpt_5_1_codex_max_unified_exec_web_search() {
|
||||
let mut features = Features::with_defaults();
|
||||
features.enable(Feature::UnifiedExec);
|
||||
assert_model_tools(
|
||||
@@ -545,6 +622,7 @@ fn test_gpt_5_1_codex_max_unified_exec_web_search() {
|
||||
"request_user_input",
|
||||
"apply_patch",
|
||||
"web_search",
|
||||
"image_generation",
|
||||
"view_image",
|
||||
"spawn_agent",
|
||||
"send_input",
|
||||
@@ -552,12 +630,13 @@ fn test_gpt_5_1_codex_max_unified_exec_web_search() {
|
||||
"wait_agent",
|
||||
"close_agent",
|
||||
],
|
||||
);
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_specs_default_shell_present() {
|
||||
let config = test_config();
|
||||
#[tokio::test]
|
||||
async fn test_build_specs_default_shell_present() {
|
||||
let config = test_config().await;
|
||||
let model_info = construct_model_info_offline("o3", &config);
|
||||
let mut features = Features::with_defaults();
|
||||
features.enable(Feature::UnifiedExec);
|
||||
@@ -588,9 +667,9 @@ fn test_build_specs_default_shell_present() {
|
||||
assert_contains_tool_names(&tools, &subset);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shell_zsh_fork_prefers_shell_command_over_unified_exec() {
|
||||
let config = test_config();
|
||||
#[tokio::test]
|
||||
async fn shell_zsh_fork_prefers_shell_command_over_unified_exec() {
|
||||
let config = test_config().await;
|
||||
let model_info = construct_model_info_offline("o3", &config);
|
||||
let mut features = Features::with_defaults();
|
||||
features.enable(Feature::UnifiedExec);
|
||||
@@ -652,9 +731,10 @@ fn shell_zsh_fork_prefers_shell_command_over_unified_exec() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spawn_agent_description_omits_usage_hint_when_disabled() {
|
||||
#[tokio::test]
|
||||
async fn spawn_agent_description_omits_usage_hint_when_disabled() {
|
||||
let tools_config = multi_agent_v2_tools_config()
|
||||
.await
|
||||
.with_spawn_agent_usage_hint(/*spawn_agent_usage_hint*/ false);
|
||||
let description = multi_agent_v2_spawn_agent_description(&tools_config);
|
||||
|
||||
@@ -673,11 +753,13 @@ fn spawn_agent_description_omits_usage_hint_when_disabled() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn spawn_agent_description_uses_configured_usage_hint_text() {
|
||||
let tools_config = multi_agent_v2_tools_config().with_spawn_agent_usage_hint_text(Some(
|
||||
/*spawn_agent_usage_hint_text*/ "Custom delegation guidance only.".to_string(),
|
||||
));
|
||||
#[tokio::test]
|
||||
async fn spawn_agent_description_uses_configured_usage_hint_text() {
|
||||
let tools_config = multi_agent_v2_tools_config()
|
||||
.await
|
||||
.with_spawn_agent_usage_hint_text(Some(
|
||||
/*spawn_agent_usage_hint_text*/ "Custom delegation guidance only.".to_string(),
|
||||
));
|
||||
let description = multi_agent_v2_spawn_agent_description(&tools_config);
|
||||
|
||||
assert_regex_match(
|
||||
@@ -696,9 +778,9 @@ fn spawn_agent_description_uses_configured_usage_hint_text() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_suggest_requires_apps_and_plugins_features() {
|
||||
let model_info = search_capable_model_info();
|
||||
#[tokio::test]
|
||||
async fn tool_suggest_requires_apps_and_plugins_features() {
|
||||
let model_info = search_capable_model_info().await;
|
||||
let discoverable_tools = Some(vec![discoverable_connector(
|
||||
"connector_2128aebfecb84f64a069897515042a44",
|
||||
"Google Calendar",
|
||||
@@ -728,6 +810,7 @@ fn tool_suggest_requires_apps_and_plugins_features() {
|
||||
&tools_config,
|
||||
/*mcp_tools*/ None,
|
||||
/*deferred_mcp_tools*/ None,
|
||||
Vec::new(),
|
||||
discoverable_tools.clone(),
|
||||
&[],
|
||||
)
|
||||
@@ -742,9 +825,9 @@ fn tool_suggest_requires_apps_and_plugins_features() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_tool_description_handles_no_enabled_mcp_tools() {
|
||||
let model_info = search_capable_model_info();
|
||||
#[tokio::test]
|
||||
async fn search_tool_description_handles_no_enabled_mcp_tools() {
|
||||
let model_info = search_capable_model_info().await;
|
||||
let mut features = Features::with_defaults();
|
||||
features.enable(Feature::Apps);
|
||||
features.enable(Feature::ToolSearch);
|
||||
@@ -776,9 +859,9 @@ fn search_tool_description_handles_no_enabled_mcp_tools() {
|
||||
assert!(!description.contains("{{source_descriptions}}"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_tool_description_falls_back_to_connector_name_without_description() {
|
||||
let model_info = search_capable_model_info();
|
||||
#[tokio::test]
|
||||
async fn search_tool_description_falls_back_to_connector_name_without_description() {
|
||||
let model_info = search_capable_model_info().await;
|
||||
let mut features = Features::with_defaults();
|
||||
features.enable(Feature::Apps);
|
||||
features.enable(Feature::ToolSearch);
|
||||
@@ -827,9 +910,9 @@ fn search_tool_description_falls_back_to_connector_name_without_description() {
|
||||
assert!(!description.contains("- Calendar:"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn search_tool_registers_namespaced_mcp_tool_aliases() {
|
||||
let model_info = search_capable_model_info();
|
||||
#[tokio::test]
|
||||
async fn search_tool_registers_namespaced_mcp_tool_aliases() {
|
||||
let model_info = search_capable_model_info().await;
|
||||
let mut features = Features::with_defaults();
|
||||
features.enable(Feature::Apps);
|
||||
features.enable(Feature::ToolSearch);
|
||||
@@ -910,13 +993,97 @@ fn search_tool_registers_namespaced_mcp_tool_aliases() {
|
||||
assert!(registry.has_handler(&ToolName::plain(TOOL_SEARCH_TOOL_NAME)));
|
||||
assert!(registry.has_handler(&app_alias));
|
||||
assert!(registry.has_handler(&mcp_alias));
|
||||
assert!(registry.has_handler(&ToolName::plain("mcp__codex_apps__calendar_create_event")));
|
||||
assert!(registry.has_handler(&ToolName::plain("mcp__rmcp__echo")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mcp_tool_property_missing_type_defaults_to_string() {
|
||||
let config = test_config();
|
||||
#[tokio::test]
|
||||
async fn direct_mcp_tools_register_namespaced_handlers() {
|
||||
let config = test_config().await;
|
||||
let model_info = construct_model_info_offline("gpt-5-codex", &config);
|
||||
let mut features = Features::with_defaults();
|
||||
features.enable(Feature::UnifiedExec);
|
||||
let available_models = Vec::new();
|
||||
let tools_config = ToolsConfig::new(&ToolsConfigParams {
|
||||
model_info: &model_info,
|
||||
available_models: &available_models,
|
||||
features: &features,
|
||||
image_generation_tool_auth_allowed: true,
|
||||
web_search_mode: Some(WebSearchMode::Cached),
|
||||
session_source: SessionSource::Cli,
|
||||
sandbox_policy: &SandboxPolicy::DangerFullAccess,
|
||||
windows_sandbox_level: WindowsSandboxLevel::Disabled,
|
||||
});
|
||||
|
||||
let (_, registry) = build_specs(
|
||||
&tools_config,
|
||||
Some(HashMap::from([(
|
||||
"mcp__test_server__echo".to_string(),
|
||||
mcp_tool_info(mcp_tool(
|
||||
"echo",
|
||||
"Echo",
|
||||
serde_json::json!({"type": "object"}),
|
||||
)),
|
||||
)])),
|
||||
/*deferred_mcp_tools*/ None,
|
||||
&[],
|
||||
)
|
||||
.build();
|
||||
|
||||
assert!(registry.has_handler(&ToolName::namespaced("mcp__test_server__", "echo")));
|
||||
assert!(!registry.has_handler(&ToolName::plain("mcp__test_server__echo")));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn unavailable_mcp_tools_are_exposed_as_dummy_function_tools() {
|
||||
let config = test_config().await;
|
||||
let model_info = construct_model_info_offline("gpt-5-codex", &config);
|
||||
let mut features = Features::with_defaults();
|
||||
features.enable(Feature::UnifiedExec);
|
||||
let available_models = Vec::new();
|
||||
let tools_config = ToolsConfig::new(&ToolsConfigParams {
|
||||
model_info: &model_info,
|
||||
available_models: &available_models,
|
||||
features: &features,
|
||||
image_generation_tool_auth_allowed: true,
|
||||
web_search_mode: Some(WebSearchMode::Cached),
|
||||
session_source: SessionSource::Cli,
|
||||
sandbox_policy: &SandboxPolicy::DangerFullAccess,
|
||||
windows_sandbox_level: WindowsSandboxLevel::Disabled,
|
||||
});
|
||||
|
||||
let unavailable_tool = ToolName::namespaced("mcp__codex_apps__calendar", "_create_event");
|
||||
let (tools, registry) = build_specs_with_unavailable_tools(
|
||||
&tools_config,
|
||||
/*mcp_tools*/ None,
|
||||
/*deferred_mcp_tools*/ None,
|
||||
vec![unavailable_tool],
|
||||
&[],
|
||||
)
|
||||
.build();
|
||||
|
||||
let tool = find_tool(&tools, "mcp__codex_apps__calendar_create_event");
|
||||
let ToolSpec::Function(ResponsesApiTool {
|
||||
description,
|
||||
parameters,
|
||||
..
|
||||
}) = &tool.spec
|
||||
else {
|
||||
panic!("unavailable MCP tool should be exposed as a function tool");
|
||||
};
|
||||
assert!(description.contains("not currently available"));
|
||||
assert_eq!(
|
||||
parameters.additional_properties,
|
||||
Some(AdditionalProperties::Boolean(false))
|
||||
);
|
||||
assert!(registry.has_handler(&ToolName::namespaced(
|
||||
"mcp__codex_apps__calendar",
|
||||
"_create_event"
|
||||
)));
|
||||
assert!(!registry.has_handler(&ToolName::plain("mcp__codex_apps__calendar_create_event")));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_mcp_tool_property_missing_type_defaults_to_string() {
|
||||
let config = test_config().await;
|
||||
let model_info = construct_model_info_offline("gpt-5-codex", &config);
|
||||
let mut features = Features::with_defaults();
|
||||
features.enable(Feature::UnifiedExec);
|
||||
@@ -936,27 +1103,30 @@ fn test_mcp_tool_property_missing_type_defaults_to_string() {
|
||||
&tools_config,
|
||||
Some(HashMap::from([(
|
||||
"dash/search".to_string(),
|
||||
mcp_tool_info(mcp_tool(
|
||||
"search",
|
||||
"Search docs",
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {"description": "search query"}
|
||||
}
|
||||
}),
|
||||
)),
|
||||
mcp_tool_info_with_display_name(
|
||||
"dash/search",
|
||||
mcp_tool(
|
||||
"search",
|
||||
"Search docs",
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {"description": "search query"}
|
||||
}
|
||||
}),
|
||||
),
|
||||
),
|
||||
)])),
|
||||
/*deferred_mcp_tools*/ None,
|
||||
&[],
|
||||
)
|
||||
.build();
|
||||
|
||||
let tool = find_tool(&tools, "dash/search");
|
||||
let tool = find_namespace_function_tool(&tools, "dash/", "search");
|
||||
assert_eq!(
|
||||
tool.spec,
|
||||
ToolSpec::Function(ResponsesApiTool {
|
||||
name: "dash/search".to_string(),
|
||||
*tool,
|
||||
ResponsesApiTool {
|
||||
name: "search".to_string(),
|
||||
parameters: JsonSchema::object(
|
||||
/*properties*/
|
||||
BTreeMap::from([(
|
||||
@@ -970,13 +1140,13 @@ fn test_mcp_tool_property_missing_type_defaults_to_string() {
|
||||
strict: false,
|
||||
output_schema: Some(mcp_call_tool_result_output_schema(serde_json::json!({}))),
|
||||
defer_loading: None,
|
||||
})
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mcp_tool_preserves_integer_schema() {
|
||||
let config = test_config();
|
||||
#[tokio::test]
|
||||
async fn test_mcp_tool_preserves_integer_schema() {
|
||||
let config = test_config().await;
|
||||
let model_info = construct_model_info_offline("gpt-5-codex", &config);
|
||||
let mut features = Features::with_defaults();
|
||||
features.enable(Feature::UnifiedExec);
|
||||
@@ -996,25 +1166,28 @@ fn test_mcp_tool_preserves_integer_schema() {
|
||||
&tools_config,
|
||||
Some(HashMap::from([(
|
||||
"dash/paginate".to_string(),
|
||||
mcp_tool_info(mcp_tool(
|
||||
"paginate",
|
||||
"Pagination",
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {"page": {"type": "integer"}}
|
||||
}),
|
||||
)),
|
||||
mcp_tool_info_with_display_name(
|
||||
"dash/paginate",
|
||||
mcp_tool(
|
||||
"paginate",
|
||||
"Pagination",
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {"page": {"type": "integer"}}
|
||||
}),
|
||||
),
|
||||
),
|
||||
)])),
|
||||
/*deferred_mcp_tools*/ None,
|
||||
&[],
|
||||
)
|
||||
.build();
|
||||
|
||||
let tool = find_tool(&tools, "dash/paginate");
|
||||
let tool = find_namespace_function_tool(&tools, "dash/", "paginate");
|
||||
assert_eq!(
|
||||
tool.spec,
|
||||
ToolSpec::Function(ResponsesApiTool {
|
||||
name: "dash/paginate".to_string(),
|
||||
*tool,
|
||||
ResponsesApiTool {
|
||||
name: "paginate".to_string(),
|
||||
parameters: JsonSchema::object(
|
||||
/*properties*/
|
||||
BTreeMap::from([(
|
||||
@@ -1028,13 +1201,13 @@ fn test_mcp_tool_preserves_integer_schema() {
|
||||
strict: false,
|
||||
output_schema: Some(mcp_call_tool_result_output_schema(serde_json::json!({}))),
|
||||
defer_loading: None,
|
||||
})
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mcp_tool_array_without_items_gets_default_string_items() {
|
||||
let config = test_config();
|
||||
#[tokio::test]
|
||||
async fn test_mcp_tool_array_without_items_gets_default_string_items() {
|
||||
let config = test_config().await;
|
||||
let model_info = construct_model_info_offline("gpt-5-codex", &config);
|
||||
let mut features = Features::with_defaults();
|
||||
features.enable(Feature::UnifiedExec);
|
||||
@@ -1055,25 +1228,28 @@ fn test_mcp_tool_array_without_items_gets_default_string_items() {
|
||||
&tools_config,
|
||||
Some(HashMap::from([(
|
||||
"dash/tags".to_string(),
|
||||
mcp_tool_info(mcp_tool(
|
||||
"tags",
|
||||
"Tags",
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {"tags": {"type": "array"}}
|
||||
}),
|
||||
)),
|
||||
mcp_tool_info_with_display_name(
|
||||
"dash/tags",
|
||||
mcp_tool(
|
||||
"tags",
|
||||
"Tags",
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {"tags": {"type": "array"}}
|
||||
}),
|
||||
),
|
||||
),
|
||||
)])),
|
||||
/*deferred_mcp_tools*/ None,
|
||||
&[],
|
||||
)
|
||||
.build();
|
||||
|
||||
let tool = find_tool(&tools, "dash/tags");
|
||||
let tool = find_namespace_function_tool(&tools, "dash/", "tags");
|
||||
assert_eq!(
|
||||
tool.spec,
|
||||
ToolSpec::Function(ResponsesApiTool {
|
||||
name: "dash/tags".to_string(),
|
||||
*tool,
|
||||
ResponsesApiTool {
|
||||
name: "tags".to_string(),
|
||||
parameters: JsonSchema::object(
|
||||
/*properties*/
|
||||
BTreeMap::from([(
|
||||
@@ -1090,13 +1266,13 @@ fn test_mcp_tool_array_without_items_gets_default_string_items() {
|
||||
strict: false,
|
||||
output_schema: Some(mcp_call_tool_result_output_schema(serde_json::json!({}))),
|
||||
defer_loading: None,
|
||||
})
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_mcp_tool_anyof_defaults_to_string() {
|
||||
let config = test_config();
|
||||
#[tokio::test]
|
||||
async fn test_mcp_tool_anyof_defaults_to_string() {
|
||||
let config = test_config().await;
|
||||
let model_info = construct_model_info_offline("gpt-5-codex", &config);
|
||||
let mut features = Features::with_defaults();
|
||||
features.enable(Feature::UnifiedExec);
|
||||
@@ -1116,27 +1292,30 @@ fn test_mcp_tool_anyof_defaults_to_string() {
|
||||
&tools_config,
|
||||
Some(HashMap::from([(
|
||||
"dash/value".to_string(),
|
||||
mcp_tool_info(mcp_tool(
|
||||
"value",
|
||||
"AnyOf Value",
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"value": {"anyOf": [{"type": "string"}, {"type": "number"}]}
|
||||
}
|
||||
}),
|
||||
)),
|
||||
mcp_tool_info_with_display_name(
|
||||
"dash/value",
|
||||
mcp_tool(
|
||||
"value",
|
||||
"AnyOf Value",
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"value": {"anyOf": [{"type": "string"}, {"type": "number"}]}
|
||||
}
|
||||
}),
|
||||
),
|
||||
),
|
||||
)])),
|
||||
/*deferred_mcp_tools*/ None,
|
||||
&[],
|
||||
)
|
||||
.build();
|
||||
|
||||
let tool = find_tool(&tools, "dash/value");
|
||||
let tool = find_namespace_function_tool(&tools, "dash/", "value");
|
||||
assert_eq!(
|
||||
tool.spec,
|
||||
ToolSpec::Function(ResponsesApiTool {
|
||||
name: "dash/value".to_string(),
|
||||
*tool,
|
||||
ResponsesApiTool {
|
||||
name: "value".to_string(),
|
||||
parameters: JsonSchema::object(
|
||||
/*properties*/
|
||||
BTreeMap::from([(
|
||||
@@ -1156,13 +1335,13 @@ fn test_mcp_tool_anyof_defaults_to_string() {
|
||||
strict: false,
|
||||
output_schema: Some(mcp_call_tool_result_output_schema(serde_json::json!({}))),
|
||||
defer_loading: None,
|
||||
})
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_get_openai_tools_mcp_tools_with_additional_properties_schema() {
|
||||
let config = test_config();
|
||||
#[tokio::test]
|
||||
async fn test_get_openai_tools_mcp_tools_with_additional_properties_schema() {
|
||||
let config = test_config().await;
|
||||
let model_info = construct_model_info_offline("gpt-5-codex", &config);
|
||||
let mut features = Features::with_defaults();
|
||||
features.enable(Feature::UnifiedExec);
|
||||
@@ -1181,12 +1360,14 @@ fn test_get_openai_tools_mcp_tools_with_additional_properties_schema() {
|
||||
&tools_config,
|
||||
Some(HashMap::from([(
|
||||
"test_server/do_something_cool".to_string(),
|
||||
mcp_tool_info(mcp_tool(
|
||||
"do_something_cool",
|
||||
"Do something cool",
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
mcp_tool_info_with_display_name(
|
||||
"test_server/do_something_cool",
|
||||
mcp_tool(
|
||||
"do_something_cool",
|
||||
"Do something cool",
|
||||
serde_json::json!({
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"string_argument": {"type": "string"},
|
||||
"number_argument": {"type": "number"},
|
||||
"object_argument": {
|
||||
@@ -1203,22 +1384,23 @@ fn test_get_openai_tools_mcp_tools_with_additional_properties_schema() {
|
||||
},
|
||||
"required": ["addtl_prop"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
)),
|
||||
}),
|
||||
),
|
||||
),
|
||||
)])),
|
||||
/*deferred_mcp_tools*/ None,
|
||||
&[],
|
||||
)
|
||||
.build();
|
||||
|
||||
let tool = find_tool(&tools, "test_server/do_something_cool");
|
||||
let tool = find_namespace_function_tool(&tools, "test_server/", "do_something_cool");
|
||||
assert_eq!(
|
||||
tool.spec,
|
||||
ToolSpec::Function(ResponsesApiTool {
|
||||
name: "test_server/do_something_cool".to_string(),
|
||||
*tool,
|
||||
ResponsesApiTool {
|
||||
name: "do_something_cool".to_string(),
|
||||
parameters: JsonSchema::object(
|
||||
/*properties*/
|
||||
BTreeMap::from([
|
||||
@@ -1268,12 +1450,12 @@ fn test_get_openai_tools_mcp_tools_with_additional_properties_schema() {
|
||||
strict: false,
|
||||
output_schema: Some(mcp_call_tool_result_output_schema(serde_json::json!({}))),
|
||||
defer_loading: None,
|
||||
})
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn code_mode_only_restricts_model_tools_to_exec_tools() {
|
||||
#[tokio::test]
|
||||
async fn code_mode_only_restricts_model_tools_to_exec_tools() {
|
||||
let mut features = Features::with_defaults();
|
||||
features.enable(Feature::CodeMode);
|
||||
features.enable(Feature::CodeModeOnly);
|
||||
@@ -1283,5 +1465,6 @@ fn code_mode_only_restricts_model_tools_to_exec_tools() {
|
||||
&features,
|
||||
Some(WebSearchMode::Live),
|
||||
&["exec", "wait"],
|
||||
);
|
||||
)
|
||||
.await;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::collections::HashSet;
|
||||
|
||||
use codex_protocol::models::ResponseItem;
|
||||
use codex_tools::ToolName;
|
||||
|
||||
pub(crate) fn collect_unavailable_called_tools(
|
||||
input: &[ResponseItem],
|
||||
exposed_tool_names: &HashSet<&str>,
|
||||
) -> Vec<ToolName> {
|
||||
let mut unavailable_tools = BTreeMap::new();
|
||||
|
||||
for item in input {
|
||||
let ResponseItem::FunctionCall {
|
||||
name, namespace, ..
|
||||
} = item
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if !should_collect_unavailable_tool(name, namespace.as_deref()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let tool_name = match namespace {
|
||||
Some(namespace) => ToolName::namespaced(namespace.clone(), name.clone()),
|
||||
None => ToolName::plain(name.clone()),
|
||||
};
|
||||
let display_name = tool_name.display();
|
||||
if exposed_tool_names.contains(display_name.as_str()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
unavailable_tools
|
||||
.entry(display_name)
|
||||
.or_insert_with(|| tool_name);
|
||||
}
|
||||
|
||||
unavailable_tools.into_values().collect()
|
||||
}
|
||||
|
||||
fn should_collect_unavailable_tool(name: &str, namespace: Option<&str>) -> bool {
|
||||
namespace.is_some_and(|namespace| namespace.starts_with("mcp__")) || name.starts_with("mcp__")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
fn function_call(name: &str, namespace: Option<&str>) -> ResponseItem {
|
||||
ResponseItem::FunctionCall {
|
||||
id: None,
|
||||
name: name.to_string(),
|
||||
namespace: namespace.map(str::to_string),
|
||||
arguments: "{}".to_string(),
|
||||
call_id: format!("call-{name}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_unavailable_called_tools_detects_mcp_function_calls() {
|
||||
let input = vec![
|
||||
function_call("shell", /*namespace*/ None),
|
||||
function_call("mcp__server__lookup", /*namespace*/ None),
|
||||
function_call("_create_event", Some("mcp__codex_apps__calendar")),
|
||||
];
|
||||
|
||||
let tools = collect_unavailable_called_tools(&input, &HashSet::new());
|
||||
|
||||
assert_eq!(
|
||||
tools,
|
||||
vec![
|
||||
ToolName::namespaced("mcp__codex_apps__calendar", "_create_event"),
|
||||
ToolName::plain("mcp__server__lookup"),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collect_unavailable_called_tools_skips_currently_available_tools() {
|
||||
let exposed_tool_names = HashSet::from(["mcp__server__lookup", "mcp__server__search"]);
|
||||
let input = vec![
|
||||
function_call("mcp__server__lookup", /*namespace*/ None),
|
||||
function_call("mcp__server__search", /*namespace*/ None),
|
||||
function_call("mcp__server__missing", /*namespace*/ None),
|
||||
];
|
||||
|
||||
let tools = collect_unavailable_called_tools(&input, &exposed_tool_names);
|
||||
|
||||
assert_eq!(tools, vec![ToolName::plain("mcp__server__missing")]);
|
||||
}
|
||||
}
|
||||
@@ -152,6 +152,7 @@ fn exec_server_params_for_request(
|
||||
env_policy,
|
||||
env,
|
||||
tty,
|
||||
pipe_stdin: false,
|
||||
arg0: request.arg0.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user