mind.rs
Rust · 812 lines · 39.7 KB
//! The mind itself: opening and creating it, loading entries, validating and committing.
use super::paths::{named_dir, DATA_MARKERS, DATA_WEAK_MARKERS};
use super::{app_cache_dir, create_private_dir, strip_controls, data_dir, env_setting, expand, kind, sections, secret_check, split_frontmatter,
valid_id, Config, Entry, Meta, Project, KINDS};
use anyhow::{anyhow, bail, Context, Result};
use fs2::FileExt;
use serde::Deserialize;
use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
pub struct Mind {
pub home: PathBuf,
pub projects: BTreeMap<String, Project>,
pub config: Config,
}
// No trailing slash on the private folders: `reviews/` matches only a real folder, so a symlink
// named `reviews` could be committed (and replace the folder on pull).
pub const GITIGNORE: &str = "index.db\nindex.db-*\n.models/\nmodels/\n.lock\nreviews\nhook-state\n.auth-cache*\nauth-cache*\n.edit\n.*.tmp-*\n";
impl Mind {
pub fn home() -> PathBuf {
env_setting(&["MIND_HOME"])
.map(PathBuf::from)
.unwrap_or_else(|| named_dir(&data_dir(), "", DATA_MARKERS, DATA_WEAK_MARKERS))
}
pub fn open() -> Result<Mind> {
let home = Self::home();
require_git()?;
if !home.join("entries").is_dir() {
bail!("no mind at {} — run `mind setup` first", home.display());
}
// Minds made by older versions: make sure newer private files stay out of git.
let gi = home.join(".gitignore");
let mut gi_changed = false;
// Never through a link (a pulled `.gitignore` symlink to ~/.bashrc would get our lines).
let gi_is_file = fs::symlink_metadata(&gi).is_ok_and(|m| m.file_type().is_file() && !hard_linked(&gi, &m));
if let Some(cur) = gi_is_file.then(|| fs::read_to_string(&gi).ok()).flatten() {
let missing: Vec<&str> = GITIGNORE.lines().filter(|l| !cur.lines().any(|c| c == *l)).collect();
if !missing.is_empty() {
gi_changed = super::write_atomic(&gi, format!("{}\n{}\n", cur.trim_end(), missing.join("\n"))).is_ok();
}
}
// Tokens used to be cached inside the mind folder; they now live in the cache folder.
// (and on Windows, 0.3.0 previews cached them as auth-cache.json in the same folder)
let _ = fs::remove_file(home.join(".auth-cache.json"));
if home != app_cache_dir() {
let _ = fs::remove_file(home.join("auth-cache.json"));
}
guard_private_dirs(&home)?;
untrack_local_only(&home);
// Only projects approved on this machine are used; projects.toml keeps them all.
let projects = approved_projects(&home, read_projects(&home)?);
let config = match read_plain(&home.join("config.toml")) {
Ok(s) => {
let mut c: Config = toml::from_str(&s).map_err(|e| toml_error("config.toml", &s, &e))?;
c.identity = c.identity.sanitized();
c.clamp_limits();
check_ai_approval(&home, &mut c);
c
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Config::default(),
Err(e) => bail!("can't read {} ({e}); {READ_FIX}", home.join("config.toml").display()),
};
let mind = Mind { home, projects, config };
if gi_changed {
// Committed, so doctor doesn't report a hand edit (only this file), under the lock.
// Never wait for it: a hook opening the mind while a long command holds the lock skips
// this (the next open commits it).
let lock = fs::OpenOptions::new().create(true).truncate(false).write(true).open(mind.home.join(".lock"));
if let Ok(f) = lock
&& f.try_lock_exclusive().is_ok()
{
let _ = mind.commit(&[&gi], "gitignore: keep newer private files out of git", "mind");
}
}
Ok(mind)
}
pub fn init() -> Result<PathBuf> {
require_git()?;
let home = Self::home();
// The mind holds notes about private systems: only its owner can read it.
create_private_dir(&home);
for d in ["entries", "proposals", "reviews", "hook-state"] {
fs::create_dir_all(home.join(d))?;
}
let write_new = |name: &str, content: &str| -> Result<()> { create_if_absent(&home.join(name), content) };
write_new(".gitignore", GITIGNORE)?;
write_new("projects.toml", "# Projects the mind knows. Refs are written as `<project>:<path inside it>`.\n")?;
write_new(
"config.toml",
&toml::to_string_pretty(&Config::default())?,
)?;
if !home.join(".git").exists() {
git(&home, &["init", "-q"])?;
git(&home, &["config", "user.name", "Mega Mind"])?;
git(&home, &["config", "user.email", "mind@localhost"])?;
git(&home, &["add", "-A"])?;
git(&home, &["commit", "-q", "-m", "Mega Mind: initial layout"])?;
}
Ok(home)
}
pub fn entries_dir(&self) -> PathBuf {
self.home.join("entries")
}
pub fn lock(&self) -> Result<fs::File> {
let f = match fs::OpenOptions::new().create(true).truncate(false).write(true).open(self.home.join(".lock")) {
Ok(f) => f,
// Every write starts here, so say why it can't happen (an agent can't act on a bare
// "Permission denied"). Searching and reading still work.
Err(e) if matches!(e.kind(), std::io::ErrorKind::PermissionDenied | std::io::ErrorKind::ReadOnlyFilesystem) => {
bail!("{} ({e})", cant_write_help(&self.home))
}
Err(e) => return Err(e.into()),
};
f.lock_exclusive()?;
Ok(f)
}
pub fn entry_path(&self, id: &str) -> Result<PathBuf> {
if !valid_id(id) {
bail!("bad entry id {id:?}");
}
Ok(self.entries_dir().join(format!("{id}.md")))
}
pub fn load(&self, id: &str) -> Result<Entry> {
let path = self.entry_path(id)?;
// A pulled symlink would make us read (index, embed, show) a file outside the mind.
if fs::symlink_metadata(&path).is_ok_and(|m| m.file_type().is_symlink() || hard_linked(&path, &m)) {
bail!("entry {id:?} is a link; entries must be plain files in the mind");
}
let raw = fs::read_to_string(&path).map_err(|_| anyhow!("no entry {id:?} (try `mind search`)"))?;
let (meta, body) = split_frontmatter(&raw).with_context(|| format!("entry {id}"))?;
Ok(Entry { id: id.to_string(), meta, body, raw })
}
/// `projects.toml` as it is on disk now, every project in it, approved here or not (re-read under
/// the lock before changing it, so two writers don't drop each other's projects).
pub fn projects_now(&self) -> Result<BTreeMap<String, Project>> {
read_projects(&self.home)
}
/// The ids of the entries: every `entries/*.md` whose name is a valid id. Anything else there
/// (a README.md, an editor's `x copy.md`) is skipped with a warning, so it can't break every command.
pub fn all_ids(&self) -> Result<Vec<String>> {
let mut ids: Vec<String> = fs::read_dir(self.entries_dir())?
.filter_map(|e| e.ok())
.filter_map(|e| {
let p = e.path();
if p.extension()? != "md" {
return None;
}
let id = p.file_stem()?.to_string_lossy().to_string();
if e.file_type().is_ok_and(|t| t.is_symlink()) || fs::symlink_metadata(&p).is_ok_and(|m| hard_linked(&p, &m)) {
eprintln!("warning: skipping {} (a link; entries must be plain files)", strip_controls(&p.display().to_string()));
return None;
}
// An entry is a page of notes; a huge file would make every hook and search slow.
if e.metadata().is_ok_and(|m| m.len() > MAX_ENTRY_BYTES) {
eprintln!("warning: skipping {} (over 1 MB; entries are notes, not files)", strip_controls(&p.display().to_string()));
return None;
}
if !valid_id(&id) {
static WARNED: std::sync::Once = std::sync::Once::new();
WARNED.call_once(|| {
eprintln!("warning: skipping {} (not an entry name: lowercase letters, digits and dashes)", p.display())
});
return None;
}
Some(id)
})
.collect();
ids.sort();
Ok(ids)
}
/// Project whose path contains `dir` (the deepest match), e.g. the cwd of an agent. A relative
/// project path (from an older `project add .`) matches nothing: it would mean a different
/// folder in every process.
pub fn project_for_dir(&self, dir: &Path) -> Option<String> {
let dir = dir.canonicalize().unwrap_or_else(|_| dir.to_path_buf());
self.projects
.iter()
.filter_map(|(name, p)| {
let root = expand(&p.path);
if !root.is_absolute() {
return None;
}
let root = root.canonicalize().unwrap_or(root);
dir.starts_with(&root).then(|| (root.components().count(), name.clone()))
})
.max()
.map(|(_, n)| n)
}
/// Validate an entry before it is written.
pub fn validate(&self, meta: &Meta, body: &str) -> Result<()> {
let mut errs = vec![];
if meta.title.trim().is_empty() {
errs.push("frontmatter needs a `title`".to_string());
}
if super::has_conflict_markers(body) {
errs.push("the entry has git conflict markers (<<<<<<<, =======, >>>>>>>): resolve them first".to_string());
}
let k = kind(&meta.kind);
if k.is_none() {
errs.push(format!(
"`kind` must be one of: {}",
KINDS.iter().map(|k| k.name).collect::<Vec<_>>().join(", ")
));
}
if meta.project.trim().is_empty() {
errs.push("frontmatter needs a `project`".into());
} else if !self.projects.contains_key(&meta.project) && meta.project != "general" {
errs.push(format!(
"unknown project {:?}: use one of {} or `general`, or add it with `mind project add`",
meta.project,
self.projects.keys().cloned().collect::<Vec<_>>().join(", ")
));
}
if let Some(k) = k {
let have: Vec<String> = sections(body).into_iter().map(|(h, _)| h.to_lowercase()).collect();
for (h, _, req) in k.sections {
if *req && !have.iter().any(|x| x == &h.to_lowercase()) {
errs.push(format!("a `{}` entry needs a `## {h}` section (see `mind template {}`)", k.name, k.name));
}
}
}
if body.trim().len() < 40 {
errs.push("the body is nearly empty".into());
}
if !errs.is_empty() {
bail!("{}", errs.join("\n"));
}
secret_check(&format!("{}\n{}", serde_yaml::to_string(meta).unwrap_or_default(), body))
}
pub fn commit(&self, paths: &[&Path], message: &str, author: &str) -> Result<()> {
let rel: Vec<String> = paths
.iter()
.map(|p| p.strip_prefix(&self.home).unwrap_or(p).to_string_lossy().to_string())
.collect();
// A git killed mid-commit leaves .git/index.lock, and every later commit fails. All writers
// hold the mind lock (`lock()`), so one older than a minute is a leftover, not a live git.
let git_lock = self.home.join(".git").join("index.lock");
if fs::metadata(&git_lock).and_then(|m| m.modified()).is_ok_and(|t| t.elapsed().is_ok_and(|d| d.as_secs() > 60)) {
eprintln!("warning: removing a leftover {} (from an interrupted git)", git_lock.display());
let _ = fs::remove_file(&git_lock);
}
// A merge or rebase stopped on a conflict blocks every partial commit: say so plainly.
if let Some(what) = self.unfinished_git() {
bail!("{}", unfinished_git_help(&self.home, what));
}
// `-f`: these are files we just wrote; a pulled .gitignore listing entries/ mustn't block saving.
let mut add = vec!["add", "-A", "-f", "--"];
add.extend(rel.iter().map(String::as_str));
if let Err(e) = git(&self.home, &add) {
// Codex's sandbox keeps every `.git` read-only, even inside a folder it may write.
let m = format!("{e:#}");
if m.contains("Read-only file system") || m.contains("Permission denied") {
bail!("{}", cant_write_help(&self.home));
}
return Err(e);
}
// Nothing changed (an `ack` twice in a day, an identical update): nothing to commit, not an error.
let mut diff = vec!["diff", "--cached", "--quiet", "--no-ext-diff", "--no-textconv", "--"];
diff.extend(rel.iter().map(String::as_str));
if git(&self.home, &diff).is_ok() {
return Ok(());
}
// Names come from --as / $MIND_AGENT; keep them to characters git accepts in an author.
let clean: String = author.chars().filter(|c| c.is_alphanumeric() || " ._-".contains(*c)).take(60).collect();
let author = format!("--author={} <agent@localhost>", if clean.trim().is_empty() { "unknown" } else { clean.trim() });
let mut c = vec!["commit", "-q", "-m", message, &author, "--"];
c.extend(rel.iter().map(String::as_str));
if let Err(e) = git(&self.home, &c) {
// Unstage what we added, so the index matches HEAD again (the caller puts the file back);
// else a later `git commit` or `merge --abort` would trip over it.
let mut reset = vec!["reset", "-q", "--"];
reset.extend(rel.iter().map(String::as_str));
let _ = git(&self.home, &reset);
return Err(e);
}
Ok(())
}
/// "merge" or "rebase" when the mind's repo is stopped in the middle of one.
pub fn unfinished_git(&self) -> Option<&'static str> {
let g = self.home.join(".git");
if g.join("MERGE_HEAD").exists() {
Some("merge")
} else if g.join("rebase-merge").exists() || g.join("rebase-apply").exists() {
Some("rebase")
} else if g.join("CHERRY_PICK_HEAD").exists() {
Some("cherry-pick")
} else if g.join("REVERT_HEAD").exists() {
Some("revert")
} else {
None
}
}
}
/// Run git in the mind's own repo. Commit signing and git hooks are off for it: they come from the
/// user's global config (often enforced on work machines) and would block or fail every mind write.
/// git settings for the mind's repo that keep its content from starting programs or sending data.
const GIT_SAFE: [&str; 12] = [
"-c", "diff.external=", "-c", "filter.lfs.process=", "-c", "filter.lfs.clean=", "-c", "filter.lfs.smudge=",
"-c", "filter.lfs.required=false", "-c", "core.pager=cat",
];
/// git's empty tree, used as the attribute source.
const EMPTY_TREE: &str = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
/// Entries larger than this are skipped (see `all_ids`).
pub const MAX_ENTRY_BYTES: u64 = 1 << 20;
pub fn git(dir: &Path, args: &[&str]) -> Result<()> {
let o = Command::new("git")
.arg("-C")
.arg(dir)
.args(["-c", "commit.gpgsign=false", "-c", "tag.gpgsign=false", "-c", "core.fsmonitor=false"])
.args(GIT_SAFE)
// Ignore the mind's own .gitattributes (a pulled `* filter=lfs` would run git-lfs and could
// upload entries): attributes come from the empty tree. Older git ignores the variable.
.env("GIT_ATTR_SOURCE", EMPTY_TREE)
.arg("-c")
// Outside the repo: a pulled commit could add an executable `<mind>/.no-git-hooks/pre-commit`.
.arg(format!("core.hooksPath={}", super::app_cache_dir().join("no-hooks").display()))
.args(args)
.output()?;
if !o.status.success() {
bail!(
"git {} failed: {}",
args.first().unwrap_or(&""),
// git says some things ("nothing to commit") on stdout only.
[String::from_utf8_lossy(&o.stderr).trim(), String::from_utf8_lossy(&o.stdout).trim()]
.into_iter()
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
.join(" ")
);
}
Ok(())
}
pub fn git_out(dir: &Path, args: &[&str]) -> String {
Command::new("git")
.arg("-C")
.arg(dir)
.args(["-c", "core.fsmonitor=false"])
.arg("-c")
.arg(format!("core.hooksPath={}", super::app_cache_dir().join("no-hooks").display()))
.args(GIT_SAFE)
.env("GIT_ATTR_SOURCE", EMPTY_TREE)
.args(args)
.output()
.map(|o| String::from_utf8_lossy(&o.stdout).to_string())
.unwrap_or_default()
}
/// `projects.toml` in `home`: empty if missing, an error if unreadable (UTF-16, permissions), since
/// setup would otherwise save defaults over it.
fn read_projects(home: &Path) -> Result<BTreeMap<String, Project>> {
match read_plain(&home.join("projects.toml")) {
Ok(s) => {
#[derive(Deserialize)]
struct F {
#[serde(default)]
projects: BTreeMap<String, Project>,
}
let mut projects = toml::from_str::<F>(&s).map_err(|e| toml_error("projects.toml", &s, &e))?.projects;
// Names reach agents' context: a quoted TOML key could hold newlines or instructions.
// Clean the path first (a control or invisible character in front of `\\\\host` would hide it).
for p in projects.values_mut() {
p.path = strip_controls(&p.path).replace(['\n', '\r', '\t'], " ").trim().to_string();
}
projects.retain(|name, p| {
let ok = valid_id(name);
if !ok {
eprintln!("warning: skipping project {:?} in projects.toml (names are lowercase letters, digits and dashes)", strip_controls(name));
} else if !expand(&p.path).is_absolute() {
eprintln!("warning: skipping project {name:?} in projects.toml (its path isn't a full path)");
return false;
} else if is_network_path(&p.path) || is_network_path(&expand(&p.path).to_string_lossy()) {
// Checked on the text alone, before anything touches the path: on Windows even
// looking up `\\\\host\\share` connects to it and sends the login hash.
eprintln!("warning: skipping project {name:?} in projects.toml (a network path)");
return false;
} else if inside_mind(home, &p.path) {
eprintln!("warning: skipping project {name:?} in projects.toml (its path is inside the mind folder)");
return false;
} else if too_broad(&p.path) {
eprintln!("warning: skipping project {name:?} in projects.toml (its path {:?} is {TOO_BROAD})", strip_controls(&p.path));
return false;
}
ok
});
// Shown as the CLI's own lines (`mind who`, `mind projects`): one line, no controls.
let line = |s: &str| strip_controls(s).split_whitespace().collect::<Vec<_>>().join(" ");
for p in projects.values_mut() {
// Printed as the CLI's own lines and in the sentence telling an agent whom to message.
p.description = line(&p.description).chars().take(200).collect();
p.session = super::clean_name(&p.session).unwrap_or_default();
// A path keeps its own spaces; only line breaks and tabs go.
p.path = strip_controls(&p.path).replace(['\n', '\r', '\t'], " ").trim().to_string();
p.stack = p.stack.iter().take(12).map(|s| line(s).chars().take(40).collect()).collect();
}
Ok(projects)
}
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(BTreeMap::new()),
Err(e) => bail!("can't read {} ({e}); {READ_FIX}", home.join("projects.toml").display()),
}
}
/// Mega Mind keeps its memory in a git repo: say so plainly when git is missing, instead of a bare
/// "No such file or directory" after an entry was already written.
pub fn require_git() -> Result<()> {
static OK: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
if *OK.get_or_init(|| Command::new("git").arg("--version").output().is_ok_and(|o| o.status.success())) {
Ok(())
} else {
let how = if cfg!(windows) {
"`winget install Git.Git`, or https://git-scm.com"
} else if cfg!(target_os = "macos") {
"`xcode-select --install` or `brew install git`"
} else {
"`sudo apt install git`, `sudo dnf install git`, or your package manager"
};
bail!("git isn't installed (Mega Mind keeps its memory in a git repo). Install it ({how}), then try again")
}
}
/// Where this machine keeps the fingerprint of the AI settings it approved, per mind, outside the
/// mind's git repo (so a pull can't approve itself).
pub(crate) fn ai_approval_path(home: &Path) -> PathBuf {
use sha2::{Digest, Sha256};
// The real path, so `~/x` vs its full path (or case on macOS/Windows) is the same mind.
let real = home.canonicalize().unwrap_or_else(|_| home.to_path_buf());
let key = hex::encode(Sha256::digest(real.to_string_lossy().as_bytes()));
// Tests never touch the real cache folder.
let root = if cfg!(test) { std::env::temp_dir().join(format!("mind-test-approved-{}", std::process::id())) } else { app_cache_dir().join("approved") };
root.join(format!("ai-{}", &key[..16]))
}
/// config.toml is in the mind's git repo, so a pull or another agent can change it, and its AI
/// settings can run a program (`auth = "command:…"`, the LM Studio path) or send a key and entry text
/// to a URL. Changed settings that could do that are held back until `mind setup` approves them. With
/// no record yet, only settings that send nothing anywhere (local or no embeddings, no librarian)
/// are approved by themselves.
pub(crate) fn check_ai_approval(home: &Path, c: &mut Config) {
let path = ai_approval_path(home);
let fp = c.ai_fingerprint();
let held = "no AI settings have been approved for this mind on this machine yet (a new clone, a cleared cache?), \
so the ones in config.toml are held back: search is keyword-only and the librarian is off. Run `mind setup` to review and approve them";
match fs::read_to_string(&path) {
Ok(approved) if approved.trim() == fp => {}
// Approved under the older fingerprint (0.5.1 and earlier): the same settings, so the
// approval stands and is rewritten in the current form.
Ok(approved) if approved.trim() == c.ai_fingerprint_v3() => {
let _ = approve_ai(home, c);
}
// Any change waits for `mind setup`, even to local settings (a pull switching to the local
// model makes the next search download ~420 MB; thresholds can break `record`).
Ok(_) => c.hold_ai(
"the AI settings in config.toml changed since you approved them on this machine (a pull or another agent?), \
so they're held back: search is keyword-only and the librarian is off. Run `mind setup` to review and approve them"
.to_string(),
),
// No record. Settings that send nothing anywhere are fine; anything else (a cloned mind,
// a cleared cache) waits for `mind setup`, which shows it.
Err(_) if !c.ai_is_risky() => {
let _ = approve_ai(home, c);
}
Err(_) => c.hold_ai(held.to_string()),
}
}
/// Record `c`'s AI settings as approved on this machine (setup does this when you save them).
pub fn approve_ai(home: &Path, c: &Config) -> std::io::Result<()> {
let path = ai_approval_path(home);
if let Some(d) = path.parent() {
fs::create_dir_all(d)?;
}
super::write_atomic(&path, c.ai_fingerprint())
}
/// Why a save failed when the mind folder (or its `.git`) can't be written here, and what to do. An
/// agent can't act on a bare "Permission denied"; this tells it to ask for the one command to run
/// outside its sandbox, which is how Codex and similar tools handle it.
pub fn cant_write_help(home: &Path) -> String {
format!(
"this process can't write to the mind folder {}: a sandbox (Codex's blocks writes outside the project \
and keeps every .git folder read-only) or a read-only folder. Searching and reading still work. To save, \
run this same command outside the sandbox: in Codex, request escalated permissions for it (the user is \
asked to approve); otherwise ask the user to run it",
home.display()
)
}
/// What to do when a mind file can't be read (a link, too big, not UTF-8): said in the error.
const READ_FIX: &str = "it must be a plain UTF-8 text file in the mind folder (not a symbolic or hard link) and at most 1 MB; fix it and try again";
/// What `too_broad` refuses, in words.
pub const TOO_BROAD: &str = "too broad for a project: a whole drive, your home folder, a personal folder such as Documents or OneDrive, a settings or system folder, or a network share";
/// A project path that would let refs reach everything: a filesystem root or the home folder itself.
pub fn too_broad(path: &str) -> bool {
// Network shares and device paths (`\\\\host\\share`, `//host/x`, `\\\\?\\…`): opening one on Windows
// sends the user's login hash to that host.
if is_network_path(path) {
return true;
}
let p = expand(path);
let p = p.canonicalize().unwrap_or(p);
let home = super::home_dir();
let home = home.canonicalize().unwrap_or(home);
// A root, the home folder or anything above it (`/home`, `/Users`), or a folder of secrets.
const SECRET_DIRS: [&str; 8] = [".ssh", ".aws", ".gnupg", ".azure", ".kube", ".docker", ".password-store", ".config"];
// Hidden folders right under home hold tool settings and credentials (~/.claude, ~/.codex, …).
// …and the per-user settings and credential stores of Windows and macOS.
let first = p.strip_prefix(&home).ok().and_then(|r| r.components().next()).map(|c| c.as_os_str().to_string_lossy().to_lowercase());
let hidden_in_home = first.as_deref().is_some_and(|c| c.starts_with('.') || c == "appdata" || c == "library");
const SYSTEM: [&str; 7] = ["/etc", "/proc", "/sys", "/dev", "/boot", "/root", "/var/lib"];
// The personal folders themselves (a project inside one is fine): everything a user keeps.
const PERSONAL: [&str; 7] = ["documents", "desktop", "downloads", "pictures", "music", "videos", "onedrive"];
let name = |q: &Path| q.file_name().map(|n| n.to_string_lossy().to_lowercase()).unwrap_or_default();
// OneDrive's own folder is `OneDrive` or `OneDrive - <Company>`, and it can hold the redirected
// Documents, Desktop and Pictures (`~/OneDrive/Documents`).
let onedrive = |q: &Path| q.parent() == Some(home.as_path()) && (name(q) == "onedrive" || name(q).starts_with("onedrive - "));
let personal = (p.parent() == Some(home.as_path()) && (PERSONAL.contains(&name(&p).as_str()) || onedrive(&p)))
|| (p.parent().is_some_and(onedrive) && PERSONAL.contains(&name(&p).as_str()));
p.parent().is_none()
|| home.starts_with(&p)
|| hidden_in_home
|| personal
|| SECRET_DIRS.iter().any(|d| p.starts_with(home.join(d)))
|| (cfg!(unix) && SYSTEM.iter().any(|d| p.starts_with(d)))
}
/// The mind's own folders must be real folders inside it. A pulled commit can put a symlink where
/// an ignored folder is (`reviews -> ~/.claude`), and the nightly upkeep would then write review
/// files into another program's settings. Ignored, local-only ones (and the index) are replaced
/// with a real folder (removing a link never touches its target); tracked ones are refused.
pub(crate) fn guard_private_dirs(home: &Path) -> Result<()> {
let is_link = |p: &Path| fs::symlink_metadata(p).is_ok_and(|m| is_link_meta(&m));
for d in ["entries", "proposals"] {
if is_link(&home.join(d)) {
bail!("{} is a symbolic link; the mind's {d} folder must be a real folder inside it", home.join(d).display());
}
}
// Something of the wrong kind at one of our names is moved aside, never deleted.
let move_aside = |p: &Path| -> Result<()> {
let to = p.with_file_name(format!("{}.moved-aside-{}", p.file_name().unwrap_or_default().to_string_lossy(), std::process::id()));
eprintln!("warning: {} wasn't what Mega Mind keeps there (from a pull?); moved to {}", strip_controls(&p.display().to_string()), to.display());
fs::rename(p, &to).with_context(|| format!("moving {} aside", p.display()))
};
for d in ["reviews", "hook-state", ".edit"] {
let p = home.join(d);
if is_link(&p) {
eprintln!("warning: {} was a symbolic link (from a pull?); replaced with an empty folder", p.display());
fs::remove_file(&p)?;
} else if p.exists() && !p.is_dir() {
move_aside(&p)?;
}
if d != ".edit" {
fs::create_dir_all(&p)?;
}
// Links (or anything but plain files) inside them: a pulled `reviews/x.md -> ~/.bashrc`
// would be written through by the nightly upkeep. They are local-only, so remove the link.
for e in fs::read_dir(&p).into_iter().flatten().flatten() {
if fs::symlink_metadata(e.path()).is_ok_and(|m| is_link_meta(&m)) {
eprintln!("warning: removed the symbolic link {} (from a pull?)", strip_controls(&e.path().display().to_string()));
fs::remove_file(e.path())?;
}
}
}
for f in ["index.db", "index.db-wal", "index.db-shm", ".lock"] {
let p = home.join(f);
if is_link(&p) {
eprintln!("warning: {} was a symbolic link (from a pull?); removed", p.display());
fs::remove_file(&p)?;
} else if p.is_dir() {
move_aside(&p)?;
}
}
Ok(())
}
/// How to finish or undo a stopped merge/rebase/cherry-pick/revert, with the mind's path quoted.
pub fn unfinished_git_help(home: &Path, what: &str) -> String {
let dir = format!("\"{}\"", home.display());
let finish = if what == "merge" { "commit".to_string() } else { format!("{what} --continue") };
format!(
"the mind's git repo is in the middle of a {what} (from a pull?), so nothing can be saved. Resolve the conflicts \
and finish it (`git -C {dir} add -A` then `git -C {dir} {finish}`), or undo it (`git -C {dir} {what} --abort`), then try again"
)
}
/// A mind file's text, only if it is a plain file in the mind (a pulled symlink would read, and
/// then print in errors, a file from anywhere: `projects.toml -> ~/.aws/credentials`).
pub fn read_plain(path: &Path) -> std::io::Result<String> {
let m = fs::symlink_metadata(path)?;
if !m.file_type().is_file() || hard_linked(path, &m) {
return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "not a plain file (a symbolic or hard link?)"));
}
if m.len() > MAX_ENTRY_BYTES {
return Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "larger than 1 MB"));
}
fs::read_to_string(path)
}
/// A TOML error without the file's text: toml's own message quotes the offending line, which could
/// be anything a pull put there (instructions, or another file's contents), and errors reach agents.
pub fn toml_error(name: &str, src: &str, e: &toml::de::Error) -> anyhow::Error {
let line = e.span().map(|s| src[..s.start.min(src.len())].matches('\n').count() + 1);
let what = e.message().split(['"', '`', '\'']).step_by(2).collect::<Vec<_>>().join("…");
let what: String = strip_controls(&what).chars().take(80).collect();
anyhow!("{name}: {}{} (fix it, then `mind doctor`)", line.map(|l| format!("line {l}: ")).unwrap_or_default(), what.replace('\n', " "))
}
/// Whether a project path is the mind folder or inside it (a repo planted in a synced mind must
/// never be run git in).
pub fn inside_mind(home: &Path, path: &str) -> bool {
let mind = home.canonicalize().unwrap_or_else(|_| home.to_path_buf());
let p = expand(path);
let p = p.canonicalize().unwrap_or(p);
p.starts_with(&mind)
}
/// The local-only files (reviews, hook state, the index) are never meant to be in git; a pull can
/// still bring them (`git add -f`), as fake librarian verdicts or a poisoned index. Tracked copies are
/// deleted and untracked (the user's own copies are untracked, so they're never touched).
fn untrack_local_only(home: &Path) {
if !home.join(".git").exists() {
return;
}
const LOCAL: [&str; 7] = ["reviews", "hook-state", ".edit", "index.db", "index.db-wal", "index.db-shm", ".lock"];
let mut ls = vec!["ls-files", "-z", "--"];
ls.extend(LOCAL);
let tracked = git_out(home, &ls);
let paths: Vec<&str> = tracked.split('\0').filter(|p| !p.is_empty()).collect();
if paths.is_empty() {
return;
}
eprintln!("warning: removing {} file(s) a commit put in the mind's local-only folders (reviews, index…)", paths.len());
for p in &paths {
let full = home.join(p);
if full.starts_with(home) && fs::symlink_metadata(&full).is_ok_and(|m| !m.is_dir()) {
let _ = fs::remove_file(&full);
}
}
let mut rm = vec!["rm", "-r", "-q", "--cached", "--ignore-unmatch", "--"];
rm.extend(LOCAL);
if git(home, &rm).is_ok() {
let mut c = vec!["commit", "-q", "-m", "untrack local-only files (reviews, index) that a commit added", "--author=mind <agent@localhost>", "--"];
c.extend(paths.iter().copied());
let _ = git(home, &c);
}
}
#[cfg(test)]
pub(crate) fn untrack_local_only_for_test(home: &Path) {
untrack_local_only(home)
}
/// A symbolic link, or on Windows any reparse point (a junction isn't `is_symlink`).
fn is_link_meta(m: &fs::Metadata) -> bool {
// Rust reports name-surrogate reparse points (symlinks, junctions) as symlinks on Windows;
// other reparse points (OneDrive placeholders) are ordinary files and must not be removed.
m.file_type().is_symlink()
}
/// A file with more than one name (a hard link): writing it changes the other name too, and
/// reading it may read a file from outside the mind.
fn hard_linked(path: &Path, m: &fs::Metadata) -> bool {
#[cfg(unix)]
{
use std::os::unix::fs::MetadataExt;
let _ = path;
m.nlink() > 1
}
#[cfg(windows)]
{
// std's link count is unstable on Windows: ask the file itself. Opened for no access (just
// its attributes), sharing everything, never through a link, so nothing is locked or read.
use std::os::windows::fs::OpenOptionsExt;
use std::os::windows::io::AsRawHandle;
use windows_sys::Win32::Storage::FileSystem::{
GetFileInformationByHandle, BY_HANDLE_FILE_INFORMATION, FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT,
FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE,
};
if !m.is_file() {
return false;
}
let Ok(f) = fs::OpenOptions::new()
.access_mode(0)
.share_mode(FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE)
.custom_flags(FILE_FLAG_OPEN_REPARSE_POINT | FILE_FLAG_BACKUP_SEMANTICS)
.open(path)
else {
return false;
};
let mut info: BY_HANDLE_FILE_INFORMATION = unsafe { std::mem::zeroed() };
// SAFETY: a valid open handle and a zeroed struct of the right type for the call to fill.
let ok = unsafe { GetFileInformationByHandle(f.as_raw_handle() as _, &mut info) } != 0;
ok && info.nNumberOfLinks > 1
}
#[cfg(not(any(unix, windows)))]
{
let _ = (path, m);
false
}
}
fn project_approval_path(home: &Path) -> PathBuf {
ai_approval_path(home).with_file_name(format!(
"projects-{}",
ai_approval_path(home).file_name().unwrap_or_default().to_string_lossy().trim_start_matches("ai-")
))
}
/// The `name\tpath` lines approved on this machine, or None if there is no record yet.
fn approved_project_lines(home: &Path) -> Option<std::collections::BTreeSet<String>> {
fs::read_to_string(project_approval_path(home)).ok().map(|s| s.lines().map(str::to_string).collect())
}
/// Projects whose name and path this machine approved. A project only points agents at folders on
/// this machine, so one that arrived through a pull (a synced mind) waits for `mind setup` or
/// `mind project add`. With no record yet, a mind that syncs nowhere (no git remote) approves the
/// projects it has; a synced one approves none.
fn approved_projects(home: &Path, all: BTreeMap<String, Project>) -> BTreeMap<String, Project> {
let line = |n: &str, p: &Project| format!("{n}\t{}", p.path);
let approved = match approved_project_lines(home) {
Some(a) => a,
None => {
let synced = home.join(".git").exists() && !git_out(home, &["remote"]).trim().is_empty();
if !synced {
let _ = approve_projects(home, &all);
return all;
}
Default::default()
}
};
let (ok, held): (BTreeMap<_, _>, BTreeMap<_, _>) = all.into_iter().partition(|(n, p)| approved.contains(&line(n, p)));
if !held.is_empty() {
static WARNED: std::sync::Once = std::sync::Once::new();
WARNED.call_once(|| {
eprintln!(
"warning: {} project(s) in projects.toml aren't approved on this machine (from a pull?), so they're not used: {}. \
Run `mind setup` to review them, or `mind project add <name> <folder>`",
held.len(),
held.keys().cloned().collect::<Vec<_>>().join(", ")
)
});
}
ok
}
/// The projects in projects.toml this machine hasn't approved.
pub fn held_projects(home: &Path) -> BTreeMap<String, Project> {
let all = read_projects(home).unwrap_or_default();
let ok = approved_projects(home, all.clone());
all.into_iter().filter(|(n, _)| !ok.contains_key(n)).collect()
}
/// Approve these projects (name and path) on this machine, keeping earlier approvals.
pub fn approve_projects(home: &Path, projects: &BTreeMap<String, Project>) -> std::io::Result<()> {
let mut lines = approved_project_lines(home).unwrap_or_default();
for (n, p) in projects {
lines.insert(format!("{n}\t{}", p.path));
}
let path = project_approval_path(home);
if let Some(d) = path.parent() {
fs::create_dir_all(d)?;
}
super::write_atomic(&path, lines.into_iter().collect::<Vec<_>>().join("\n") + "\n")
}
/// A network share, device path or automount path, judged on the text only (never resolve it
/// first): two leading slashes in either direction (`\\\\host`, `//host`, `\\/host`, `/\\host`),
/// `\\\\?\\`, `\\??\\`, and macOS `/net/…`.
pub fn is_network_path(path: &str) -> bool {
let t = path.trim();
let b = t.as_bytes();
let slash = |c: u8| c == b'/' || c == b'\\';
(b.len() >= 2 && slash(b[0]) && slash(b[1])) || t.starts_with("\\??\\") || t.starts_with("/net/") || t == "/net"
}
/// Write `content` to `path` only if nothing is there, links included: a dangling link from a pull
/// (`.gitignore -> ~/.ssh/config`) reports "doesn't exist", and writing would follow it.
pub(crate) fn create_if_absent(path: &Path, content: &str) -> Result<()> {
if fs::symlink_metadata(path).is_ok() {
return Ok(());
}
use std::io::Write;
match fs::OpenOptions::new().write(true).create_new(true).open(path) {
Ok(mut f) => f.write_all(content.as_bytes())?,
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {}
Err(e) => return Err(e.into()),
}
Ok(())
}