插件、密钥和记忆文件存储
这个阶段像系统的本地库房,属于幕后支撑:把会长期留下来的东西安全、整齐地放到磁盘上。插件仓库管家决定插件装哪、怎么装卸,避免半成品和版本混乱。密钥部分用 Windows 的加密保险箱和本机钥匙串,把 token、密码等锁进文件。记忆部分把记忆限制在指定文件夹里,可读、搜、追加;写入模块再把数据库结果同步成 Markdown,并负责清空旧记忆、清理过期资源,防止误删和堆垃圾。
插件存储
实现由文件系统支持的插件缓存,包括发现、安装、版本选择、验证和移除。
core-plugins/src/store.rs源码 ↗
这个文件把 Codex 本地的插件缓存目录当成一个小仓库来用。它会根据插件的来源、名字和版本,算出固定的存放位置;安装时先检查来源是不是目录、plugin.json 里的名字对不对、版本字符串安不安全;然后把插件复制到临时目录,再用重命名的方式切换到正式目录。这个做法有点像先在后台摆好新货,再一次性换上货架,避免用户看到半装好的插件。它还会找出当前应该使用哪个版本:如果有 local 版本就优先用它,否则挑最新版本。卸载时则删除整个插件缓存入口。文件里也定义了统一的错误类型,把“磁盘读写失败”和“插件内容不合法”分清楚,方便上层给用户更准确的提示。
PluginStore::new34–37 ↗
fn new(codex_home: PathBuf) -> Self
作用:创建一个插件仓库对象,用来之后查找、安装或删除插件。它假设传进来的 Codex 主目录一定是绝对路径;如果不是,就直接让程序报错。
数据流:输入一个 Codex 主目录路径 → 交给 PluginStore::try_new 去算插件缓存目录和数据目录 → 成功时得到 PluginStore;失败时直接 panic,也就是程序明确中止。
调用关系:这是很多测试和启动配置会用的便捷入口。真正的检查工作交给 PluginStore::try_new,它自己只是把“这里不该失败”的场景包装成更简单的调用方式。
调用图:被 17 处调用(hooks_only_scope_shares_plugin_resolution_without_loading_other_capabilities, new_with_options, load_plugins_ignores_project_config_files, active_plugin_version_compares_semver_versions_semantically, active_plugin_version_prefers_default_local_version_when_multiple_versions_exist, active_plugin_version_reads_version_directory_name, active_plugin_version_returns_latest_version_when_default_is_missing, install_copies_plugin_into_default_marketplace, install_rejects_blank_manifest_version, install_rejects_manifest_names_that_do_not_match_marketplace_plugin_name (+7 more));外部调用 1 个(try_new)。
PluginStore::try_new39–47 ↗
fn try_new(codex_home: PathBuf) -> Result<Self, PluginStoreError>
作用:安全地创建插件仓库对象,并检查算出来的插件目录是不是绝对路径。有人想处理失败情况时会用它,而不是用会直接报错的 PluginStore::new。
数据流:输入 Codex 主目录 → 拼出 plugins/cache 和 plugins/data 两个目录 → 用 AbsolutePathBuf 检查它们必须是绝对路径 → 输出 PluginStore,或输出带原因的 PluginStoreError。
调用关系:远程插件缓存刷新、安装远程插件包、删除远程缓存等流程会调用它。它是进入这个文件大部分功能前的“建仓库”步骤。
调用图:调用 1 个内部函数(from_absolute_path_checked);被 7 处调用(installed_plugin_telemetry_metadata, refresh_curated_plugin_cache, refresh_non_curated_plugin_cache_with_mode, sync_remote_installed_plugin_bundles_once, remove_remote_plugin_cache, install_remote_plugin_bundle, try_new_rejects_relative_codex_home);外部调用 1 个(join)。
PluginStore::root49–51 ↗
fn root(&self) -> &AbsolutePathBuf
作用:返回插件缓存的根目录。外部代码需要知道插件文件实际存在本机哪里时会用它。
数据流:输入是已经创建好的 PluginStore → 直接读取里面保存的 root 字段 → 输出这个缓存根目录的引用,不改任何文件。
调用关系:它是一个简单的查看口,不参与安装或删除流程,只把仓库的总入口位置告诉调用者。
PluginStore::plugin_base_root53–57 ↗
fn plugin_base_root(&self, plugin_id: &PluginId) -> AbsolutePathBuf
作用:算出某个插件所有版本共同所在的目录。比如同一个插件的 local、1.0.0、2.0.0 都会放在这个基础目录下面。
数据流:输入插件编号 PluginId,里面有 marketplace 名和插件名 → 在缓存根目录下依次拼上这两段名字 → 输出这个插件的基础目录路径。
调用关系:active_plugin_version 用它去找已安装版本;plugin_root 在它下面继续拼版本号;install_with_version 和 uninstall 也靠它确定要写入或删除哪里。
调用图:调用 1 个内部函数(join);被 4 处调用(active_plugin_version, install_with_version, plugin_root, uninstall)。
PluginStore::plugin_root59–61 ↗
fn plugin_root(&self, plugin_id: &PluginId, plugin_version: &str) -> AbsolutePathBuf
作用:算出某个插件某个具体版本的目录。安装完成后,插件文件就应该在这个目录里。
数据流:输入插件编号和版本字符串 → 先调用 PluginStore::plugin_base_root 得到插件基础目录 → 再拼上版本号 → 输出完整的版本目录路径。
调用关系:install_with_version 用它记录安装结果里的 installed_path。它依赖 PluginStore::plugin_base_root,把“插件在哪”和“哪个版本”两步连起来。
调用图:调用 1 个内部函数(plugin_base_root);被 1 处调用(install_with_version)。
PluginStore::plugin_data_root63–68 ↗
fn plugin_data_root(&self, plugin_id: &PluginId) -> AbsolutePathBuf
作用:算出某个插件自己的数据目录,也就是插件运行时可以放私有数据的地方。它和插件代码缓存目录分开,避免升级插件时误删运行数据。
数据流:输入插件编号 → 把插件名和 marketplace 名组合成一个目录名 → 拼到 plugins/data 根目录下 → 输出该插件的数据目录路径。
调用关系:load_plugin 和 read_plugin_detail_for_marketplace_plugin 会用它给插件加载流程提供数据存放位置。它不读写磁盘,只负责算路径。
调用图:调用 1 个内部函数(join);被 2 处调用(load_plugin, read_plugin_detail_for_marketplace_plugin);外部调用 1 个(format!)。
PluginStore::active_plugin_version70–91 ↗
fn active_plugin_version(&self, plugin_id: &PluginId) -> Option<String>
作用:找出某个插件当前应该使用的版本。它会优先选择 local 版本;没有 local 时,才从已有版本里挑最新的。
数据流:输入插件编号 → 读取该插件基础目录下的子目录名 → 过滤掉不合法版本名 → 排序比较版本 → 如果有 local 就输出 local,否则输出排序后的最新版本;找不到则输出 None。
调用关系:配置合并、按配置读取插件、active_plugin_root 和 is_installed 都会问它“这个插件现在有没有可用版本”。它内部用 PluginStore::plugin_base_root 找目录,用 compare_plugin_versions 排序。
调用图:调用 1 个内部函数(plugin_base_root);被 4 处调用(merge_configured_plugins_with_remote_installed, read_plugin_for_config, active_plugin_root, is_installed);外部调用 1 个(read_dir)。
PluginStore::active_plugin_root93–96 ↗
fn active_plugin_root(&self, plugin_id: &PluginId) -> Option<AbsolutePathBuf>
作用:直接给出某个插件当前可用版本的完整目录。调用者不想先拿版本号再拼路径时,会用这个函数。
数据流:输入插件编号 → 调用 PluginStore::active_plugin_version 找当前版本 → 如果找到了,就调用 PluginStore::plugin_root 拼成目录;如果没找到,就输出 None。
调用关系:读取已安装插件名字、读取插件详情、卸载指定插件时会用它。它是 active_plugin_version 的路径版结果。
调用图:调用 1 个内部函数(active_plugin_version);被 3 处调用(installed_plugin_name_for_marketplace, read_plugin_detail_for_marketplace_plugin, uninstall_plugin_id)。
PluginStore::is_installed98–100 ↗
fn is_installed(&self, plugin_id: &PluginId) -> bool
作用:判断某个插件本机是否已经安装。它只关心有没有可用版本,不关心具体版本是多少。
数据流:输入插件编号 → 调用 PluginStore::active_plugin_version → 如果返回了版本就是 true,没有版本就是 false。
调用关系:它是一个简短的判断入口,把“查版本再判断空不空”这件事封装起来,底层仍然依赖 active_plugin_version。
调用图:调用 1 个内部函数(active_plugin_version)。
PluginStore::install102–109 ↗
fn install(
&self,
source_path: AbsolutePathBuf,
plugin_id: PluginId,
) -> Result<PluginInstallResult, PluginStoreError>
作用:按插件目录里的 plugin.json 自动决定版本,然后安装插件。适合最常见的安装场景:给一个来源目录和插件身份,让仓库自己处理剩下的事。
数据流:输入插件来源目录和插件编号 → 调用 plugin_version_for_source 从 plugin.json 读取或补默认版本 → 再调用 PluginStore::install_with_version 真正安装 → 输出安装结果或错误。
调用关系:它是 install_with_version 的便捷包装。版本判断交给 plugin_version_for_source,复制和切换目录交给 install_with_version。
调用图:调用 3 个内部函数(install_with_version, plugin_version_for_source, as_path)。
PluginStore::install_with_version111–144 ↗
fn install_with_version(
&self,
source_path: AbsolutePathBuf,
plugin_id: PluginId,
plugin_version: String,
) -> Result<PluginInstallResult, PluginStoreError>
作用:把一个插件目录安装到本地缓存里的指定版本位置。它会做关键校验,防止把名字不匹配或版本名危险的东西装进去。
数据流:输入来源目录、插件编号、版本字符串 → 检查来源必须是目录 → 读取 plugin.json 里的插件名并确认和 PluginId 一致 → 检查版本名只能包含安全字符 → 算出目标目录 → 调用 replace_plugin_root_atomically 复制并切换 → 输出 PluginInstallResult。
调用关系:PluginStore::install 会调用它。它把校验工作交给 plugin_name_for_source 和 validate_plugin_version_segment,把真正的磁盘替换交给 replace_plugin_root_atomically。
调用图:调用 6 个内部函数(plugin_base_root, plugin_root, plugin_name_for_source, replace_plugin_root_atomically, validate_plugin_version_segment, as_path);被 1 处调用(install);外部调用 2 个(Invalid, format!)。
PluginStore::uninstall146–148 ↗
fn uninstall(&self, plugin_id: &PluginId) -> Result<(), PluginStoreError>
作用:卸载某个插件,也就是删除这个插件在缓存里的整个目录。这样它的所有已缓存版本都会被移走。
数据流:输入插件编号 → 调用 PluginStore::plugin_base_root 算出插件基础目录 → 调用 remove_existing_target 删除该目录或文件 → 成功时输出空结果,失败时输出错误。
调用关系:它是卸载流程的入口。路径计算自己做,实际删除交给 remove_existing_target。
调用图:调用 2 个内部函数(plugin_base_root, remove_existing_target)。
PluginStoreError::io165–167 ↗
fn io(context: &'static str, source: io::Error) -> Self
作用:把底层磁盘读写错误包装成插件仓库自己的错误格式。这样上层能知道“哪一步失败了”,而不只是看到一个孤零零的系统错误。
数据流:输入一段固定的上下文说明和一个 io::Error → 组合成 PluginStoreError::Io → 输出这个统一错误对象。
调用关系:replace_plugin_root_atomically 等磁盘操作密集的函数会用它。它让创建目录、复制文件、重命名失败时都能带上更好懂的说明。
调用图:被 1 处调用(replace_plugin_root_atomically)。
plugin_version_for_source170–175 ↗
fn plugin_version_for_source(source_path: &Path) -> Result<String, PluginStoreError>
作用:从插件来源目录里决定要安装的版本。如果 plugin.json 没写版本,就使用默认的 local。
数据流:输入插件来源路径 → 调用 plugin_manifest_version_for_source 读取可选版本 → 没有版本就填 DEFAULT_PLUGIN_VERSION,也就是 local → 调用 validate_plugin_version_segment 检查版本名安全 → 输出版本字符串或错误。
调用关系:PluginStore::install 和刷新非精选插件缓存的流程会调用它。它负责“版本从哪里来”,后续安装由 install_with_version 完成。
调用图:调用 2 个内部函数(plugin_manifest_version_for_source, validate_plugin_version_segment);被 2 处调用(refresh_non_curated_plugin_cache_with_mode, install)。
validate_plugin_version_segment177–194 ↗
fn validate_plugin_version_segment(plugin_version: &str) -> Result<(), String>
作用:检查插件版本名能不能安全地当作目录名使用。它会拒绝空字符串、点号跳目录写法,以及奇怪字符。
数据流:输入版本字符串 → 检查不能为空、不能是 . 或 ..、只能包含英文字母数字和 . + _ - → 合法就输出 Ok,非法就输出一段说明原因的字符串。
调用关系:安装、读取版本、清理旧版本、校验远程插件包都会用它。它像门卫一样,防止版本名变成危险路径。
调用图:被 4 处调用(validate_remote_plugin_bundle, install_with_version, plugin_version_for_source, remove_old_plugin_versions);外部调用 1 个(matches!)。
plugin_manifest_for_source196–199 ↗
fn plugin_manifest_for_source(source_path: &Path) -> Result<PluginManifest, PluginStoreError>
作用:读取插件目录里的完整 plugin.json 清单。清单就是插件的说明书,里面有名字等重要信息。
数据流:输入插件来源路径 → 调用 load_plugin_manifest 尝试加载并解析 plugin.json → 成功输出 PluginManifest,失败或缺失时输出 Invalid 错误。
调用关系:plugin_name_for_source 会调用它来拿插件名。它把外部清单加载函数的结果转换成这个文件自己的错误类型。
调用图:调用 1 个内部函数(load_plugin_manifest);被 1 处调用(plugin_name_for_source)。
plugin_manifest_version_for_source208–233 ↗
fn plugin_manifest_version_for_source(
source_path: &Path,
) -> Result<Option<String>, PluginStoreError>
作用:只读取 plugin.json 里的 version 字段。它故意只解析版本字段,不要求整份清单都完整,这样能专注判断安装版本。
数据流:输入插件来源路径 → 找 plugin.json 的位置 → 读成文本 → 用 JSON 解析出 version → 没有 version 就输出 None;version 不是字符串或空白就报错;合法则输出去掉前后空格后的版本字符串。
调用关系:plugin_version_for_source 会调用它。它负责从文件系统和 JSON 里取出原始版本信息,再让 plugin_version_for_source 做默认值和安全校验。
调用图:被 1 处调用(plugin_version_for_source);外部调用 4 个(find_plugin_manifest_path, Invalid, read_to_string, from_str)。
plugin_name_for_source235–242 ↗
fn plugin_name_for_source(source_path: &Path) -> Result<String, PluginStoreError>
作用:从插件来源目录读取插件名,并确认这个名字本身合法。安装时用它防止“声明的插件名”和“要安装的插件名”对不上。
数据流:输入插件来源路径 → 调用 plugin_manifest_for_source 读取完整清单 → 取出 manifest.name → 调用 validate_plugin_segment 检查名字是否能安全使用 → 输出插件名或错误。
调用关系:install_with_version 会调用它。它是安装前身份核对的一部分,确保不会把 A 插件的文件装到 B 插件的目录下。
调用图:调用 1 个内部函数(plugin_manifest_for_source);被 1 处调用(install_with_version);外部调用 1 个(validate_plugin_segment)。
remove_existing_target244–258 ↗
fn remove_existing_target(path: &Path) -> Result<(), PluginStoreError>
作用:删除一个已经存在的目标路径,不管它是文件还是目录。卸载插件时用它清空缓存入口。
数据流:输入一个路径 → 如果路径不存在,直接成功 → 如果是目录,就递归删除整个目录;如果是文件,就删除文件 → 输出成功或带上下文的磁盘错误。
调用关系:PluginStore::uninstall 会调用它。它把“路径可能是目录也可能是文件”的麻烦封装起来。
调用图:被 1 处调用(uninstall);外部调用 4 个(exists, is_dir, remove_dir_all, remove_file)。
replace_plugin_root_atomically260–334 ↗
fn replace_plugin_root_atomically(
source: &Path,
target_root: &Path,
plugin_version: &str,
) -> Result<(), PluginStoreError>
作用:尽量安全地把新插件内容替换到正式缓存目录。所谓“原子式”在这里可以理解成:先准备好,再一次性切换,减少装到一半就坏掉的风险。
数据流:输入来源目录、目标插件基础目录、版本号 → 在目标父目录下创建临时安装目录 → 调用 copy_dir_recursive 把来源复制进去 → 如果只是新增一个版本,就把该版本目录移动到目标下并清理旧版本 → 如果要替换整个插件目录,就先备份旧目录,再移动新目录;失败时尽量回滚 → 输出成功或详细错误。
调用关系:install_with_version 在所有校验通过后调用它。它是安装流程里真正碰磁盘的核心步骤,并会把复制工作交给 copy_dir_recursive,把旧版本清理交给 remove_old_plugin_versions。
调用图:调用 3 个内部函数(io, copy_dir_recursive, remove_old_plugin_versions);被 1 处调用(install_with_version);外部调用 9 个(exists, file_name, join, parent, Invalid, format!, create_dir_all, rename, new)。
remove_old_plugin_versions336–368 ↗
fn remove_old_plugin_versions(
target_root: &Path,
plugin_version: &str,
) -> Result<(), PluginStoreError>
作用:安装新版本后,清掉同一插件下面不再需要的旧版本目录。这样缓存不会一直膨胀,也避免旧版本继续被选中。
数据流:输入插件目标目录和新版本号 → 读取目录下各个版本子目录 → 跳过新版本和非法名字 → 尝试删除旧版本目录 → 如果删不掉且旧版本会比新版本更优先,就返回错误;否则继续 → 输出成功或错误。
调用关系:replace_plugin_root_atomically 在新增版本成功后调用它。它会用 old_plugin_version_would_stay_active 判断删不掉的旧版本是否会影响当前生效版本。
调用图:调用 2 个内部函数(old_plugin_version_would_stay_active, validate_plugin_version_segment);被 1 处调用(replace_plugin_root_atomically);外部调用 4 个(Invalid, format!, read_dir, remove_dir_all)。
old_plugin_version_would_stay_active370–373 ↗
fn old_plugin_version_would_stay_active(old_version: &str, new_version: &str) -> bool
作用:判断某个删不掉的旧版本会不会仍然压过新版本,成为实际启用的版本。这个判断能避免安装看似成功,其实系统还在用旧版本。
数据流:输入旧版本和新版本 → 如果旧版本是 local,认为它一定会继续优先 → 否则调用 compare_plugin_versions 比较新旧大小 → 旧版本更大时输出 true,否则 false。
调用关系:remove_old_plugin_versions 在删除旧目录失败时调用它。它帮助决定这个失败能不能忽略,还是必须报错。
调用图:调用 1 个内部函数(compare_plugin_versions);被 1 处调用(remove_old_plugin_versions)。
compare_plugin_versions375–380 ↗
fn compare_plugin_versions(left: &str, right: &str) -> Ordering
作用:比较两个插件版本谁更大。它优先按语义化版本比较;语义化版本就是像 1.2.3 这种有数字层级的版本号,比较时不能简单按字母顺序来。
数据流:输入两个版本字符串 → 尝试把两边都解析成 semver Version → 如果都解析成功,就按版本数字规则比较;否则退回普通字符串比较 → 输出 Ordering,表示左边小于、等于或大于右边。
调用关系:old_plugin_version_would_stay_active 会调用它,active_plugin_version 排序版本时也依赖同样的比较逻辑。它保证 1.10.0 不会被误认为小于 1.2.0。
调用图:被 1 处调用(old_plugin_version_would_stay_active);外部调用 1 个(parse)。
copy_dir_recursive382–406 ↗
fn copy_dir_recursive(source: &Path, target: &Path) -> Result<(), PluginStoreError>
作用:把一个目录里的文件和子目录完整复制到另一个目录。安装插件时,它负责把插件包内容搬到临时安装位置。
数据流:输入来源目录和目标目录 → 创建目标目录 → 遍历来源里的每一项 → 遇到子目录就递归复制,遇到普通文件就复制文件 → 成功时目标目录拥有来源目录的内容;失败时返回带说明的错误。
调用关系:replace_plugin_root_atomically 会调用它来准备新插件内容。它只负责复制,不负责版本判断、目录切换或回滚。
调用图:被 1 处调用(replace_plugin_root_atomically);外部调用 4 个(join, copy, create_dir_all, read_dir)。
密钥后端
定义密钥 API,并使用受 OS 保护的密钥材料,通过本地加密文件后端实现它。
windows-sandbox-rs/src/dpapi.rs源码 ↗
这个文件解决的是“秘密怎么安全存放”的问题。DPAPI 是 Windows Data Protection API,也就是 Windows 提供的一套本机数据保护能力,可以理解成系统自带的保险柜。这里的代码把 Rust 里的普通字节数组,转换成 Windows API 能看懂的格式,然后调用系统函数加密或解密。它特别选择了“本机范围”的保护方式,意思是同一台机器上的普通进程和管理员进程都能解密,避免因为权限高低不同导致读不回密码。同时它禁止弹出系统 UI,所以适合后台程序安静运行。一个重要细节是:Windows 加密/解密后分配出来的内存,要用 LocalFree 还给系统,否则会漏内存。这个文件就像一个小转换插头:一边接 Rust 的 Vec<u8>,另一边接 Windows 的 DPAPI。
make_blob12–17 ↗
fn make_blob(data: &[u8]) -> CRYPT_INTEGER_BLOB
作用:把 Rust 里的字节切片包装成 Windows 加密接口要求的“数据块”格式。它不复制数据,只是告诉 Windows:数据从哪里开始、有多长。
数据流:进去的是一段字节数据引用 → 它取出这段数据的长度和内存地址,填进 CRYPT_INTEGER_BLOB 这个 Windows 结构里 → 出来的是一个 Windows API 能直接读取的数据描述,不会改变原始数据。
调用关系:它是 protect 和 unprotect 的前置小工具。加密和解密函数在真正调用 Windows 系统函数前,都会先用它把 Rust 数据翻译成 Windows 看得懂的样子。
protect20–51 ↗
fn protect(data: &[u8]) -> Result<Vec<u8>>
作用:把一段普通字节数据交给 Windows DPAPI 加密,返回一段受保护的字节。调用者可以把返回值保存起来,而不用直接保存明文秘密。
数据流:进去的是原始字节,比如密码内容 → 它先用 make_blob 包装输入,再调用 Windows 的 CryptProtectData 进行加密,并要求不要弹窗、按本机范围保护 → 如果失败,就读取 Windows 错误码并返回错误;如果成功,就把 Windows 返回的内存复制成 Rust 的 Vec<u8>,再用 LocalFree 释放系统分配的内存 → 出来的是加密后的字节数组。
调用关系:它是“保存秘密”这条路上的工具函数。上层代码需要把敏感数据变成不可直接看懂的内容时会调用它;它自己把格式转换交给 make_blob,把真正的加密交给 Windows 的 CryptProtectData。
调用图:调用 1 个内部函数(make_blob);外部调用 6 个(anyhow!, null, null_mut, from_raw_parts, LocalFree, CryptProtectData)。
unprotect54–85 ↗
fn unprotect(blob: &[u8]) -> Result<Vec<u8>>
作用:把之前由 DPAPI 保护过的字节解密回原始内容。它通常用于程序启动或读取配置时,把保存的密码恢复出来使用。
数据流:进去的是一段加密后的字节 → 它先用 make_blob 把输入包装成 Windows 格式,再调用 CryptUnprotectData 让 Windows 解密 → 如果系统解不开,就带着 Windows 错误码返回失败;如果成功,就把系统返回的明文字节复制到 Rust 的 Vec<u8>,然后用 LocalFree 释放系统内存 → 出来的是解密后的原始字节。
调用关系:它是“读取秘密”这条路上的工具函数。调用图里 decode_password 会使用它来还原密码;它自己负责准备 Windows 需要的数据格式,并把真正的解密动作交给 Windows 的 CryptUnprotectData。
调用图:调用 1 个内部函数(make_blob);被 1 处调用(decode_password);外部调用 6 个(anyhow!, null, null_mut, from_raw_parts, LocalFree, CryptUnprotectData)。
secrets/src/lib.rs源码 ↗
这个文件解决的是“敏感信息该怎么安全、统一地存取”的问题。没有它,项目里各处可能会自己拼文件名、自己找钥匙串、自己处理 token,很容易泄露或写乱。它先定义 SecretName,要求秘密名只能是大写字母、数字和下划线,避免奇怪字符造成文件或键名混乱;再定义 SecretScope,表示秘密是全局可用,还是只属于某个环境。SecretsBackend 是一套接口,像插座标准,规定任何秘密存储后端都要能 set、get、delete、list。SecretsManager 则是外面真正使用的门面,它现在会接上本地后端 LocalSecretsBackend,并把操作转交给后端。文件还提供两个小工具:一个根据当前目录推断环境 ID,另一个根据 codex_home 算出操作系统钥匙串里保存口令用的账号名。
SecretName::new29–39 ↗
fn new(raw: &str) -> Result<Self>
作用:把用户给的字符串变成一个合格的秘密名字。它会拒绝空名字,也会拒绝小写字母、空格、斜杠等不安全或不统一的字符。
数据流:输入是一段原始文字 → 先去掉前后空白,再检查是否为空,以及每个字符是否只属于 A-Z、0-9、下划线 → 成功时输出 SecretName,失败时输出带原因的错误,不改动外部数据。
调用关系:这是创建秘密名的入口。保存、解析已有键名和多项测试都会先用它把普通字符串变成可靠的 SecretName,后面的存取流程才能安全地拼接键名。
调用图:被 6 处调用(compute_secret_name, parse_canonical_key, local_namespaces_write_separate_files, save_file_does_not_leave_temp_files, set_fails_when_keyring_is_unavailable, manager_round_trips_local_backend);外部调用 1 个(ensure!)。
SecretName::as_str41–43 ↗
fn as_str(&self) -> &str
作用:把 SecretName 里的名字拿出来,当作普通字符串读取。它让别的代码能用这个名字拼键、显示或传给后端。
数据流:输入是一个已经验证过的 SecretName → 直接借出内部那段字符串 → 输出字符串引用,不复制内容,也不修改任何东西。
调用关系:它通常被 SecretScope::canonical_key 使用,用来把秘密名拼进最终的存储键里。它是一个很小的读取接口。
SecretName::fmt47–49 ↗
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result
作用:让 SecretName 可以像普通文字一样被打印或格式化。比如日志或错误信息里需要显示秘密名时会用到它。
数据流:输入是 SecretName 和格式化输出器 → 把内部名字写进输出器 → 输出格式化结果,本身不改变 SecretName。
调用关系:这是 Rust 的 Display 显示接口实现。谁需要把 SecretName 转成可读文本,格式化系统就会调用它。
调用图:外部调用 1 个(write!)。
SecretScope::environment59–64 ↗
fn environment(environment_id: impl Into<String>) -> Result<Self>
作用:创建一个“某个环境专用”的秘密作用范围。它会防止空的环境 ID 被当成有效范围。
数据流:输入是可以转成字符串的环境 ID → 转成字符串后去掉前后空白,并检查不能为空 → 成功时输出 SecretScope::Environment,失败时输出错误。
调用关系:解析存储键名时会用它恢复环境范围。它保证进入系统的环境范围至少有一个明确名字。
调用图:被 1 处调用(parse_canonical_key);外部调用 3 个(into, Environment, ensure!)。
SecretScope::canonical_key66–74 ↗
fn canonical_key(&self, name: &SecretName) -> String
作用:把“作用范围 + 秘密名”变成一个稳定的存储键。这个键像抽屉标签,用来让后端知道该把秘密放在哪里或从哪里取。
数据流:输入是 SecretScope 和 SecretName → 如果是全局范围,就生成 global/名字;如果是环境范围,就生成 env/环境ID/名字 → 输出这段统一格式的字符串。
调用关系:本地后端在 set、get、delete 时会用它定位同一个秘密。它保证同一份秘密每次都会算出同一个键,避免存取时对不上号。
SecretsManager::new103–111 ↗
fn new(codex_home: PathBuf, backend_kind: SecretsBackendKind) -> Self
作用:用默认方式创建一个 SecretsManager。一般生产环境会用它,因为它自动接上系统默认的钥匙串存储。
数据流:输入是 codex_home 路径和后端类型 → 目前后端类型只有 Local,于是创建默认钥匙串 DefaultKeyringStore,再创建 LocalSecretsBackend → 输出包装好的 SecretsManager。
调用关系:这是最普通的构造入口。外部代码拿到 SecretsManager 后,就不用关心底层本地后端和钥匙串是怎么接起来的。
SecretsManager::new_with_keyring_store113–124 ↗
fn new_with_keyring_store(
codex_home: PathBuf,
backend_kind: SecretsBackendKind,
keyring_store: Arc<dyn KeyringStore>,
) -> Self
作用:创建 SecretsManager,但允许调用者自己提供钥匙串存储。它主要方便测试,或者让特殊环境换一套钥匙串实现。
数据流:输入是 codex_home、后端类型和一个 keyring_store → 对 Local 后端来说,把这个 keyring_store 交给 LocalSecretsBackend → 输出 SecretsManager。
调用关系:测试 manager_round_trips_local_backend 会用它塞入假的钥匙串,避免真的碰用户电脑里的系统钥匙串。
调用图:调用 1 个内部函数(new);被 1 处调用(manager_round_trips_local_backend);外部调用 1 个(new)。
SecretsManager::new_with_keyring_store_and_namespace126–140 ↗
fn new_with_keyring_store_and_namespace(
codex_home: PathBuf,
backend_kind: SecretsBackendKind,
keyring_store: Arc<dyn KeyringStore>,
namespace: LocalSecretsNamespace,
作用:创建 SecretsManager,并且还能指定本地秘密的命名空间。命名空间可以理解成同一个存储区域里的隔离小格子,避免不同用途互相撞名。
数据流:输入是 codex_home、后端类型、钥匙串存储和 namespace → 对 Local 后端来说,调用带 namespace 的本地后端构造方法 → 输出 SecretsManager。
调用关系:这是更细粒度的构造入口。认证 token 的保存、读取、删除流程以及相关测试会用它,把秘密后端放到指定命名空间里,和普通本地秘密隔开。
调用图:调用 1 个内部函数(new_with_namespace);被 9 处调用(new, assert_keyring_saved_auth_and_removed_fallback, seed_secrets_backend_and_fallback_auth_file_for_delete, seed_secrets_backend_with_auth, delete_oauth_tokens_from_secrets_keyring, load_oauth_tokens_from_secrets_keyring, save_oauth_tokens_to_secrets_keyring, delete_oauth_tokens_with_secrets_backend_removes_secrets_and_file, save_oauth_tokens_with_secrets_backend_writes_encrypted_storage);外部调用 1 个(new)。
SecretsManager::set142–144 ↗
fn set(&self, scope: &SecretScope, name: &SecretName, value: &str) -> Result<()>
作用:保存一个秘密值。调用者只需要给范围、名字和值,不需要知道底层是文件、钥匙串还是别的存储方式。
数据流:输入是 SecretScope、SecretName 和秘密内容字符串 → SecretsManager 把这三个东西原样交给当前后端的 set → 输出成功或失败结果,实际写入由后端完成。
调用关系:上层的 save 流程会调用它。它自己不加密也不写文件,而是作为统一门面把保存动作交给 SecretsBackend。
调用图:被 1 处调用(save)。
SecretsManager::get146–148 ↗
fn get(&self, scope: &SecretScope, name: &SecretName) -> Result<Option<String>>
作用:读取一个秘密值。找不到时不会硬报错,而是返回“没有这个秘密”。
数据流:输入是 SecretScope 和 SecretName → 转交给后端 get 查找 → 输出可能存在的字符串;如果没有就是 None,如果存储出问题就是错误。
调用关系:上层的 load 流程会调用它。它把读取细节隐藏起来,让调用者只面对统一接口。
调用图:被 1 处调用(load)。
SecretsManager::delete150–152 ↗
fn delete(&self, scope: &SecretScope, name: &SecretName) -> Result<bool>
作用:删除一个秘密。它会告诉调用者到底有没有删掉东西。
数据流:输入是 SecretScope 和 SecretName → 转交给后端 delete → 输出布尔值,true 表示原来有并已删除,false 表示本来就没有;出错时返回错误。
调用关系:上层的 delete 流程会调用它。它是删除秘密时经过的统一门面。
调用图:被 1 处调用(delete)。
SecretsManager::list154–156 ↗
fn list(&self, scope_filter: Option<&SecretScope>) -> Result<Vec<SecretListEntry>>
作用:列出已经保存的秘密条目。可以列全部,也可以只列某个作用范围里的秘密。
数据流:输入是可选的 SecretScope 过滤条件 → 转交给后端 list → 输出 SecretListEntry 列表,里面包含每个秘密的范围和名字,不包含秘密值本身。
调用关系:它用于浏览或检查已有秘密。为了安全,它只列名字和范围,不把真正的敏感内容拿出来。
environment_id_from_cwd159–180 ↗
fn environment_id_from_cwd(cwd: &Path) -> String
作用:根据当前工作目录生成一个环境 ID。这样同一个项目目录里的秘密可以自动归到同一个环境,不用用户每次手填。
数据流:输入是当前目录路径 → 先尝试找到它所在的 Git 仓库根目录,并用仓库文件夹名当环境 ID;如果找不到可用名字,就把目录的规范路径做 SHA-256 哈希(一种把文字变成固定摘要的算法),取前 12 位生成 cwd-xxxx → 输出环境 ID 字符串。
调用关系:测试 environment_id_fallback_has_cwd_prefix 会验证兜底格式。它让“按项目隔离秘密”这件事能自动发生,而不是完全依赖人工命名。
调用图:被 1 处调用(environment_id_fallback_has_cwd_prefix);外部调用 4 个(canonicalize, new, get_git_repo_root, format!)。
compute_keyring_account183–195 ↗
fn compute_keyring_account(codex_home: &Path) -> String
作用:算出在操作系统钥匙串里存本地秘密口令时用的账号名。它把 codex_home 路径变成稳定且不太暴露真实路径的短标识。
数据流:输入是 codex_home 路径 → 尽量转成规范路径,失败就用原路径 → 对路径文字做 SHA-256 哈希,取前 16 位 → 输出 secrets|短哈希 这种账号名。
调用关系:本地秘密后端会依赖这种账号名去系统钥匙串里找对应口令。这样不同 codex_home 不会挤在同一个钥匙串条目里。
调用图:外部调用 3 个(canonicalize, new, format!)。
keyring_service197–199 ↗
fn keyring_service() -> &'static str
作用:返回系统钥匙串里使用的服务名。这里固定是 codex,用来把本项目的钥匙串条目归到同一个服务下面。
数据流:没有输入 → 读取文件里的常量 KEYRING_SERVICE → 输出静态字符串 codex,不修改任何状态。
调用关系:本地后端需要和操作系统钥匙串打交道时会用到这个服务名。它把服务名集中放在一个地方,避免各处写散。
tests::environment_id_fallback_has_cwd_prefix208–223 ↗
fn environment_id_fallback_has_cwd_prefix()
作用:测试当目录不是 Git 仓库时,environment_id_from_cwd 会生成 cwd- 开头的兜底环境 ID。它确保自动命名规则稳定可靠。
数据流:输入是测试临时创建的目录 → 调用 environment_id_from_cwd,再在测试里按同样规则算出期望的短哈希 → 用断言比较两者是否完全一致。
调用关系:这是 environment_id_from_cwd 的保护网。以后有人改环境 ID 算法时,如果破坏了兜底格式,这个测试会提醒。
调用图:调用 1 个内部函数(environment_id_from_cwd);外部调用 4 个(new, assert_eq!, format!, tempdir)。
tests::manager_round_trips_local_backend226–247 ↗
fn manager_round_trips_local_backend() -> Result<()>
作用:测试 SecretsManager 接本地后端时,能完整完成保存、读取、列出、删除这一圈。它证明统一门面和本地后端能配合工作。
数据流:输入是临时 codex_home 和假的钥匙串 → 创建 SecretsManager,保存 GITHUB_TOKEN=token-1,随后读取并检查值,再列出并检查条目,最后删除并确认读不到 → 输出测试成功或错误。
调用关系:这个测试会调用 SecretsManager::new_with_keyring_store 和 SecretName::new。它模拟真实使用流程,但用 MockKeyringStore 避免污染用户系统钥匙串。
调用图:调用 2 个内部函数(new, new_with_keyring_store);外部调用 5 个(new, assert!, assert_eq!, default, tempdir)。
secrets/src/local.rs源码 ↗
这个文件实现了本地版的秘密存储后端。它解决的问题是:程序需要保存敏感内容,但不能明文写进配置文件或普通文本里。它会按用途选择不同的加密文件,比如普通秘密用 local.age,Codex 登录信息用 codex_auth.age,MCP OAuth 信息用 mcp_oauth.age。读的时候,它先找到文件,再从系统钥匙串拿到口令,用 age 加密库解密,然后把 JSON 里的键值表读出来。写的时候,它先把秘密整理成 JSON,再加密,最后用“先写临时文件、再整体替换”的方式落盘,尽量避免写到一半断电导致文件坏掉。第一次使用时,如果钥匙串里还没有口令,它会生成一个高强度随机口令并保存进去。这个文件还包含一些测试,确认版本检查、钥匙串失败、临时文件清理、不同命名空间分开存储这些关键行为没有出错。
SecretsFile::new_empty60–65 ↗
LocalSecretsBackend::new76–82 ↗
fn new(codex_home: PathBuf, keyring_store: Arc<dyn KeyringStore>) -> Self
作用:创建一个默认的本地秘密存储器。默认会使用普通托管秘密的文件,也就是 local.age。
数据流:进去是 Codex 的主目录路径和钥匙串存储对象 → 它把命名空间固定为 ManagedSecrets,再交给 LocalSecretsBackend::new_with_namespace → 出来一个可以读写本地秘密的后端对象。
调用关系:这是最常用的构造入口。测试和外部创建代码会用它快速得到默认后端;它自己不做复杂初始化,而是把实际组装工作交给 LocalSecretsBackend::new_with_namespace。
调用图:被 5 处调用(new, new_with_keyring_store, load_file_rejects_newer_schema_versions, save_file_does_not_leave_temp_files, set_fails_when_keyring_is_unavailable);外部调用 1 个(new_with_namespace)。
LocalSecretsBackend::new_with_namespace84–94 ↗
fn new_with_namespace(
codex_home: PathBuf,
keyring_store: Arc<dyn KeyringStore>,
namespace: LocalSecretsNamespace,
) -> Self
作用:创建一个指定用途的本地秘密存储器。有人需要把不同类型的秘密分开存放时会用它。
数据流:进去是 Codex 主目录、钥匙串存储对象、以及命名空间 → 它把这三样保存进结构体 → 出来一个知道自己应该读写哪个加密文件的 LocalSecretsBackend。
调用关系:LocalSecretsBackend::new 会间接使用它来创建默认后端;外部帮助函数和 local_namespaces_write_separate_files 测试会直接用它验证不同命名空间会写到不同文件。
调用图:被 2 处调用(new_with_keyring_store_and_namespace, local_namespaces_write_separate_files)。
LocalSecretsBackend::secrets_dir138–140 ↗
fn secrets_dir(&self) -> PathBuf
作用:算出秘密文件应该放在哪个目录下。它把所有本地秘密文件统一放到 Codex 主目录里的 secrets 子目录。
数据流:进去是后端里保存的 codex_home 路径 → 它在这个路径后面拼上 secrets → 出来一个秘密目录路径。
调用关系:LocalSecretsBackend::secrets_path 用它继续拼出具体文件名;LocalSecretsBackend::save_file 用它确保目录存在。
调用图:被 2 处调用(save_file, secrets_path);外部调用 1 个(join)。
LocalSecretsBackend::secrets_path142–149 ↗
fn secrets_path(&self) -> PathBuf
作用:算出当前后端真正要读写的加密文件路径。不同命名空间会得到不同文件名。
数据流:进去是后端自己的命名空间和 Codex 主目录 → 它先通过 LocalSecretsBackend::secrets_dir 得到 secrets 目录,再根据命名空间选择 local.age、codex_auth.age 或 mcp_oauth.age → 出来完整文件路径。
调用关系:LocalSecretsBackend::load_file 靠它知道从哪里读;LocalSecretsBackend::save_file 靠它知道写到哪里。
调用图:调用 1 个内部函数(secrets_dir);被 2 处调用(load_file, save_file)。
LocalSecretsBackend::load_file151–177 ↗
fn load_file(&self) -> Result<SecretsFile>
作用:读取并打开本地的加密秘密文件。它把磁盘上的密文变回程序能使用的秘密列表。
数据流:进去是后端自身保存的路径、命名空间和钥匙串信息 → 它算出文件路径;如果文件不存在,就返回空文件;如果存在,就读出密文,从钥匙串加载或创建口令,调用 decrypt_with_passphrase 解密,再把 JSON 反序列化成 SecretsFile,并检查版本不能比程序支持的版本更新 → 出来一个可查询和修改的 SecretsFile。
调用关系:LocalSecretsBackend::set、LocalSecretsBackend::get、LocalSecretsBackend::delete、LocalSecretsBackend::list 都会先调用它拿到当前内容。它依赖 LocalSecretsBackend::secrets_path、LocalSecretsBackend::load_or_create_passphrase、SecretsFile::new_empty 和 decrypt_with_passphrase。
调用图:调用 4 个内部函数(load_or_create_passphrase, secrets_path, new_empty, decrypt_with_passphrase);被 4 处调用(delete, get, list, set);外部调用 3 个(ensure!, read, from_slice)。
LocalSecretsBackend::save_file179–190 ↗
fn save_file(&self, file: &SecretsFile) -> Result<()>
作用:把内存里的秘密文件安全写回磁盘。它负责加密、建目录,并尽量保证写文件过程不留下半截坏文件。
数据流:进去是一个 SecretsFile → 它先确保 secrets 目录存在,再从钥匙串加载或创建口令,把 SecretsFile 转成 JSON 字节,用 encrypt_with_passphrase 加密,最后调用 write_file_atomically 原子写入目标路径 → 出来没有直接返回数据,但磁盘上的加密秘密文件被更新。
调用关系:LocalSecretsBackend::set 修改内容后会调用它保存;LocalSecretsBackend::delete 确实删掉内容后也会调用它保存。它把加密交给 encrypt_with_passphrase,把可靠落盘交给 write_file_atomically。
调用图:调用 5 个内部函数(load_or_create_passphrase, secrets_dir, secrets_path, encrypt_with_passphrase, write_file_atomically);被 2 处调用(delete, set);外部调用 2 个(create_dir_all, to_vec)。
LocalSecretsBackend::load_or_create_passphrase192–213 ↗
fn load_or_create_passphrase(&self) -> Result<SecretString>
作用:取得用来加密本地秘密文件的口令;如果第一次使用还没有口令,就生成一个新的并存到系统钥匙串里。
数据流:进去是后端里的 Codex 主目录和钥匙串对象 → 它根据主目录算出钥匙串账号名,向钥匙串读取口令;如果读到了就包装成 SecretString;如果没有,就调用 generate_passphrase 生成新口令,再保存进钥匙串 → 出来一个加密和解密都要用的 SecretString。
调用关系:LocalSecretsBackend::load_file 解密前需要它;LocalSecretsBackend::save_file 加密前也需要它。它连接了本文件的加密流程和外部的操作系统钥匙串。
调用图:调用 1 个内部函数(generate_passphrase);被 2 处调用(load_file, save_file);外部调用 3 个(from, compute_keyring_account, keyring_service)。
LocalSecretsBackend::set217–219 ↗
fn set(&self, scope: &SecretScope, name: &SecretName, value: &str) -> Result<()>
作用:保存或覆盖一个秘密值。比如把某个名字的令牌写进本地加密保险箱。
数据流:进去是作用范围、秘密名字和值 → 它先拒绝空值,再把范围和名字合成统一的内部键,调用 LocalSecretsBackend::load_file 读出现有内容,把新值放进去,最后调用 LocalSecretsBackend::save_file 写回磁盘 → 出来成功或错误;成功时本地加密文件里的对应秘密已更新。
调用关系:这是 SecretsBackend 接口中的写入动作。它位于用户操作和底层加密文件之间:上层只说“保存这个秘密”,它负责读旧文件、改键值表、再保存。
调用图:调用 3 个内部函数(canonical_key, load_file, save_file);外部调用 1 个(ensure!)。
LocalSecretsBackend::get221–223 ↗
fn get(&self, scope: &SecretScope, name: &SecretName) -> Result<Option<String>>
作用:读取一个秘密值。调用者给出范围和名字,它返回对应的内容,找不到就返回空。
数据流:进去是作用范围和秘密名字 → 它把两者合成统一内部键,调用 LocalSecretsBackend::load_file 读出秘密表,然后按键查找 → 出来是 Some 字符串或 None;磁盘内容不会被修改。
调用关系:这是 SecretsBackend 接口中的读取动作。它只依赖 LocalSecretsBackend::load_file 打开保险箱,不会调用保存流程。
调用图:调用 2 个内部函数(canonical_key, load_file)。
LocalSecretsBackend::delete225–227 ↗
fn delete(&self, scope: &SecretScope, name: &SecretName) -> Result<bool>
作用:删除一个指定秘密。它会告诉调用者到底有没有删到东西。
数据流:进去是作用范围和秘密名字 → 它合成内部键,调用 LocalSecretsBackend::load_file 读出现有内容,尝试从键值表里移除;如果确实移除了,就调用 LocalSecretsBackend::save_file 保存新文件 → 出来一个布尔值,true 表示原来存在并已删除,false 表示本来就没有。
调用关系:这是 SecretsBackend 接口中的删除动作。它和 set 一样会改文件,但只有真的发生删除时才保存,避免没必要的磁盘写入。
调用图:调用 3 个内部函数(canonical_key, load_file, save_file)。
LocalSecretsBackend::list229–231 ↗
fn list(&self, scope_filter: Option<&SecretScope>) -> Result<Vec<SecretListEntry>>
作用:列出已经保存的秘密名字。它不会泄露秘密值,只返回范围和名称这类目录信息。
数据流:进去是一个可选的范围过滤条件 → 它调用 LocalSecretsBackend::load_file 读出所有内部键,逐个用 parse_canonical_key 解析成可展示的条目;遇到坏格式会记录警告并跳过;如果有过滤条件,就只保留对应范围 → 出来一个 SecretListEntry 列表。
调用关系:这是 SecretsBackend 接口中的列表动作。它依赖 parse_canonical_key 把内部字符串键翻译成人能理解的范围和名字。
调用图:调用 2 个内部函数(load_file, parse_canonical_key);外部调用 2 个(new, warn!)。
write_file_atomically234–311 ↗
fn write_file_atomically(path: &Path, contents: &[u8]) -> Result<()>
作用:尽量安全地写文件,避免秘密文件写到一半时损坏。它采用“先写临时文件,再改名替换”的做法,像先写好新合同,再一次性把旧合同换掉。
数据流:进去是目标路径和要写入的字节内容 → 它算出父目录和文件名,生成带进程号和时间的临时文件名,创建临时文件,写入内容并同步到磁盘,然后把临时文件重命名为目标文件;如果替换失败,会清理临时文件,Windows 下还会在必要时先删除旧文件再重试 → 出来成功或带上下文的错误;成功时目标文件已被完整替换。
调用关系:LocalSecretsBackend::save_file 在加密完成后调用它。它是保存流程最后一道保险,专门处理磁盘写入的可靠性问题。
调用图:被 1 处调用(save_file);外部调用 8 个(exists, file_name, parent, now, format!, new, remove_file, rename)。
generate_passphrase313–322 ↗
fn generate_passphrase() -> Result<SecretString>
作用:生成一个新的高强度加密口令。第一次使用本地秘密存储时,需要靠它创建钥匙。
数据流:进去没有业务输入 → 它向操作系统随机数生成器要 32 字节随机数据,把这些字节编码成 Base64 字符串,随后调用 wipe_bytes 清掉原始字节数组 → 出来一个 SecretString 形式的口令。
调用关系:LocalSecretsBackend::load_or_create_passphrase 在钥匙串里找不到旧口令时会调用它。它生成口令后,把清理内存的细节交给 wipe_bytes。
调用图:调用 1 个内部函数(wipe_bytes);被 1 处调用(load_or_create_passphrase);外部调用 1 个(from)。
wipe_bytes324–331 ↗
fn wipe_bytes(bytes: &mut [u8])
作用:把内存里的敏感字节清零。它用更不容易被编译器省掉的方式擦除,减少随机钥匙残留在内存里的机会。
数据流:进去是一段可修改的字节数组 → 它逐个字节写入 0,并加一道编译器内存屏障,提醒编译器不要随便重排或删掉这些擦除动作 → 出来没有返回值,但传入的数组内容被清零。
调用关系:generate_passphrase 在把随机字节转成字符串后调用它。它是口令生成流程里的清理步骤,不直接参与文件读写。
调用图:被 1 处调用(generate_passphrase);外部调用 2 个(write_volatile, compiler_fence)。
encrypt_with_passphrase333–336 ↗
fn encrypt_with_passphrase(plaintext: &[u8], passphrase: &SecretString) -> Result<Vec<u8>>
作用:用口令把明文秘密文件加密成密文。它让磁盘上保存的不是可直接阅读的 JSON。
数据流:进去是明文字节和 SecretString 口令 → 它用这个口令创建 age 的 scrypt 接收者;scrypt 可以理解成一种把口令变成加密钥匙的办法,然后调用 age 的 encrypt → 出来是一段加密后的字节。
调用关系:LocalSecretsBackend::save_file 在写磁盘前调用它。它把“保护内容”的加密工作封装起来,让保存流程不用关心 age 库的细节。
decrypt_with_passphrase338–341 ↗
fn decrypt_with_passphrase(ciphertext: &[u8], passphrase: &SecretString) -> Result<Vec<u8>>
作用:用口令把磁盘上的密文解开。没有正确口令时,秘密文件就无法恢复成可读内容。
数据流:进去是密文字节和 SecretString 口令 → 它用口令创建 age 的 scrypt 身份,也就是解密用的身份信息,然后调用 age 的 decrypt → 出来是解密后的明文字节,通常是 JSON。
调用关系:LocalSecretsBackend::load_file 读到加密文件后调用它。它和 encrypt_with_passphrase 是一对,一个负责锁上保险箱,一个负责打开保险箱。
parse_canonical_key343–370 ↗
fn parse_canonical_key(canonical_key: &str) -> Option<SecretListEntry>
作用:把内部保存用的字符串键解析成可展示的秘密条目。比如把 global/NAME 或 env/ENV/NAME 拆成范围和名字。
数据流:进去是一个内部键字符串 → 它按斜杠拆开,识别 global 或 env 两种格式,检查多余字段,校验秘密名字和环境范围是否合法 → 出来是 SecretListEntry;如果格式不对,就返回 None。
调用关系:LocalSecretsBackend::list 在列目录时调用它。它承担“内部格式到用户可理解结构”的翻译工作,坏键会被 list 跳过并记录警告。
调用图:调用 2 个内部函数(new, environment);被 1 处调用(list)。
tests::load_file_rejects_newer_schema_versions380–399 ↗
fn load_file_rejects_newer_schema_versions() -> Result<()>
作用:测试程序不会误读未来版本的秘密文件。这样可以避免老程序把自己不理解的新格式当成旧格式处理。
数据流:进去是测试临时目录和模拟钥匙串 → 它创建后端,手工保存一个版本号比当前支持版本更高的 SecretsFile,然后调用 load_file → 出来应当是错误,并断言错误信息包含“版本更新”的提示。
调用关系:这是测试运行器调用的测试用例。它主要覆盖 LocalSecretsBackend::save_file 和 LocalSecretsBackend::load_file 的版本检查行为。
调用图:调用 1 个内部函数(new);外部调用 5 个(new, new, assert!, default, tempdir)。
tests::save_file_does_not_leave_temp_files427–450 ↗
fn save_file_does_not_leave_temp_files() -> Result<()>
作用:测试保存秘密后不会留下临时文件。临时文件如果残留,既乱目录,也可能让人误解或带来安全隐患。
数据流:进去是测试临时目录和模拟钥匙串 → 它创建后端,对同一个秘密写入两次,然后读取 secrets 目录下的文件名,并再次 get 确认最终值 → 出来断言目录里只有 local.age,且秘密值是第二次写入的 two。
调用关系:这是测试运行器调用的测试用例。它主要检查 LocalSecretsBackend::set、LocalSecretsBackend::get、LocalSecretsBackend::secrets_dir,以及背后的 write_file_atomically 是否正确清理临时文件。
调用图:调用 2 个内部函数(new, new);外部调用 5 个(new, assert_eq!, read_dir, default, tempdir)。
tests::local_namespaces_write_separate_files453–496 ↗
fn local_namespaces_write_separate_files() -> Result<()>
作用:测试不同命名空间会写到不同加密文件里。这样 Codex 登录信息和 MCP OAuth 信息不会混在同一个文件中。
数据流:进去是测试临时目录和共享的模拟钥匙串 → 它分别创建 CodexAuth 和 McpOAuth 两个后端,用同一个范围和名字写入不同值,再分别读取,并检查对应文件存在、默认 local.age 不存在 → 出来所有断言通过,说明两个命名空间互不串味。
调用关系:这是测试运行器调用的测试用例。它直接使用 LocalSecretsBackend::new_with_namespace,并通过 set 和 get 验证 LocalSecretsBackend::secrets_path 的文件选择逻辑。
调用图:调用 2 个内部函数(new, new_with_namespace);外部调用 5 个(new, assert!, assert_eq!, default, tempdir)。
记忆存储后端
提供具体的本地记忆后端及其根作用域文件系统访问层。
ext/memories/src/local.rs源码 ↗
这个文件定义了 LocalMemoriesBackend,也就是“本地记忆仓库”。可以把它想成一个只允许在某个抽屉里找东西的管理员:抽屉就是 root 目录,所有记忆文件都必须在里面。它提供两种创建方式:一种从 codex_home 下面自动找 memories 文件夹,一种直接指定根目录。真正读文件、列目录、搜索内容、添加临时笔记的细节放在旁边几个模块里,这个文件主要负责接线和守门。最关键的是 resolve_scoped_path:用户给一个相对路径时,它会检查这个路径不能往上跳到父目录,不能是绝对路径,不能碰隐藏目录,还会拒绝符号链接(像快捷方式,可能偷偷指到别处)。这样即使外部传来奇怪路径,也不会越界访问磁盘。metadata_or_none 则是一个小帮手:查看文件信息,文件不存在时不算严重错误,而是返回“没有”。
LocalMemoriesBackend::from_codex_home30–32 ↗
fn from_codex_home(codex_home: &AbsolutePathBuf) -> Self
作用:从 Codex 的主目录创建一个本地记忆仓库,并默认把记忆放在其中的 memories 子文件夹里。调用者不用自己拼路径,少出错。
数据流:输入是 codex_home,也就是 Codex 的主目录 → 函数在它后面接上 memories 这个文件夹名 → 再交给 from_memory_root 生成 LocalMemoriesBackend,结果是一个根目录指向 codex_home/memories 的本地记忆仓库。
调用关系:工具层会用它来快速得到默认的本地记忆后端。它自己不直接保存文件,只是先调用路径的 join 拼出默认位置,然后把创建工作交给 LocalMemoriesBackend::from_memory_root。
调用图:调用 1 个内部函数(join);被 1 处调用(tools);外部调用 1 个(from_memory_root)。
LocalMemoriesBackend::from_memory_root34–36 ↗
fn from_memory_root(root: impl Into<PathBuf>) -> Self
作用:用指定的文件夹创建一个本地记忆仓库。适合测试、工具或特殊配置想把记忆放到别的位置时使用。
数据流:输入是一个可以变成 PathBuf 的路径 → 函数把它转换成内部使用的路径类型 → 输出一个 LocalMemoriesBackend,里面只记住这个 root,后续所有记忆操作都会以这里为起点。
调用关系:memory_tool 会直接用它指定记忆根目录;LocalMemoriesBackend::from_codex_home 也会调用它。它是创建本地后端的基础入口。
调用图:被 1 处调用(memory_tool);外部调用 1 个(into)。
LocalMemoriesBackend::resolve_scoped_path38–88 ↗
async fn resolve_scoped_path(
&self,
relative_path: Option<&str>,
) -> Result<PathBuf, MemoriesBackendError>
作用:把用户给的相对路径安全地变成本地磁盘上的真实路径,同时保证它不能逃出 memories 根目录。它是防止路径越界和符号链接绕路的关键守门函数。
数据流:输入是一个可选的相对路径 → 如果没有路径,就直接返回 root;如果有路径,就检查里面不能有“..”、不能是绝对路径、不能带磁盘前缀,也不能包含隐藏路径段 → 然后一段一段拼到 root 后面,每走一步就读取已有文件的信息,拒绝符号链接,并确认中间路径必须是目录 → 输出安全后的磁盘路径;如果发现危险或不合法,就返回 MemoriesBackendError 错误。
调用关系:list、read、search 在处理用户指定路径前会调用它,先把“用户说的路径”变成“确认安全的本地路径”。它会用 Path::new 解析路径,用 metadata_or_none 查看每一段是否存在,用 display_relative_path 生成好读的错误位置,再用 reject_symlink 拦住符号链接。
调用图:调用 3 个内部函数(invalid_path, display_relative_path, reject_symlink);被 3 处调用(list, read, search);外部调用 3 个(new, clone, metadata_or_none)。
LocalMemoriesBackend::metadata_or_none90–98 ↗
async fn metadata_or_none(
path: &Path,
) -> Result<Option<std::fs::Metadata>, MemoriesBackendError>
作用:读取某个路径的文件信息,但把“文件不存在”当成普通情况处理。这样上层可以区分“还没创建”与“真的出故障”。
数据流:输入是一个磁盘路径 → 函数调用 symlink_metadata 读取文件信息,这种读取方式会看符号链接本身而不是直接跟着跳走 → 如果读到了,就返回 Some(metadata);如果文件不存在,就返回 None;如果是权限、磁盘等其他错误,就转成 MemoriesBackendError 返回。
调用关系:resolve_scoped_path 会用它逐段检查路径;ensure_directory、list、read、search、search_entries 也会用它判断文件或目录是否存在。它是本地文件系统检查的通用小帮手。
调用图:被 5 处调用(ensure_directory, list, read, search, search_entries);外部调用 1 个(symlink_metadata)。
LocalMemoriesBackend::add_ad_hoc_note102–107 ↗
async fn add_ad_hoc_note(
&self,
request: AddAdHocMemoryNoteRequest,
) -> Result<AddAdHocMemoryNoteResponse, MemoriesBackendError>
作用:实现“添加临时记忆笔记”的后端接口。外部只需要调用统一的 MemoriesBackend 接口,不必知道具体文件写入细节在哪里。
数据流:输入是 AddAdHocMemoryNoteRequest,里面包含要添加的笔记信息 → 这个函数把当前本地后端和请求一起交给 ad_hoc_note 模块的 add_ad_hoc_note → 输出 AddAdHocMemoryNoteResponse,或在失败时输出 MemoriesBackendError。
调用关系:这是 MemoriesBackend trait 的实现方法,属于统一接口的一部分。它本身不展开写文件步骤,而是把工作转交给 ad_hoc_note::add_ad_hoc_note,让专门模块处理临时笔记的保存。
调用图:调用 1 个内部函数(add_ad_hoc_note)。
LocalMemoriesBackend::list109–114 ↗
async fn list(
&self,
request: ListMemoriesRequest,
) -> Result<ListMemoriesResponse, MemoriesBackendError>
作用:实现“列出记忆”的后端接口。调用者可以通过统一接口查看某个目录下有哪些记忆项。
数据流:输入是 ListMemoriesRequest,通常包含想列出的路径或范围 → 函数把本地后端和请求交给 list 模块 → 输出 ListMemoriesResponse,里面是列出的结果;如果路径不安全或读取失败,就返回 MemoriesBackendError。
调用关系:这是 MemoriesBackend trait 的 list 方法。它把具体列目录的活交给 list::list;而 list 模块在需要确认路径安全时,会再调用 LocalMemoriesBackend::resolve_scoped_path 和 metadata_or_none。
调用图:调用 1 个内部函数(list)。
LocalMemoriesBackend::read116–121 ↗
async fn read(
&self,
request: ReadMemoryRequest,
) -> Result<ReadMemoryResponse, MemoriesBackendError>
作用:实现“读取某条记忆内容”的后端接口。它让外部用同一种方式读取本地磁盘里的记忆文件。
数据流:输入是 ReadMemoryRequest,说明要读哪条记忆 → 函数把请求和当前后端交给 read 模块 → 输出 ReadMemoryResponse,包含读到的内容;如果路径非法、文件不存在或读取失败,就返回 MemoriesBackendError。
调用关系:这是 MemoriesBackend trait 的 read 方法。它只做接口转发,具体读文件和安全检查由 read::read 负责;read 模块会用 resolve_scoped_path 确保目标仍在 memories 根目录内。
调用图:调用 1 个内部函数(read)。
LocalMemoriesBackend::search123–128 ↗
async fn search(
&self,
request: SearchMemoriesRequest,
) -> Result<SearchMemoriesResponse, MemoriesBackendError>
作用:实现“搜索记忆”的后端接口。它让调用者可以在本地记忆文件中查找匹配内容。
数据流:输入是 SearchMemoriesRequest,包含搜索词和可能的搜索范围 → 函数把这些交给 search 模块 → 输出 SearchMemoriesResponse,里面是搜索结果;如果路径不安全或遍历文件时出错,就返回 MemoriesBackendError。
调用关系:这是 MemoriesBackend trait 的 search 方法。它把搜索流程交给 search::search;search 模块会在进入目录或读取条目前使用 resolve_scoped_path、metadata_or_none 等工具,保证搜索只发生在允许的记忆目录里。
调用图:调用 1 个内部函数(search)。
记忆工作区写入
定义写入侧记忆工作区布局,并处理磁盘上记忆制品的同步、清理和扩展裁剪。
memories/write/src/lib.rs源码 ↗
这套代码要解决的问题是:Codex 需要把从对话、工作区和扩展来源里整理出来的“记忆”保存成一套稳定的文件。如果大家各自随便拼路径、随便建目录,就很容易把文件放错地方,或者启动时缺少目录导致后续写入失败。这个文件就像一张仓库平面图:它声明了有哪些内部房间,比如启动流程、存储、提示词、扩展清理、工作区差异等;也把少数外部需要用的功能重新导出,让别人不用知道内部文件怎么分。它还集中定义了几个固定名字,比如 memories 目录、rollout_summaries 子目录、extensions 子目录、raw_memories.md 文件名,以及阶段一、阶段二任务的一些限制。最后,ensure_layout 会在运行前确保必要目录已经建好,避免后面保存记忆摘要时因为“文件夹不存在”而出错。
memory_root116–118 ↗
fn memory_root(codex_home: &AbsolutePathBuf) -> AbsolutePathBuf
作用:给定 Codex 的主目录,算出记忆系统自己的根目录在哪里。别人想读写记忆文件时,先要知道这个总文件夹。
数据流:进去的是 codex_home,也就是 Codex 的家目录路径。函数只做一件事:在这个路径后面接上 memories。出来的是一个新的绝对路径,表示所有记忆相关文件的总目录;它不创建文件夹,也不改磁盘。
调用关系:它位于最外层的路径约定里,给其他启动流程、存储流程或清理流程提供统一入口。内部只是调用路径的 join,把 memories 这个固定名字拼到 Codex 主目录后面。
调用图:调用 1 个内部函数(join)。
rollout_summaries_dir120–122 ↗
fn rollout_summaries_dir(root: &Path) -> PathBuf
作用:算出“对话摘要”目录的位置。这里的 rollout summaries 可以理解成一段段历史运行或对话整理后的摘要文件。
数据流:进去的是记忆根目录 root。函数把固定子目录名 rollout_summaries 拼到 root 后面。出来的是这个摘要目录的路径;它本身不检查目录是否存在,也不写入任何内容。
调用关系:它是路径小工具,ensure_layout 会用它先算出摘要目录在哪里,然后再创建这个目录。其他保存或同步摘要的代码也可以用同一个约定,避免路径写散。
调用图:被 1 处调用(ensure_layout);外部调用 1 个(join)。
memory_extensions_root124–126 ↗
fn memory_extensions_root(root: &Path) -> PathBuf
作用:算出“记忆扩展”目录的位置。扩展可以理解成额外的记忆来源,每个来源有自己的说明文件和资源。
数据流:进去的是记忆根目录 root。函数把固定子目录名 extensions 拼到 root 后面。出来的是扩展总目录路径;它只计算路径,不读取扩展,也不创建目录。
调用关系:它给扩展相关流程提供统一的文件夹位置,比如读取扩展说明、清理过期扩展资源、根据扩展信号更新记忆等。内部只是使用 join 拼出标准路径。
调用图:外部调用 1 个(join)。
raw_memories_file128–130 ↗
fn raw_memories_file(root: &Path) -> PathBuf
作用:算出原始记忆文件 raw_memories.md 的完整路径。这个文件名固定,集中放在这里可以避免其他地方写错。
数据流:进去的是记忆根目录 root。函数把 raw_memories.md 这个固定文件名接到 root 后面。出来的是这个 Markdown 文件的路径;它不会打开文件,也不会读写内容。
调用关系:它服务于存储相关流程,比如重建原始记忆文件时需要知道目标文件放哪。它和其他路径函数一样,承担“统一地址簿”的作用。
调用图:外部调用 1 个(join)。
ensure_layout132–134 ↗
async fn ensure_layout(root: &Path) -> std::io::Result<()>
作用:在记忆系统开始工作前,确保必要的文件夹已经存在。这样后面的写入步骤就不会因为目录没建好而失败。
数据流:进去的是记忆根目录 root。函数先通过 rollout_summaries_dir 算出摘要目录路径,再调用异步的 create_dir_all 创建这个目录;create_dir_all 的意思是“连同缺少的上级目录一起创建,已存在也不算错”。出来的是成功或失败的结果;成功时磁盘上会有 rollout_summaries 目录。
调用关系:它通常在启动阶段被调用,是后续保存摘要、同步记忆之前的准备动作。它自己不处理复杂业务,只把“算目录路径”的活交给 rollout_summaries_dir,把“真正建目录”的活交给 Tokio 的异步文件系统函数 create_dir_all。
调用图:调用 1 个内部函数(rollout_summaries_dir);外部调用 1 个(create_dir_all)。
memories/write/src/storage.rs源码 ↗
这个文件像一个“文件整理员”。输入是一批 Stage1Output,也就是第一阶段从会话里提炼出来的记忆结果;输出是磁盘上的几个 Markdown 文件。它先确保目录结构存在,然后按数量上限只取前面一部分记忆,重建总览文件 raw_memories.md。这个总览文件会把每条记忆的线程编号、更新时间、工作目录、原始记忆内容等写进去。它还会同步每个会话单独的 rollout summary 文件:先删掉不再需要的旧摘要,再为仍保留的记忆逐个写新文件。文件名不是随便起的,而是用时间、短哈希和可读 slug 拼出来,既尽量稳定,又方便人看。这里的异步文件操作,就是“不堵住程序等磁盘”的写法。
rebuild_raw_memories_file_from_memories13–20 ↗
async fn rebuild_raw_memories_file_from_memories(
root: &Path,
memories: &[Stage1Output],
max_raw_memories_for_consolidation: usize,
) -> std::io::Result<()>
作用:这是重建 raw_memories.md 的公开入口。别人想把数据库里的记忆重新写成一个总的 Markdown 文件时,会调用它。
数据流:进去的是根目录 root、一组记忆 memories、以及最多保留多少条的上限 → 它先调用 ensure_layout 确保目录和文件布局准备好 → 然后把真正拼内容、写文件的活交给 rebuild_raw_memories_file,最后返回写文件是否成功。
调用关系:它站在最外层,负责先把“场地”准备好,再调用 rebuild_raw_memories_file 干实际写入工作。这样调用方不用关心目录有没有提前创建。
调用图:调用 1 个内部函数(rebuild_raw_memories_file);外部调用 1 个(ensure_layout)。
sync_rollout_summaries_from_memories23–42 ↗
async fn sync_rollout_summaries_from_memories(
root: &Path,
memories: &[Stage1Output],
max_raw_memories_for_consolidation: usize,
) -> std::io::Result<()>
作用:这是同步每个会话摘要文件的公开入口。它保证摘要目录里只留下当前还需要的文件,并把每条保留记忆写成单独的 Markdown 摘要。
数据流:进去的是根目录、一组记忆、保留上限 → 它先确保目录存在,再用 retained_memories 截取要保留的记忆 → 根据这些记忆算出应该保留的文件名 → 调用 prune_rollout_summaries 删除多余旧文件 → 最后逐条调用 write_rollout_summary_for_thread 写入最新摘要。
调用关系:它是 rollout summary 同步流程的总指挥。它自己不直接写每个文件的内容,而是分派给 prune_rollout_summaries 清旧文件,再分派给 write_rollout_summary_for_thread 写新文件。
调用图:调用 3 个内部函数(prune_rollout_summaries, retained_memories, write_rollout_summary_for_thread);外部调用 1 个(ensure_layout)。
rebuild_raw_memories_file44–78 ↗
async fn rebuild_raw_memories_file(
root: &Path,
memories: &[Stage1Output],
max_raw_memories_for_consolidation: usize,
) -> std::io::Result<()>
作用:这个函数真正生成 raw_memories.md 的文本内容,并写到磁盘上。它把多条记忆合并成一个人能读的总文件。
数据流:进去的是根目录、记忆列表和数量上限 → 它先用 retained_memories 选出要写入的记忆 → 如果没有记忆,就写入“No raw memories yet.” → 如果有,就逐条写线程编号、更新时间、目录、摘要文件名和原始记忆内容 → 最后调用文件写入,把结果保存到 raw_memories.md。
调用关系:它由 rebuild_raw_memories_file_from_memories 调用,是重建总览文件的核心步骤。它会用 raw_memories_file 找到目标文件路径,也会用 rollout_summary_file_stem 算出每条记忆对应的摘要文件名。
调用图:调用 1 个内部函数(retained_memories);被 1 处调用(rebuild_raw_memories_file_from_memories);外部调用 5 个(from, raw_memories_file, format!, write, writeln!)。
prune_rollout_summaries80–108 ↗
async fn prune_rollout_summaries(root: &Path, keep: &HashSet<String>) -> std::io::Result<()>
作用:这个函数清理摘要目录里已经不该存在的旧 Markdown 文件。它防止磁盘上残留过期摘要,让目录内容和数据库里的当前记忆保持一致。
数据流:进去的是根目录和一个 keep 集合,也就是应该保留的文件名前缀 → 它打开 rollout summaries 目录,逐个检查 .md 文件 → 如果某个文件的前缀不在 keep 里,就尝试删除 → 删除失败但文件已经不存在时不算错,其他删除失败会记一条警告。
调用关系:它由 sync_rollout_summaries_from_memories 在写新摘要前调用。先扫地再摆新东西,避免旧文件和新文件混在一起误导后续读取者。
调用图:被 1 处调用(sync_rollout_summaries_from_memories);外部调用 4 个(rollout_summaries_dir, read_dir, remove_file, warn!)。
write_rollout_summary_for_thread110–136 ↗
async fn write_rollout_summary_for_thread(
root: &Path,
memory: &Stage1Output,
) -> std::io::Result<()>
作用:这个函数为一条记忆写一个单独的 rollout summary Markdown 文件。它让每个会话的摘要能被单独打开、引用和追踪。
数据流:进去的是根目录和一条 Stage1Output 记忆 → 它先用 rollout_summary_file_stem 算出稳定的文件名前缀 → 再拼出目标路径 → 然后把线程编号、更新时间、路径、工作目录、可选的 Git 分支和摘要正文写成文本 → 最后保存到磁盘。
调用关系:它由 sync_rollout_summaries_from_memories 对每条保留记忆逐个调用。文件名生成这件事交给 rollout_summary_file_stem,目录位置由 rollout_summaries_dir 提供。
调用图:调用 1 个内部函数(rollout_summary_file_stem);被 1 处调用(sync_rollout_summaries_from_memories);外部调用 5 个(new, rollout_summaries_dir, format!, write, writeln!)。
retained_memories138–143 ↗
fn retained_memories(
memories: &[Stage1Output],
max_raw_memories_for_consolidation: usize,
) -> &[Stage1Output]
作用:这个小函数按上限截取要保留的记忆。它的作用是防止一次写入太多原始记忆,影响后续整理和阅读。
数据流:进去的是完整记忆列表和最大数量 → 它比较列表长度和上限,取较小值 → 出来的是原列表开头的一段切片,不复制内容,只是借用这一段。
调用关系:它被 rebuild_raw_memories_file 和 sync_rollout_summaries_from_memories 共用。两个流程都需要先决定“哪些记忆算当前要处理的记忆”。
调用图:被 2 处调用(rebuild_raw_memories_file, sync_rollout_summaries_from_memories);外部调用 1 个(len)。
raw_memories_format_error145–147 ↗
fn raw_memories_format_error(err: std::fmt::Error) -> std::io::Error
作用:这个函数把“拼 raw_memories.md 文本时出错”包装成普通的输入输出错误。这样上层只需要按文件写入错误来处理。
数据流:进去的是格式化文本时产生的 std::fmt::Error → 它加上一句说明“format raw memories” → 出来的是 std::io::Error,也就是常见的文件/输入输出错误类型。
调用关系:它被 rebuild_raw_memories_file 里的文本拼接步骤用作错误转换。虽然往 String 里写文本通常很少失败,但代码仍然把错误路径处理完整。
调用图:外部调用 2 个(other, format!)。
rollout_summary_format_error149–151 ↗
fn rollout_summary_format_error(err: std::fmt::Error) -> std::io::Error
作用:这个函数把“拼单个 rollout summary 文本时出错”包装成普通的输入输出错误。它让摘要写入流程的错误类型保持一致。
数据流:进去的是格式化文本时产生的错误 → 它加上“format rollout summary”的上下文说明 → 出来的是 std::io::Error,方便一路返回给调用方。
调用关系:它服务于 write_rollout_summary_for_thread。该函数在写每一行元信息时,如果格式化失败,就通过这里转成统一的错误。
调用图:外部调用 2 个(other, format!)。
rollout_summary_file_stem153–159 ↗
fn rollout_summary_file_stem(memory: &Stage1Output) -> String
作用:这个函数根据一条记忆生成 rollout summary 文件名的主体部分,也就是不带 .md 的文件名。它让同一条记忆总能得到可预测、相对好读的文件名。
数据流:进去的是一条 Stage1Output → 它取出线程编号、更新时间和可选的 rollout_slug → 把这些交给 rollout_summary_file_stem_from_parts → 出来的是文件名前缀字符串。
调用关系:它被 write_rollout_summary_for_thread 调用。它自己只是从完整记忆对象里拆出需要的字段,真正的命名规则放在 rollout_summary_file_stem_from_parts 里。
调用图:调用 1 个内部函数(rollout_summary_file_stem_from_parts);被 1 处调用(write_rollout_summary_for_thread)。
rollout_summary_file_stem_from_parts161–238 ↗
fn rollout_summary_file_stem_from_parts(
thread_id: codex_protocol::ThreadId,
source_updated_at: chrono::DateTime<chrono::Utc>,
rollout_slug: Option<&str>,
) -> String
作用:这个函数是摘要文件名的命名规则中心。它把线程编号、时间和可选的可读标签组合成一个稳定、短一点、适合当文件名的字符串。
数据流:进去的是线程编号、更新时间和可选 slug → 如果线程编号是 UUID,它尽量从 UUID 里取时间和哈希种子;如果不是 UUID,就用更新时间,并根据线程编号字节算一个短哈希 → 再把哈希压成 4 个由数字和字母组成的字符 → 如果有 slug,就把它转成小写,只保留字母数字,其他字符改成下划线,并限制长度 → 出来的是类似“时间-短码-说明文字”的文件名前缀。
调用关系:它由 rollout_summary_file_stem 调用,是文件名稳定性的关键。write_rollout_summary_for_thread 和 sync_rollout_summaries_from_memories 都间接受它影响,因为写文件和判断哪些旧文件该保留,都依赖这个名字。
调用图:被 1 处调用(rollout_summary_file_stem);外部调用 7 个(format, with_capacity, parse_str, format!, bytes, to_string, from)。
memories/write/src/control.rs源码 ↗
这个文件解决的是“重新写记忆前,先把旧记忆清干净”的问题。它会处理两个固定位置:memories 和 memories_extensions。做法像清空抽屉:抽屉还在,里面的纸和小盒子都拿走。这里的“抽屉”就是记忆根目录。关键点是安全检查:如果记忆根目录其实是符号链接(一种看起来像目录、实际指向别处的快捷方式),它会拒绝清空。否则可能表面上删的是记忆目录,实际删掉了别的地方的重要文件。文件里的异步函数使用 Tokio(一套让程序等待磁盘操作时不堵住其他任务的工具)来读目录、建目录、删文件和删子目录。测试部分确认两件事:清空后根目录仍然存在且为空;如果根目录是符号链接,就报错,并且链接指向的真实文件不会被删除。
clear_memory_roots_contents3–12 ↗
async fn clear_memory_roots_contents(codex_home: &Path) -> std::io::Result<()>
作用:清空 Codex 主目录下面两个记忆相关文件夹的内容:memories 和 memories_extensions。外部代码想“一键清掉所有记忆缓存”时,会用这个函数。
数据流:输入是 codex_home,也就是 Codex 的主目录路径。它先在这个路径后面拼出 memories,再拼出 memories_extensions,然后分别交给 clear_memory_root_contents 去清空。只要其中任何一个清理失败,它就立刻把错误返回;两个都成功,就返回成功。
调用关系:它是更外层的入口,自己不直接删文件,只负责决定要清哪两个目录。真正检查目录安全、创建目录、删除内容的活儿,都交给 clear_memory_root_contents。
调用图:调用 1 个内部函数(clear_memory_root_contents);外部调用 1 个(join)。
clear_memory_root_contents14–44 ↗
async fn clear_memory_root_contents(memory_root: &Path) -> std::io::Result<()>
作用:清空某一个记忆根目录里的所有内容,但保留这个根目录本身。它还会先检查这个目录是不是符号链接,避免顺着“快捷方式”删到别的地方。
数据流:输入是一个记忆根目录路径。它先读取这个路径本身的信息:如果发现它是符号链接,就返回一个 InvalidInput 错误;如果目录不存在,也没关系,后面会创建出来。接着它确保目录存在,然后逐个读取目录里的条目:如果是子目录,就连同里面内容一起删除;如果是文件或其他非目录条目,就直接删除。最后目录还在,但里面变空,并返回成功。
调用关系:clear_memory_roots_contents 会调用它来清理两个标准记忆目录。两个测试函数也直接调用它,分别验证“清空但保留根目录”和“拒绝清空符号链接目录”这两个关键行为。它是本文件里真正执行清理动作的核心函数。
调用图:被 3 处调用(clear_memory_roots_contents, clear_memory_root_contents_preserves_root_directory, clear_memory_root_contents_rejects_symlinked_root);外部调用 7 个(new, format!, create_dir_all, read_dir, remove_dir_all, remove_file, symlink_metadata)。
tests::clear_memory_root_contents_preserves_root_directory52–87 ↗
async fn clear_memory_root_contents_preserves_root_directory()
作用:这个测试确认:清空记忆目录时,只会删掉里面的东西,不会把记忆根目录本身删掉。这样后续写新记忆时,目录还可以继续使用。
数据流:它先创建一个临时目录,再在里面造出一个 memories 目录、一个子目录和两个旧文件。然后调用 clear_memory_root_contents。调用之后,它检查 memories 目录仍然存在,并继续读取这个目录,确认里面已经没有任何条目。
调用关系:它像一次小型演练,专门验证 clear_memory_root_contents 的正常清理流程。它会准备假文件和假子目录,把它们交给核心函数处理,再检查结果是不是“根目录保留、内容清空”。
调用图:调用 1 个内部函数(clear_memory_root_contents);外部调用 5 个(assert!, tempdir, create_dir_all, read_dir, write)。
tests::clear_memory_root_contents_rejects_symlinked_root91–115 ↗
async fn clear_memory_root_contents_rejects_symlinked_root()
作用:这个测试确认:如果记忆根目录是符号链接,清理函数必须拒绝操作。这样可以防止误删符号链接背后真正目录里的文件。
数据流:它先创建一个临时目录,并在其中准备一个真正的目标目录和一个应该被保留的文件。然后创建一个名叫 memories 的符号链接,让它指向这个目标目录。接着调用 clear_memory_root_contents,期望得到 InvalidInput 错误。最后它检查目标文件还存在,证明函数没有顺着链接删进去。
调用关系:它验证的是 clear_memory_root_contents 里最重要的安全保护。这个测试只在 Unix 系统上运行,因为这里使用的是 Unix 的符号链接创建方式。
调用图:调用 1 个内部函数(clear_memory_root_contents);外部调用 6 个(assert!, assert_eq!, symlink, tempdir, create_dir_all, write)。
memories/write/src/extensions/prune.rs源码 ↗
这个文件像一个定期打扫储物间的清洁工。它会到记忆根目录下面找到扩展目录,只认那些看起来真的是扩展的文件夹:必须是目录,而且里面有 instructions.md。然后它进入每个扩展的 resources 文件夹,逐个检查资源文件。它只处理普通文件、文件名以 .md 结尾、并且文件名前 19 个字符能解析成时间戳的文件。系统会用“当前时间减去保留天数”算出一个截止时间,早于或等于这个时间的资源就会被删除。这里有一个重要做法:遇到目录不存在就安静跳过,遇到读目录或删文件失败才记录警告。也就是说,清理失败不会让整个系统崩掉,只会尽量清理能清理的部分。
prune_old_extension_resources9–11 ↗
async fn prune_old_extension_resources(memory_root: &Path)
作用:这是对外使用的清理入口。调用者不需要自己提供时间,它会直接拿当前时间来判断哪些资源已经过期。
数据流:输入是一条记忆根目录路径 → 它读取当前的 UTC 时间,也就是统一世界时间,避免不同时区带来的混乱 → 然后把路径和当前时间交给内部清理函数,自己不直接删文件。
调用关系:它站在最外层,通常由某个维护流程或后台清理流程调用。它只负责补上“现在是什么时候”,真正扫描目录、判断过期、删除文件的工作交给 prune_old_extension_resources_with_now。
调用图:调用 1 个内部函数(prune_old_extension_resources_with_now);外部调用 1 个(now)。
prune_old_extension_resources_with_now13–88 ↗
async fn prune_old_extension_resources_with_now(memory_root: &Path, now: DateTime<Utc>)
作用:这是实际执行清理的函数。它根据给定的时间算出过期线,然后在各个扩展的 resources 文件夹里删除太旧的 Markdown 资源文件。
数据流:输入是记忆根目录和一个指定的当前时间 → 它先算出截止时间,再找到扩展总目录,读取里面的每个扩展文件夹 → 对每个合格扩展读取 resources 目录,筛出 .md 文件,并用 resource_timestamp 从文件名里取出时间 → 如果文件时间早于或等于截止时间,就尝试删除;如果目录不存在就跳过,如果发生异常就写警告日志。
调用关系:它由 prune_old_extension_resources 调用,是这份文件的核心工人。它会调用 memory_extensions_root 来拼出扩展目录,调用系统的异步文件接口读取目录、检查文件、删除文件,并把文件名解析时间这件小事交给 resource_timestamp。
调用图:调用 1 个内部函数(resource_timestamp);被 1 处调用(prune_old_extension_resources);外部调用 6 个(days, memory_extensions_root, read_dir, remove_file, try_exists, warn!)。
resource_timestamp90–96 ↗
fn resource_timestamp(file_name: &str) -> Option<DateTime<Utc>>
作用:这个函数从资源文件名里取出开头的时间戳,并把它变成程序能比较的时间。它让清理函数知道一个资源文件到底有多旧。
数据流:输入是一个文件名字符串 → 它取文件名前 19 个字符,按项目规定的时间格式解析成一个不带时区的时间,再把它当作 UTC 时间 → 如果解析成功就返回时间,如果文件名不符合规则就返回空结果。
调用关系:它被 prune_old_extension_resources_with_now 用在筛选资源文件时。主清理函数不自己研究文件名格式,而是把这一步交给它;如果它解析不出来,主流程就认为这个文件不适合自动清理,直接跳过。
调用图:被 1 处调用(prune_old_extension_resources_with_now);外部调用 2 个(from_naive_utc_and_offset, parse_from_str)。