use crate::packages::{is_sha256_hex, sha256_matches};
use anyhow::Result;
fn describe_digest(digest: &str) -> String {
match digest.len() {
0 => "no digest".to_string(),
32 => "a 32-character digest, which is MD5 (MD5 is not accepted)".to_string(),
n => format!("a {n}-character digest"),
}
}
const MAX_NAME_LEN: usize = 128;
pub fn validate_package_name(name: &str, what: &str) -> Result<()> {
let ok = !name.is_empty()
&& name.len() <= MAX_NAME_LEN
&& !name.starts_with(['.', '-'])
&& name
.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.'));
let stem = name.split('.').next().unwrap_or("").to_ascii_uppercase();
let device = matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL")
|| (stem.len() == 4
&& (stem.starts_with("COM") || stem.starts_with("LPT"))
&& stem.as_bytes()[3].is_ascii_digit());
if ok && !device {
return Ok(());
}
Err(registry_coded(
crate::safety::ErrorCode::BadArg,
format!(
"marketplace: {what} {name:?} is not one safe name: use ASCII letters, digits, '-', '_' and '.', not starting with '.' or '-', at most {MAX_NAME_LEN} characters, and not a device name"
),
"pass the agent's name or version as the registry lists it; a '/', '\\' or ':' is refused",
))
}
fn registry_coded(code: crate::safety::ErrorCode, message: String, hint: &str) -> anyhow::Error {
anyhow::Error::new(crate::safety::SafetyError {
code,
message,
builtin: "marketplace".to_string(),
hint: hint.to_string(),
approval: None,
did_you_mean: Vec::new(),
expected: String::new(),
got: String::new(),
extra: serde_json::Map::new(),
})
}
fn registry_client(total_secs: u64) -> Result<reqwest::Client> {
crate::tls::install_provider();
reqwest::Client::builder()
.connect_timeout(std::time::Duration::from_secs(10))
.timeout(std::time::Duration::from_secs(total_secs))
.redirect(reqwest::redirect::Policy::none())
.build()
.map_err(|e| {
registry_coded(
crate::safety::ErrorCode::Io,
format!("registry HTTP client could not be built: {}", e),
"the local HTTP stack could not start; a TLS or network configuration problem on this host",
)
})
}
async fn registry_get(client: &reqwest::Client, url: &str) -> Result<reqwest::Response> {
let mut hop = url.to_string();
for _ in 0..=crate::builtins::WEB_MAX_REDIRS {
let response = client.get(&hop).send().await.map_err(|e| {
registry_coded(
crate::safety::ErrorCode::Network,
format!("could not reach the registry at {hop}: {e}"),
"the registry is down or the network is unavailable; nothing was changed, so retry later",
)
})?;
let location = response
.headers()
.get(reqwest::header::LOCATION)
.and_then(|v| v.to_str().ok());
match crate::security::judged_redirect(
"marketplace",
&hop,
response.status().as_u16(),
location,
)? {
Some(next) => hop = next.url,
None => return Ok(response),
}
}
Err(crate::safety::budget_exceeded(
"marketplace",
format!("more than {} redirects", crate::builtins::WEB_MAX_REDIRS),
"request the final URL directly",
))
}
fn registry_status_error(
status: reqwest::StatusCode,
missing: String,
hint: &str,
) -> anyhow::Error {
if status == reqwest::StatusCode::NOT_FOUND {
return registry_coded(crate::safety::ErrorCode::NotFound, missing, hint);
}
registry_coded(
crate::safety::ErrorCode::Network,
format!("the registry answered HTTP {}", status.as_u16()),
"the registry is unavailable right now; nothing was changed, so retry later",
)
}
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentManifest {
pub name: String,
pub display_name: String,
pub description: String,
pub version: String,
pub author: AuthorInfo,
pub license: String,
pub repository: Option<String>,
pub homepage: Option<String>,
pub keywords: Vec<String>,
pub categories: Vec<String>,
pub capabilities: Vec<String>,
pub ironshell_version: String,
pub dependencies: Vec<AgentDependency>,
pub agent: AgentDefinition,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AuthorInfo {
pub name: String,
pub email: Option<String>,
pub url: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentDependency {
pub name: String,
pub version: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentDefinition {
pub system_prompt: String,
pub model: Option<String>,
pub tools: Vec<String>,
pub tool_choice: Option<String>,
pub temperature: Option<f32>,
pub max_tokens: Option<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PublishedAgent {
pub manifest: AgentManifest,
pub downloads: u64,
pub stars: u64,
pub forks: u64,
pub verified: bool,
pub published_at: u64,
pub updated_at: u64,
pub checksum: String,
pub size: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SearchQuery {
pub query: Option<String>,
pub category: Option<String>,
pub keyword: Option<String>,
pub author: Option<String>,
pub sort_by: Option<SortBy>,
pub page: Option<u32>,
pub per_page: Option<u32>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SortBy {
Downloads,
Stars,
Recent,
Name,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SearchResults {
pub total: u64,
pub page: u32,
pub per_page: u32,
pub results: Vec<PublishedAgent>,
}
pub struct RegistryClient {
registry_url: String,
cache_dir: PathBuf,
installed: HashMap<String, InstalledAgent>,
}
impl std::fmt::Debug for RegistryClient {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("RegistryClient")
.field("registry_url", &self.registry_url)
.field("cache_dir", &self.cache_dir)
.field("installed_count", &self.installed.len())
.finish()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InstalledAgent {
pub manifest: AgentManifest,
pub installed_at: u64,
pub path: PathBuf,
}
impl RegistryClient {
pub fn new(registry_url: Option<String>) -> Result<Self> {
let cache_dir = dirs::cache_dir()
.ok_or_else(|| {
registry_coded(
crate::safety::ErrorCode::Io,
"Could not determine cache directory".to_string(),
"this host reports no cache directory; set HOME (or XDG_CACHE_HOME on Linux) and retry",
)
})?
.join("ironshell")
.join("agents");
Self::with_cache_dir(registry_url, cache_dir)
}
pub fn with_cache_dir(registry_url: Option<String>, cache_dir: PathBuf) -> Result<Self> {
let registry_url =
registry_url.unwrap_or_else(|| "https://registry.ironshell.dev".to_string());
std::fs::create_dir_all(&cache_dir)?;
let installed = Self::load_installed(&cache_dir)?;
Ok(Self {
registry_url,
cache_dir,
installed,
})
}
pub async fn search(&self, query: SearchQuery) -> Result<SearchResults> {
let client = registry_client(30)?;
let mut url = format!("{}/api/v1/agents/search", self.registry_url);
let mut params = vec![];
if let Some(q) = &query.query {
params.push(format!("q={}", urlencoding::encode(q)));
}
if let Some(cat) = &query.category {
params.push(format!("category={}", urlencoding::encode(cat)));
}
if let Some(kw) = &query.keyword {
params.push(format!("keyword={}", urlencoding::encode(kw)));
}
if let Some(author) = &query.author {
params.push(format!("author={}", urlencoding::encode(author)));
}
if let Some(sort) = &query.sort_by {
params.push(format!("sort={}", serde_json::to_string(sort)?));
}
if let Some(page) = query.page {
params.push(format!("page={}", page));
}
if let Some(per_page) = query.per_page {
params.push(format!("per_page={}", per_page));
}
if !params.is_empty() {
url = format!("{}?{}", url, params.join("&"));
}
let response = registry_get(&client, &url).await?;
let results: SearchResults =
crate::http_body::body_json_capped(response, crate::http_body::AI_BODY_MAX).await?;
Ok(results)
}
pub async fn get(&self, name: &str, version: Option<&str>) -> Result<PublishedAgent> {
validate_package_name(name, "agent name")?;
if let Some(v) = version {
validate_package_name(v, "agent version")?;
}
let client = registry_client(30)?;
let version_suffix = version
.map(|v| format!("/{}", urlencoding::encode(v)))
.unwrap_or_default();
let url = format!(
"{}/api/v1/agents/{}{}",
self.registry_url,
urlencoding::encode(name),
version_suffix
);
let response = registry_get(&client, &url).await?;
if !response.status().is_success() {
return Err(registry_status_error(
response.status(),
format!("Agent not found: {}", name),
"list the registry's agents with marketplace search, then use one of their names",
));
}
let agent: PublishedAgent =
crate::http_body::body_json_capped(response, crate::http_body::AI_BODY_MAX).await?;
Ok(agent)
}
pub fn install<'a>(
&'a mut self,
name: &'a str,
version: Option<&'a str>,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<InstalledAgent>> + Send + 'a>>
{
Box::pin(async move {
validate_package_name(name, "agent name")?;
if let Some(v) = version {
validate_package_name(v, "agent version")?;
}
if let Some(installed) = self.installed.get(name) {
if version.is_none() || version == Some(&installed.manifest.version) {
return Ok(installed.clone());
}
}
let agent = self.get(name, version).await?;
validate_package_name(&agent.manifest.version, "agent version from the registry")?;
for dep in &agent.manifest.dependencies {
self.install(&dep.name, Some(&dep.version)).await?;
}
let client = registry_client(120)?;
let url = format!(
"{}/api/v1/agents/{}/{}/download",
self.registry_url,
urlencoding::encode(name),
urlencoding::encode(&agent.manifest.version)
);
let response = registry_get(&client, &url).await?;
if !response.status().is_success() {
return Err(registry_status_error(
response.status(),
format!("Agent not found: {}", name),
"list the registry's agents with marketplace search, then use one of their names",
));
}
let max =
usize::try_from(crate::security::max_file_size_bytes()?).unwrap_or(usize::MAX);
let bytes = crate::http_body::body_bytes_capped(response, max).await?;
if !is_sha256_hex(&agent.checksum) {
return Err(registry_coded(
crate::safety::ErrorCode::Io,
format!(
"Agent {} has {}; only a SHA-256 digest (64 hex digits) is accepted, so it was not installed",
name,
describe_digest(&agent.checksum)
),
"the registry must publish a SHA-256 digest for this agent; nothing was written, so retry after the registry fixes it",
));
}
if !sha256_matches(&bytes, &agent.checksum)? {
return Err(registry_coded(
crate::safety::ErrorCode::Io,
format!("Checksum mismatch for agent {}", name),
"the downloaded package does not match its published digest; retry the install, and report the agent if it keeps failing",
));
}
let agent_dir = self.cache_dir.join(name).join(&agent.manifest.version);
std::fs::create_dir_all(&agent_dir)?;
let manifest_path = agent_dir.join("manifest.json");
std::fs::write(
&manifest_path,
serde_json::to_string_pretty(&agent.manifest)?,
)?;
let agent_path = agent_dir.join("agent.ish");
std::fs::write(&agent_path, &bytes)?;
let installed = InstalledAgent {
manifest: agent.manifest.clone(),
installed_at: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)?
.as_secs(),
path: agent_dir.clone(),
};
self.installed.insert(name.to_string(), installed.clone());
self.save_installed()?;
Ok(installed)
})
}
pub fn uninstall(&mut self, name: &str) -> Result<()> {
validate_package_name(name, "agent name")?;
let Some(installed) = self.installed.get(name) else {
return Err(registry_coded(
crate::safety::ErrorCode::NotFound,
format!("Agent not installed: {}", name),
"list installed agents with marketplace list; nothing was removed",
));
};
if let Some(dir) = self.cache_contained(&installed.path)? {
std::fs::remove_dir_all(dir)?;
}
self.installed.remove(name);
self.save_installed()?;
Ok(())
}
fn cache_contained(&self, path: &std::path::Path) -> Result<Option<PathBuf>> {
let cache = std::fs::canonicalize(&self.cache_dir)?;
let target = match std::fs::canonicalize(path) {
Ok(t) => t,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => return Err(e.into()),
};
if target != cache && target.starts_with(&cache) {
return Ok(Some(target));
}
Err(registry_coded(
crate::safety::ErrorCode::BadState,
format!(
"installed path {} is outside the agent cache {}; refusing to touch it",
path.display(),
cache.display()
),
"the installed list (installed.json in the agent cache) names a path outside the cache; nothing was removed, so correct or delete that entry by hand",
))
}
pub fn list_installed(&self) -> Vec<&InstalledAgent> {
self.installed.values().collect()
}
pub async fn publish(
&self,
manifest: &AgentManifest,
agent_code: &str,
api_key: &str,
) -> Result<PublishedAgent> {
validate_package_name(&manifest.name, "agent name")?;
validate_package_name(&manifest.version, "agent version")?;
let client = registry_client(30)?;
let url = format!("{}/api/v1/agents/publish", self.registry_url);
let response = client
.post(&url)
.header("Authorization", format!("Bearer {}", api_key))
.json(&serde_json::json!({
"manifest": manifest,
"code": agent_code,
}))
.send()
.await?;
if !response.status().is_success() {
let error: serde_json::Value =
crate::http_body::body_json_capped(response, crate::http_body::AI_BODY_MAX).await?;
return Err(registry_coded(
crate::safety::ErrorCode::Io,
format!("Failed to publish: {}", error),
"the registry rejected the publish; the message it sent says why",
));
}
let published: PublishedAgent =
crate::http_body::body_json_capped(response, crate::http_body::AI_BODY_MAX).await?;
Ok(published)
}
fn load_installed(cache_dir: &PathBuf) -> Result<HashMap<String, InstalledAgent>> {
let index_path = cache_dir.join("installed.json");
if index_path.exists() {
let content = std::fs::read_to_string(&index_path)?;
Ok(serde_json::from_str(&content)?)
} else {
Ok(HashMap::new())
}
}
fn save_installed(&self) -> Result<()> {
let index_path = self.cache_dir.join("installed.json");
let content = serde_json::to_string_pretty(&self.installed)?;
std::fs::write(&index_path, content)?;
Ok(())
}
pub fn get_installed(&self, name: &str) -> Option<&InstalledAgent> {
self.installed.get(name)
}
pub fn load_agent_code(&self, name: &str) -> Result<String> {
let installed = self.get_installed(name).ok_or_else(|| {
registry_coded(
crate::safety::ErrorCode::NotFound,
format!("Agent not installed: {}", name),
"list installed agents with marketplace list, then use one of their names",
)
})?;
let dir = self.cache_contained(&installed.path)?.ok_or_else(|| {
registry_coded(
crate::safety::ErrorCode::NotFound,
format!("Agent {} has no files on disk", name),
"reinstall it with marketplace install",
)
})?;
Ok(std::fs::read_to_string(dir.join("agent.ish"))?)
}
}
#[derive(Debug, Clone)]
pub enum MarketplaceCommand {
Search {
query: String,
},
Install {
name: String,
version: Option<String>,
},
Uninstall {
name: String,
},
List,
Info {
name: String,
},
Publish {
path: PathBuf,
},
Update {
name: Option<String>,
},
}
pub async fn process_command(command: MarketplaceCommand) -> Result<String> {
let mut client = RegistryClient::new(None)?;
match command {
MarketplaceCommand::Search { query } => {
let results = client
.search(SearchQuery {
query: Some(query),
..Default::default()
})
.await?;
let mut output = format!("Found {} agents:\n\n", results.total);
for agent in results.results {
output.push_str(&format!(
" {} v{} - {}\n by {} | ⬇ {} | ⭐ {}\n\n",
agent.manifest.name,
agent.manifest.version,
agent.manifest.description,
agent.manifest.author.name,
agent.downloads,
agent.stars
));
}
Ok(output)
}
MarketplaceCommand::Install { name, version } => {
let installed = client.install(&name, version.as_deref()).await?;
Ok(format!(
"✓ Installed {} v{}\n Location: {}",
installed.manifest.name,
installed.manifest.version,
installed.path.display()
))
}
MarketplaceCommand::Uninstall { name } => {
client.uninstall(&name)?;
Ok(format!("✓ Uninstalled {}", name))
}
MarketplaceCommand::List => {
let installed = client.list_installed();
if installed.is_empty() {
return Ok("No agents installed.".to_string());
}
let mut output = format!("Installed agents ({}):\n\n", installed.len());
for agent in installed {
output.push_str(&format!(
" {} v{}\n {}\n\n",
agent.manifest.name, agent.manifest.version, agent.manifest.description
));
}
Ok(output)
}
MarketplaceCommand::Info { name } => {
let agent = client.get(&name, None).await?;
Ok(format!(
"{}\n{}\n\nVersion: {}\nAuthor: {} <{}>\nLicense: {}\n\nCapabilities: {}\nKeywords: {}\n\nDownloads: {}\nStars: {}\nVerified: {}",
agent.manifest.display_name,
agent.manifest.description,
agent.manifest.version,
agent.manifest.author.name,
agent.manifest.author.email.unwrap_or_default(),
agent.manifest.license,
agent.manifest.capabilities.join(", "),
agent.manifest.keywords.join(", "),
agent.downloads,
agent.stars,
if agent.verified { "✓" } else { "✗" }
))
}
MarketplaceCommand::Publish { path } => {
let manifest_path = path.join("manifest.json");
let code_path = path.join("agent.ish");
if !manifest_path.exists() {
return Err(registry_coded(
crate::safety::ErrorCode::NotFound,
format!("manifest.json not found in {}", path.display()),
"point the publish at a directory that holds manifest.json and agent.ish",
));
}
if !code_path.exists() {
return Err(registry_coded(
crate::safety::ErrorCode::NotFound,
format!("agent.ish not found in {}", path.display()),
"point the publish at a directory that holds manifest.json and agent.ish",
));
}
let manifest: AgentManifest =
serde_json::from_str(&std::fs::read_to_string(&manifest_path)?)?;
let code = std::fs::read_to_string(&code_path)?;
let api_key = std::env::var("IRONSHELL_API_KEY").map_err(|_| {
registry_coded(
crate::safety::ErrorCode::BadState,
"IRONSHELL_API_KEY environment variable not set".to_string(),
"set IRONSHELL_API_KEY to your registry API key, then retry",
)
})?;
let published = client.publish(&manifest, &code, &api_key).await?;
Ok(format!(
"✓ Published {} v{}\n Registry: {}",
published.manifest.name, published.manifest.version, client.registry_url
))
}
MarketplaceCommand::Update { name } => {
let agents_to_update: Vec<_> = if let Some(name) = name {
vec![name]
} else {
client.installed.keys().cloned().collect()
};
let mut output = String::new();
for agent_name in agents_to_update {
let latest = client.get(&agent_name, None).await?;
let needs_update = client
.get_installed(&agent_name)
.map(|installed| latest.manifest.version != installed.manifest.version)
.unwrap_or(false);
let old_version = client
.get_installed(&agent_name)
.map(|i| i.manifest.version.clone());
if needs_update {
client.install(&agent_name, None).await?;
if let Some(old_ver) = old_version {
output.push_str(&format!(
"✓ Updated {} {} → {}\n",
agent_name, old_ver, latest.manifest.version
));
}
} else if client.get_installed(&agent_name).is_some() {
output.push_str(&format!(" {} is up to date\n", agent_name));
}
}
if output.is_empty() {
output = "All agents are up to date.".to_string();
}
Ok(output)
}
}
}
impl Default for SearchQuery {
fn default() -> Self {
Self {
query: None,
category: None,
keyword: None,
author: None,
sort_by: Some(SortBy::Downloads),
page: Some(1),
per_page: Some(20),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn uninstalling_a_missing_agent_carries_not_found() {
let mut client = RegistryClient {
registry_url: String::new(),
cache_dir: std::env::temp_dir(),
installed: HashMap::new(),
};
let err = client
.uninstall("no-such-agent")
.expect_err("nothing is installed, so uninstall must refuse");
let coded = err
.downcast_ref::<crate::safety::SafetyError>()
.expect("the refusal carries a SafetyError");
assert!(matches!(coded.code, crate::safety::ErrorCode::NotFound));
assert!(err.to_string().contains("no-such-agent"));
}
#[test]
fn test_agent_manifest_serialization() {
let manifest = AgentManifest {
name: "nervosys/code-reviewer".to_string(),
display_name: "Code Reviewer".to_string(),
description: "Automated code review agent".to_string(),
version: "1.0.0".to_string(),
author: AuthorInfo {
name: "Nervosys".to_string(),
email: Some("contact@nervosys.ai".to_string()),
url: Some("https://nervosys.ai".to_string()),
},
license: "MIT".to_string(),
repository: Some("https://github.com/nervosys/code-reviewer".to_string()),
homepage: None,
keywords: vec!["code".to_string(), "review".to_string()],
categories: vec!["development".to_string()],
capabilities: vec!["code_analysis".to_string(), "suggestions".to_string()],
ironshell_version: ">=0.2.0".to_string(),
dependencies: vec![],
agent: AgentDefinition {
system_prompt: "You are a code reviewer...".to_string(),
model: Some("gpt-4o-mini".to_string()),
tools: vec!["read_file".to_string(), "grep".to_string()],
tool_choice: None,
temperature: Some(0.3),
max_tokens: Some(4096),
},
};
let json = serde_json::to_string(&manifest).unwrap();
let parsed: AgentManifest = serde_json::from_str(&json).unwrap();
assert_eq!(parsed.name, manifest.name);
}
#[test]
fn test_search_query_default() {
let query = SearchQuery::default();
assert!(query.query.is_none());
assert_eq!(query.page, Some(1));
assert_eq!(query.per_page, Some(20));
}
}