Skip to main content

supercode_harness/
agent_package.rs

1//! Repository-native agent packages.
2//!
3//! An agent package is deliberately data, not executable plugin code:
4//! `.supercode/package.toml` describes an `agent/` folder, capability
5//! requirements, workspace storage, and frontend-neutral JSON contributions.
6//! This lets a repository deepen its integration with any Supercode frontend
7//! without shipping another agent loop, CLI, MCP server, or UI bundle.
8
9use std::fs;
10use std::path::{Component, Path, PathBuf};
11
12use serde::{Deserialize, Serialize};
13use serde_json::Value;
14
15use crate::error::{Error, Result};
16
17/// Repository-relative location of an agent package manifest.
18pub const AGENT_PACKAGE_MANIFEST: &str = ".supercode/package.toml";
19/// Current manifest schema version.
20pub const AGENT_PACKAGE_SCHEMA_VERSION: u32 = 1;
21/// Stable contribution envelope understood by Supercode frontends.
22pub const CONTRIBUTION_SCHEMA: &str = "supercode/contribution-v1";
23/// Stable envelope for one resolved contribution resource.
24pub const RESOURCE_SCHEMA: &str = "supercode/package-resource-v1";
25
26const MAX_MANIFEST_BYTES: u64 = 64 * 1024;
27const MAX_CONTRIBUTION_BYTES: u64 = 256 * 1024;
28const MAX_CONTRIBUTIONS: usize = 128;
29const MAX_RESOURCE_BYTES: u64 = 512 * 1024;
30
31/// A fully resolved, validated repository agent package.
32#[derive(Debug, Clone, PartialEq, Serialize)]
33#[serde(rename_all = "camelCase")]
34pub struct AgentPackage {
35    /// Manifest schema version.
36    pub schema_version: u32,
37    /// Stable package identifier.
38    pub id: String,
39    /// Human-readable package name.
40    pub name: String,
41    /// Descriptive package version.
42    pub version: String,
43    /// Optional package description.
44    #[serde(skip_serializing_if = "Option::is_none")]
45    pub description: Option<String>,
46    /// Canonical workspace that owns this package.
47    pub workspace: PathBuf,
48    /// Canonical `.supercode` package root.
49    pub package_root: PathBuf,
50    /// Canonical manifest path.
51    pub manifest_path: PathBuf,
52    /// Resolved portable agent folder.
53    pub agent: AgentFolder,
54    /// Capabilities the package requires or can optionally use.
55    pub capabilities: AgentPackageCapabilities,
56    /// Workspace-owned durable data location. Supercode does not create it.
57    pub storage: AgentPackageStorage,
58    /// Declarative frontend contributions, sorted by source filename.
59    pub contributions: Vec<AgentContribution>,
60}
61
62/// The resolved instruction and skill roots of an agent package.
63#[derive(Debug, Clone, PartialEq, Serialize)]
64#[serde(rename_all = "camelCase")]
65pub struct AgentFolder {
66    /// Canonical root of the agent folder.
67    pub root: PathBuf,
68    /// Canonical instruction files, in manifest order.
69    pub instructions: Vec<PathBuf>,
70    /// Canonical skills directory when one exists.
71    #[serde(skip_serializing_if = "Option::is_none")]
72    pub skills_root: Option<PathBuf>,
73}
74
75/// Capability requirements declared by an agent package.
76#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
77#[serde(rename_all = "camelCase")]
78pub struct AgentPackageCapabilities {
79    /// Capabilities without which the package cannot perform its core role.
80    pub required: Vec<String>,
81    /// Capabilities that enhance the package when a host provides them.
82    pub optional: Vec<String>,
83}
84
85/// Durable workspace storage requested by an agent package.
86#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
87#[serde(rename_all = "camelCase")]
88pub struct AgentPackageStorage {
89    /// Workspace-relative storage path as declared by the package.
90    pub relative_path: PathBuf,
91    /// Resolved path. It may not exist yet and is never created while loading.
92    pub path: PathBuf,
93}
94
95/// A portable, inert contribution a frontend may choose to render.
96///
97/// Unknown `kind` values remain available to newer frontends and are ignored
98/// by older ones. `placement` and `data` are declarative JSON; loading a
99/// contribution never evaluates code or imports a module.
100#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
101#[serde(rename_all = "camelCase")]
102pub struct AgentContribution {
103    /// Must be [`CONTRIBUTION_SCHEMA`].
104    pub schema: String,
105    /// Stable package-scoped contribution id.
106    pub id: String,
107    /// Host-neutral kind such as `panel`, `tool-result`, `action`, or `status`.
108    pub kind: String,
109    /// Optional display title.
110    #[serde(default, skip_serializing_if = "Option::is_none")]
111    pub title: Option<String>,
112    /// Frontend-neutral placement hints.
113    #[serde(default, skip_serializing_if = "Option::is_none")]
114    pub placement: Option<Value>,
115    /// Kind-specific inert data interpreted by the rendering host.
116    #[serde(default)]
117    pub data: Value,
118    /// Canonical JSON source file, supplied by the loader.
119    #[serde(default, skip_deserializing)]
120    pub source: PathBuf,
121}
122
123/// Browser-safe content loaded for a contribution's declared storage resource.
124///
125/// The resolved filesystem path is deliberately absent. Consumers address the
126/// content by contribution id and receive only bounded JSON or UTF-8 text.
127#[derive(Debug, Clone, PartialEq, Serialize)]
128#[serde(rename_all = "camelCase")]
129pub struct AgentPackageResource {
130    /// Must be [`RESOURCE_SCHEMA`].
131    pub schema: String,
132    /// Contribution that declared this resource.
133    pub contribution_id: String,
134    /// `json` or `text`.
135    pub format: String,
136    /// Best-effort media type for presentation.
137    pub media_type: String,
138    /// Parsed JSON or a JSON string containing bounded UTF-8 text.
139    pub data: Value,
140}
141
142#[derive(Debug, Deserialize)]
143struct RawManifest {
144    schema_version: u32,
145    id: String,
146    name: Option<String>,
147    #[serde(default)]
148    version: String,
149    description: Option<String>,
150    agent: RawAgentFolder,
151    #[serde(default)]
152    capabilities: RawCapabilities,
153    storage: RawStorage,
154    #[serde(default)]
155    contributions: RawContributions,
156}
157
158#[derive(Debug, Deserialize)]
159struct RawAgentFolder {
160    #[serde(default = "default_agent_root")]
161    root: PathBuf,
162    #[serde(default = "default_instructions")]
163    instructions: Vec<PathBuf>,
164    #[serde(default = "default_skills_root")]
165    skills: PathBuf,
166}
167
168#[derive(Debug, Default, Deserialize)]
169struct RawCapabilities {
170    #[serde(default)]
171    required: Vec<String>,
172    #[serde(default)]
173    optional: Vec<String>,
174}
175
176#[derive(Debug, Deserialize)]
177struct RawStorage {
178    path: PathBuf,
179}
180
181#[derive(Debug, Deserialize)]
182struct RawContributions {
183    #[serde(default = "default_contributions_root")]
184    root: PathBuf,
185}
186
187impl Default for RawContributions {
188    fn default() -> Self {
189        Self {
190            root: default_contributions_root(),
191        }
192    }
193}
194
195fn default_agent_root() -> PathBuf {
196    PathBuf::from("agent")
197}
198
199fn default_instructions() -> Vec<PathBuf> {
200    vec![PathBuf::from("AGENTS.md")]
201}
202
203fn default_skills_root() -> PathBuf {
204    PathBuf::from("skills")
205}
206
207fn default_contributions_root() -> PathBuf {
208    PathBuf::from("contributions")
209}
210
211fn package_error(message: impl Into<String>) -> Error {
212    Error::tool("agent_package", message)
213}
214
215fn validate_id(value: &str, field: &str) -> Result<()> {
216    let valid = !value.is_empty()
217        && value.len() <= 128
218        && value
219            .bytes()
220            .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'-' | b'_'));
221    if valid {
222        Ok(())
223    } else {
224        Err(package_error(format!(
225            "{field} must contain only ASCII letters, digits, `.`, `-`, or `_`"
226        )))
227    }
228}
229
230fn validate_capabilities(values: Vec<String>, field: &str) -> Result<Vec<String>> {
231    let mut out = Vec::with_capacity(values.len());
232    for value in values {
233        validate_id(&value, field)?;
234        if !out.contains(&value) {
235            out.push(value);
236        }
237    }
238    Ok(out)
239}
240
241fn safe_relative(path: &Path, field: &str) -> Result<()> {
242    if path.as_os_str().is_empty()
243        || path.is_absolute()
244        || path
245            .components()
246            .any(|component| !matches!(component, Component::Normal(_)))
247    {
248        return Err(package_error(format!(
249            "{field} must be a non-empty relative path without `.` or `..`: {}",
250            path.display()
251        )));
252    }
253    Ok(())
254}
255
256fn read_bounded(path: &Path, max_bytes: u64, label: &str) -> Result<String> {
257    let metadata = fs::metadata(path)
258        .map_err(|error| package_error(format!("reading {label} {}: {error}", path.display())))?;
259    if !metadata.is_file() {
260        return Err(package_error(format!(
261            "{label} is not a regular file: {}",
262            path.display()
263        )));
264    }
265    if metadata.len() > max_bytes {
266        return Err(package_error(format!(
267            "{label} exceeds the {max_bytes}-byte limit: {}",
268            path.display()
269        )));
270    }
271    fs::read_to_string(path)
272        .map_err(|error| package_error(format!("reading {label} {}: {error}", path.display())))
273}
274
275fn contained_existing(root: &Path, relative: &Path, field: &str) -> Result<PathBuf> {
276    safe_relative(relative, field)?;
277    let candidate = root.join(relative);
278    let canonical = fs::canonicalize(&candidate).map_err(|error| {
279        package_error(format!(
280            "resolving {field} {}: {error}",
281            candidate.display()
282        ))
283    })?;
284    if !canonical.starts_with(root) {
285        return Err(package_error(format!(
286            "{field} resolves outside the package root: {}",
287            candidate.display()
288        )));
289    }
290    Ok(canonical)
291}
292
293/// Load the agent package rooted at `workspace`.
294///
295/// `Ok(None)` means this is an ordinary repository with no
296/// `.supercode/package.toml`. Loading is read-only and never executes package
297/// content or creates its requested storage directory.
298pub fn load_workspace_agent_package(workspace: &Path) -> Result<Option<AgentPackage>> {
299    let workspace = fs::canonicalize(workspace).map_err(|error| {
300        package_error(format!(
301            "resolving workspace {}: {error}",
302            workspace.display()
303        ))
304    })?;
305    let manifest_path = workspace.join(AGENT_PACKAGE_MANIFEST);
306    if !manifest_path.exists() {
307        return Ok(None);
308    }
309    let manifest_path = fs::canonicalize(&manifest_path).map_err(|error| {
310        package_error(format!("resolving {}: {error}", manifest_path.display()))
311    })?;
312    let package_root = manifest_path
313        .parent()
314        .expect("the manifest always has a .supercode parent")
315        .to_path_buf();
316    if !package_root.starts_with(&workspace) {
317        return Err(package_error(
318            "agent package manifest resolves outside workspace",
319        ));
320    }
321
322    let text = read_bounded(&manifest_path, MAX_MANIFEST_BYTES, "agent package manifest")?;
323    let raw: RawManifest = toml::from_str(&text)
324        .map_err(|error| package_error(format!("parsing {}: {error}", manifest_path.display())))?;
325    if raw.schema_version != AGENT_PACKAGE_SCHEMA_VERSION {
326        return Err(package_error(format!(
327            "unsupported agent package schema_version {}; expected {}",
328            raw.schema_version, AGENT_PACKAGE_SCHEMA_VERSION
329        )));
330    }
331    validate_id(&raw.id, "package id")?;
332    let name = raw.name.unwrap_or_else(|| raw.id.clone());
333    if name.trim().is_empty() {
334        return Err(package_error("package name cannot be empty"));
335    }
336
337    let agent_root = contained_existing(&package_root, &raw.agent.root, "agent.root")?;
338    if !agent_root.is_dir() {
339        return Err(package_error(format!(
340            "agent.root is not a directory: {}",
341            agent_root.display()
342        )));
343    }
344    let mut instructions = Vec::with_capacity(raw.agent.instructions.len());
345    for relative in raw.agent.instructions {
346        let path = contained_existing(&agent_root, &relative, "agent.instructions")?;
347        if !path.is_file() {
348            return Err(package_error(format!(
349                "agent instruction is not a file: {}",
350                path.display()
351            )));
352        }
353        instructions.push(path);
354    }
355    let skills_candidate = agent_root.join(&raw.agent.skills);
356    safe_relative(&raw.agent.skills, "agent.skills")?;
357    let skills_root = if skills_candidate.exists() {
358        let path = contained_existing(&agent_root, &raw.agent.skills, "agent.skills")?;
359        if !path.is_dir() {
360            return Err(package_error(format!(
361                "agent.skills is not a directory: {}",
362                path.display()
363            )));
364        }
365        Some(path)
366    } else {
367        None
368    };
369
370    safe_relative(&raw.storage.path, "storage.path")?;
371    let storage_path = workspace.join(&raw.storage.path);
372    if storage_path.exists() {
373        let canonical_storage = fs::canonicalize(&storage_path).map_err(|error| {
374            package_error(format!(
375                "resolving storage.path {}: {error}",
376                storage_path.display()
377            ))
378        })?;
379        if !canonical_storage.starts_with(&workspace) {
380            return Err(package_error(format!(
381                "storage.path resolves outside the workspace: {}",
382                storage_path.display()
383            )));
384        }
385        if !canonical_storage.is_dir() {
386            return Err(package_error(format!(
387                "storage.path is not a directory: {}",
388                storage_path.display()
389            )));
390        }
391    }
392    let storage = AgentPackageStorage {
393        path: storage_path,
394        relative_path: raw.storage.path,
395    };
396
397    let contributions_root = package_root.join(&raw.contributions.root);
398    safe_relative(&raw.contributions.root, "contributions.root")?;
399    let contributions = if contributions_root.exists() {
400        let contributions_root =
401            contained_existing(&package_root, &raw.contributions.root, "contributions.root")?;
402        if !contributions_root.is_dir() {
403            return Err(package_error(format!(
404                "contributions.root is not a directory: {}",
405                contributions_root.display()
406            )));
407        }
408        load_contributions(&contributions_root, &raw.id)?
409    } else {
410        Vec::new()
411    };
412
413    Ok(Some(AgentPackage {
414        schema_version: raw.schema_version,
415        id: raw.id,
416        name,
417        version: if raw.version.trim().is_empty() {
418            "0.0.0".to_string()
419        } else {
420            raw.version
421        },
422        description: raw.description.filter(|value| !value.trim().is_empty()),
423        workspace,
424        package_root,
425        manifest_path,
426        agent: AgentFolder {
427            root: agent_root,
428            instructions,
429            skills_root,
430        },
431        capabilities: AgentPackageCapabilities {
432            required: validate_capabilities(raw.capabilities.required, "required capability")?,
433            optional: validate_capabilities(raw.capabilities.optional, "optional capability")?,
434        },
435        storage,
436        contributions,
437    }))
438}
439
440fn load_contributions(root: &Path, package_id: &str) -> Result<Vec<AgentContribution>> {
441    let mut files = fs::read_dir(root)
442        .map_err(|error| package_error(format!("reading {}: {error}", root.display())))?
443        .filter_map(std::result::Result::ok)
444        .map(|entry| entry.path())
445        .filter(|path| path.extension().and_then(|value| value.to_str()) == Some("json"))
446        .collect::<Vec<_>>();
447    files.sort();
448    if files.len() > MAX_CONTRIBUTIONS {
449        return Err(package_error(format!(
450            "contributions.root contains {} JSON files; limit is {MAX_CONTRIBUTIONS}",
451            files.len()
452        )));
453    }
454
455    let mut contributions = Vec::with_capacity(files.len());
456    let mut ids = std::collections::HashSet::new();
457    for path in files {
458        let canonical = fs::canonicalize(&path).map_err(|error| {
459            package_error(format!(
460                "resolving contribution {}: {error}",
461                path.display()
462            ))
463        })?;
464        if !canonical.starts_with(root) {
465            return Err(package_error(format!(
466                "contribution resolves outside contributions.root: {}",
467                path.display()
468            )));
469        }
470        let text = read_bounded(&canonical, MAX_CONTRIBUTION_BYTES, "contribution")?;
471        let mut contribution: AgentContribution = serde_json::from_str(&text).map_err(|error| {
472            package_error(format!(
473                "parsing contribution {}: {error}",
474                canonical.display()
475            ))
476        })?;
477        if contribution.schema != CONTRIBUTION_SCHEMA {
478            return Err(package_error(format!(
479                "contribution {} has unsupported schema `{}`",
480                canonical.display(),
481                contribution.schema
482            )));
483        }
484        validate_id(&contribution.id, "contribution id")?;
485        if contribution.id != package_id && !contribution.id.starts_with(&format!("{package_id}."))
486        {
487            return Err(package_error(format!(
488                "contribution id `{}` must be `{package_id}` or start with `{package_id}.`",
489                contribution.id
490            )));
491        }
492        validate_id(&contribution.kind, "contribution kind")?;
493        contribution_resource_relative(&contribution)?;
494        if !ids.insert(contribution.id.clone()) {
495            return Err(package_error(format!(
496                "duplicate contribution id `{}`",
497                contribution.id
498            )));
499        }
500        contribution.source = canonical;
501        contributions.push(contribution);
502    }
503    Ok(contributions)
504}
505
506fn contribution_resource_relative(contribution: &AgentContribution) -> Result<Option<PathBuf>> {
507    let Some(resource) = contribution.data.get("resource") else {
508        return Ok(None);
509    };
510    let resource = resource.as_str().ok_or_else(|| {
511        package_error(format!(
512            "contribution `{}` data.resource must be a string",
513            contribution.id
514        ))
515    })?;
516    let relative = PathBuf::from(resource);
517    safe_relative(
518        &relative,
519        &format!("contribution `{}` data.resource", contribution.id),
520    )?;
521    Ok(Some(relative))
522}
523
524/// Contribution ids that declare a storage-scoped resource, in source order.
525pub fn declared_resource_contribution_ids(package: &AgentPackage) -> Vec<&str> {
526    package
527        .contributions
528        .iter()
529        .filter(|contribution| contribution.data.get("resource").is_some())
530        .map(|contribution| contribution.id.as_str())
531        .collect()
532}
533
534/// Read one contribution's declared resource from inside package storage.
535///
536/// `Ok(None)` means either the contribution has no resource declaration or
537/// the agent has not created the declared file yet. Existing resources must be
538/// regular bounded UTF-8 files whose canonical path remains under the package
539/// storage directory. This function is read-only and never creates storage.
540pub fn read_contribution_resource(
541    package: &AgentPackage,
542    contribution_id: &str,
543) -> Result<Option<AgentPackageResource>> {
544    let contribution = package
545        .contributions
546        .iter()
547        .find(|contribution| contribution.id == contribution_id)
548        .ok_or_else(|| package_error(format!("unknown contribution id `{contribution_id}`")))?;
549    let Some(relative) = contribution_resource_relative(contribution)? else {
550        return Ok(None);
551    };
552    if !package.storage.path.exists() {
553        return Ok(None);
554    }
555    let storage = fs::canonicalize(&package.storage.path).map_err(|error| {
556        package_error(format!(
557            "resolving storage.path {}: {error}",
558            package.storage.path.display()
559        ))
560    })?;
561    if !storage.starts_with(&package.workspace) || !storage.is_dir() {
562        return Err(package_error("package storage is unavailable"));
563    }
564    let candidate = storage.join(&relative);
565    if !candidate.exists() {
566        return Ok(None);
567    }
568    let resource = fs::canonicalize(&candidate).map_err(|error| {
569        package_error(format!(
570            "resolving contribution resource {}: {error}",
571            candidate.display()
572        ))
573    })?;
574    if !resource.starts_with(&storage) {
575        return Err(package_error(format!(
576            "contribution `{contribution_id}` resource resolves outside package storage"
577        )));
578    }
579    let text = read_bounded(&resource, MAX_RESOURCE_BYTES, "contribution resource")?;
580    let extension = resource.extension().and_then(|value| value.to_str());
581    let (format, media_type, data) = if extension == Some("json") {
582        let data = serde_json::from_str(&text).map_err(|error| {
583            package_error(format!(
584                "parsing contribution `{contribution_id}` JSON resource: {error}"
585            ))
586        })?;
587        ("json", "application/json", data)
588    } else {
589        let media_type = match extension {
590            Some("md" | "markdown") => "text/markdown",
591            Some("yaml" | "yml") => "application/yaml",
592            _ => "text/plain",
593        };
594        ("text", media_type, Value::String(text))
595    };
596    Ok(Some(AgentPackageResource {
597        schema: RESOURCE_SCHEMA.to_string(),
598        contribution_id: contribution_id.to_string(),
599        format: format.to_string(),
600        media_type: media_type.to_string(),
601        data,
602    }))
603}
604
605/// Resolve package instruction files for prompt assembly. Invalid packages
606/// fail closed by contributing nothing; callers that need diagnostics should
607/// use [`load_workspace_agent_package`] directly.
608pub(crate) fn workspace_package_instruction_files(workspace: &Path) -> Vec<PathBuf> {
609    match load_workspace_agent_package(workspace) {
610        Ok(Some(package)) => package.agent.instructions,
611        Ok(None) => Vec::new(),
612        Err(error) => {
613            eprintln!(
614                "warning: ignoring invalid Supercode agent package in {}: {error}",
615                workspace.display()
616            );
617            Vec::new()
618        }
619    }
620}