Skip to main content

agent_plugins/
load.rs

1//! The sans-IO loader: a conversation, not a filesystem walk.
2//!
3//! [`Loading`] is a state machine that knows the Agent Plugins specification
4//! and nothing about IO. It asks [`Question`]s — *what kind of entry is
5//! `plugin.json`? read it for me; list `skills/`* — and you feed back
6//! [`Answer`]s from wherever the plugin actually lives: a directory, a
7//! tarball, an in-memory map, an object store, an async runtime. The machine
8//! sequences the spec; the caller owns every byte of IO.
9//!
10//! ```no_run
11//! use agent_plugins::{Answer, Loading, Question};
12//!
13//! let mut loading = Loading::start();
14//! while let Some(question) = loading.question() {
15//!     let answer: Answer = match question {
16//!         Question::Kind(path) => todo!("stat {path}"),
17//!         Question::Confined(path) => todo!("does {path} resolve inside the root?"),
18//!         Question::Read(path) => todo!("read {path}"),
19//!         Question::List(path) => todo!("list {path}"),
20//!     };
21//!     loading.answer(answer)?;
22//! }
23//! let outcome = loading.finish();
24//! # Ok::<(), agent_plugins::AnswerMismatch>(())
25//! ```
26//!
27//! Every failure lands on the narrowest boundary §4.1 and §11.3 allow: a bad
28//! manifest rejects the plugin, a bad `mcp.json` disables one component type,
29//! a bad skill or server entry skips only itself — each recorded as a
30//! [`Diagnostic`](crate::Diagnostic).
31
32use std::collections::VecDeque;
33use std::fmt;
34
35use crate::diag::{Diagnostic, Origin, Rule};
36use crate::manifest::{Manifest, ManifestRejection};
37use crate::mcp::{McpConfig, McpDisabledReason};
38use crate::path::PackagePath;
39use crate::skill::{parse_skill_md, Skill};
40
41/// What kind of entry a package path resolves to.
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub enum FileKind {
44    /// A regular file (or a symlink resolving to one).
45    File,
46    /// A directory (or a symlink resolving to one).
47    Directory,
48    /// Anything else — sockets, devices, dangling links.
49    Other,
50}
51
52/// One entry from a directory listing.
53#[derive(Debug, Clone, PartialEq, Eq)]
54pub struct DirEntry {
55    /// The entry's name within its directory (no separators).
56    pub name: String,
57    /// What the entry resolves to.
58    pub kind: FileKind,
59}
60
61impl DirEntry {
62    /// A directory entry with the given name and kind.
63    pub fn new(name: impl Into<String>, kind: FileKind) -> Self {
64        Self { name: name.into(), kind }
65    }
66}
67
68/// A question the loader needs answered before it can continue.
69///
70/// Deliberately exhaustive: a driver must answer every kind of question, so
71/// a new kind would rightly be a breaking change.
72#[derive(Debug, Clone, PartialEq, Eq)]
73pub enum Question {
74    /// What kind of entry is this path? Follow symlinks; answer
75    /// `Answer::Kind(None)` if it does not exist.
76    Kind(PackagePath),
77    /// Does this path, fully resolved (symlinks and all), stay within the
78    /// filesystem-resolved plugin root? Sources without links — archives,
79    /// memory maps — truthfully answer `true` (§4.1).
80    Confined(PackagePath),
81    /// Read the file's bytes.
82    Read(PackagePath),
83    /// List the directory's immediate entries. Order is up to the source;
84    /// sort for reproducible output.
85    List(PackagePath),
86}
87
88impl Question {
89    /// The package path this question is about.
90    pub fn path(&self) -> &PackagePath {
91        match self {
92            Self::Kind(p) | Self::Confined(p) | Self::Read(p) | Self::List(p) => p,
93        }
94    }
95}
96
97impl fmt::Display for Question {
98    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
99        match self {
100            Self::Kind(p) => write!(f, "kind of {p}"),
101            Self::Confined(p) => write!(f, "confinement of {p}"),
102            Self::Read(p) => write!(f, "contents of {p}"),
103            Self::List(p) => write!(f, "entries of {p}"),
104        }
105    }
106}
107
108/// The caller's reply to the pending [`Question`].
109#[derive(Debug, Clone, PartialEq, Eq)]
110pub enum Answer {
111    /// Reply to [`Question::Kind`]; `None` means the path does not exist.
112    Kind(Option<FileKind>),
113    /// Reply to [`Question::Confined`].
114    Confined(bool),
115    /// Reply to [`Question::Read`].
116    Bytes(Vec<u8>),
117    /// Reply to [`Question::List`].
118    Entries(Vec<DirEntry>),
119    /// Reply to any question the source could not answer. The loader applies
120    /// the narrowest failure boundary the spec allows for that question.
121    Unavailable {
122        /// The source's explanation, carried into diagnostics.
123        detail: String,
124    },
125}
126
127/// The reply did not match the pending question — a driver bug, not a plugin
128/// problem.
129#[derive(Debug, Clone, PartialEq, Eq)]
130pub struct AnswerMismatch {
131    /// What the loader had asked, if anything.
132    pub expected: Option<&'static str>,
133    /// What the reply was.
134    pub got: &'static str,
135}
136
137impl fmt::Display for AnswerMismatch {
138    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
139        match self.expected {
140            Some(expected) => write!(
141                f,
142                "answer {got} does not match the pending question ({expected} expected)",
143                got = self.got
144            ),
145            None => write!(f, "answer {} arrived with no question pending", self.got),
146        }
147    }
148}
149
150impl std::error::Error for AnswerMismatch {}
151
152/// A fully loaded plugin: the §11.1 conformance outcome.
153#[derive(Debug, Clone, PartialEq)]
154#[non_exhaustive]
155pub struct LoadedPlugin {
156    /// The validated manifest.
157    pub manifest: Manifest,
158    /// Valid skills, in discovery order.
159    pub skills: Vec<Skill>,
160    /// The MCP component's fate.
161    pub mcp: McpOutcome,
162    /// Everything non-fatal the loader decided along the way.
163    pub diagnostics: Vec<Diagnostic>,
164}
165
166/// What became of the MCP component type (§6.2, §7.2.2).
167#[derive(Debug, Clone, PartialEq)]
168#[non_exhaustive]
169pub enum McpOutcome {
170    /// No `mcp.json` — expressly not an error (§6.2).
171    Absent,
172    /// MCP is disabled for this plugin; other components loaded on (§7.2.2).
173    Disabled(McpDisabledReason),
174    /// Valid configuration; individually invalid entries were skipped.
175    Configured(McpConfig),
176}
177
178/// The plugin was rejected outright — nothing may be discovered or executed
179/// (§5.2, §5.3, §4.1 rule 1).
180#[derive(Debug, Clone, PartialEq)]
181#[non_exhaustive]
182pub enum Rejection {
183    /// No `plugin.json` at the plugin root (§5.1).
184    ManifestMissing,
185    /// `plugin.json` is not a regular file.
186    ManifestNotAFile,
187    /// `plugin.json` resolves outside the plugin root (§4.1 rule 1).
188    ManifestEscapes,
189    /// The manifest failed validation fatally (§5.2, §5.3, §5.5).
190    Manifest(ManifestRejection),
191    /// The IO source failed while the manifest was still in question.
192    Source {
193        /// The source's explanation.
194        detail: String,
195    },
196    /// [`Loading::finish`] was called before the conversation ended.
197    Incomplete,
198}
199
200impl fmt::Display for Rejection {
201    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
202        match self {
203            Self::ManifestMissing => f.write_str("no plugin.json at the plugin root (§5.1)"),
204            Self::ManifestNotAFile => f.write_str("plugin.json is not a regular file"),
205            Self::ManifestEscapes => {
206                f.write_str("plugin.json resolves outside the plugin root (§4.1)")
207            }
208            Self::Manifest(rejection) => write!(f, "manifest rejected: {rejection}"),
209            Self::Source { detail } => write!(f, "IO source failed: {detail}"),
210            Self::Incomplete => f.write_str("loading finished before the machine was done"),
211        }
212    }
213}
214
215impl std::error::Error for Rejection {}
216
217impl Rejection {
218    /// Stable machine-readable code for this rejection, shared with the
219    /// TypeScript implementation and the conformance fixtures.
220    pub fn code(&self) -> &'static str {
221        match self {
222            Self::ManifestMissing => "manifest-missing",
223            Self::ManifestNotAFile => "manifest-not-a-file",
224            Self::ManifestEscapes => "manifest-escapes",
225            Self::Manifest(ManifestRejection::UnsupportedSchema { .. }) => "unsupported-schema",
226            Self::Manifest(_) => "invalid-manifest",
227            Self::Source { .. } => "source-error",
228            Self::Incomplete => "incomplete",
229        }
230    }
231}
232
233enum State {
234    ManifestKind,
235    ManifestConfined,
236    ManifestRead,
237    SkillsKind,
238    SkillsConfined,
239    SkillsList,
240    SkillKind {
241        current: String,
242    },
243    SkillConfined {
244        current: String,
245    },
246    SkillRead {
247        current: String,
248    },
249    McpKind,
250    McpConfined,
251    McpRead,
252    Done(Box<Result<LoadedPlugin, Rejection>>),
253    /// Transient marker while ownership moves between states.
254    Poisoned,
255}
256
257/// The Agent Plugins loading conversation.
258///
259/// Ask [`question`](Self::question), reply with [`answer`](Self::answer),
260/// repeat until there is no question left, then [`finish`](Self::finish).
261pub struct Loading {
262    state: State,
263    manifest: Option<Manifest>,
264    skills: Vec<Skill>,
265    skill_queue: VecDeque<String>,
266    diagnostics: Vec<Diagnostic>,
267}
268
269impl Loading {
270    /// Begin loading a plugin.
271    pub fn start() -> Self {
272        Self {
273            state: State::ManifestKind,
274            manifest: None,
275            skills: Vec::new(),
276            skill_queue: VecDeque::new(),
277            diagnostics: Vec::new(),
278        }
279    }
280
281    /// The pending question, or `None` when the conversation is over and
282    /// [`finish`](Self::finish) may be called. Idempotent.
283    pub fn question(&self) -> Option<Question> {
284        let path = |s: &str| PackagePath::new(s);
285        Some(match &self.state {
286            State::ManifestKind => Question::Kind(path("plugin.json")),
287            State::ManifestConfined => Question::Confined(path("plugin.json")),
288            State::ManifestRead => Question::Read(path("plugin.json")),
289            State::SkillsKind => Question::Kind(path("skills")),
290            State::SkillsConfined => Question::Confined(path("skills")),
291            State::SkillsList => Question::List(path("skills")),
292            State::SkillKind { current } => Question::Kind(skill_md(current)),
293            State::SkillConfined { current } => Question::Confined(skill_md(current)),
294            State::SkillRead { current } => Question::Read(skill_md(current)),
295            State::McpKind => Question::Kind(path("mcp.json")),
296            State::McpConfined => Question::Confined(path("mcp.json")),
297            State::McpRead => Question::Read(path("mcp.json")),
298            State::Done(_) | State::Poisoned => return None,
299        })
300    }
301
302    /// Answer the pending question and advance.
303    pub fn answer(&mut self, answer: Answer) -> Result<(), AnswerMismatch> {
304        let state = std::mem::replace(&mut self.state, State::Poisoned);
305        let (next, verdict) = self.transition(state, answer);
306        self.state = next;
307        verdict
308    }
309
310    /// Conclude the conversation. Returns [`Rejection::Incomplete`] if
311    /// questions remain unanswered.
312    pub fn finish(self) -> Result<LoadedPlugin, Rejection> {
313        match self.state {
314            State::Done(outcome) => *outcome,
315            _ => Err(Rejection::Incomplete),
316        }
317    }
318
319    fn transition(&mut self, state: State, answer: Answer) -> (State, Result<(), AnswerMismatch>) {
320        use Answer as A;
321        use State as S;
322
323        let mismatch = |expected: &'static str, got: &Answer| AnswerMismatch {
324            expected: Some(expected),
325            got: match got {
326                A::Kind(_) => "Kind",
327                A::Confined(_) => "Confined",
328                A::Bytes(_) => "Bytes",
329                A::Entries(_) => "Entries",
330                A::Unavailable { .. } => "Unavailable",
331            },
332        };
333
334        let next = match state {
335            // ---- plugin.json: every failure here rejects the plugin ----
336            S::ManifestKind => match answer {
337                A::Kind(Some(FileKind::File)) => S::ManifestConfined,
338                A::Kind(None) => S::Done(Box::new(Err(Rejection::ManifestMissing))),
339                A::Kind(Some(_)) => S::Done(Box::new(Err(Rejection::ManifestNotAFile))),
340                A::Unavailable { detail } => S::Done(Box::new(Err(Rejection::Source { detail }))),
341                other => return (S::ManifestKind, Err(mismatch("Kind", &other))),
342            },
343            S::ManifestConfined => match answer {
344                A::Confined(true) => S::ManifestRead,
345                A::Confined(false) => S::Done(Box::new(Err(Rejection::ManifestEscapes))),
346                A::Unavailable { detail } => S::Done(Box::new(Err(Rejection::Source { detail }))),
347                other => return (S::ManifestConfined, Err(mismatch("Confined", &other))),
348            },
349            S::ManifestRead => match answer {
350                A::Bytes(bytes) => match Manifest::parse(&bytes) {
351                    Ok((manifest, mut diags)) => {
352                        self.manifest = Some(manifest);
353                        self.diagnostics.append(&mut diags);
354                        S::SkillsKind
355                    }
356                    Err(rejection) => S::Done(Box::new(Err(Rejection::Manifest(rejection)))),
357                },
358                A::Unavailable { detail } => S::Done(Box::new(Err(Rejection::Source { detail }))),
359                other => return (S::ManifestRead, Err(mismatch("Bytes", &other))),
360            },
361
362            // ---- skills/: failures narrow to the component or one skill ----
363            S::SkillsKind => match answer {
364                A::Kind(Some(FileKind::Directory)) => S::SkillsConfined,
365                A::Kind(None) => S::McpKind, // absent is not an error (§6.2)
366                A::Kind(Some(_)) => {
367                    self.report(
368                        Rule::ComponentInvalid,
369                        Origin::Skills,
370                        "`skills` exists but is not a directory (§6.2)",
371                    );
372                    S::McpKind
373                }
374                A::Unavailable { detail } => {
375                    self.report(Rule::ComponentInvalid, Origin::Skills, detail);
376                    S::McpKind
377                }
378                other => return (S::SkillsKind, Err(mismatch("Kind", &other))),
379            },
380            S::SkillsConfined => match answer {
381                A::Confined(true) => S::SkillsList,
382                A::Confined(false) => {
383                    self.report(
384                        Rule::ComponentInvalid,
385                        Origin::Skills,
386                        "`skills` resolves outside the plugin root (§4.1)",
387                    );
388                    S::McpKind
389                }
390                A::Unavailable { detail } => {
391                    self.report(Rule::ComponentInvalid, Origin::Skills, detail);
392                    S::McpKind
393                }
394                other => return (S::SkillsConfined, Err(mismatch("Confined", &other))),
395            },
396            S::SkillsList => match answer {
397                A::Entries(entries) => {
398                    // §7.1: each *immediate child directory* may hold one
399                    // skill; files and deeper descendants are never searched.
400                    self.skill_queue = entries
401                        .into_iter()
402                        .filter(|e| e.kind == FileKind::Directory)
403                        .map(|e| e.name)
404                        .collect();
405                    self.next_skill()
406                }
407                A::Unavailable { detail } => {
408                    self.report(Rule::ComponentInvalid, Origin::Skills, detail);
409                    S::McpKind
410                }
411                other => return (S::SkillsList, Err(mismatch("Entries", &other))),
412            },
413            S::SkillKind { current } => match answer {
414                A::Kind(Some(FileKind::File)) => S::SkillConfined { current },
415                // No SKILL.md, or not a regular file: simply not a skill (§7.1).
416                A::Kind(_) => self.next_skill(),
417                A::Unavailable { detail } => {
418                    self.skip_skill(&current, detail);
419                    self.next_skill()
420                }
421                other => return (S::SkillKind { current }, Err(mismatch("Kind", &other))),
422            },
423            S::SkillConfined { current } => match answer {
424                A::Confined(true) => S::SkillRead { current },
425                A::Confined(false) => {
426                    self.skip_skill(
427                        &current,
428                        "SKILL.md resolves outside the plugin root (§4.1)".to_owned(),
429                    );
430                    self.next_skill()
431                }
432                A::Unavailable { detail } => {
433                    self.skip_skill(&current, detail);
434                    self.next_skill()
435                }
436                other => return (S::SkillConfined { current }, Err(mismatch("Confined", &other))),
437            },
438            S::SkillRead { current } => match answer {
439                A::Bytes(bytes) => {
440                    match parse_skill_md(&current, &bytes) {
441                        Ok((meta, body)) => self.skills.push(Skill {
442                            directory: current.clone(),
443                            path: PackagePath::new("skills").child(&current),
444                            meta,
445                            body,
446                        }),
447                        Err(why) => self.skip_skill(&current, why.to_string()),
448                    }
449                    self.next_skill()
450                }
451                A::Unavailable { detail } => {
452                    self.skip_skill(&current, detail);
453                    self.next_skill()
454                }
455                other => return (S::SkillRead { current }, Err(mismatch("Bytes", &other))),
456            },
457
458            // ---- mcp.json: failures disable MCP, never the plugin ----
459            S::McpKind => match answer {
460                A::Kind(Some(FileKind::File)) => S::McpConfined,
461                A::Kind(None) => self.done(McpOutcome::Absent),
462                A::Kind(Some(_)) => {
463                    self.report(
464                        Rule::ComponentInvalid,
465                        Origin::Mcp,
466                        "`mcp.json` exists but is not a regular file (§6.2)",
467                    );
468                    self.done(McpOutcome::Disabled(McpDisabledReason::NotAFile))
469                }
470                A::Unavailable { detail } => {
471                    let reason = McpDisabledReason::Unavailable { detail };
472                    self.report(Rule::McpDisabled, Origin::Mcp, reason.to_string());
473                    self.done(McpOutcome::Disabled(reason))
474                }
475                other => return (S::McpKind, Err(mismatch("Kind", &other))),
476            },
477            S::McpConfined => match answer {
478                A::Confined(true) => S::McpRead,
479                A::Confined(false) => {
480                    self.report(
481                        Rule::ComponentInvalid,
482                        Origin::Mcp,
483                        "`mcp.json` resolves outside the plugin root (§4.1)",
484                    );
485                    self.done(McpOutcome::Disabled(McpDisabledReason::LocationEscapes))
486                }
487                A::Unavailable { detail } => {
488                    let reason = McpDisabledReason::Unavailable { detail };
489                    self.report(Rule::McpDisabled, Origin::Mcp, reason.to_string());
490                    self.done(McpOutcome::Disabled(reason))
491                }
492                other => return (S::McpConfined, Err(mismatch("Confined", &other))),
493            },
494            S::McpRead => match answer {
495                A::Bytes(bytes) => {
496                    let spec =
497                        self.manifest.as_ref().expect("manifest parsed before MCP phase").spec;
498                    match McpConfig::parse(&bytes, spec) {
499                        Ok((config, mut diags)) => {
500                            self.diagnostics.append(&mut diags);
501                            self.done(McpOutcome::Configured(config))
502                        }
503                        Err(reason) => {
504                            self.report(Rule::McpDisabled, Origin::Mcp, reason.to_string());
505                            self.done(McpOutcome::Disabled(reason))
506                        }
507                    }
508                }
509                A::Unavailable { detail } => {
510                    let reason = McpDisabledReason::Unavailable { detail };
511                    self.report(Rule::McpDisabled, Origin::Mcp, reason.to_string());
512                    self.done(McpOutcome::Disabled(reason))
513                }
514                other => return (S::McpRead, Err(mismatch("Bytes", &other))),
515            },
516
517            S::Done(outcome) => {
518                let got = mismatch("nothing", &answer);
519                return (S::Done(outcome), Err(AnswerMismatch { expected: None, got: got.got }));
520            }
521            S::Poisoned => unreachable!("Loading state invariant broken"),
522        };
523        (next, Ok(()))
524    }
525
526    fn next_skill(&mut self) -> State {
527        match self.skill_queue.pop_front() {
528            Some(current) => State::SkillKind { current },
529            None => State::McpKind,
530        }
531    }
532
533    fn report(&mut self, rule: Rule, origin: Origin, message: impl Into<String>) {
534        self.diagnostics.push(Diagnostic::new(rule, origin, message));
535    }
536
537    fn skip_skill(&mut self, directory: &str, why: String) {
538        self.report(
539            Rule::SkillSkipped,
540            Origin::Skill(directory.to_owned()),
541            format!("skill skipped (§7.1): {why}"),
542        );
543    }
544
545    fn done(&mut self, mcp: McpOutcome) -> State {
546        State::Done(Box::new(Ok(LoadedPlugin {
547            manifest: self.manifest.take().expect("manifest parsed before completion"),
548            skills: std::mem::take(&mut self.skills),
549            mcp,
550            diagnostics: std::mem::take(&mut self.diagnostics),
551        })))
552    }
553}
554
555fn skill_md(directory: &str) -> PackagePath {
556    PackagePath::new("skills").child(directory).child("SKILL.md")
557}
558
559impl fmt::Debug for Loading {
560    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
561        f.debug_struct("Loading")
562            .field("pending", &self.question())
563            .field("skills_loaded", &self.skills.len())
564            .field("diagnostics", &self.diagnostics.len())
565            .finish_non_exhaustive()
566    }
567}