Skip to main content

yapi_core/chest/
startup.rs

1//! What yapi loads from chest manifests: the installed extensions, with the
2//! grants, event policy and module root each runs with, and the placed
3//! skills.
4
5use std::collections::HashMap;
6use std::path::{Path, PathBuf};
7
8use yapi_types::settings::FilteredPackage;
9
10use super::fetch::placed_name;
11use super::lock::fresh;
12use super::manifest::{Entry, MANIFEST_ENV, Manifest, Section, from_env};
13use super::{Mode, Project, locked_items};
14use crate::extensions::{EventPolicy, Grants};
15use crate::packages::npm::{Npm, escaped};
16use crate::packages::resolve::{PackageInput, ResolvedPaths, ResourceType, package_resources};
17use crate::settings::Scope;
18use crate::tools::path::home_dir;
19
20/// The project manifest of `project_root`: the file [`MANIFEST_ENV`] names
21/// relative to it, else `chest.toml`. `None` when the variable names a file
22/// outside the project, as chest refuses it too. Its lock is the file beside
23/// it named with `.lock`.
24pub fn project_manifest(project_root: &Path) -> Option<PathBuf> {
25    let name = from_env().ok()?;
26    Some(project_root.join(name.as_deref().unwrap_or("chest.toml")))
27}
28
29/// Whether `manifest` installs anything, beyond describing its folder as a
30/// package with `[package]`.
31fn consumes(manifest: &Manifest) -> bool {
32    [Section::Skills, Section::Agents, Section::Extensions]
33        .into_iter()
34        .any(|section| !manifest.section(section).is_empty())
35        || !manifest.patch.is_empty()
36}
37
38/// Whether the project manifest of `project_root` installs anything, so it
39/// needs the project's trust as other project files that run code do. One
40/// with only `[package]`, which describes the project as a package, does not.
41/// A manifest that does not parse needs trust too.
42pub fn needs_trust(project_root: &Path) -> bool {
43    let Some(Ok(text)) = project_manifest(project_root).map(std::fs::read_to_string) else {
44        return false;
45    };
46    Manifest::parse(&text, Scope::Project).map_or(true, |manifest| consumes(&manifest))
47}
48
49/// What an extension runs with: its grants, its `pi.events` policy, and the
50/// folder it loads modules from without file access. It has no default, so
51/// every file says which one it gets.
52#[derive(Clone, Debug, PartialEq)]
53pub struct Policy {
54    /// What it may reach.
55    pub grants: Grants,
56    /// The channels it may send to, and hear from, extensions whose grants
57    /// differ.
58    pub events: EventPolicy,
59    /// The folder that holds its package and the package's own
60    /// dependencies.
61    pub root: Option<PathBuf>,
62}
63
64impl Policy {
65    /// What a package from `yapi install` runs with, as in pi: every grant
66    /// and the shared event policy.
67    pub fn unrestricted() -> Policy {
68        Policy {
69            grants: Grants::default(),
70            events: EventPolicy::shared(),
71            root: None,
72        }
73    }
74
75    /// What a file chest cannot vouch for runs with: no grant, the
76    /// isolated event policy, and modules only from `root`, a folder or
77    /// the file itself.
78    pub fn closed(root: &Path) -> Policy {
79        Policy {
80            grants: Grants::none(),
81            events: EventPolicy::isolated(),
82            root: Some(root.to_path_buf()),
83        }
84    }
85
86    /// What both `self` and `other` allow: the grants and channels of both,
87    /// and the narrower of two nested module roots, or none.
88    fn intersection(&self, other: &Policy) -> Policy {
89        let canonical = |root: &PathBuf| root.canonicalize().unwrap_or_else(|_| root.clone());
90        let roots = (
91            self.root.as_ref().map(canonical),
92            other.root.as_ref().map(canonical),
93        );
94        let root = match roots {
95            (Some(left), Some(right)) if left.starts_with(&right) => Some(left),
96            (Some(left), Some(right)) if right.starts_with(&left) => Some(right),
97            _ => None,
98        };
99        Policy {
100            grants: self.grants.intersection(other.grants),
101            events: self.events.intersection(&other.events),
102            root,
103        }
104    }
105}
106
107/// The policies of chest's packages, by the scope and source the resolver
108/// gives their files, such as `("project", "chest:todo")`.
109/// [`installed_inputs`] builds the packages and their policies in one place,
110/// so they always match.
111pub type Policies = HashMap<(String, String), Policy>;
112
113/// The policies of extension files, so a policy applies whichever source of
114/// the same file loads it.
115#[derive(Clone, Debug, Default)]
116pub struct FilePolicies {
117    /// The policy of each file of chest's packages, by canonical path: what
118    /// every entry that gives it allows.
119    pub files: HashMap<PathBuf, Policy>,
120    /// The canonical folders and files of entries chest left out, whose
121    /// files run with no grant, whatever loads them.
122    pub closed: Vec<PathBuf>,
123}
124
125impl FilePolicies {
126    /// What extension file `file`, a canonical path, runs with: no grant
127    /// and no module but itself in an entry chest left out, else its
128    /// entries' policy, and for a file chest does not know, every grant, as
129    /// packages from `yapi install` keep pi's grants.
130    pub fn of(&self, file: &Path) -> Policy {
131        // Not the entry's folder as the root, which may hold far more.
132        if self.closes(file) {
133            return Policy::closed(file);
134        }
135        self.files
136            .get(file)
137            .cloned()
138            .unwrap_or_else(Policy::unrestricted)
139    }
140
141    /// Whether chest decides what `file` runs with.
142    pub fn manages(&self, file: &Path) -> bool {
143        self.files.contains_key(file) || self.closes(file)
144    }
145
146    /// Whether `file` belongs to an entry chest left out.
147    pub fn closes(&self, file: &Path) -> bool {
148        self.closed.iter().any(|root| file.starts_with(root))
149    }
150
151    /// The warning for extension file `file`, a canonical path, that
152    /// `source` names by `path` though chest decides what it runs with, as
153    /// the path and message to show, or `None` when chest does not. Both are
154    /// escaped, since a cloned project's file names can hold anything.
155    pub fn also_loaded(&self, file: &Path, path: &str, source: &str) -> Option<(String, String)> {
156        if !self.manages(file) {
157            return None;
158        }
159        let message = match self.closes(file) {
160            true => format!(
161                "{path} is also loaded from {source}, but its chest entry does not load, so it runs with no capabilities"
162            ),
163            false => format!("{path} is also loaded from {source}; chest's grants apply"),
164        };
165        Some((escaped(path), escaped(&message)))
166    }
167}
168
169/// The policy of each extension file the resolver gave a `chest:` source in
170/// `resolved`, loaded or shadowed, from its package's entry in
171/// `installed`, with the entries `installed` left out. A file that several
172/// entries give gets what all of them allow. A chest file whose package has
173/// no entry runs with no grant and the isolated event policy, and adds a
174/// line to `problems` under its source.
175pub fn file_policies(
176    resolved: &ResolvedPaths,
177    installed: &Installed,
178    problems: &mut Vec<(String, String)>,
179) -> FilePolicies {
180    let files = resolved.of(ResourceType::Extensions).iter();
181    let files = files.map(|file| &file.info).chain(&resolved.shadowed);
182    let mut found = FilePolicies {
183        files: HashMap::new(),
184        closed: installed.closed.clone(),
185    };
186    for info in files.filter(|info| info.source.starts_with("chest:")) {
187        let key = (info.scope.clone(), info.source.clone());
188        let file = Path::new(&info.path);
189        let file = file.canonicalize().unwrap_or_else(|_| file.into());
190        let policy = installed.policies.get(&key).cloned().unwrap_or_else(|| {
191            let message = format!(
192                "{}: no chest entry grants this file, so it runs with no capabilities",
193                info.path
194            );
195            problems.push((escaped(&info.source), escaped(&message)));
196            Policy::closed(&file)
197        });
198        let policy = match found.files.get(&file) {
199            Some(other) => other.intersection(&policy),
200            None => policy,
201        };
202        found.files.insert(file, policy);
203    }
204    found
205}
206
207/// What the installed entries of chest manifests give yapi.
208#[derive(Clone, Debug, Default)]
209pub struct Installed {
210    /// Extension packages, each as source `chest:<name>`.
211    pub packages: Vec<PackageInput>,
212    /// The canonical copies of placed skills, each a skill folder, with the
213    /// source and scope of its entry.
214    pub skills: Vec<PackageInput>,
215    /// The packages' policies.
216    pub policies: Policies,
217    /// The canonical folders and files of extension entries left out
218    /// because their lock is stale or missing, and the extension files of
219    /// those whose folder holds too much, which run with no grant if
220    /// something else loads them.
221    pub closed: Vec<PathBuf>,
222}
223
224impl Installed {
225    fn extend(&mut self, other: Installed) {
226        self.packages.extend(other.packages);
227        self.skills.extend(other.skills);
228        self.policies.extend(other.policies);
229        self.closed.extend(other.closed);
230    }
231}
232
233/// What chest gives one run of yapi, with the problems to show by manifest
234/// path: warnings and notices of the startup sync, entries left out, and
235/// manifests that fail to load.
236#[derive(Clone, Debug, Default)]
237pub struct Loaded {
238    /// The installed entries.
239    pub installed: Installed,
240    /// Each problem's manifest path and message.
241    pub problems: Vec<(String, String)>,
242}
243
244impl Loaded {
245    /// Adds problem `message` under manifest `path`, both with their
246    /// unprintable characters escaped: they quote files a cloned project
247    /// holds, and reach the terminal.
248    fn warn(&mut self, (path, message): (String, String)) {
249        self.problems.push((escaped(&path), escaped(&message)));
250    }
251}
252
253/// The extensions and skills `project` has installed, locked and up to
254/// date, or patched, for a run in `project_root`: a stale or unlocked entry
255/// gives nothing until `chest install`. Entries left out for a reason the
256/// user should see add a line to `problems`.
257pub fn installed_inputs(
258    project: &Project,
259    project_root: &Path,
260    problems: &mut Vec<String>,
261) -> Installed {
262    let scope = match project.scope {
263        Scope::Project => "project",
264        Scope::Global => "user",
265    };
266    let mut installed = Installed::default();
267    let lock = project.lock.clone().unwrap_or_default();
268    let current = |section: Section, name: &str, entry: &Entry| {
269        lock.section(section)
270            .iter()
271            .find(|locked| locked.name == name)
272            .filter(|locked| fresh(entry, locked))
273            .filter(|locked| project.present(section, locked))
274            .cloned()
275    };
276    for (name, entry) in &project.manifest.extensions {
277        let patched = project.manifest.patch.contains_key(name);
278        let roots = project.extension_roots(name);
279        // Locked or not, it never loads. Only the files it would load run
280        // closed, as other files there belong to other sources.
281        if let Some((root, module_root)) = &roots
282            && project.too_wide(module_root, &[project_root])
283        {
284            problems.push(format!(
285                "extensions.{name}: {} holds more than the extension, such as the project, the home or the agent folder, so it does not load",
286                module_root.display()
287            ));
288            let local = crate::packages::source::is_local(&format!("chest:{name}"));
289            let files = package_resources(root, None, local).extensions;
290            let files = files.iter().map(|file| Path::new(&file.info.path));
291            installed
292                .closed
293                .extend(files.filter_map(|file| file.canonicalize().ok()));
294            continue;
295        }
296        if !patched && current(Section::Extensions, name, entry).is_none() {
297            // Its files may still load through pi's discovery or settings.
298            if let Some(root) = roots.and_then(|(_, root)| root.canonicalize().ok()) {
299                installed.closed.push(root);
300            }
301            continue;
302        }
303        let Some((root, module_root)) = roots else {
304            continue;
305        };
306        if !root.exists() {
307            // A store folder that is missing is the sync's to put back, or
308            // to warn of.
309            if patched || entry.git.is_none() && entry.npm.is_none() {
310                problems.push(format!(
311                    "extensions.{name}: {} does not exist",
312                    root.display()
313                ));
314            }
315            continue;
316        }
317        let source = format!("chest:{name}");
318        let filter = [&entry.skills, &entry.prompts, &entry.themes]
319            .iter()
320            .any(|list| list.is_some())
321            .then(|| FilteredPackage {
322                source: source.clone(),
323                autoload: None,
324                extensions: None,
325                skills: entry.skills.clone(),
326                prompts: entry.prompts.clone(),
327                themes: entry.themes.clone(),
328            });
329        let policy = Policy {
330            grants: project.manifest.grants(entry),
331            events: entry.events.clone().unwrap_or_default(),
332            root: Some(module_root),
333        };
334        let key = (scope.to_owned(), source.clone());
335        installed.policies.insert(key, policy);
336        installed.packages.push(PackageInput {
337            source,
338            scope: scope.to_owned(),
339            root,
340            filter,
341        });
342    }
343    // Skills load from their canonical copy, wherever the folders put it.
344    let Some(home) = project.homes(Section::Skills).into_iter().next() else {
345        return installed;
346    };
347    for (name, entry) in &project.manifest.skills {
348        let patch = project.manifest.patch.get(name);
349        let roots = match (
350            patch.and_then(|patch| patch.path.as_deref()),
351            current(Section::Skills, name, entry),
352        ) {
353            // A patched skill loads where `chest install` placed it, else
354            // from its patch.
355            (Some(path), _) => {
356                let placed = home.join(placed_name(Section::Skills, name));
357                let root = if placed.exists() {
358                    placed
359                } else {
360                    project.resolve(path)
361                };
362                if !root.exists() {
363                    problems.push(format!("skills.{name}: {} does not exist", root.display()));
364                    continue;
365                }
366                vec![root]
367            }
368            (None, Some(locked)) => locked_items(Section::Skills, &locked)
369                .into_iter()
370                .map(|(item, _)| home.join(item))
371                .collect(),
372            (None, None) => continue,
373        };
374        for root in roots {
375            installed.skills.push(PackageInput {
376                source: format!("chest:{name}"),
377                scope: scope.to_owned(),
378                root,
379                filter: None,
380            });
381        }
382    }
383    installed
384}
385
386/// The manifests yapi reads for `project_root` that install anything: the
387/// project's, only when `trusted`, then the user's global one, or why each
388/// cannot be read.
389fn manifests(project_root: &Path, trusted: bool) -> Vec<Result<Project, (String, String)>> {
390    let mut found = Vec::new();
391    if trusted && let Err(problem) = from_env() {
392        let problem = format!("{problem}, so no project manifest loads");
393        found.push(Err((MANIFEST_ENV.to_owned(), problem)));
394    }
395    let project = trusted.then(|| project_manifest(project_root)).flatten();
396    let project = project.map(|path| (path, Scope::Project));
397    let global = Some((home_dir().join(".chest/chest.toml"), Scope::Global));
398    for (path, scope) in [project, global].into_iter().flatten() {
399        if !path.exists() {
400            continue;
401        }
402        match Project::open(&path, scope) {
403            Ok(project) if consumes(&project.manifest) => found.push(Ok(project)),
404            Ok(_) => {}
405            Err(err) => found.push(Err((path.display().to_string(), err.to_string()))),
406        }
407    }
408    found
409}
410
411/// Adds the inputs of `project` for a run in `project_root` to `loaded`,
412/// with its problems under its path.
413fn add(loaded: &mut Loaded, project: &Project, project_root: &Path) {
414    let mut problems = Vec::new();
415    loaded
416        .installed
417        .extend(installed_inputs(project, project_root, &mut problems));
418    let path = project.path.display().to_string();
419    for problem in problems {
420        loaded.warn((path.clone(), problem));
421    }
422}
423
424/// What chest gives a run in `project_root`, as [`load`] would, without
425/// installing anything: for `yapi config`, which shows what a run loads.
426pub fn installed(project_root: &Path, trusted: bool) -> Loaded {
427    let mut loaded = Loaded::default();
428    for project in manifests(project_root, trusted) {
429        match project {
430            Ok(project) => add(&mut loaded, &project, project_root),
431            Err(problem) => loaded.warn(problem),
432        }
433    }
434    loaded
435}
436
437/// What chest gives a run of yapi in `project_root`: the project manifest,
438/// only when `trusted`, and the user's global one, each when it installs
439/// anything. Each puts back what its lock pins and is missing, unless
440/// `offline`, and skips stale or unlocked entries with a warning. It never
441/// writes a lock or `.gitignore`.
442pub async fn load(project_root: &Path, trusted: bool, offline: bool) -> Loaded {
443    let mut loaded = Loaded::default();
444    let mut npm = None;
445    for project in manifests(project_root, trusted) {
446        let mut project = match project {
447            Ok(project) => project,
448            Err(problem) => {
449                loaded.warn(problem);
450                continue;
451            }
452        };
453        let path = project.path.display().to_string();
454        let npm = npm.get_or_insert_with(|| Npm::new(crate::packages::npm::config()));
455        match project
456            .sync(Mode::Startup { offline }, npm, &mut |_| {})
457            .await
458        {
459            Ok(report) => {
460                for message in report.warnings.into_iter().chain(report.notices) {
461                    loaded.warn((path.clone(), message));
462                }
463            }
464            Err(err) => loaded.warn((path.clone(), err.to_string())),
465        }
466        add(&mut loaded, &project, project_root);
467    }
468    loaded
469}
470
471#[cfg(test)]
472mod tests {
473    use super::*;
474    use crate::packages::resolve::Resolved;
475    use yapi_types::rpc::SourceInfo;
476
477    /// yapi's startup runs `load` in futures that move between threads.
478    #[test]
479    fn startup_load_send() {
480        fn send<T: Send>(_: T) {}
481        send(load(Path::new("."), false, true));
482    }
483
484    /// A chest file whose package has no policy runs with none, with a
485    /// warning, while other sources' files get no policy at all. Warnings
486    /// escape a file name's control characters. A file
487    /// that two entries give loads modules from the narrower root, and one
488    /// in an entry chest left out from itself alone.
489    #[test]
490    fn chest_files_fail_closed() {
491        let file = |path: &str, source: &str, scope: &str| SourceInfo {
492            path: path.into(),
493            source: source.into(),
494            scope: scope.into(),
495            origin: "package".into(),
496            base_dir: None,
497        };
498        let enabled = |info| Resolved {
499            info,
500            enabled: true,
501        };
502        // A cloned project's file name may hold a terminal escape.
503        let spoof = "/p/\x1b[2J.js";
504        let resolved = ResolvedPaths {
505            extensions: vec![
506                enabled(file("/p/a.js", "chest:a", "project")),
507                enabled(file("/p/b.js", "npm:b", "project")),
508                enabled(file(spoof, "chest:a", "project")),
509            ],
510            ..ResolvedPaths::default()
511        };
512        let mut problems = Vec::new();
513        let policies = file_policies(&resolved, &Installed::default(), &mut problems);
514        let (file_a, file_spoof) = (PathBuf::from("/p/a.js"), PathBuf::from(spoof));
515        let files = HashMap::from([
516            (file_a.clone(), Policy::closed(&file_a)),
517            (file_spoof.clone(), Policy::closed(&file_spoof)),
518        ]);
519        assert_eq!(policies.files, files);
520        assert_eq!(problems.len(), 2, "{problems:?}");
521        let warned = policies.also_loaded(&file_spoof, spoof, "cli");
522        let warned = warned
523            .into_iter()
524            .flat_map(|(path, message)| [path, message]);
525        let texts: Vec<String> = problems
526            .iter()
527            .flat_map(|(source, message)| [source.clone(), message.clone()])
528            .chain(warned)
529            .collect();
530        assert!(
531            texts.len() == 6 && texts.iter().all(|text| !text.contains('\x1b')),
532            "{texts:?}"
533        );
534        let rooted = |root: &str| Policy {
535            root: Some(root.into()),
536            ..Policy::unrestricted()
537        };
538        let key = |scope: &str| (scope.to_owned(), "chest:s".to_owned());
539        let installed = Installed {
540            policies: HashMap::from([
541                (key("project"), rooted("/p")),
542                (key("user"), rooted("/p/sub")),
543            ]),
544            closed: vec!["/q".into()],
545            ..Installed::default()
546        };
547        let resolved = ResolvedPaths {
548            extensions: vec![
549                enabled(file("/p/sub/a.js", "chest:s", "project")),
550                enabled(file("/p/sub/b.js", "chest:s", "user")),
551            ],
552            shadowed: vec![
553                file("/p/sub/a.js", "chest:s", "user"),
554                file("/p/sub/b.js", "chest:s", "project"),
555            ],
556            ..ResolvedPaths::default()
557        };
558        let policies = file_policies(&resolved, &installed, &mut problems);
559        let root = |path: &str| policies.of(Path::new(path)).root;
560        for file in ["/p/sub/a.js", "/p/sub/b.js"] {
561            assert_eq!(root(file), Some("/p/sub".into()), "{file}");
562        }
563        assert_eq!(root("/q/a.js"), Some("/q/a.js".into()));
564    }
565}