mirror of
https://github.com/openai/codex.git
synced 2026-11-13 14:46:14 +00:00
## Why Codex Apps cache writes are already restricted to Codex Apps call paths: startup invokes the helper only from the Codex Apps branch, and hard refresh operates on the reserved Codex Apps server directly. Rechecking the server name inside the cache helper duplicates that classification and leaves the helper with an argument that cannot change valid behavior. ## What changed - Remove the redundant server-name check and parameter from the cache writer. - Rename the helper to `write_codex_apps_tools_cache` to reflect its narrower contract. - Update production and test callsites to use the simplified API.
273 lines
8.6 KiB
Rust
273 lines
8.6 KiB
Rust
//! Codex Apps support for the host-owned apps MCP server.
|
|
//!
|
|
//! This module owns the pieces that are unique to ChatGPT-hosted app
|
|
//! connectors: cache scoping by authenticated user, disk cache reads/writes,
|
|
//! connector allow-list filtering, and the normalization that turns app
|
|
//! connector/tool metadata into model-visible MCP callable names.
|
|
|
|
use std::path::PathBuf;
|
|
use std::time::Instant;
|
|
|
|
use crate::runtime::emit_duration;
|
|
use crate::tools::MCP_TOOLS_CACHE_WRITE_DURATION_METRIC;
|
|
use crate::tools::ToolInfo;
|
|
use anyhow::Context;
|
|
use codex_login::CodexAuth;
|
|
use codex_protocol::mcp::McpServerInfo;
|
|
use codex_utils_plugins::mcp_connector::sanitize_name;
|
|
use serde::Deserialize;
|
|
use serde::Serialize;
|
|
use sha1::Digest;
|
|
use sha1::Sha1;
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
pub struct CodexAppsToolsCacheKey {
|
|
pub(crate) account_id: Option<String>,
|
|
pub(crate) chatgpt_user_id: Option<String>,
|
|
pub(crate) is_workspace_account: bool,
|
|
}
|
|
|
|
pub fn codex_apps_tools_cache_key(auth: Option<&CodexAuth>) -> CodexAppsToolsCacheKey {
|
|
CodexAppsToolsCacheKey {
|
|
account_id: auth.and_then(CodexAuth::get_account_id),
|
|
chatgpt_user_id: auth.and_then(CodexAuth::get_chatgpt_user_id),
|
|
is_workspace_account: auth.is_some_and(CodexAuth::is_workspace_account),
|
|
}
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
pub(crate) struct CodexAppsToolsCacheContext {
|
|
pub(crate) codex_home: PathBuf,
|
|
pub(crate) user_key: CodexAppsToolsCacheKey,
|
|
}
|
|
|
|
impl CodexAppsToolsCacheContext {
|
|
pub(crate) fn tools_cache_path(&self) -> PathBuf {
|
|
self.cache_path_in(CODEX_APPS_TOOLS_CACHE_DIR)
|
|
}
|
|
|
|
pub(crate) fn server_info_cache_path(&self) -> PathBuf {
|
|
self.cache_path_in(CODEX_APPS_SERVER_INFO_CACHE_DIR)
|
|
}
|
|
|
|
fn cache_path_in(&self, cache_dir: &str) -> PathBuf {
|
|
let user_key_json = serde_json::to_string(&self.user_key).unwrap_or_default();
|
|
let user_key_hash = sha1_hex(&user_key_json);
|
|
self.codex_home
|
|
.join(cache_dir)
|
|
.join(format!("{user_key_hash}.json"))
|
|
}
|
|
}
|
|
|
|
pub(crate) enum CachedCodexAppsToolsLoad {
|
|
Hit(Vec<ToolInfo>),
|
|
Missing,
|
|
Invalid,
|
|
}
|
|
|
|
pub(crate) fn normalize_codex_apps_tool_title(connector_name: Option<&str>, value: &str) -> String {
|
|
let Some(connector_name) = connector_name
|
|
.map(str::trim)
|
|
.filter(|name| !name.is_empty())
|
|
else {
|
|
return value.to_string();
|
|
};
|
|
|
|
let prefix = format!("{connector_name}_");
|
|
if let Some(stripped) = value.strip_prefix(&prefix)
|
|
&& !stripped.is_empty()
|
|
{
|
|
return stripped.to_string();
|
|
}
|
|
|
|
value.to_string()
|
|
}
|
|
|
|
pub(crate) fn normalize_codex_apps_callable_name(
|
|
tool_name: &str,
|
|
connector_id: Option<&str>,
|
|
connector_name: Option<&str>,
|
|
) -> String {
|
|
let tool_name = sanitize_name(tool_name);
|
|
|
|
if let Some(connector_name) = connector_name
|
|
.map(str::trim)
|
|
.map(sanitize_name)
|
|
.filter(|name| !name.is_empty())
|
|
&& let Some(stripped) = tool_name.strip_prefix(&connector_name)
|
|
&& !stripped.is_empty()
|
|
{
|
|
return stripped.to_string();
|
|
}
|
|
|
|
if let Some(connector_id) = connector_id
|
|
.map(str::trim)
|
|
.map(sanitize_name)
|
|
.filter(|name| !name.is_empty())
|
|
&& let Some(stripped) = tool_name.strip_prefix(&connector_id)
|
|
&& !stripped.is_empty()
|
|
{
|
|
return stripped.to_string();
|
|
}
|
|
|
|
tool_name
|
|
}
|
|
|
|
pub(crate) fn normalize_codex_apps_callable_namespace(
|
|
server_name: &str,
|
|
connector_name: Option<&str>,
|
|
) -> String {
|
|
if let Some(connector_name) = connector_name {
|
|
format!("{}__{}", server_name, sanitize_name(connector_name))
|
|
} else {
|
|
server_name.to_string()
|
|
}
|
|
}
|
|
|
|
pub(crate) fn write_codex_apps_tools_cache(
|
|
cache_context: Option<&CodexAppsToolsCacheContext>,
|
|
server_info: &McpServerInfo,
|
|
tools: &[ToolInfo],
|
|
) {
|
|
if let Some(cache_context) = cache_context {
|
|
let cache_write_start = Instant::now();
|
|
write_cached_codex_apps_tools(cache_context, tools);
|
|
if let Err(err) = write_cached_codex_apps_server_info(cache_context, server_info) {
|
|
tracing::warn!("failed to write Codex Apps server info cache: {err:#}");
|
|
}
|
|
emit_duration(
|
|
MCP_TOOLS_CACHE_WRITE_DURATION_METRIC,
|
|
cache_write_start.elapsed(),
|
|
&[],
|
|
);
|
|
}
|
|
}
|
|
|
|
pub(crate) fn load_startup_cached_codex_apps_tools_snapshot(
|
|
cache_context: Option<&CodexAppsToolsCacheContext>,
|
|
) -> Option<Vec<ToolInfo>> {
|
|
let cache_context = cache_context?;
|
|
|
|
match load_cached_codex_apps_tools(cache_context) {
|
|
CachedCodexAppsToolsLoad::Hit(tools) => Some(tools),
|
|
CachedCodexAppsToolsLoad::Missing | CachedCodexAppsToolsLoad::Invalid => None,
|
|
}
|
|
}
|
|
|
|
pub(crate) fn load_startup_cached_codex_apps_server_info(
|
|
cache_context: Option<&CodexAppsToolsCacheContext>,
|
|
) -> Option<McpServerInfo> {
|
|
load_cached_codex_apps_server_info(cache_context?)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub(crate) fn read_cached_codex_apps_tools(
|
|
cache_context: &CodexAppsToolsCacheContext,
|
|
) -> Option<Vec<ToolInfo>> {
|
|
match load_cached_codex_apps_tools(cache_context) {
|
|
CachedCodexAppsToolsLoad::Hit(tools) => Some(tools),
|
|
CachedCodexAppsToolsLoad::Missing | CachedCodexAppsToolsLoad::Invalid => None,
|
|
}
|
|
}
|
|
|
|
pub(crate) fn load_cached_codex_apps_tools(
|
|
cache_context: &CodexAppsToolsCacheContext,
|
|
) -> CachedCodexAppsToolsLoad {
|
|
let cache_path = cache_context.tools_cache_path();
|
|
let bytes = match std::fs::read(cache_path) {
|
|
Ok(bytes) => bytes,
|
|
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
|
|
return CachedCodexAppsToolsLoad::Missing;
|
|
}
|
|
Err(_) => return CachedCodexAppsToolsLoad::Invalid,
|
|
};
|
|
let cache: CodexAppsToolsDiskCache = match serde_json::from_slice(&bytes) {
|
|
Ok(cache) => cache,
|
|
Err(_) => return CachedCodexAppsToolsLoad::Invalid,
|
|
};
|
|
if cache.schema_version != CODEX_APPS_TOOLS_CACHE_SCHEMA_VERSION {
|
|
return CachedCodexAppsToolsLoad::Invalid;
|
|
}
|
|
CachedCodexAppsToolsLoad::Hit(cache.tools)
|
|
}
|
|
|
|
pub(crate) fn write_cached_codex_apps_tools(
|
|
cache_context: &CodexAppsToolsCacheContext,
|
|
tools: &[ToolInfo],
|
|
) {
|
|
let cache_path = cache_context.tools_cache_path();
|
|
if let Some(parent) = cache_path.parent()
|
|
&& std::fs::create_dir_all(parent).is_err()
|
|
{
|
|
return;
|
|
}
|
|
let Ok(bytes) = serde_json::to_vec_pretty(&CodexAppsToolsDiskCache {
|
|
schema_version: CODEX_APPS_TOOLS_CACHE_SCHEMA_VERSION,
|
|
tools: tools.to_vec(),
|
|
}) else {
|
|
return;
|
|
};
|
|
let _ = std::fs::write(cache_path, bytes);
|
|
}
|
|
|
|
pub(crate) fn load_cached_codex_apps_server_info(
|
|
cache_context: &CodexAppsToolsCacheContext,
|
|
) -> Option<McpServerInfo> {
|
|
let bytes = std::fs::read(cache_context.server_info_cache_path()).ok()?;
|
|
let cache: CodexAppsServerInfoDiskCache = serde_json::from_slice(&bytes).ok()?;
|
|
(cache.schema_version == CODEX_APPS_SERVER_INFO_CACHE_SCHEMA_VERSION)
|
|
.then_some(cache.server_info)
|
|
}
|
|
|
|
fn write_cached_codex_apps_server_info(
|
|
cache_context: &CodexAppsToolsCacheContext,
|
|
server_info: &McpServerInfo,
|
|
) -> anyhow::Result<()> {
|
|
let cache_path = cache_context.server_info_cache_path();
|
|
if let Some(parent) = cache_path.parent() {
|
|
std::fs::create_dir_all(parent).with_context(|| {
|
|
format!(
|
|
"failed to create Codex Apps server info cache directory `{}`",
|
|
parent.display()
|
|
)
|
|
})?;
|
|
}
|
|
let bytes = serde_json::to_vec_pretty(&CodexAppsServerInfoDiskCache {
|
|
schema_version: CODEX_APPS_SERVER_INFO_CACHE_SCHEMA_VERSION,
|
|
server_info: server_info.clone(),
|
|
})
|
|
.context("failed to serialize Codex Apps server info cache")?;
|
|
std::fs::write(&cache_path, bytes).with_context(|| {
|
|
format!(
|
|
"failed to write Codex Apps server info cache `{}`",
|
|
cache_path.display()
|
|
)
|
|
})?;
|
|
Ok(())
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
struct CodexAppsToolsDiskCache {
|
|
schema_version: u8,
|
|
tools: Vec<ToolInfo>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
struct CodexAppsServerInfoDiskCache {
|
|
schema_version: u8,
|
|
server_info: McpServerInfo,
|
|
}
|
|
|
|
const CODEX_APPS_TOOLS_CACHE_DIR: &str = "cache/codex_apps_tools";
|
|
pub(crate) const CODEX_APPS_TOOLS_CACHE_SCHEMA_VERSION: u8 = 4;
|
|
|
|
const CODEX_APPS_SERVER_INFO_CACHE_DIR: &str = "cache/codex_apps_server_info";
|
|
const CODEX_APPS_SERVER_INFO_CACHE_SCHEMA_VERSION: u8 = 1;
|
|
|
|
fn sha1_hex(s: &str) -> String {
|
|
let mut hasher = Sha1::new();
|
|
hasher.update(s.as_bytes());
|
|
let sha1 = hasher.finalize();
|
|
format!("{sha1:x}")
|
|
}
|