diff --git a/codex-rs/ext/extension-api/Cargo.toml b/codex-rs/ext/extension-api/Cargo.toml index 7745ed3233..b59c264137 100644 --- a/codex-rs/ext/extension-api/Cargo.toml +++ b/codex-rs/ext/extension-api/Cargo.toml @@ -12,12 +12,12 @@ path = "src/lib.rs" workspace = true [dependencies] -rmcp = { workspace = true, default-features = false, features = [ - "schemars", - "server", -] } +codex-tools = { workspace = true } +serde_json = { workspace = true } +thiserror = { workspace = true } [dev-dependencies] +codex-guardian = { workspace = true } codex-git-attribution = { workspace = true } codex-memories = { workspace = true } codex-multi-agent-v2 = { workspace = true } diff --git a/codex-rs/ext/extension-api/examples/enabled_extensions.rs b/codex-rs/ext/extension-api/examples/enabled_extensions.rs index 96df0f447c..cc12bd9f8b 100644 --- a/codex-rs/ext/extension-api/examples/enabled_extensions.rs +++ b/codex-rs/ext/extension-api/examples/enabled_extensions.rs @@ -2,23 +2,27 @@ use std::sync::Arc; use codex_extension_api::ExtensionRegistryBuilder; use codex_git_attribution as git_attribution; -use codex_git_attribution::GitAttributionContext; -use codex_memories::MemoriesContext; +use codex_guardian as guardian; use codex_memories::MemoriesExtension; use codex_multi_agent_v2 as multi_agent_v2; -use codex_multi_agent_v2::MultiAgentV2Context; use codex_multi_agent_v2::UsageHintAudience; fn main() { let registry = ExtensionRegistryBuilder::::new() + .with_extension(guardian::extension()) .with_extension(git_attribution::extension()) - .with_extension(Arc::new(MemoriesExtension::new(Some( + .with_extension(Arc::new(MemoriesExtension::with_read_prompt( "Please use FS access bla bla bla.".to_string(), - )))) + std::env::temp_dir().join("codex-memories-example"), + ))) .with_extension(multi_agent_v2::extension()) .build(); let root_context = ctx::RuntimeContext { + automatic_review_enabled: true, + approval_policy_allows_automatic_review: true, + is_guardian_reviewer: false, + guardian_policy_prompt: Some("Guardian policy.".to_string()), commit_attribution: None, memory_tool_enabled: true, use_memories: true, @@ -39,34 +43,44 @@ fn main() { .flat_map(|contributor| contributor.contribute(&root_context)) .collect::>(); - // Get tools (MCP here but this should shift to handlers) + // Get native tools let tools = registry - .mcp_tool_contributors() + .tool_contributors() .iter() .flat_map(|contributor| contributor.tools(&root_context)) .collect::>(); let tools_without_memories = registry - .mcp_tool_contributors() + .tool_contributors() .iter() .flat_map(|contributor| contributor.tools(&memories_disabled_context)) .collect::>(); + let active_approval_interceptors = registry + .approval_interceptor_contributors() + .iter() + .filter(|contributor| contributor.intercepts_approvals(&root_context)) + .count(); println!("prompt fragments: {}", prompt_fragments.len()); - println!("mcp tools: {}", tools.len()); + println!("approval interceptors: {active_approval_interceptors}"); + println!("native tools: {}", tools.len()); println!( - "mcp tools when use_memories=false: {}", + "native tools when use_memories=false: {}", tools_without_memories.len() ); } - - mod ctx { use codex_git_attribution::GitAttributionContext; + use codex_guardian::GuardianContext; use codex_memories::MemoriesContext; - use codex_multi_agent_v2::{MultiAgentV2Context, UsageHintAudience}; + use codex_multi_agent_v2::MultiAgentV2Context; + use codex_multi_agent_v2::UsageHintAudience; #[derive(Clone)] pub struct RuntimeContext { + pub automatic_review_enabled: bool, + pub approval_policy_allows_automatic_review: bool, + pub is_guardian_reviewer: bool, + pub guardian_policy_prompt: Option, // Ideally this should be at the config layer instead pub commit_attribution: Option, pub memory_tool_enabled: bool, @@ -77,6 +91,24 @@ mod ctx { pub subagent_usage_hint_text: Option, } + impl GuardianContext for RuntimeContext { + fn automatic_review_enabled(&self) -> bool { + self.automatic_review_enabled + } + + fn approval_policy_allows_automatic_review(&self) -> bool { + self.approval_policy_allows_automatic_review + } + + fn is_guardian_reviewer(&self) -> bool { + self.is_guardian_reviewer + } + + fn guardian_policy_prompt(&self) -> Option<&str> { + self.guardian_policy_prompt.as_deref() + } + } + impl GitAttributionContext for RuntimeContext { fn commit_attribution(&self) -> Option<&str> { self.commit_attribution.as_deref() @@ -110,4 +142,4 @@ mod ctx { self.subagent_usage_hint_text.as_deref() } } -} \ No newline at end of file +} diff --git a/codex-rs/ext/extension-api/src/contributors.rs b/codex-rs/ext/extension-api/src/contributors.rs index 94d4cd18e9..33e1dd1977 100644 --- a/codex-rs/ext/extension-api/src/contributors.rs +++ b/codex-rs/ext/extension-api/src/contributors.rs @@ -6,11 +6,23 @@ //! supporting types nearby. mod prompt; +mod tool; pub use prompt::PromptFragment; pub use prompt::PromptSlot; +pub use tool::ToolCallError; +pub use tool::ToolContribution; +pub use tool::ToolHandler; -use rmcp::model::Tool; +/// Extension contribution that can claim approval requests for a runtime context. +/// +/// Implementations should make only the routing decision here. The host keeps +/// ownership of executing the chosen review flow and translating its result +/// back into the surrounding runtime. +pub trait ApprovalInterceptorContributor: Send + Sync { + /// Returns whether this contributor should intercept approvals in `context`. + fn intercepts_approvals(&self, context: &C) -> bool; +} /// Extension contribution that adds prompt fragments during prompt assembly. /// @@ -22,15 +34,13 @@ pub trait PromptContributor: Send + Sync { fn contribute(&self, context: &C) -> Vec; } -/// Extension contribution that exposes MCP tool definitions owned by a feature. +/// Extension contribution that exposes native tools owned by a feature. /// /// Implementations should inspect only their feature-owned slice of the /// current runtime context and return the tools exposed for that invocation. -/// The host remains responsible for mounting those tools and routing -/// execution. -/// -/// This is intentionally MCP-shaped for now because the more general tool -/// abstraction has not been extracted yet. -pub trait McpToolContributor: Send + Sync { - fn tools(&self, context: &C) -> Vec; +/// The host remains responsible for mounting those tools and adapting calls +/// into its runtime. +pub trait ToolContributor: Send + Sync { + /// Returns the native tools visible for the supplied runtime context. + fn tools(&self, context: &C) -> Vec>; } diff --git a/codex-rs/ext/extension-api/src/contributors/tool.rs b/codex-rs/ext/extension-api/src/contributors/tool.rs new file mode 100644 index 0000000000..6d149ef541 --- /dev/null +++ b/codex-rs/ext/extension-api/src/contributors/tool.rs @@ -0,0 +1,71 @@ +use std::future::Future; +use std::pin::Pin; +use std::sync::Arc; + +use codex_tools::ResponsesApiTool; +use serde_json::Value; +use thiserror::Error; + +// TMP +#[derive(Clone)] +pub struct ToolContribution { + spec: ResponsesApiTool, + handler: Arc>, + supports_parallel_tool_calls: bool, +} + +impl ToolContribution { + pub fn new(spec: ResponsesApiTool, handler: Arc>) -> Self { + Self { + spec, + handler, + supports_parallel_tool_calls: false, + } + } + + #[must_use] + pub fn allow_parallel_calls(mut self) -> Self { + self.supports_parallel_tool_calls = true; + self + } + + pub fn spec(&self) -> &ResponsesApiTool { + &self.spec + } + + pub fn supports_parallel_tool_calls(&self) -> bool { + self.supports_parallel_tool_calls + } + + pub fn handler(&self) -> Arc> { + Arc::clone(&self.handler) + } +} + + + +//////// Just to make it compile //////////////////////////////// +pub trait ToolHandler: Send + Sync { + /// Handles one JSON-encoded invocation for this tool. + fn handle<'a>( + &'a self, + context: &'a C, + arguments: Value, + ) -> Pin> + Send + 'a>>; +} + +/// Error returned by a contributed native tool handler. +#[derive(Clone, Debug, Error, PartialEq, Eq)] +#[error("{message}")] +pub struct ToolCallError { + message: String, +} + +impl ToolCallError { + /// Creates a contributed-tool error with the supplied model-visible text. + pub fn new(message: impl Into) -> Self { + Self { + message: message.into(), + } + } +} diff --git a/codex-rs/ext/extension-api/src/lib.rs b/codex-rs/ext/extension-api/src/lib.rs index ad28cc876e..a1a0e4adc3 100644 --- a/codex-rs/ext/extension-api/src/lib.rs +++ b/codex-rs/ext/extension-api/src/lib.rs @@ -11,10 +11,14 @@ mod contributors; mod extension; mod registry; -pub use contributors::McpToolContributor; +pub use contributors::ApprovalInterceptorContributor; pub use contributors::PromptContributor; pub use contributors::PromptFragment; pub use contributors::PromptSlot; +pub use contributors::ToolCallError; +pub use contributors::ToolContribution; +pub use contributors::ToolContributor; +pub use contributors::ToolHandler; pub use extension::CodexExtension; pub use registry::ExtensionRegistry; pub use registry::ExtensionRegistryBuilder; diff --git a/codex-rs/ext/extension-api/src/registry.rs b/codex-rs/ext/extension-api/src/registry.rs index 84a4511742..0d96333fad 100644 --- a/codex-rs/ext/extension-api/src/registry.rs +++ b/codex-rs/ext/extension-api/src/registry.rs @@ -1,20 +1,23 @@ use std::sync::Arc; +use crate::ApprovalInterceptorContributor; use crate::CodexExtension; -use crate::McpToolContributor; use crate::PromptContributor; +use crate::ToolContributor; /// Mutable registry used while extensions install their typed contributions. pub struct ExtensionRegistryBuilder { - mcp_tool_contributors: Vec>>, + approval_interceptor_contributors: Vec>>, prompt_contributors: Vec>>, + tool_contributors: Vec>>, } impl Default for ExtensionRegistryBuilder { fn default() -> Self { Self { - mcp_tool_contributors: Vec::new(), + approval_interceptor_contributors: Vec::new(), prompt_contributors: Vec::new(), + tool_contributors: Vec::new(), } } } @@ -43,9 +46,12 @@ impl ExtensionRegistryBuilder { extension.install(self); } - /// Registers one MCP tool contributor. - pub fn mcp_tool_contributor(&mut self, contributor: Arc>) { - self.mcp_tool_contributors.push(contributor); + /// Registers one approval interceptor contributor. + pub fn approval_interceptor_contributor( + &mut self, + contributor: Arc>, + ) { + self.approval_interceptor_contributors.push(contributor); } /// Registers one prompt contributor. @@ -53,29 +59,43 @@ impl ExtensionRegistryBuilder { self.prompt_contributors.push(contributor); } + /// Registers one native tool contributor. + pub fn tool_contributor(&mut self, contributor: Arc>) { + self.tool_contributors.push(contributor); + } + /// Finishes construction and returns the immutable registry. pub fn build(self) -> ExtensionRegistry { ExtensionRegistry { - mcp_tool_contributors: self.mcp_tool_contributors, + approval_interceptor_contributors: self.approval_interceptor_contributors, prompt_contributors: self.prompt_contributors, + tool_contributors: self.tool_contributors, } } } /// Immutable typed registry produced after extensions are installed. pub struct ExtensionRegistry { - mcp_tool_contributors: Vec>>, + approval_interceptor_contributors: Vec>>, prompt_contributors: Vec>>, + tool_contributors: Vec>>, } impl ExtensionRegistry { - /// Returns the registered MCP tool contributors. - pub fn mcp_tool_contributors(&self) -> &[Arc>] { - &self.mcp_tool_contributors + /// Returns the registered approval interceptor contributors. + pub fn approval_interceptor_contributors( + &self, + ) -> &[Arc>] { + &self.approval_interceptor_contributors } /// Returns the registered prompt contributors. pub fn prompt_contributors(&self) -> &[Arc>] { &self.prompt_contributors } + + /// Returns the registered native tool contributors. + pub fn tool_contributors(&self) -> &[Arc>] { + &self.tool_contributors + } } diff --git a/codex-rs/ext/guardian/BUILD.bazel b/codex-rs/ext/guardian/BUILD.bazel new file mode 100644 index 0000000000..adcbb090bc --- /dev/null +++ b/codex-rs/ext/guardian/BUILD.bazel @@ -0,0 +1,6 @@ +load("//:defs.bzl", "codex_rust_crate") + +codex_rust_crate( + name = "guardian", + crate_name = "codex_guardian", +) diff --git a/codex-rs/ext/guardian/Cargo.toml b/codex-rs/ext/guardian/Cargo.toml new file mode 100644 index 0000000000..ae49f88e1f --- /dev/null +++ b/codex-rs/ext/guardian/Cargo.toml @@ -0,0 +1,15 @@ +[package] +edition.workspace = true +license.workspace = true +name = "codex-guardian" +version.workspace = true + +[lib] +name = "codex_guardian" +path = "src/lib.rs" + +[lints] +workspace = true + +[dependencies] +codex-extension-api = { workspace = true } diff --git a/codex-rs/ext/guardian/src/lib.rs b/codex-rs/ext/guardian/src/lib.rs new file mode 100644 index 0000000000..10ed94a6bd --- /dev/null +++ b/codex-rs/ext/guardian/src/lib.rs @@ -0,0 +1,75 @@ +//! Guardian routing contribution packaged as a Codex extension. + +#![forbid(unsafe_code)] + +use std::sync::Arc; + +use codex_extension_api::ApprovalInterceptorContributor; +use codex_extension_api::CodexExtension; +use codex_extension_api::ExtensionRegistryBuilder; +use codex_extension_api::PromptContributor; +use codex_extension_api::PromptFragment; + +/// Runtime facts needed to expose Guardian surfaces. +/// +/// Hosts should provide the effective approval settings for the current turn, +/// whether the current session is the Guardian reviewer itself, and the prompt +/// text to show to that reviewer. +pub trait GuardianContext { + fn automatic_review_enabled(&self) -> bool; + fn approval_policy_allows_automatic_review(&self) -> bool; + fn is_guardian_reviewer(&self) -> bool; + fn guardian_policy_prompt(&self) -> Option<&str>; +} + +/// Extension that contributes Guardian approval routing and reviewer policy. +#[derive(Clone, Copy, Debug, Default)] +pub struct GuardianExtension; + +impl GuardianExtension { + /// Creates an extension instance. + pub fn new() -> Self { + Self + } + + /// Returns whether Guardian should intercept approvals in this context. + pub fn should_intercept_approvals(&self, context: &C) -> bool { + context.automatic_review_enabled() && context.approval_policy_allows_automatic_review() + } + + /// Returns the policy prompt shown only to Guardian reviewer sessions. + pub fn policy_prompt<'a, C: GuardianContext>(&self, context: &'a C) -> Option<&'a str> { + if context.is_guardian_reviewer() { + context.guardian_policy_prompt() + } else { + None + } + } +} + +impl ApprovalInterceptorContributor for GuardianExtension { + fn intercepts_approvals(&self, context: &C) -> bool { + self.should_intercept_approvals(context) + } +} + +impl PromptContributor for GuardianExtension { + fn contribute(&self, context: &C) -> Vec { + self.policy_prompt(context) + .map(PromptFragment::separate_developer) + .into_iter() + .collect() + } +} + +impl CodexExtension for GuardianExtension { + fn install(self: Arc, registry: &mut ExtensionRegistryBuilder) { + registry.approval_interceptor_contributor(self.clone()); + registry.prompt_contributor(self); + } +} + +/// Creates a shared Guardian extension instance. +pub fn extension() -> Arc { + Arc::new(GuardianExtension::new()) +} diff --git a/codex-rs/ext/memories/Cargo.toml b/codex-rs/ext/memories/Cargo.toml index c21b774cb7..9275dfbe79 100644 --- a/codex-rs/ext/memories/Cargo.toml +++ b/codex-rs/ext/memories/Cargo.toml @@ -14,9 +14,9 @@ workspace = true [dependencies] codex-extension-api = { workspace = true } codex-memories-read = { workspace = true } +codex-tools = { workspace = true } codex-utils-absolute-path = { workspace = true } -rmcp = { workspace = true, default-features = false, features = [ - "schemars", - "server", -] } +serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } +thiserror = { workspace = true } +tokio = { workspace = true, features = ["fs"] } diff --git a/codex-rs/ext/memories/src/lib.rs b/codex-rs/ext/memories/src/lib.rs index d18d026f47..bae504880a 100644 --- a/codex-rs/ext/memories/src/lib.rs +++ b/codex-rs/ext/memories/src/lib.rs @@ -2,19 +2,21 @@ #![forbid(unsafe_code)] +mod list_tool; + +use std::path::PathBuf; use std::sync::Arc; use codex_extension_api::CodexExtension; use codex_extension_api::ExtensionRegistryBuilder; -use codex_extension_api::McpToolContributor; use codex_extension_api::PromptContributor; use codex_extension_api::PromptFragment; +use codex_extension_api::ToolContribution; +use codex_extension_api::ToolContributor; use codex_memories_read::build_memory_tool_developer_instructions; +use codex_memories_read::memory_root; use codex_utils_absolute_path::AbsolutePathBuf; -use rmcp::model::Tool; -use rmcp::model::ToolAnnotations; -use serde_json::Map; -use serde_json::Value; +use list_tool::ListMemoriesTool; /// Runtime facts needed to decide whether read-memory surfaces are visible. /// @@ -25,23 +27,37 @@ pub trait MemoriesContext { fn use_memories(&self) -> bool; } -/// Extension that contributes memories MCP tools plus their read-path guidance. +/// Extension that contributes memories read surfaces. #[derive(Clone, Debug)] pub struct MemoriesExtension { read_prompt: Option, + list_tool: Arc, } impl MemoriesExtension { - /// Creates an extension from a pre-rendered read prompt. - pub fn new(read_prompt: Option) -> Self { - Self { read_prompt } + fn new(read_prompt: Option, memories_root: impl Into) -> Self { + Self { + read_prompt, + list_tool: Arc::new(ListMemoriesTool::new(memories_root)), + } + } + + /// Creates an extension that contributes native tools but no prompt fragment. + pub fn tools_only(memories_root: impl Into) -> Self { + Self::new(None, memories_root) + } + + /// Creates an extension with one pre-rendered read prompt and native tools. + pub fn with_read_prompt(read_prompt: String, memories_root: impl Into) -> Self { + Self::new(Some(read_prompt), memories_root) } /// Creates an extension using the live memories read prompt for this Codex home. pub async fn from_codex_home(codex_home: &AbsolutePathBuf) -> Self { - Self { - read_prompt: build_memory_tool_developer_instructions(codex_home).await, - } + Self::new( + build_memory_tool_developer_instructions(codex_home).await, + memory_root(codex_home).to_path_buf(), + ) } /// Returns the rendered developer instruction for read access, if available. @@ -54,17 +70,13 @@ impl MemoriesExtension { } } -impl McpToolContributor for MemoriesExtension { - fn tools(&self, context: &C) -> Vec { +impl ToolContributor for MemoriesExtension { + fn tools(&self, context: &C) -> Vec> { if !self.is_read_surface_enabled(context) { return Vec::new(); } - vec![ - simple_tool("list", "List memory entries."), - simple_tool("read", "Read one memory entry."), - simple_tool("search", "Search memory entries."), - ] + vec![self.list_tool.contribution()] } } @@ -81,9 +93,9 @@ impl PromptContributor for MemoriesExtension { } } -impl CodexExtension for MemoriesExtension { +impl CodexExtension for MemoriesExtension { fn install(self: Arc, registry: &mut ExtensionRegistryBuilder) { - registry.mcp_tool_contributor(self.clone()); + registry.tool_contributor(self.clone()); registry.prompt_contributor(self); } } @@ -92,12 +104,3 @@ impl CodexExtension for MemoriesExtension { pub async fn extension(codex_home: &AbsolutePathBuf) -> Arc { Arc::new(MemoriesExtension::from_codex_home(codex_home).await) } - -fn simple_tool(name: &'static str, description: &'static str) -> Tool { - Tool::new(name, description, simple_input_schema()) - .annotate(ToolAnnotations::new().read_only(true)) -} - -fn simple_input_schema() -> Map { - Map::from_iter([("type".to_string(), Value::String("object".to_string()))]) -} diff --git a/codex-rs/ext/memories/src/list_tool.rs b/codex-rs/ext/memories/src/list_tool.rs new file mode 100644 index 0000000000..7b6996f65b --- /dev/null +++ b/codex-rs/ext/memories/src/list_tool.rs @@ -0,0 +1,321 @@ +use std::borrow::Cow; +use std::future::Future; +use std::path::Component; +use std::path::Path; +use std::path::PathBuf; +use std::pin::Pin; +use std::sync::Arc; + +use codex_extension_api::ToolCallError; +use codex_extension_api::ToolContribution; +use codex_extension_api::ToolHandler; +use codex_tools::JsonSchema; +use codex_tools::ResponsesApiTool; +use serde::Deserialize; +use serde::Serialize; +use serde_json::Value; + +const LIST_MEMORIES_TOOL_NAME: &str = "list_memories"; +const DEFAULT_LIST_MAX_RESULTS: usize = 2_000; +const MAX_LIST_RESULTS: usize = 2_000; + +#[derive(Debug)] +pub(super) struct ListMemoriesTool { + memories_root: PathBuf, +} + +impl ListMemoriesTool { + pub(super) fn new(memories_root: impl Into) -> Self { + Self { + memories_root: memories_root.into(), + } + } + + pub(super) fn contribution(self: &Arc) -> ToolContribution + where + C: Send + Sync + 'static, + { + let handler: Arc> = self.clone(); + ToolContribution::new(create_list_memories_tool(), handler).allow_parallel_calls() + } +} + +impl ToolHandler for ListMemoriesTool +where + C: Send + Sync, +{ + fn handle<'a>( + &'a self, + _context: &'a C, + arguments: Value, + ) -> Pin> + Send + 'a>> { + Box::pin(async move { + let args: ListMemoriesArgs = serde_json::from_value(arguments) + .map_err(|err| ToolCallError::new(format!("invalid arguments: {err}")))?; + tokio::fs::create_dir_all(&self.memories_root) + .await + .map_err(|err| { + ToolCallError::new(format!( + "failed to create memories root at {}: {err}", + self.memories_root.display() + )) + })?; + let response = list_memories(&self.memories_root, args) + .await + .map_err(|err| ToolCallError::new(err.to_string()))?; + serde_json::to_value(response) + .map_err(|err| ToolCallError::new(format!("failed to serialize output: {err}"))) + }) + } +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct ListMemoriesArgs { + path: Option, + cursor: Option, + max_results: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct ListMemoriesResponse { + path: Option, + entries: Vec, + next_cursor: Option, + truncated: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +struct MemoryEntry { + path: String, + entry_type: MemoryEntryType, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +enum MemoryEntryType { + File, + Directory, +} + +#[derive(Debug, thiserror::Error)] +enum ListMemoriesError { + #[error("path '{path}' {reason}")] + InvalidPath { path: String, reason: String }, + #[error("cursor '{cursor}' {reason}")] + InvalidCursor { cursor: String, reason: String }, + #[error("path '{path}' was not found")] + NotFound { path: String }, + #[error("I/O error while reading memories: {0}")] + Io(#[from] std::io::Error), +} + +async fn list_memories( + memories_root: &Path, + args: ListMemoriesArgs, +) -> Result { + let max_results = args + .max_results + .unwrap_or(DEFAULT_LIST_MAX_RESULTS) + .min(MAX_LIST_RESULTS); + let start = resolve_scoped_path(memories_root, args.path.as_deref()).await?; + let start_index = match args.cursor.as_deref() { + Some(cursor) => cursor + .parse::() + .map_err(|_| ListMemoriesError::InvalidCursor { + cursor: cursor.to_string(), + reason: "must be a non-negative integer".to_string(), + })?, + None => 0, + }; + let Some(metadata) = metadata_or_none(&start).await? else { + return Err(ListMemoriesError::NotFound { + path: args.path.unwrap_or_default(), + }); + }; + reject_symlink(&display_relative_path(memories_root, &start), &metadata)?; + + let mut entries = if metadata.is_file() { + vec![MemoryEntry { + path: display_relative_path(memories_root, &start), + entry_type: MemoryEntryType::File, + }] + } else if metadata.is_dir() { + let mut entries = Vec::new(); + for path in read_sorted_dir_paths(&start).await? { + if is_hidden_path(&path) { + continue; + } + let Some(metadata) = metadata_or_none(&path).await? else { + continue; + }; + if metadata.file_type().is_symlink() { + continue; + } + + let entry_type = if metadata.is_dir() { + MemoryEntryType::Directory + } else if metadata.is_file() { + MemoryEntryType::File + } else { + continue; + }; + entries.push(MemoryEntry { + path: display_relative_path(memories_root, &path), + entry_type, + }); + } + entries + } else { + Vec::new() + }; + if start_index > entries.len() { + return Err(ListMemoriesError::InvalidCursor { + cursor: start_index.to_string(), + reason: "exceeds result count".to_string(), + }); + } + + let end_index = start_index.saturating_add(max_results).min(entries.len()); + let next_cursor = (end_index < entries.len()).then(|| end_index.to_string()); + let truncated = next_cursor.is_some(); + Ok(ListMemoriesResponse { + path: args.path, + entries: entries.drain(start_index..end_index).collect(), + next_cursor, + truncated, + }) +} + +async fn resolve_scoped_path( + memories_root: &Path, + relative_path: Option<&str>, +) -> Result { + let Some(relative_path) = relative_path else { + return Ok(memories_root.to_path_buf()); + }; + let relative = Path::new(relative_path); + if relative.components().any(|component| { + matches!( + component, + Component::ParentDir | Component::RootDir | Component::Prefix(_) + ) + }) { + return Err(ListMemoriesError::InvalidPath { + path: relative_path.to_string(), + reason: "must stay within the memories root".to_string(), + }); + } + if relative.components().any(is_hidden_component) { + return Err(ListMemoriesError::NotFound { + path: relative_path.to_string(), + }); + } + + let components = relative.components().collect::>(); + let mut scoped_path = memories_root.to_path_buf(); + for (index, component) in components.iter().enumerate() { + scoped_path.push(component.as_os_str()); + + let Some(metadata) = metadata_or_none(&scoped_path).await? else { + for remaining_component in components.iter().skip(index + 1) { + scoped_path.push(remaining_component.as_os_str()); + } + return Ok(scoped_path); + }; + + reject_symlink( + &display_relative_path(memories_root, &scoped_path), + &metadata, + )?; + if index + 1 < components.len() && !metadata.is_dir() { + return Err(ListMemoriesError::InvalidPath { + path: relative_path.to_string(), + reason: "traverses through a non-directory path component".to_string(), + }); + } + } + + Ok(scoped_path) +} + +async fn metadata_or_none(path: &Path) -> Result, ListMemoriesError> { + match tokio::fs::symlink_metadata(path).await { + Ok(metadata) => Ok(Some(metadata)), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(err) => Err(err.into()), + } +} + +fn reject_symlink( + relative_path: &str, + metadata: &std::fs::Metadata, +) -> Result<(), ListMemoriesError> { + if metadata.file_type().is_symlink() { + return Err(ListMemoriesError::InvalidPath { + path: relative_path.to_string(), + reason: "must not be a symlink".to_string(), + }); + } + Ok(()) +} + +fn display_relative_path(root: &Path, path: &Path) -> String { + path.strip_prefix(root) + .map_or(Cow::Borrowed(path), Cow::Borrowed) + .to_string_lossy() + .replace(std::path::MAIN_SEPARATOR, "/") +} + +fn is_hidden_path(path: &Path) -> bool { + path.file_name() + .is_some_and(|name| name.to_string_lossy().starts_with('.')) +} + +fn is_hidden_component(component: Component<'_>) -> bool { + matches!(component, Component::Normal(name) if name.to_string_lossy().starts_with('.')) +} + +async fn read_sorted_dir_paths(path: &Path) -> Result, std::io::Error> { + let mut entries = tokio::fs::read_dir(path).await?; + let mut paths = Vec::new(); + while let Some(entry) = entries.next_entry().await? { + paths.push(entry.path()); + } + paths.sort(); + Ok(paths) +} + +fn create_list_memories_tool() -> ResponsesApiTool { + let properties = std::collections::BTreeMap::from([ + ( + "path".to_string(), + JsonSchema::string(Some( + "Optional relative path to list inside the Codex memories store.".to_string(), + )), + ), + ( + "cursor".to_string(), + JsonSchema::string(Some( + "Optional cursor returned by a previous list_memories call.".to_string(), + )), + ), + ( + "max_results".to_string(), + JsonSchema::integer(Some( + "Optional maximum number of entries to return.".to_string(), + )), + ), + ]); + + ResponsesApiTool { + name: LIST_MEMORIES_TOOL_NAME.to_string(), + description: + "List immediate files and directories under a path in the Codex memories store." + .to_string(), + strict: false, + defer_loading: None, + parameters: JsonSchema::object(properties, /*required*/ None, Some(false.into())), + output_schema: None, + } +}