Codex 系统手册

缓存和本地持久化查找数据

stage-21.36 个文件

这一阶段是幕后支撑,不是主流程干活,而是给系统准备“本地小抄”。云端配置、连接器目录、远程插件目录、可用模型列表,都会先存一份到磁盘,下次启动或刷新时先看本地,少等网络。每份缓存都会查用户、服务器、版本、过期时间和格式,发现不对就丢掉。另有一张表记录远程插件对应本机哪个文件夹,还有更新提示缓存,记住上次检查和“不再提示”,避免反复打扰用户。

本阶段的文件6

签名云配置缓存

这一缓存层定义最安全敏感的持久化查询数据,处理带身份作用域、TTL 检查和篡改检测的签名 cloud-config 包。

cloud-config/src/cache.rs源码 ↗
io_transportconfig load / request handling

这个文件像一个带封条的小抽屉:云端配置包下载回来后,会放进本地一个 JSON 文件里;下次要用时,先看看抽屉里有没有可用的。为了防止出岔子,它做了几层检查:必须知道当前 ChatGPT 用户和账号;缓存文件必须能读、能解析;文件里写的用户和当前用户必须一致;缓存只能用 1 小时;还要用 HMAC(一种用密钥做的校验封条,用来发现内容是否被改过)验证签名。写入时,它会把配置、用户身份、缓存时间、过期时间打包成 payload,再签名后一起写到磁盘。读取时,它会重新计算签名并比较,任何异常都会“失败关闭”,也就是宁可不用缓存,也不冒险使用坏数据。

函数细节9
CloudConfigBundleCache::new39–43 ↗
fn new(codex_home: AbsolutePathBuf) -> Self

作用:创建一个缓存对象,告诉程序缓存文件应该放在哪里。有人拿到 Codex 的主目录后,就用它拼出固定的缓存文件路径。

数据流:进去的是 Codex 的本地目录路径 → 它在这个目录后面接上固定文件名 cloud-config-bundle-cache.json → 出来的是一个 CloudConfigBundleCache,里面记住了完整缓存路径。

调用关系:它是使用缓存前的第一步。测试里的 create_test_cache 和其他创建流程会先调用它;它内部只把路径拼好,后面的读取、写入、记录日志都靠这个路径办事。

调用图:调用 1 个内部函数(join);被 3 处调用(create_test_cache, new, create_test_cache)。

CloudConfigBundleCache::path45–47 ↗
fn path(&self) -> &Path

作用:把缓存文件的实际路径拿出来给别人看或使用。比如测试或辅助代码想直接往这个位置写文件时会用到它。

数据流:进去的是已经创建好的缓存对象 → 它读取对象里保存的 path 字段 → 出来的是这个缓存文件路径的引用,不会改动任何东西。

调用关系:它是一个很小的取路径接口。调用方 write_cache_file 会用它知道该把测试缓存文件写到哪里;真正的缓存读写函数则直接使用对象内部的路径。

调用图:被 1 处调用(write_cache_file)。

CloudConfigBundleCache::load49–107 ↗
async fn load(
        &self,
        chatgpt_user_id: Option<&str>,
        account_id: Option<&str>,
    ) -> Result<CloudConfigBundleCacheSignedPayload, CacheLoadStatus>

作用:尝试从本地磁盘读取云配置缓存,并判断它能不能安全使用。它不会只因为文件存在就相信它,而是逐项检查身份、签名、版本和过期时间。

数据流:进去的是当前 ChatGPT 用户 ID 和账号 ID,以及缓存对象里保存的文件路径 → 它先确认身份信息完整,再读文件、把 JSON 解析成缓存结构、把被签名的内容重新序列化、验证 HMAC 签名、检查版本、检查缓存里的身份是否等于当前身份、检查是否过期 → 成功时出来的是可用的缓存内容;失败时出来的是具体原因,比如文件不存在、签名无效、身份不匹配或已过期。它不修改磁盘文件。

调用关系:它是“先试试本地缓存能不能用”的核心入口。调用方如 load_valid_cached_bundle 会用它拿缓存;它把序列化工作交给 cache_payload_bytes,把签名检查交给 verify_cache_signature,并使用系统读文件和 JSON 解析能力。

调用图:调用 2 个内部函数(cache_payload_bytes, verify_cache_signature);被 1 处调用(load_valid_cached_bundle);外部调用 6 个(now, CacheParseFailed, CacheReadFailed, CacheVersionUnsupported, read, from_slice)。

CloudConfigBundleCache::log_load_status109–126 ↗
fn log_load_status(&self, status: &CacheLoadStatus)

作用:把缓存读取失败或被跳过的原因写进日志,方便排查问题。普通情况用提示级别,像文件损坏、读不了、签名不对这种更可疑的情况用警告级别。

数据流:进去的是一次缓存读取的状态 → 如果只是文件不存在,它什么也不记,因为这很正常 → 如果是读文件失败、解析失败或签名无效,它写警告日志;其他状态写普通信息日志 → 出来没有返回值,只是在日志里留下说明。

调用关系:它通常跟在 load 后面使用。调用方拿到 load 的失败状态后,可以交给它决定该不该记录、用什么级别记录;它不修复缓存,只负责把情况讲清楚。

调用图:被 1 处调用(load_valid_cached_bundle);外部调用 3 个(matches!, info!, warn!)。

CloudConfigBundleCache::save128–167 ↗
async fn save(
        &self,
        chatgpt_user_id: Option<String>,
        account_id: Option<String>,
        bundle: CloudConfigBundle,
    ) -> Result<(), CloudConfigBundleCacheError>

作用:把刚拿到并确认可用的云配置包写成本地缓存文件。它会加上当前时间、过期时间、用户身份,并给内容盖上 HMAC 签名,方便以后判断有没有被改过。

数据流:进去的是可选的 ChatGPT 用户 ID、可选账号 ID、以及云配置包 → 它获取当前时间,算出 1 小时后的过期时间,把这些信息和配置包组成被签名的内容,再转成字节、生成签名、包装成漂亮格式的 JSON → 如果目录不存在就先创建目录,然后把 JSON 写到缓存文件 → 成功时返回空结果;失败时返回统一的写缓存错误。

调用关系:它是远程配置成功获取后的落盘步骤。调用方 validate_and_cache_remote_bundle 会在远程配置通过验证后调用它;它依赖 cache_payload_bytes 生成稳定的签名内容,依赖 sign_cache_payload 生成签名,然后调用文件系统创建目录和写文件。

调用图:调用 3 个内部函数(cache_payload_bytes, sign_cache_payload, parent);被 1 处调用(validate_and_cache_remote_bundle);外部调用 5 个(from_std, now, create_dir_all, write, to_vec_pretty)。

cache_payload_bytes214–218 ↗
fn cache_payload_bytes(
    payload: &CloudConfigBundleCacheSignedPayload,
) -> Option<Vec<u8>>

作用:把要签名的缓存内容转成一串字节。签名和验签必须面对同一串字节,这个函数就是负责准备这份“原文”。

数据流:进去的是 CloudConfigBundleCacheSignedPayload,也就是缓存里真正要保护的内容 → 它用 JSON 序列化把结构转成字节数组 → 成功时出来字节数组,失败时出来空值 None;它不改动输入内容。

调用关系:它同时服务写入和读取。save 用它生成要签名的字节;load 用它把读出来的内容重新变成字节,再拿去验签。这样写和读使用同一套规则,避免签名对不上。

调用图:被 2 处调用(load, save);外部调用 1 个(to_vec)。

sign_cache_payload220–225 ↗
fn sign_cache_payload(payload_bytes: &[u8]) -> Option<String>

作用:给缓存内容生成签名,也就是给文件盖一个只有程序知道怎么盖的防篡改封条。之后读取时,只要封条对不上,就说明内容不可信。

数据流:进去的是已经序列化好的缓存内容字节 → 它用固定的 HMAC 密钥和 SHA-256(一种常见的摘要算法)计算校验值,再把结果用 Base64(一种把二进制变成文本的编码)转成字符串 → 出来的是签名字符串;如果密钥初始化失败,就出来 None。

调用关系:它只在 save 时使用。save 先准备 payload 字节,再交给它生成签名,最后把签名和内容一起写进缓存文件。读取时不会调用它,而是走 verify_cache_signature 做检查。

调用图:被 1 处调用(save);外部调用 1 个(new_from_slice)。

verify_cache_signature227–236 ↗
fn verify_cache_signature(payload_bytes: &[u8], signature: &str) -> bool

作用:检查缓存文件里的签名是否真的对应当前内容。它的作用是发现手工修改、文件损坏或恶意篡改。

数据流:进去的是缓存内容字节和文件里保存的签名字符串 → 它先把 Base64 签名解码成原始字节;如果解码失败,直接判定无效 → 然后用允许读取的 HMAC 密钥逐个尝试验证 → 只要有一个密钥验证通过,就出来 true;否则出来 false。

调用关系:它是 load 里安全检查的关键关卡。load 读出文件并准备好 payload 字节后,会调用它;它再把每个具体密钥的验证工作交给 verify_cache_signature_with_key。

调用图:被 1 处调用(load)。

verify_cache_signature_with_key238–249 ↗
fn verify_cache_signature_with_key(
    payload_bytes: &[u8],
    signature_bytes: &[u8],
    key: &[u8],
) -> bool

作用:用某一个具体密钥验证一次签名。它是更底层的小工具,判断“这把钥匙能不能打开这个封条”。

数据流:进去的是缓存内容字节、签名原始字节、以及一个 HMAC 密钥 → 它用这个密钥重新计算内容的 HMAC 值,再和传入的签名做安全比较 → 如果一致出来 true,不一致或密钥不能用就出来 false。

调用关系:它被 verify_cache_signature 用来逐个尝试可接受的读取密钥。这样以后如果需要换签名密钥,外层可以保留旧密钥用于读取旧缓存,而这个函数仍只负责单次验证。

调用图:外部调用 1 个(new_from_slice)。

目录持久化缓存

这些文件持久化为连接器、插件和模型获取的目录式数据,使更高层的刷新流程可以复用本地结果,并在远程获取不可用时回退。

connectors/src/directory_cache.rs源码 ↗
io_transportconnector directory cache load/write

连接器目录可以理解成一张“有哪些可用应用/连接器”的清单。这个文件做的事,就是给这张清单准备一个本地小仓库:先根据用户的 codex_home 和缓存 key 算出一个固定文件名,再把连接器列表写成 JSON 文件;下次需要时,先去这个文件里读。为了避免不同条件下的缓存互相撞车,它不会直接用原始 key 当文件名,而是把 key 转成 JSON 后做 SHA-1 哈希(一种把内容压成固定指纹的算法),再用这个指纹命名。缓存文件里还带 schema_version(格式版本号),如果程序升级后格式变了,旧缓存会被认为无效并删除。读缓存时分三种结果:命中、有文件但坏了、根本没有。写缓存时很保守,建目录、序列化、写文件任何一步失败都直接放弃,不打断主流程。

函数细节5
ConnectorDirectoryCacheContext::new22–27 ↗
fn new(codex_home: PathBuf, cache_key: ConnectorDirectoryCacheKey) -> Self

作用:创建一个缓存上下文,也就是把“缓存放在哪个主目录”和“这份缓存对应什么条件”打包在一起。后面读写缓存文件时都靠它找到正确的位置。

数据流:输入是 codex_home 路径和 cache_key 缓存键;函数只是把这两样东西放进 ConnectorDirectoryCacheContext 这个小容器里;输出就是一个可以继续传给读缓存、写缓存流程使用的上下文对象,不会碰磁盘。

调用关系:它是准备阶段用的小入口。connector_directory_cache_context、cache_context、cached_directory_connectors_for_tool_suggest_with_auth 这些上层流程会先调用它做出上下文,然后后续的读取和写入函数再用这个上下文算缓存文件路径。

调用图:被 3 处调用(connector_directory_cache_context, cache_context, cached_directory_connectors_for_tool_suggest_with_auth)。

ConnectorDirectoryCacheContext::cache_path29–35 ↗
fn cache_path(&self) -> PathBuf

作用:算出这份连接器目录缓存应该存在哪个具体文件里。这样同一套条件总是落到同一个文件,不同条件尽量落到不同文件。

数据流:它读取上下文里的 cache_key 和 codex_home;先把 cache_key 转成 JSON 字符串,再交给 sha1_hex 算出一段哈希指纹;最后把 codex_home、固定缓存目录 cache/codex_app_directory、以及“指纹.json”拼成完整路径并返回。

调用关系:它是读写磁盘前的“找地址”步骤。load_cached_directory_connectors_from_disk 和 write_cached_directory_connectors_to_disk 都会先调用它;它自己把指纹计算交给 sha1_hex,避免文件名又长又容易包含特殊字符。

调用图:调用 1 个内部函数(sha1_hex);被 2 处调用(load_cached_directory_connectors_from_disk, write_cached_directory_connectors_to_disk);外部调用 3 个(join, format!, to_string)。

load_cached_directory_connectors_from_disk44–80 ↗
fn load_cached_directory_connectors_from_disk(
    cache_context: &ConnectorDirectoryCacheContext,
) -> CachedConnectorDirectoryDiskLoad

作用:从本地磁盘读取连接器目录缓存。它不只是读文件,还会判断缓存是命中、缺失,还是已经坏掉不能用。

数据流:输入是缓存上下文;函数先用 cache_path 找到缓存文件,然后尝试读取字节。文件不存在就返回 Missing;读文件失败或 JSON 解析失败就记录警告,必要时删掉坏文件,并返回 Invalid;解析成功后还会检查 schema_version 是否等于当前版本,不匹配也删除并返回 Invalid;只有一切正常时,才把缓存里的 connectors 列表作为 Hit 返回。

调用关系:它在需要连接器目录时被 cached_directory_connectors 调用,属于“先看看本地有没有现成答案”的步骤。它依赖 cache_path 找文件,依赖 JSON 解析把文件内容变回程序里的数据;如果发现文件坏了,会用 remove_file 清理现场,避免下次继续踩坑。

调用图:调用 1 个内部函数(cache_path);被 1 处调用(cached_directory_connectors);外部调用 4 个(from_slice, read, remove_file, warn!)。

write_cached_directory_connectors_to_disk82–99 ↗
fn write_cached_directory_connectors_to_disk(
    cache_context: &ConnectorDirectoryCacheContext,
    connectors: &[AppInfo],
)

作用:把刚拿到的连接器目录写进本地缓存文件,方便下次直接读取。它的目标是加速以后使用,而不是影响当前主流程。

数据流:输入是缓存上下文和一组 AppInfo 连接器信息;函数先用 cache_path 算出文件位置,再确保父目录存在;接着把版本号和连接器列表包成 ConnectorDirectoryDiskCache,并序列化成好看的 JSON 字节;最后写入磁盘。目录创建、序列化、写入如果失败,它会安静返回,不抛出结果给调用方。

调用关系:它在上层 write_cached_directory_connectors 流程里被调用,通常发生在已经成功拿到新连接器目录之后。它和 load_cached_directory_connectors_from_disk 是一对:一个负责把清单存起来,一个负责下次取出来;两者都靠 cache_path 保证读写的是同一个位置。

调用图:调用 1 个内部函数(cache_path);被 1 处调用(write_cached_directory_connectors);外部调用 4 个(to_vec, to_vec_pretty, create_dir_all, write)。

sha1_hex107–112 ↗
fn sha1_hex(value: &str) -> String

作用:把一段字符串变成 SHA-1 十六进制指纹,用来做安全、短小、稳定的缓存文件名。这里不是为了加密保密,而是为了把复杂缓存键变成适合当文件名的字符串。

数据流:输入是一段字符串;函数创建 SHA-1 计算器,把字符串的字节喂进去,算出摘要;最后把摘要格式化成十六进制文本并返回。它不读写外部状态。

调用关系:它只被 cache_path 使用,是路径生成里的小工具。cache_path 先把缓存键变成 JSON,再调用它得到指纹,最后拼出缓存文件名。

调用图:被 1 处调用(cache_path);外部调用 2 个(new, format!)。

core-plugins/src/remote/catalog_cache.rs源码 ↗
io_transport发现或拉取远程插件目录时读写本地缓存

远程插件目录可以理解成“网上插件商店的货架列表”。每次都去网上拉会慢,也可能因为网络问题失败,所以这个文件做了一个本地小仓库:读的时候,它先根据当前服务地址和登录身份算出一个专属文件名,再去 Codex 的 home 目录下找对应 JSON 文件;找不到就当没有缓存,读坏了或版本不对就删掉,防止继续用脏数据。写的时候,它把插件列表连同一个缓存格式版本号一起保存成 JSON。这里的“schema version(格式版本号)”就像文件说明书的版本,代码升级后如果旧缓存看不懂,就直接丢弃。整体设计是“尽力而为”:缓存失败不会让主流程崩掉,只是退回去走正常网络获取。

函数细节4
RemotePluginCatalogCacheKey::global22–29 ↗
fn global(config: &RemotePluginServiceConfig, auth: &CodexAuth) -> Self

作用:这个函数生成一把“缓存钥匙”,用来说明当前缓存属于哪个远程服务、哪个账号、哪个用户。这样不同登录身份不会共用同一份插件目录。

数据流:输入是远程插件服务配置和当前登录认证信息;它从配置里取服务器地址,从认证信息里取账号 ID、ChatGPT 用户 ID,以及是否是工作区账号;最后产出一个 RemotePluginCatalogCacheKey,后面会拿它去算缓存文件名。

调用关系:读缓存和写缓存都会先调用它。它自己把具体身份信息交给认证对象的 get_account_id、get_chatgpt_user_id、is_workspace_account 去取,然后把这些信息打包成统一的缓存标识。

调用图:调用 3 个内部函数(get_account_id, get_chatgpt_user_id, is_workspace_account);被 2 处调用(load_cached_global_directory_plugins, write_cached_global_directory_plugins)。

load_cached_global_directory_plugins38–75 ↗
fn load_cached_global_directory_plugins(
    codex_home: &Path,
    config: &RemotePluginServiceConfig,
    auth: &CodexAuth,
) -> Option<Vec<RemotePluginDirectoryItem>>

作用:这个函数尝试从本地磁盘读取已经缓存好的远程插件目录。如果缓存不存在、坏了、或格式版本不匹配,它不会报错中断,而是返回“没有缓存”。

数据流:输入是 Codex 主目录、远程服务配置和登录信息;它先生成缓存钥匙,再算出缓存文件路径;随后读取文件、把 JSON 字节解析成缓存结构,检查格式版本号;成功时输出插件列表,失败时输出 None。遇到读不懂或版本不对的缓存,它还会顺手删除这个坏文件。

调用关系:当系统想快速拿到远程插件列表时,cached_global_remote_discoverable_plugins、fetch_remote_marketplaces、has_cached_global_remote_plugin_catalog 会调用它。它把“该找哪个文件”的工作交给 RemotePluginCatalogCacheKey::global 和 cache_path,把“JSON 变回数据”的工作交给 serde_json。

调用图:调用 2 个内部函数(global, cache_path);被 3 处调用(cached_global_remote_discoverable_plugins, fetch_remote_marketplaces, has_cached_global_remote_plugin_catalog);外部调用 4 个(from_slice, read, remove_file, warn!)。

write_cached_global_directory_plugins77–99 ↗
fn write_cached_global_directory_plugins(
    codex_home: &Path,
    config: &RemotePluginServiceConfig,
    auth: &CodexAuth,
    plugins: &[RemotePluginDirectoryItem],
)

作用:这个函数把刚拿到的远程插件目录写进本地缓存,方便下次启动或下次查询时直接使用。它是后台式的辅助动作,写失败也不会影响主流程。

数据流:输入是 Codex 主目录、远程服务配置、登录信息和插件列表;它先生成缓存钥匙并算出文件路径,再确保父目录存在;然后把格式版本号和插件列表一起转成漂亮的 JSON,最后写入磁盘。它没有返回值,成功的结果就是磁盘上多了一份缓存文件。

调用关系:fetch_and_cache_global_remote_plugin_catalog 和 fetch_remote_marketplaces 在成功拿到远程插件目录后会调用它。它依赖 RemotePluginCatalogCacheKey::global 区分账号和环境,依赖 cache_path 决定保存位置,并把序列化与文件写入交给 serde_json 和标准文件系统函数。

调用图:调用 2 个内部函数(global, cache_path);被 2 处调用(fetch_and_cache_global_remote_plugin_catalog, fetch_remote_marketplaces);外部调用 4 个(to_vec, to_vec_pretty, create_dir_all, write)。

cache_path101–111 ↗
fn cache_path(codex_home: &Path, cache_key: &RemotePluginCatalogCacheKey) -> PathBuf

作用:这个函数把一把缓存钥匙变成一个稳定的本地文件路径。它的作用像给每个账号和服务地址生成一个不容易冲突的文件柜编号。

数据流:输入是 Codex 主目录和缓存钥匙;它先把缓存钥匙转成 JSON 字节,再用一套简单的哈希算法把这些字节压成 16 位十六进制名字;最后拼出类似 cache/remote_plugin_catalog/xxxxxxxxxxxxxxxx.json 的路径并返回。

调用关系:读缓存和写缓存都会调用它,因为两边必须用同一套规则找到同一个文件。它不读写磁盘,只负责算路径;真正的读取由 load_cached_global_directory_plugins 做,真正的写入由 write_cached_global_directory_plugins 做。

调用图:被 2 处调用(load_cached_global_directory_plugins, write_cached_global_directory_plugins);外部调用 4 个(join, format!, to_vec, from)。

models-manager/src/cache.rs源码 ↗
io_transportconfig load / request handling / cache refresh

这份文件像一个“模型名单的小冰箱”:刚从服务器拿到的模型列表会被装进一个 JSON 文件里,连同保存时间、ETag(服务器用来标记内容版本的标签)和客户端版本一起存好。下次程序要模型列表时,ModelsCacheManager 会先去磁盘找这份缓存;如果文件不存在、内容坏了、版本不对,或者超过了保鲜期 TTL(time to live,意思是缓存能被信任多久),就当缓存不可用。真正保存的数据结构是 ModelsCache,它记录“什么时候拿到的、对应哪个版本、有哪些模型”。这个文件的重要点是:它不是盲目相信本地文件,而是先验版本、再验时间;同时写入时会自动创建目录,减少因为文件夹不存在导致保存失败的问题。测试里还提供了几个专门改缓存时间或内容的小工具,用来模拟过期、损坏、版本变化等情况。

函数细节10
ModelsCacheManager::new23–28 ↗
fn new(cache_path: PathBuf, cache_ttl: Duration) -> Self

作用:创建一个缓存管理器,告诉它缓存文件放在哪里,以及缓存最多能保鲜多久。别人要使用本地模型缓存时,会先用它搭出这个“管缓存的小工具”。

数据流:进去的是一个磁盘路径和一个时间长度 → 函数把它们装进 ModelsCacheManager 里 → 出来的是一个可以后续读写缓存的管理器对象,不会立刻读文件或写文件。

调用关系:它是后续所有缓存操作的起点。外部初始化模型管理相关组件时会调用它,之后 load_fresh、persist_cache、renew_cache_ttl 等方法都依赖这里保存下来的路径和保鲜时间。

调用图:被 1 处调用(new)。

ModelsCacheManager::load_fresh31–74 ↗
async fn load_fresh(&self, expected_version: &str) -> Option<ModelsCache>

作用:尝试从磁盘读出一份“还能放心用”的模型缓存。它不只是看文件在不在,还会检查客户端版本是否一致、缓存是否过期。

数据流:进去的是期望的客户端版本号,同时它会读取自己的缓存路径和 TTL → 它先调用 load 读缓存文件,再比较缓存里的 client_version,接着用 ModelsCache::is_fresh 判断时间是否还新鲜 → 如果一切合格就返回 ModelsCache;只要文件不存在、读取失败、版本不对或过期,就返回 None,并写日志说明原因。

调用关系:它通常由 try_load_cache 在需要模型列表前调用,像是“先看看冰箱里有没有还能吃的”。它把读文件的细活交给 load,把新鲜度判断交给 ModelsCache::is_fresh,自己负责把这些条件串起来做最终决定。

调用图:调用 1 个内部函数(load);被 1 处调用(try_load_cache);外部调用 2 个(error!, info!)。

ModelsCacheManager::persist_cache77–92 ↗
async fn persist_cache(
        &self,
        models: &[ModelInfo],
        etag: Option<String>,
        client_version: String,
    )

作用:把刚拿到的模型列表写进本地缓存,供以后快速复用。它会顺手记录当前时间、ETag 和客户端版本。

数据流:进去的是模型列表、可选的 ETag、客户端版本号 → 它把这些和当前时间 Utc::now() 打包成 ModelsCache,并把模型列表复制成缓存自己的数据 → 然后调用 save_internal 写到磁盘;如果写失败,不把错误往外抛,而是记一条错误日志。

调用关系:它由 fetch_and_update_models 在成功从远端获取模型后调用。也就是说,网络拿到新名单之后,这个函数负责“放进本地小冰箱”,具体的目录创建和 JSON 写文件交给 save_internal。

调用图:调用 1 个内部函数(save_internal);被 1 处调用(fetch_and_update_models);外部调用 3 个(now, error!, to_vec)。

ModelsCacheManager::renew_cache_ttl95–102 ↗
async fn renew_cache_ttl(&self) -> io::Result<()>

作用:只刷新缓存的保存时间,不改模型列表本身。常见场景是服务器确认内容没变,那本地这份缓存就可以继续算新鲜。

数据流:进去没有额外数据,只使用管理器里的缓存路径 → 它调用 load 读出现有缓存;如果没有缓存,就返回 NotFound 错误 → 如果有,就把 fetched_at 改成当前时间,再调用 save_internal 写回磁盘 → 出来是成功或失败的 io::Result。

调用关系:它由 refresh_if_new_etag 在确认远端内容没有变化时调用。它不重新下载模型,而是给旧缓存“续保鲜期”,读旧文件靠 load,写回文件靠 save_internal。

调用图:调用 2 个内部函数(load, save_internal);被 1 处调用(refresh_if_new_etag);外部调用 2 个(now, new)。

ModelsCacheManager::load104–114 ↗
async fn load(&self) -> io::Result<Option<ModelsCache>>

作用:从磁盘读取缓存文件,并把 JSON 内容还原成程序能用的 ModelsCache。它是这个文件里最基础的“读缓存”动作。

数据流:进去的是管理器里保存的缓存文件路径 → 它异步读取文件;如果文件存在,就用 serde_json 把字节内容解析成 ModelsCache;如果文件不存在,就返回 Ok(None);如果文件存在但读不了或 JSON 格式不对,就返回错误 → 出来是“有缓存、没缓存、或出错”三种结果之一。

调用关系:load_fresh、renew_cache_ttl 和测试辅助函数都会先找它要当前缓存内容。它不判断缓存是否新鲜,也不管版本是否匹配,只负责把磁盘上的文件安全地读出来。

调用图:被 4 处调用(load_fresh, manipulate_cache_for_test, mutate_cache_for_test, renew_cache_ttl);外部调用 2 个(read, from_slice)。

ModelsCacheManager::save_internal116–123 ↗
async fn save_internal(&self, cache: &ModelsCache) -> io::Result<()>

作用:把一份 ModelsCache 真正写到磁盘上。它会先确保父目录存在,再把缓存转成漂亮排版的 JSON 文件。

数据流:进去的是一份 ModelsCache → 它查看缓存路径的父目录,如果需要就创建目录;然后用 serde_json 把缓存转成格式化 JSON 字节;最后写入目标文件 → 出来是写入成功或具体的磁盘/序列化错误。

调用关系:persist_cache、renew_cache_ttl 和测试辅助函数都会把最终写文件的任务交给它。它是所有“保存缓存”操作共用的底层出口,避免每个地方重复写创建目录和转 JSON 的代码。

调用图:被 4 处调用(manipulate_cache_for_test, mutate_cache_for_test, persist_cache, renew_cache_ttl);外部调用 4 个(parent, create_dir_all, write, to_vec_pretty)。

ModelsCacheManager::set_ttl127–129 ↗
fn set_ttl(&mut self, ttl: Duration)

作用:在测试里临时修改缓存的保鲜时间 TTL。这样测试可以很方便地模拟“缓存马上过期”或“缓存长期有效”。

数据流:进去的是一个新的时间长度 → 它直接替换管理器里的 cache_ttl 字段 → 出来没有返回值,但之后 load_fresh 判断新鲜度时会使用这个新 TTL。

调用关系:这个函数只在测试编译时存在。它不参与正式运行流程,主要帮助测试代码控制时间规则,从而验证 load_fresh 对过期缓存的处理是否正确。

ModelsCacheManager::manipulate_cache_for_test133–143 ↗
async fn manipulate_cache_for_test(&self, f: F) -> io::Result<()>

作用:在测试里专门改缓存的 fetched_at 时间。它让测试可以伪造“这份缓存是很久以前拿到的”或“刚刚拿到的”。

数据流:进去的是一个小函数,这个小函数会拿到 fetched_at 的可修改引用 → 它先调用 load 读出现有缓存;如果没有缓存就返回 NotFound 错误;如果有,就把缓存时间交给传入的小函数修改;最后调用 save_internal 写回磁盘 → 出来是成功或错误。

调用关系:它只服务测试场景。测试用它改时间,然后再让 load_fresh 去判断缓存是否该命中或过期;实际读写缓存文件的工作仍然交给 load 和 save_internal。

调用图:调用 2 个内部函数(load, save_internal);外部调用 1 个(new)。

ModelsCacheManager::mutate_cache_for_test147–157 ↗
async fn mutate_cache_for_test(&self, f: F) -> io::Result<()>

作用:在测试里修改整份缓存内容,而不只是修改时间。它可以用来模拟版本不匹配、模型列表变化、ETag 改变等情况。

数据流:进去的是一个能修改 ModelsCache 的小函数 → 它调用 load 读出现有缓存;如果没有缓存就返回 NotFound 错误;如果有,就把整份缓存交给传入的小函数随测试需要修改;然后调用 save_internal 写回 → 出来是成功或错误。

调用关系:它只在测试中出现,是更通用的缓存篡改工具。相比 manipulate_cache_for_test 只动时间,它能动整份缓存,方便测试 load_fresh、renew_cache_ttl 等逻辑在各种异常数据下的表现。

调用图:调用 2 个内部函数(load, save_internal);外部调用 1 个(new)。

ModelsCache::is_fresh173–182 ↗
fn is_fresh(&self, ttl: Duration) -> bool

作用:判断这份缓存有没有超过保鲜期。简单说,就是看“现在”距离 fetched_at 过去了多久,是否还在允许范围内。

数据流:进去的是一个 TTL 时间长度,同时它读取缓存里的 fetched_at → 如果 TTL 是 0,直接认为不新鲜;如果 TTL 转换成 chrono 能用的时间长度失败,也认为不新鲜;否则计算当前时间减去 fetched_at 得到缓存年龄 → 年龄小于等于 TTL 就返回 true,否则返回 false。

调用关系:它被 load_fresh 用来做最后的新鲜度检查。load_fresh 负责读文件和验版本,ModelsCache::is_fresh 只回答一个问题:这份缓存按时间看还能不能用。

调用图:外部调用 3 个(is_zero, now, from_std)。

本地插件路径映射

此工具维护从共享远程插件标识符到本地文件系统路径的小型持久化映射,并为文件访问提供简单的并发保护。

core-plugins/src/remote/share/local_paths.rs源码 ↗
io_transport插件分享的列出、检出、保存、删除过程中按需读写

这个文件解决的是“记住位置”的问题:远程插件分享有自己的 ID,本机也有实际存放插件的路径,两者需要对应起来。它把这份对应关系写进 .tmp/plugin-share-local-paths-v1.json。读写时会先拿互斥锁(一把锁,防止两个任务同时改同一份文件),避免两边同时写把文件弄乱。读取时,如果文件不存在,就当作还没有记录;如果更新时发现 JSON 坏了,因为这只是临时缓存,它会直接从空表重新开始,不让坏缓存一直挡路。写入时也比较小心:先写到临时文件,再一次性替换目标文件,像先写草稿再换正式文件,减少写到一半断掉留下半截内容的风险。

函数细节9
load_plugin_share_local_paths20–25 ↗
fn load_plugin_share_local_paths(
    codex_home: &Path,
) -> io::Result<BTreeMap<String, AbsolutePathBuf>>

作用:读取当前保存的“远程插件 ID → 本地插件路径”登记表。外部代码想知道某个分享在本机放在哪里时,会用它。

数据流:输入 codex_home,也就是项目用于存放状态文件的根目录 → 先加锁,确保没人同时修改这张表 → 从固定 JSON 文件读出映射表;如果文件不存在就返回空表。

调用关系:它是对外读取入口。load_share_local_paths_for_checkout、list_remote_plugin_shares、load_plugin_share_remote_ids_by_local_path 在需要查这份登记表时会调用它;它自己把加锁交给 lock_plugin_share_local_paths,把真正读文件交给 read_plugin_share_local_paths。

调用图:调用 2 个内部函数(lock_plugin_share_local_paths, read_plugin_share_local_paths);被 3 处调用(load_share_local_paths_for_checkout, list_remote_plugin_shares, load_plugin_share_remote_ids_by_local_path)。

record_plugin_share_local_path27–36 ↗
fn record_plugin_share_local_path(
    codex_home: &Path,
    remote_plugin_id: &str,
    plugin_path: AbsolutePathBuf,
) -> io::Result<()>

作用:把一个远程插件分享 ID 和它在本机的实际路径记下来。保存分享或检出分享成功后,需要它更新登记表。

数据流:输入 codex_home、remote_plugin_id 和 plugin_path → 先加锁 → 读取现有表,必要时把坏掉的临时缓存当空表处理 → 插入或覆盖这一条记录 → 把新表写回磁盘。

调用关系:checkout_remote_plugin_share 和 save_remote_plugin_share 会在确定本地路径后调用它。它串起 lock_plugin_share_local_paths、read_plugin_share_local_paths_for_update 和 write_plugin_share_local_paths,完成一次安全的“读旧表、改一条、写回去”。

调用图:调用 3 个内部函数(lock_plugin_share_local_paths, read_plugin_share_local_paths_for_update, write_plugin_share_local_paths);被 2 处调用(checkout_remote_plugin_share, save_remote_plugin_share)。

remove_plugin_share_local_path38–46 ↗
fn remove_plugin_share_local_path(
    codex_home: &Path,
    remote_plugin_id: &str,
) -> io::Result<()>

作用:删除某个远程插件分享 ID 对应的本地路径记录。删除远程插件分享时,不能让本地还留着过期登记。

数据流:输入 codex_home 和 remote_plugin_id → 先加锁 → 读取当前表,坏缓存按空表处理 → 从表里移除这个 ID → 把结果写回磁盘;如果表空了,底层会删除这个缓存文件。

调用关系:delete_remote_plugin_share 会调用它做清理。它和记录函数走同一套安全流程:先锁住,再用 read_plugin_share_local_paths_for_update 取表,最后交给 write_plugin_share_local_paths 保存。

调用图:调用 3 个内部函数(lock_plugin_share_local_paths, read_plugin_share_local_paths_for_update, write_plugin_share_local_paths);被 1 处调用(delete_remote_plugin_share)。

lock_plugin_share_local_paths48–52 ↗
fn lock_plugin_share_local_paths() -> io::Result<std::sync::MutexGuard<'static, ()>>

作用:拿到这份路径登记表的互斥锁。它的作用是让同一进程里同一时间只有一个任务能读改写这份文件。

数据流:没有业务输入 → 尝试锁住全局的 PLUGIN_SHARE_LOCAL_PATHS_LOCK → 成功就返回锁守卫,调用者持有它时别人进不来;如果锁已经被异常状态污染,就把错误包装成普通输入输出错误返回。

调用关系:load_plugin_share_local_paths、record_plugin_share_local_path、remove_plugin_share_local_path 都会先调用它。它本身不读写文件,只负责给后面的读写步骤加一道“排队闸门”。

调用图:被 3 处调用(load_plugin_share_local_paths, record_plugin_share_local_path, remove_plugin_share_local_path)。

read_plugin_share_local_paths54–74 ↗
fn read_plugin_share_local_paths(
    codex_home: &Path,
) -> io::Result<BTreeMap<String, AbsolutePathBuf>>

作用:从磁盘上的 JSON 文件读取完整登记表。它把文件内容变成程序里可用的有序映射表。

数据流:输入 codex_home → 用 plugin_share_local_paths_path 算出文件位置 → 读取文本;如果文件不存在,直接给空表 → 如果文件存在,就按 JSON 解析成 PluginShareLocalPaths,再取出里面的 ID 到绝对路径映射;解析失败会返回“数据无效”错误。

调用关系:load_plugin_share_local_paths 直接用它做普通读取;read_plugin_share_local_paths_for_update 也用它作为更新前的读取步骤。它依赖 plugin_share_local_paths_path 确定固定文件名。

调用图:调用 1 个内部函数(plugin_share_local_paths_path);被 2 处调用(load_plugin_share_local_paths, read_plugin_share_local_paths_for_update);外部调用 2 个(new, read_to_string)。

read_plugin_share_local_paths_for_update76–86 ↗
fn read_plugin_share_local_paths_for_update(
    codex_home: &Path,
) -> io::Result<BTreeMap<String, AbsolutePathBuf>>

作用:为“马上要修改登记表”的场景读取数据。它比普通读取更宽容:临时缓存坏了就当空表,不让保存或删除操作卡死。

数据流:输入 codex_home → 调用 read_plugin_share_local_paths 读表 → 成功就原样返回;如果错误是 JSON 内容坏了这类“数据无效”,就返回空表;其他磁盘错误仍然返回给调用者。

调用关系:record_plugin_share_local_path 和 remove_plugin_share_local_path 在改表前会调用它。它的位置像维修工:如果只是临时登记本写坏了,就换一本空的继续干活。

调用图:调用 1 个内部函数(read_plugin_share_local_paths);被 2 处调用(record_plugin_share_local_path, remove_plugin_share_local_path);外部调用 1 个(new)。

write_plugin_share_local_paths88–106 ↗
fn write_plugin_share_local_paths(
    codex_home: &Path,
    mapping: BTreeMap<String, AbsolutePathBuf>,
) -> io::Result<()>

作用:把新的登记表写回磁盘。它还会在登记表为空时删除缓存文件,避免留下没有意义的空文件。

数据流:输入 codex_home 和一张映射表 → 算出目标文件路径 → 如果表为空,就尝试删除文件,文件本来不存在也算成功 → 如果表不空,就把表包装成 JSON 结构、格式化成好读的文本,并通过 write_atomically 安全写入。

调用关系:record_plugin_share_local_path 和 remove_plugin_share_local_path 修改完表后都会调用它。它负责决定“删除空缓存”还是“写入 JSON”,真正的安全落盘动作交给 write_atomically。

调用图:调用 2 个内部函数(plugin_share_local_paths_path, write_atomically);被 2 处调用(record_plugin_share_local_path, remove_plugin_share_local_path);外部调用 3 个(format!, to_string_pretty, remove_file)。

write_atomically108–120 ↗
fn write_atomically(write_path: &Path, contents: &str) -> io::Result<()>

作用:用“原子写入”的方式保存文件,也就是尽量保证读者看到的不是旧完整文件,就是新完整文件,而不是写到一半的破文件。

数据流:输入目标路径和要写的文本 → 找到父目录,没有父目录就报错 → 创建父目录 → 在同一目录里建临时文件 → 写入全部内容 → 把临时文件持久化为目标文件;成功后目标路径上就是完整的新内容。

调用关系:write_plugin_share_local_paths 在需要真正写 JSON 文件时调用它。它不关心插件含义,只提供可靠写文件的小工具。

调用图:被 1 处调用(write_plugin_share_local_paths);外部调用 3 个(parent, create_dir_all, new_in)。

plugin_share_local_paths_path122–124 ↗
fn plugin_share_local_paths_path(codex_home: &Path) -> std::path::PathBuf

作用:根据 codex_home 算出登记表文件的固定位置。这样所有读写代码都用同一个路径,不会各写各的。

数据流:输入 codex_home → 在它后面拼上 .tmp/plugin-share-local-paths-v1.json → 输出完整文件路径。

调用关系:read_plugin_share_local_paths 和 write_plugin_share_local_paths 都调用它。它是这份缓存文件路径的唯一来源,避免路径字符串散落在多个地方。

调用图:被 2 处调用(read_plugin_share_local_paths, write_plugin_share_local_paths);外部调用 1 个(join)。

更新 UI 状态缓存

这个最终缓存存储轻量级持久化更新状态数据,例如 TUI 更新流程的忽略记录。

tui/src/updates_cache.rs源码 ↗
io_transportupdate check and popup handling

这个文件就像一个很小的“更新提示记事本”。程序检查到新版本后,会把结果写进用户的 Codex 主目录里的 version.json 文件。里面记录三件事:最新版本号、上次检查时间、以及用户已经忽略过的版本号。下次程序想弹出升级提示时,就可以先读这个文件,看看是不是同一个版本已经被用户关掉过。如果没有这个缓存,程序可能会反复弹同一个升级提醒,体验会很烦。这里还做了一件重要的兜底:如果原来的记录文件不存在或读坏了,dismiss_version 会新建一份记录,而不是直接失败。文件读写时使用 JSON(一种常见的文本数据格式,方便机器保存和读取),时间使用 RFC3339 这种标准时间写法,便于长期兼容。

函数细节3
version_filepath20–22 ↗
fn version_filepath(config: &Config) -> PathBuf

作用:这个函数算出更新缓存文件应该放在哪里。别人只要给它程序配置,它就能告诉你 version.json 的完整路径。

数据流:输入是一份 Config 配置,它里面有 codex_home,也就是这个程序自己的用户目录。函数把这个目录和固定文件名 version.json 拼起来,输出一个完整的文件路径。它不读文件,也不写文件,只是负责“指路”。

调用关系:当程序要检查升级信息、决定是否弹出升级窗口,或者记录用户已经忽略某个版本时,都会先通过 version_filepath 找到同一个缓存文件。dismiss_version 会先调用它确定写入位置。

调用图:被 3 处调用(get_upgrade_version, get_upgrade_version_for_popup, dismiss_version)。

read_version_info24–27 ↗
fn read_version_info(version_file: &Path) -> anyhow::Result<VersionInfo>

作用:这个函数从磁盘上的 version.json 里读出更新缓存。它把文本文件变回程序能直接使用的 VersionInfo 数据。

数据流:输入是一个文件路径。函数先把这个路径上的文件内容读成字符串,然后用 serde_json 把 JSON 文本解析成 VersionInfo。成功时输出这份版本信息;如果文件不存在、读不了,或者内容格式不对,就返回错误。

调用关系:它是所有“想知道以前更新状态”的流程都会用到的入口。check_for_update、get_upgrade_version、get_upgrade_version_for_popup 会用它读取缓存;dismiss_version 也会先读旧记录,再在旧记录基础上写入“已忽略版本”。

调用图:被 4 处调用(check_for_update, get_upgrade_version, get_upgrade_version_for_popup, dismiss_version);外部调用 2 个(from_str, read_to_string)。

dismiss_version31–48 ↗
async fn dismiss_version(config: &Config, version: &str) -> anyhow::Result<()>

作用:这个异步函数用来记住:用户已经对当前最新版本关掉了升级提示。这样同一个版本以后就不会再反复弹窗。

数据流:输入是程序配置和一个版本号。它先用 version_filepath 找到 version.json,再尝试用 read_version_info 读旧记录;如果读不到,就临时造一份默认记录。然后它把 dismissed_version 改成传入的版本号,把整份信息转成 JSON 文本,确保父目录存在,最后写回文件。结果是磁盘上留下“这个版本已被用户忽略”的记录。

调用关系:它通常在用户关闭或忽略升级弹窗时被调用。它自己负责串起几步活:先找文件位置,再读旧缓存,再改字段,最后创建目录并写文件。它依赖 read_version_info 读取旧状态,也依赖 version_filepath 保证所有地方都写同一个缓存位置。

调用图:调用 2 个内部函数(read_version_info, version_filepath);外部调用 3 个(format!, create_dir_all, write)。