use std::{fs, path::Path};
use super::{AuthSnapshot, CliError, publish_private_file, read_nofollow, set_private_directory};
const MAX_FILE_BYTES: usize = 4 * 1024 * 1024;
const MAX_TREE_BYTES: usize = 32 * 1024 * 1024;
const MAX_TREE_FILES: usize = 512;
pub(super) fn snapshot_runtime(
auth: &AuthSnapshot,
home: &Path,
project_root: &Path,
) -> Result<(), CliError> {
let auth_path = Path::new(&auth.source);
if auth_path.file_name().and_then(|name| name.to_str()) != Some("auth.json") {
return Err(CliError::message(
"Codex runtime snapshot requires its existing auth.json",
));
}
let source = auth_path
.parent()
.ok_or_else(|| CliError::message("Codex auth has no parent"))?;
reject_symlink_components(source)?;
let bytes = read_nofollow(&source.join("config.toml"), MAX_FILE_BYTES)?;
let config = selected_config(&bytes, project_root)?;
let home = fs::canonicalize(home).map_err(|error| {
CliError::message(format!("cannot resolve isolated Codex home: {error}"))
})?;
let destination = home.join(".config/codex");
private_directory(&destination)?;
let plugin = Path::new("plugins/cache/shepherd/shepherd");
let mut count = 0;
let mut total = 0;
copy_tree(
&source.join(plugin),
&destination.join(plugin),
&mut count,
&mut total,
0,
)?;
publish_identical(&destination.join("config.toml"), config.as_bytes())
}
fn selected_config(bytes: &[u8], project_root: &Path) -> Result<String, CliError> {
let source: toml::Table = std::str::from_utf8(bytes)
.map_err(|_| CliError::message("Codex configuration is not UTF-8"))?
.parse()
.map_err(|_| CliError::message("Codex configuration is invalid TOML"))?;
let mut selected = toml::Table::new();
let plugin = source
.get("plugins")
.and_then(toml::Value::as_table)
.and_then(|plugins| plugins.get("shepherd@shepherd"))
.filter(|plugin| plugin.get("enabled").and_then(toml::Value::as_bool) == Some(true))
.ok_or_else(|| {
CliError::message("Codex Shepherd plugin is not enabled in the authenticated runtime")
})?;
let mut plugins = toml::Table::new();
plugins.insert("shepherd@shepherd".into(), plugin.clone());
selected.insert("plugins".into(), plugins.into());
if let Some(marketplace) = source
.get("marketplaces")
.and_then(|table| table.get("shepherd"))
{
let mut marketplaces = toml::Table::new();
marketplaces.insert("shepherd".into(), marketplace.clone());
selected.insert("marketplaces".into(), marketplaces.into());
}
if let Some(project) = project_root.to_str().and_then(|path| {
source
.get("projects")
.and_then(|projects| projects.get(path))
.map(|value| (path, value))
}) {
let mut projects = toml::Table::new();
projects.insert(project.0.into(), project.1.clone());
selected.insert("projects".into(), projects.into());
}
if let Some(states) = source
.get("hooks")
.and_then(|hooks| hooks.get("state"))
.and_then(toml::Value::as_table)
{
let states: toml::Table = states
.iter()
.filter(|(key, _)| key.starts_with("shepherd@shepherd:"))
.map(|(key, value)| (key.clone(), value.clone()))
.collect();
let mut hooks = toml::Table::new();
hooks.insert("state".into(), states.into());
selected.insert("hooks".into(), hooks.into());
}
toml::to_string(&selected)
.map_err(|_| CliError::message("cannot serialize Codex runtime snapshot"))
}
fn reject_symlink_components(path: &Path) -> Result<(), CliError> {
for ancestor in path.ancestors() {
let metadata = fs::symlink_metadata(ancestor).map_err(|error| {
CliError::message(format!("cannot inspect Codex runtime path: {error}"))
})?;
if metadata.file_type().is_symlink() {
return Err(CliError::message(
"Codex runtime snapshot refuses symbolic links",
));
}
}
Ok(())
}
fn private_directory(path: &Path) -> Result<(), CliError> {
fs::create_dir_all(path).map_err(|error| {
CliError::message(format!("cannot create Codex runtime directory: {error}"))
})?;
reject_symlink_components(path)?;
set_private_directory(path)
}
fn publish_identical(path: &Path, bytes: &[u8]) -> Result<(), CliError> {
match fs::symlink_metadata(path) {
Ok(_) if read_nofollow(path, MAX_FILE_BYTES)? == bytes => Ok(()),
Ok(_) => Err(CliError::message(
"Codex runtime snapshot destination changed",
)),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
publish_private_file(path, bytes)
}
Err(error) => Err(CliError::message(format!(
"cannot inspect Codex snapshot destination: {error}"
))),
}
}
fn copy_tree(
source: &Path,
destination: &Path,
count: &mut usize,
total: &mut usize,
depth: usize,
) -> Result<(), CliError> {
if depth > 16 {
return Err(CliError::message(
"Codex plugin snapshot exceeds its depth bound",
));
}
reject_symlink_components(source)?;
private_directory(destination)?;
for entry in fs::read_dir(source).map_err(|error| {
CliError::message(format!(
"cannot read installed Codex Shepherd plugin: {error}"
))
})? {
let entry = entry.map_err(|error| {
CliError::message(format!("cannot read Codex plugin entry: {error}"))
})?;
let metadata = entry.file_type().map_err(|error| {
CliError::message(format!("cannot inspect Codex plugin entry: {error}"))
})?;
let target = destination.join(entry.file_name());
if metadata.is_dir() {
copy_tree(&entry.path(), &target, count, total, depth + 1)?;
} else if metadata.is_file() {
let bytes = read_nofollow(&entry.path(), MAX_FILE_BYTES)?;
*count += 1;
*total += bytes.len();
if *count > MAX_TREE_FILES || *total > MAX_TREE_BYTES {
return Err(CliError::message(
"Codex plugin snapshot exceeds its size bound",
));
}
publish_identical(&target, &bytes)?;
} else {
return Err(CliError::message(
"Codex plugin snapshot refuses non-regular entries",
));
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn snapshot_preserves_only_existing_shepherd_trust() {
let result = selected_config(
br#"
[plugins."shepherd@shepherd"]
enabled = true
[plugins."unrelated@other"]
enabled = true
[hooks.state."shepherd@shepherd:hook"]
trusted_hash = "existing-operator-decision"
[hooks.state."unrelated@other:hook"]
trusted_hash = "foreign"
[hooks.PreToolUse]
command = "unrelated command"
[projects."/project"]
trust_level = "trusted"
[projects."/other"]
trust_level = "trusted"
"#,
Path::new("/project"),
)
.unwrap();
assert!(result.contains("existing-operator-decision"));
assert!(result.contains("/project"));
for excluded in ["unrelated", "foreign", "PreToolUse", "/other"] {
assert!(!result.contains(excluded));
}
}
#[test]
fn snapshot_does_not_create_hook_trust() {
let result = selected_config(
b"[plugins.\"shepherd@shepherd\"]\nenabled = true\n",
Path::new("/project"),
)
.unwrap();
assert!(!result.contains("trusted_hash"));
assert!(!result.contains("hooks"));
assert!(selected_config(b"", Path::new("/project")).is_err());
}
#[test]
fn snapshot_copies_only_shepherd_and_refuses_changed_output() {
let temp = tempfile::tempdir().unwrap();
let root = fs::canonicalize(temp.path()).unwrap();
let source = root.join("source");
let plugin = source.join("plugins/cache/shepherd/shepherd/6.7.2");
fs::create_dir_all(&plugin).unwrap();
fs::write(plugin.join("SKILL.md"), "existing skill").unwrap();
fs::write(
source.join("config.toml"),
"[plugins.\"shepherd@shepherd\"]\nenabled = true\n",
)
.unwrap();
fs::write(source.join("unrelated"), "not copied").unwrap();
let home = root.join("home");
fs::create_dir(&home).unwrap();
let auth = AuthSnapshot {
source: source.join("auth.json").to_str().unwrap().into(),
destination: ".config/codex/auth.json".into(),
};
snapshot_runtime(&auth, &home, Path::new("/project")).unwrap();
snapshot_runtime(&auth, &home, Path::new("/project")).unwrap();
let copied = home.join(".config/codex/plugins/cache/shepherd/shepherd/6.7.2/SKILL.md");
assert_eq!(fs::read_to_string(&copied).unwrap(), "existing skill");
assert!(!home.join(".config/codex/unrelated").exists());
fs::write(copied, "changed").unwrap();
assert!(snapshot_runtime(&auth, &home, Path::new("/project")).is_err());
}
#[cfg(unix)]
#[test]
fn snapshot_refuses_plugin_symlinks() {
let temp = tempfile::tempdir().unwrap();
let root = fs::canonicalize(temp.path()).unwrap();
let source = root.join("source");
fs::create_dir(&source).unwrap();
fs::write(root.join("outside"), "outside").unwrap();
std::os::unix::fs::symlink(root.join("outside"), source.join("skill")).unwrap();
assert!(copy_tree(&source, &root.join("target"), &mut 0, &mut 0, 0).is_err());
}
}