1use 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
20pub 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
29fn 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
38pub 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#[derive(Clone, Debug, PartialEq)]
53pub struct Policy {
54 pub grants: Grants,
56 pub events: EventPolicy,
59 pub root: Option<PathBuf>,
62}
63
64impl Policy {
65 pub fn unrestricted() -> Policy {
68 Policy {
69 grants: Grants::default(),
70 events: EventPolicy::shared(),
71 root: None,
72 }
73 }
74
75 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 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
107pub type Policies = HashMap<(String, String), Policy>;
112
113#[derive(Clone, Debug, Default)]
116pub struct FilePolicies {
117 pub files: HashMap<PathBuf, Policy>,
120 pub closed: Vec<PathBuf>,
123}
124
125impl FilePolicies {
126 pub fn of(&self, file: &Path) -> Policy {
131 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 pub fn manages(&self, file: &Path) -> bool {
143 self.files.contains_key(file) || self.closes(file)
144 }
145
146 pub fn closes(&self, file: &Path) -> bool {
148 self.closed.iter().any(|root| file.starts_with(root))
149 }
150
151 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
169pub 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#[derive(Clone, Debug, Default)]
209pub struct Installed {
210 pub packages: Vec<PackageInput>,
212 pub skills: Vec<PackageInput>,
215 pub policies: Policies,
217 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#[derive(Clone, Debug, Default)]
237pub struct Loaded {
238 pub installed: Installed,
240 pub problems: Vec<(String, String)>,
242}
243
244impl Loaded {
245 fn warn(&mut self, (path, message): (String, String)) {
249 self.problems.push((escaped(&path), escaped(&message)));
250 }
251}
252
253pub 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 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 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 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 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 (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
386fn 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
411fn 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
424pub 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
437pub 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 #[test]
479 fn startup_load_send() {
480 fn send<T: Send>(_: T) {}
481 send(load(Path::new("."), false, true));
482 }
483
484 #[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 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}