From 696eefaaf059b65b65c38f10c39505d895cc9bc8 Mon Sep 17 00:00:00 2001 From: IronClaw Agent Date: Fri, 5 Jun 2026 02:22:07 +0300 Subject: [PATCH 1/9] feat(reborn): add IronHub catalog install Port IronHub catalog discovery and installation into Reborn local-dev services, first-party capabilities, and the ironclaw-reborn CLI. Co-authored-by: neo-sky --- Cargo.lock | 2 + .../src/first_party_tools/schemas.rs | 28 + .../src/lifecycle.rs | 7 + .../src/commands/ironhub.rs | 181 ++ .../ironclaw_reborn_cli/src/commands/mod.rs | 7 + crates/ironclaw_reborn_cli/tests/smoke.rs | 22 + crates/ironclaw_reborn_composition/Cargo.toml | 2 + .../src/available_extensions.rs | 13 +- .../src/extension_lifecycle.rs | 34 +- .../src/extension_lifecycle_capabilities.rs | 59 + .../src/extension_lifecycle_command.rs | 1 + .../src/factory.rs | 29 +- .../src/ironhub.rs | 1605 +++++++++++++++++ crates/ironclaw_reborn_composition/src/lib.rs | 6 + .../src/lifecycle.rs | 24 +- 15 files changed, 2003 insertions(+), 17 deletions(-) create mode 100644 crates/ironclaw_reborn_cli/src/commands/ironhub.rs create mode 100644 crates/ironclaw_reborn_composition/src/ironhub.rs diff --git a/Cargo.lock b/Cargo.lock index 65e3f08cb65..5c1c7e4e4dc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4894,8 +4894,10 @@ dependencies = [ "base64 0.22.1", "chrono", "deadpool-postgres", + "ed25519-dalek", "fs4", "futures", + "hex", "hmac 0.12.1", "http 1.4.1", "http-body-util", diff --git a/crates/ironclaw_host_runtime/src/first_party_tools/schemas.rs b/crates/ironclaw_host_runtime/src/first_party_tools/schemas.rs index c5c47f2412f..b88f62d170c 100644 --- a/crates/ironclaw_host_runtime/src/first_party_tools/schemas.rs +++ b/crates/ironclaw_host_runtime/src/first_party_tools/schemas.rs @@ -263,6 +263,34 @@ pub(crate) fn resolve_builtin_input_schema_ref(reference: &str) -> Option "required": ["extension_id"], "additionalProperties": false }), + "schemas/builtin/ironhub_search.input.v1.json" => json!({ + "type": "object", + "properties": { + "query": { "type": "string", "description": "Optional search query for the signed IronHub catalog. Omit to list all entries." } + }, + "additionalProperties": false + }), + "schemas/builtin/ironhub_info.input.v1.json" => json!({ + "type": "object", + "properties": { + "name": { "type": "string", "description": "IronHub tool or skill name." } + }, + "required": ["name"], + "additionalProperties": false + }), + "schemas/builtin/ironhub_install.input.v1.json" => json!({ + "type": "object", + "properties": { + "name": { "type": "string", "description": "IronHub tool or skill name." }, + "kind": { "type": "string", "enum": ["tool", "skill"], "description": "Disambiguate when a name exists as both a tool and a skill." }, + "force": { "type": "boolean", "description": "Replace an already installed package.", "default": false }, + "acknowledge_unverified": { "type": "boolean", "description": "Required for unverified community content.", "default": false }, + "expected_version": { "type": "string", "description": "Optional catalog version pin for signed install intents." }, + "expected_artifact_digest": { "type": "string", "description": "Optional artifact digest pin for signed install intents." } + }, + "required": ["name"], + "additionalProperties": false + }), "schemas/builtin/skill_list.input.v1.json" => json!({ "type": "object", "properties": {}, diff --git a/crates/ironclaw_product_workflow/src/lifecycle.rs b/crates/ironclaw_product_workflow/src/lifecycle.rs index 1c3dc7288e7..9563f2f7436 100644 --- a/crates/ironclaw_product_workflow/src/lifecycle.rs +++ b/crates/ironclaw_product_workflow/src/lifecycle.rs @@ -301,6 +301,11 @@ pub enum LifecycleProductPayload { extensions: Vec, count: usize, }, + CatalogSearch { + tools: Vec, + skills: Vec, + count: usize, + }, ExtensionList { extensions: Vec, count: usize, @@ -387,6 +392,7 @@ pub enum LifecycleExtensionCredentialSetup { #[serde(rename_all = "snake_case")] pub enum LifecycleExtensionSource { HostBundled, + Registry, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] @@ -426,6 +432,7 @@ pub struct LifecycleSkillSummary { pub enum LifecycleSkillSource { System, User, + Installed, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] diff --git a/crates/ironclaw_reborn_cli/src/commands/ironhub.rs b/crates/ironclaw_reborn_cli/src/commands/ironhub.rs new file mode 100644 index 00000000000..f0b83060e6c --- /dev/null +++ b/crates/ironclaw_reborn_cli/src/commands/ironhub.rs @@ -0,0 +1,181 @@ +use anyhow::Context; +use clap::{Args, Subcommand, ValueEnum}; +use ironclaw_reborn_composition::{ + IronHubCommand as RebornIronHubCommand, IronHubEntryKind, IronHubInstallOptions, + build_reborn_services, execute_reborn_ironhub_command, render_reborn_ironhub_response, +}; + +use crate::context::RebornCliContext; +use crate::runtime::{RuntimeInputCaller, RuntimeInputOptions}; + +#[derive(Debug, Args)] +pub(crate) struct IronHubCommand { + /// Confirm trusted-laptop host filesystem access for local-dev-yolo. + #[arg(long = "confirm-host-access", global = true)] + confirm_host_access: bool, + + #[command(subcommand)] + command: IronHubSubcommand, +} + +#[derive(Debug, Subcommand)] +enum IronHubSubcommand { + /// Search the signed IronHub catalog. + Search(IronHubSearchCommand), + /// List available IronHub tools or skills. + List(IronHubListCommand), + /// Show one IronHub catalog entry. + Info(IronHubInfoCommand), + /// Install an IronHub tool or skill into Reborn local-dev state. + Install(IronHubInstallCommand), +} + +#[derive(Debug, Args)] +struct IronHubSearchCommand { + /// Optional query by name or description. Omit to list all entries. + query: Option, + + /// Output the lifecycle response as JSON. + #[arg(long)] + json: bool, +} + +#[derive(Debug, Args)] +struct IronHubListCommand { + /// Limit results to tools or skills. + #[arg(long, value_enum)] + kind: Option, + + /// Output the lifecycle response as JSON. + #[arg(long)] + json: bool, +} + +#[derive(Debug, Args)] +struct IronHubInfoCommand { + /// Tool or skill name. + name: String, + + /// Output the lifecycle response as JSON. + #[arg(long)] + json: bool, +} + +#[derive(Debug, Args)] +struct IronHubInstallCommand { + /// Tool or skill name. + name: String, + + /// Disambiguate when a name exists as both a tool and a skill. + #[arg(long, value_enum)] + kind: Option, + + /// Replace an already installed package. + #[arg(long)] + force: bool, + + /// Acknowledge installing unverified community content. + #[arg(long)] + acknowledge_unverified: bool, + + /// Require the catalog entry to still have this version. + #[arg(long)] + expected_version: Option, + + /// Require the catalog entry to still have this artifact digest. + #[arg(long)] + expected_artifact_digest: Option, + + /// Output the lifecycle response as JSON. + #[arg(long)] + json: bool, +} + +#[derive(Debug, Clone, Copy, ValueEnum)] +enum IronHubKindArg { + Tool, + Skill, +} + +impl IronHubCommand { + pub(crate) fn execute(self, context: RebornCliContext) -> anyhow::Result<()> { + crate::runtime::init_tracing(); + let (command, json, label) = match self.command { + IronHubSubcommand::Search(command) => ( + RebornIronHubCommand::Search { + query: command.query.unwrap_or_default(), + }, + command.json, + "search", + ), + IronHubSubcommand::List(command) => ( + RebornIronHubCommand::List { + kind: command.kind.map(Into::into), + }, + command.json, + "list", + ), + IronHubSubcommand::Info(command) => ( + RebornIronHubCommand::Info { name: command.name }, + command.json, + "info", + ), + IronHubSubcommand::Install(command) => ( + RebornIronHubCommand::Install { + name: command.name, + options: IronHubInstallOptions { + kind: command.kind.map(Into::into), + force: command.force, + acknowledge_unverified: command.acknowledge_unverified, + expected_version: command.expected_version, + expected_artifact_digest: command.expected_artifact_digest, + }, + }, + command.json, + "install", + ), + }; + let response = execute_ironhub_command(context, command, self.confirm_host_access)?; + if json { + println!("{}", serde_json::to_string(&response)?); + } else { + print!("{}", render_reborn_ironhub_response(label, &response)); + } + Ok(()) + } +} + +impl From for IronHubEntryKind { + fn from(value: IronHubKindArg) -> Self { + match value { + IronHubKindArg::Tool => Self::Tool, + IronHubKindArg::Skill => Self::Skill, + } + } +} + +fn execute_ironhub_command( + context: RebornCliContext, + command: RebornIronHubCommand, + confirm_host_access: bool, +) -> anyhow::Result { + let runtime_services = crate::runtime::build_services_input_with_options( + context.boot_config(), + RuntimeInputCaller::Run, + RuntimeInputOptions { + confirm_host_access, + }, + )?; + let runtime = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .context("failed to build tokio runtime for IronHub command")?; + runtime.block_on(async move { + let services = build_reborn_services(runtime_services.services_input) + .await + .context("failed to assemble Reborn services for IronHub command")?; + execute_reborn_ironhub_command(&services, command) + .await + .map_err(anyhow::Error::from) + }) +} diff --git a/crates/ironclaw_reborn_cli/src/commands/mod.rs b/crates/ironclaw_reborn_cli/src/commands/mod.rs index 22503e124d0..1bcffb5248e 100644 --- a/crates/ironclaw_reborn_cli/src/commands/mod.rs +++ b/crates/ironclaw_reborn_cli/src/commands/mod.rs @@ -6,6 +6,7 @@ pub(crate) mod config; pub(crate) mod doctor; pub(crate) mod extension; pub(crate) mod hooks; +pub(crate) mod ironhub; pub(crate) mod logs; pub(crate) mod models; pub(crate) mod profile; @@ -38,6 +39,9 @@ pub(crate) enum Command { Extension(extension::ExtensionCommand), /// Inspect configured Reborn hooks. Hooks(hooks::HooksCommand), + /// Search and install from IronHub into Reborn local-dev state. + #[command(name = "ironhub")] + IronHub(ironhub::IronHubCommand), /// Inspect Reborn logs. Logs(logs::LogsCommand), /// Inspect Reborn model slots and route status. @@ -75,6 +79,9 @@ impl Command { command.execute(crate::context::RebornCliContext::resolve_from_env()?) } Self::Hooks(command) => command.execute(), + Self::IronHub(command) => { + command.execute(crate::context::RebornCliContext::resolve_from_env()?) + } Self::Logs(command) => command.execute(), Self::Models(command) => command.execute(), Self::Profile(command) => command.execute(), diff --git a/crates/ironclaw_reborn_cli/tests/smoke.rs b/crates/ironclaw_reborn_cli/tests/smoke.rs index 2b332d4147c..9126bf5cc2c 100644 --- a/crates/ironclaw_reborn_cli/tests/smoke.rs +++ b/crates/ironclaw_reborn_cli/tests/smoke.rs @@ -51,6 +51,7 @@ fn help_mentions_reborn_commands() { assert!(stdout.contains("doctor"), "stdout: {stdout}"); assert!(stdout.contains("extension"), "stdout: {stdout}"); assert!(stdout.contains("hooks"), "stdout: {stdout}"); + assert!(stdout.contains("ironhub"), "stdout: {stdout}"); assert!(stdout.contains("logs"), "stdout: {stdout}"); assert!(stdout.contains("models"), "stdout: {stdout}"); assert!(stdout.contains("profile"), "stdout: {stdout}"); @@ -64,6 +65,27 @@ fn help_mentions_reborn_commands() { assert!(stdout.contains("skills"), "stdout: {stdout}"); } +#[test] +fn ironhub_help_mentions_catalog_commands() { + let output = Command::new(reborn_bin()) + .arg("ironhub") + .arg("--help") + .output() + .expect("ironclaw-reborn ironhub --help should run"); + + assert!( + output.status.success(), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("search"), "stdout: {stdout}"); + assert!(stdout.contains("list"), "stdout: {stdout}"); + assert!(stdout.contains("info"), "stdout: {stdout}"); + assert!(stdout.contains("install"), "stdout: {stdout}"); + assert!(stdout.contains("--confirm-host-access"), "stdout: {stdout}"); +} + #[test] fn profile_list_shows_supported_profiles_without_reborn_home() { let output = Command::new(reborn_bin()) diff --git a/crates/ironclaw_reborn_composition/Cargo.toml b/crates/ironclaw_reborn_composition/Cargo.toml index f680d85323c..4489ad5dca6 100644 --- a/crates/ironclaw_reborn_composition/Cargo.toml +++ b/crates/ironclaw_reborn_composition/Cargo.toml @@ -81,7 +81,9 @@ async-trait = "0.1" base64 = "0.22" chrono = "0.4" deadpool-postgres = { version = "0.14", optional = true } +ed25519-dalek = "2.2.0" futures = "0.3" +hex = "0.4.3" ironclaw_auth = { path = "../ironclaw_auth" } ironclaw_common = { path = "../ironclaw_common" } ironclaw_capabilities = { path = "../ironclaw_capabilities" } diff --git a/crates/ironclaw_reborn_composition/src/available_extensions.rs b/crates/ironclaw_reborn_composition/src/available_extensions.rs index 72f07d9c4e5..1adaadb92b3 100644 --- a/crates/ironclaw_reborn_composition/src/available_extensions.rs +++ b/crates/ironclaw_reborn_composition/src/available_extensions.rs @@ -48,19 +48,19 @@ const WEB_ACCESS_MANIFEST: &str = const NEARAI_MCP_MANIFEST: &str = include_str!("../../ironclaw_first_party_extensions/assets/nearai-mcp/manifest.toml"); -#[derive(Debug, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct AvailableExtensionAsset { pub(crate) path: String, pub(crate) content: AvailableExtensionAssetContent, } -#[derive(Debug, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq)] pub(crate) enum AvailableExtensionAssetContent { Bytes(Vec), Filesystem(VirtualPath), } -#[derive(Debug)] +#[derive(Debug, Clone)] pub(crate) struct AvailableExtensionPackage { pub(crate) package_ref: LifecyclePackageRef, pub(crate) manifest_toml: String, @@ -81,7 +81,12 @@ impl AvailableExtensionPackage { name: self.package.manifest.name.clone(), version: self.package.manifest.version.clone(), description: self.package.manifest.description.clone(), - source: LifecycleExtensionSource::HostBundled, + source: match self.package.manifest.source { + ManifestSource::HostBundled => LifecycleExtensionSource::HostBundled, + ManifestSource::InstalledLocal | ManifestSource::RegistryInstalled => { + LifecycleExtensionSource::Registry + } + }, runtime_kind: runtime_kind(&self.package.manifest.runtime), visible_capability_ids, visible_read_only_capability_ids, diff --git a/crates/ironclaw_reborn_composition/src/extension_lifecycle.rs b/crates/ironclaw_reborn_composition/src/extension_lifecycle.rs index 745c8a661e2..1ed56f57496 100644 --- a/crates/ironclaw_reborn_composition/src/extension_lifecycle.rs +++ b/crates/ironclaw_reborn_composition/src/extension_lifecycle.rs @@ -281,14 +281,36 @@ impl RebornLocalExtensionManagementPort { package_ref: LifecyclePackageRef, ) -> Result { let available = self.catalog.resolve(&package_ref)?; - let plan = prepare_install(available)?; + self.install_available_package(available.clone(), false) + .await + } + + pub(crate) async fn install_available_package( + &self, + available: AvailableExtensionPackage, + force: bool, + ) -> Result { + let package_ref = available.package_ref.clone(); + let plan = prepare_install(&available)?; + if force + && self + .installation_store + .get_installation(plan.installation.installation_id()) + .await + .map_err(map_extension_installation_error)? + .is_some() + { + self.remove(package_ref.clone()).await?; + } let _operation_guard = self.operation_lock.lock().await; - self.ensure_not_installed(&available.package.id, plan.installation.installation_id()) - .await?; + if !force { + self.ensure_not_installed(&available.package.id, plan.installation.installation_id()) + .await?; + } self.register_lifecycle_package(&available.package).await?; if let Err(error) = - materialize_available_extension(self.filesystem.as_ref(), available).await + materialize_available_extension(self.filesystem.as_ref(), &available).await { if let Err(rollback_error) = self.rollback_lifecycle_install(&available.package.id).await @@ -322,7 +344,7 @@ impl RebornLocalExtensionManagementPort { LifecyclePhase::Installed, LifecycleProductPayload::ExtensionInstall { installed: true, - visible_capability_ids: visible_capability_ids(available) + visible_capability_ids: visible_capability_ids(&available) .map(|id| id.as_str().to_string()) .collect(), }, @@ -902,7 +924,7 @@ fn prepare_install( })?; let manifest_record = ExtensionManifestRecord::from_toml_with_contracts( &available.manifest_toml, - ManifestSource::HostBundled, + available.package.manifest.source, &host_ports, Some(manifest_hash.clone()), &contracts, diff --git a/crates/ironclaw_reborn_composition/src/extension_lifecycle_capabilities.rs b/crates/ironclaw_reborn_composition/src/extension_lifecycle_capabilities.rs index a56f03b3957..d12f4db9c47 100644 --- a/crates/ironclaw_reborn_composition/src/extension_lifecycle_capabilities.rs +++ b/crates/ironclaw_reborn_composition/src/extension_lifecycle_capabilities.rs @@ -228,6 +228,9 @@ mod tests { use ironclaw_trust::{AuthorityCeiling, EffectiveTrustClass, TrustDecision, TrustProvenance}; use super::*; + use crate::ironhub::{ + IRONHUB_INFO_CAPABILITY_ID, IRONHUB_INSTALL_CAPABILITY_ID, IRONHUB_SEARCH_CAPABILITY_ID, + }; use crate::{RebornBuildInput, build_reborn_services}; #[tokio::test] @@ -274,6 +277,62 @@ mod tests { ); } + #[tokio::test] + async fn local_dev_agent_surface_exposes_ironhub_tools() { + let dir = tempfile::tempdir().expect("tempdir"); + let services = build_reborn_services(RebornBuildInput::local_dev( + "ironhub-tools-surface-owner", + dir.path().join("local-dev"), + )) + .await + .expect("local-dev services build"); + let runtime = services.host_runtime.expect("host runtime composed"); + + let surface = runtime + .visible_capabilities(visible_request([ + IRONHUB_SEARCH_CAPABILITY_ID, + IRONHUB_INFO_CAPABILITY_ID, + IRONHUB_INSTALL_CAPABILITY_ID, + ])) + .await + .expect("visible capabilities"); + let ids = surface_capability_ids(&surface); + + assert!(ids.contains(&IRONHUB_SEARCH_CAPABILITY_ID)); + assert!(ids.contains(&IRONHUB_INFO_CAPABILITY_ID)); + assert!(ids.contains(&IRONHUB_INSTALL_CAPABILITY_ID)); + + let search = descriptor_for(&surface, IRONHUB_SEARCH_CAPABILITY_ID); + assert_eq!(search.default_permission, PermissionMode::Allow); + assert_eq!( + search.parameters_schema.get("required"), + None, + "ironhub_search query should be optional so models can list all catalog entries" + ); + + let info = descriptor_for(&surface, IRONHUB_INFO_CAPABILITY_ID); + assert_eq!(info.default_permission, PermissionMode::Allow); + assert_eq!( + info.parameters_schema["required"], + serde_json::json!(["name"]) + ); + + let install = descriptor_for(&surface, IRONHUB_INSTALL_CAPABILITY_ID); + assert_eq!(install.default_permission, PermissionMode::Ask); + assert_eq!( + install.parameters_schema["required"], + serde_json::json!(["name"]) + ); + assert!( + install.effects.contains(&EffectKind::Network), + "IronHub install downloads signed catalog artifacts through runtime HTTP egress" + ); + assert!( + install.effects.contains(&EffectKind::WriteFilesystem), + "IronHub install writes skills or extension packages into Reborn local-dev state" + ); + } + #[tokio::test] async fn local_dev_extension_lifecycle_tools_manage_visible_extension_surface() { let dir = tempfile::tempdir().expect("tempdir"); diff --git a/crates/ironclaw_reborn_composition/src/extension_lifecycle_command.rs b/crates/ironclaw_reborn_composition/src/extension_lifecycle_command.rs index b352d5b79d1..697ae84a469 100644 --- a/crates/ironclaw_reborn_composition/src/extension_lifecycle_command.rs +++ b/crates/ironclaw_reborn_composition/src/extension_lifecycle_command.rs @@ -181,6 +181,7 @@ fn phase_label(phase: LifecyclePhase) -> &'static str { fn extension_source_label(source: LifecycleExtensionSource) -> &'static str { match source { LifecycleExtensionSource::HostBundled => "host_bundled", + LifecycleExtensionSource::Registry => "registry", } } diff --git a/crates/ironclaw_reborn_composition/src/factory.rs b/crates/ironclaw_reborn_composition/src/factory.rs index cb5611bca0b..96953e283a7 100644 --- a/crates/ironclaw_reborn_composition/src/factory.rs +++ b/crates/ironclaw_reborn_composition/src/factory.rs @@ -122,6 +122,10 @@ use crate::{ gsuite::{ ProductAuthRuntimeGsuiteCredentialStager, register_bundled_gsuite_first_party_handlers, }, + ironhub::{ + extend_builtin_first_party_package as extend_builtin_first_party_package_with_ironhub, + insert_handlers as insert_ironhub_handlers, + }, web_access::register_bundled_web_access_first_party_handlers, }; @@ -814,11 +818,21 @@ async fn build_local_dev(input: RebornBuildInput) -> Result = @@ -1775,6 +1789,11 @@ fn local_dev_builtin_extension_registry() -> Result &'static str { + match self { + Self::Tool => "tool", + Self::Skill => "skill", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum IronHubProvenance { + #[serde(alias = "repo")] + Official, + Trusted, + Verified, + #[default] + #[serde(alias = "community")] + New, +} + +impl IronHubProvenance { + pub fn as_wire(self) -> &'static str { + match self { + Self::Official => "official", + Self::Trusted => "trusted", + Self::Verified => "verified", + Self::New => "new", + } + } + + pub fn is_community_unverified(self) -> bool { + matches!(self, Self::New) + } + + pub fn trust_label(self) -> &'static str { + match self { + Self::Official => "NEAR-vetted (official)", + Self::Trusted => "community, trusted publisher", + Self::Verified => "community, verified publisher", + Self::New => "UNVERIFIED community (new author)", + } + } +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct IronHubManifest { + pub version: String, + pub generated_at: String, + pub release_tag: String, + pub repo: String, + #[serde(default)] + pub tools: Vec, + #[serde(default)] + pub skills: Vec, +} + +impl IronHubManifest { + fn find_tool(&self, name: &str) -> Option<&IronHubToolEntry> { + self.tools.iter().find(|entry| entry.name == name) + } + + fn find_skill(&self, name: &str) -> Option<&IronHubSkillEntry> { + self.skills.iter().find(|entry| entry.name == name) + } +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct IronHubToolEntry { + pub name: String, + pub crate_name: String, + pub version: String, + #[serde(default)] + pub description: String, + #[serde(default)] + pub provenance: IronHubProvenance, + pub wasm: IronHubArtifact, + pub capabilities: IronHubArtifact, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct IronHubSkillEntry { + pub name: String, + #[serde(default)] + pub trunk: String, + #[serde(default)] + pub version: String, + #[serde(default)] + pub description: String, + #[serde(default)] + pub provenance: IronHubProvenance, + pub skill_md: IronHubArtifact, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct IronHubArtifact { + pub url: String, + pub size_bytes: u64, + pub sha256: String, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct IronHubInstallOptions { + pub kind: Option, + pub force: bool, + pub acknowledge_unverified: bool, + pub expected_version: Option, + pub expected_artifact_digest: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct IronHubInstallOutcome { + pub kind: IronHubEntryKind, + pub name: String, + pub version: String, + pub release_tag: String, + pub provenance: IronHubProvenance, + pub artifact_digest: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum IronHubCommand { + Search { + query: String, + }, + List { + kind: Option, + }, + Info { + name: String, + }, + Install { + name: String, + options: IronHubInstallOptions, + }, +} + +#[derive(Debug, Error)] +pub enum IronHubCommandError { + #[error("IronHub is available only for local-dev Reborn services")] + LocalRuntimeUnavailable, + #[error("IronHub runtime HTTP egress is unavailable")] + RuntimeHttpEgressUnavailable, + #[error("invalid IronHub input: {reason}")] + InvalidInput { reason: String }, + #[error("IronHub catalog failed: {reason}")] + Catalog { reason: String }, + #[error("IronHub install failed: {reason}")] + Install { reason: String }, + #[error("IronHub lifecycle failed: {0}")] + Product(#[from] ProductWorkflowError), +} + +#[derive(Debug, Deserialize)] +struct SignedManifestEnvelope { + v: u8, + key_id: String, + manifest_b64: String, + sig: String, +} + +struct CachedManifest { + manifest: Arc, + fetched_at: Instant, +} + +static MANIFEST_CACHE: LazyLock>> = + LazyLock::new(|| std::sync::Mutex::new(HashMap::new())); +static INSTALL_LOCKS: LazyLock>>>> = + LazyLock::new(|| std::sync::Mutex::new(HashMap::new())); + +pub async fn execute_reborn_ironhub_command( + services: &RebornServices, + command: IronHubCommand, +) -> Result { + let local_runtime = services + .local_runtime + .as_ref() + .ok_or(IronHubCommandError::LocalRuntimeUnavailable)?; + let extension_management = local_runtime + .extension_management + .as_ref() + .ok_or(IronHubCommandError::LocalRuntimeUnavailable)?; + let runtime_http_egress = local_runtime + .runtime_http_egress + .as_ref() + .ok_or(IronHubCommandError::RuntimeHttpEgressUnavailable)?; + let scope = ResourceScope::local_default( + UserId::new("reborn-cli").map_err(invalid_input)?, + InvocationId::new(), + ) + .map_err(invalid_input)?; + let service = IronHubService::new( + Arc::clone(&local_runtime.skill_management), + Arc::clone(extension_management), + Arc::clone(runtime_http_egress), + scope, + ); + service.execute(command).await +} + +pub(crate) fn extend_builtin_first_party_package( + mut package: ExtensionPackage, +) -> Result { + package + .manifest + .capabilities + .extend(capability_manifests()?); + ExtensionPackage::from_manifest(package.manifest, package.root) +} + +pub(crate) fn insert_handlers( + registry: &mut FirstPartyCapabilityRegistry, + skill_management: Arc, + extension_management: Arc, +) -> Result<(), ironclaw_host_api::HostApiError> { + let handler = Arc::new(IronHubCapabilityHandler { + skill_management, + extension_management, + }); + for capability_id in IRONHUB_CAPABILITY_IDS { + registry.insert_handler(CapabilityId::new(capability_id)?, handler.clone()); + } + Ok(()) +} + +fn capability_manifests() -> Result, ExtensionError> { + Ok(vec![ + capability_manifest( + IRONHUB_SEARCH_CAPABILITY_ID, + "Search the signed IronHub catalog for tools and skills", + vec![EffectKind::Network], + PermissionMode::Allow, + )?, + capability_manifest( + IRONHUB_INFO_CAPABILITY_ID, + "Inspect one signed IronHub catalog entry", + vec![EffectKind::Network], + PermissionMode::Allow, + )?, + capability_manifest( + IRONHUB_INSTALL_CAPABILITY_ID, + "Install a tool or skill from the signed IronHub catalog into Reborn local-dev state", + vec![EffectKind::Network, EffectKind::WriteFilesystem], + PermissionMode::Ask, + )?, + ]) +} + +fn capability_manifest( + id: &str, + description: &str, + effects: Vec, + default_permission: PermissionMode, +) -> Result { + let schema_name = id.strip_prefix("builtin.").unwrap_or(id).replace('.', "-"); + Ok(CapabilityManifest { + id: CapabilityId::new(id)?, + implements: Vec::new(), + description: description.to_string(), + effects, + default_permission, + visibility: CapabilityVisibility::Model, + input_schema_ref: CapabilityProfileSchemaRef::new(format!( + "schemas/builtin/{schema_name}.input.v1.json" + ))?, + output_schema_ref: CapabilityProfileSchemaRef::new(format!( + "schemas/builtin/{schema_name}.output.v1.json" + ))?, + prompt_doc_ref: None, + required_host_ports: vec![HostPortId::new("host.runtime.http_egress")?], + runtime_credentials: Vec::new(), + resource_profile: Some(ResourceProfile { + default_estimate: ResourceEstimate { + wall_clock_ms: Some(1_000), + output_bytes: Some(32 * 1024), + ..ResourceEstimate::default() + }, + hard_ceiling: None, + }), + }) +} + +struct IronHubCapabilityHandler { + skill_management: Arc, + extension_management: Arc, +} + +#[derive(Debug, Deserialize)] +struct SearchInput { + #[serde(default)] + query: String, +} + +#[derive(Debug, Deserialize)] +struct InfoInput { + name: String, +} + +#[derive(Debug, Deserialize)] +struct InstallInput { + name: String, + #[serde(default)] + kind: Option, + #[serde(default)] + force: bool, + #[serde(default)] + acknowledge_unverified: bool, + #[serde(default)] + expected_version: Option, + #[serde(default)] + expected_artifact_digest: Option, +} + +#[async_trait] +impl FirstPartyCapabilityHandler for IronHubCapabilityHandler { + async fn dispatch( + &self, + request: FirstPartyCapabilityRequest, + ) -> Result { + let started = Instant::now(); + let Some(runtime_http_egress) = request.services.runtime_http_egress.clone() else { + return Err(FirstPartyCapabilityError::new( + RuntimeDispatchErrorKind::Executor, + )); + }; + let service = IronHubService::new( + Arc::clone(&self.skill_management), + Arc::clone(&self.extension_management), + runtime_http_egress, + request.scope, + ); + let command = match request.capability_id.as_str() { + IRONHUB_SEARCH_CAPABILITY_ID => { + let input: SearchInput = parse_capability_input(request.input)?; + IronHubCommand::Search { query: input.query } + } + IRONHUB_INFO_CAPABILITY_ID => { + let input: InfoInput = parse_capability_input(request.input)?; + IronHubCommand::Info { name: input.name } + } + IRONHUB_INSTALL_CAPABILITY_ID => { + let input: InstallInput = parse_capability_input(request.input)?; + IronHubCommand::Install { + name: input.name, + options: IronHubInstallOptions { + kind: input.kind, + force: input.force, + acknowledge_unverified: input.acknowledge_unverified, + expected_version: input.expected_version, + expected_artifact_digest: input.expected_artifact_digest, + }, + } + } + _ => { + return Err(FirstPartyCapabilityError::new( + RuntimeDispatchErrorKind::UndeclaredCapability, + )); + } + }; + let response = service.execute(command).await.map_err(capability_error)?; + let output = serde_json::to_value(response) + .map_err(|_| FirstPartyCapabilityError::new(RuntimeDispatchErrorKind::OutputDecode))?; + Ok(FirstPartyCapabilityResult::new( + output, + ResourceUsage { + wall_clock_ms: started.elapsed().as_millis().try_into().unwrap_or(u64::MAX), + ..ResourceUsage::default() + }, + )) + } +} + +fn parse_capability_input(input: serde_json::Value) -> Result +where + T: for<'de> Deserialize<'de>, +{ + serde_json::from_value(input) + .map_err(|_| FirstPartyCapabilityError::new(RuntimeDispatchErrorKind::InputEncode)) +} + +fn capability_error(error: IronHubCommandError) -> FirstPartyCapabilityError { + let kind = match error { + IronHubCommandError::InvalidInput { .. } => RuntimeDispatchErrorKind::InputEncode, + IronHubCommandError::LocalRuntimeUnavailable + | IronHubCommandError::RuntimeHttpEgressUnavailable => RuntimeDispatchErrorKind::Executor, + IronHubCommandError::Catalog { .. } + | IronHubCommandError::Install { .. } + | IronHubCommandError::Product(_) => RuntimeDispatchErrorKind::OperationFailed, + }; + FirstPartyCapabilityError::new(kind) +} + +pub fn render_reborn_ironhub_response(label: &str, response: &LifecycleProductResponse) -> String { + let mut output = String::new(); + push_line(&mut output, format_args!("IronHub {label}")); + push_line( + &mut output, + format_args!("phase: {}", phase_label(response.phase)), + ); + if let Some(package_ref) = &response.package_ref { + push_line( + &mut output, + format_args!( + "package: {}/{}", + package_kind_label(package_ref.kind), + package_ref.id.as_str() + ), + ); + } + if let Some(message) = &response.message { + push_line( + &mut output, + format_args!("message: {}", terminal_safe(message)), + ); + } + match response.payload.as_ref() { + Some(LifecycleProductPayload::ExtensionSearch { extensions, count }) => { + push_line(&mut output, format_args!("count: {count}")); + for extension in extensions { + push_line( + &mut output, + format_args!( + "- tool {} {} ({})", + extension.package_ref.id.as_str(), + terminal_safe(&extension.version), + terminal_safe(&extension.description) + ), + ); + } + } + Some(LifecycleProductPayload::CatalogSearch { + tools, + skills, + count, + }) => { + push_line(&mut output, format_args!("count: {count}")); + for tool in tools { + push_line( + &mut output, + format_args!( + "- tool {} {} ({})", + tool.package_ref.id.as_str(), + terminal_safe(&tool.version), + terminal_safe(&tool.description) + ), + ); + } + for skill in skills { + push_line( + &mut output, + format_args!( + "- skill {} {} ({})", + skill.name.as_str(), + terminal_safe(&skill.version), + terminal_safe(&skill.description) + ), + ); + } + } + Some(LifecycleProductPayload::SkillSearch { + skills, + count, + truncated, + .. + }) => { + push_line(&mut output, format_args!("count: {count}")); + push_line(&mut output, format_args!("truncated: {truncated}")); + for skill in skills { + push_line( + &mut output, + format_args!( + "- skill {} {} ({})", + skill.name.as_str(), + terminal_safe(&skill.version), + terminal_safe(&skill.description) + ), + ); + } + } + Some(LifecycleProductPayload::ExtensionInstall { + installed, + visible_capability_ids, + }) => { + push_line(&mut output, format_args!("installed: {installed}")); + for id in visible_capability_ids { + push_line( + &mut output, + format_args!("visible_capability: {}", terminal_safe(id)), + ); + } + } + Some(LifecycleProductPayload::SkillInstall { installed, name }) => { + push_line(&mut output, format_args!("installed: {installed}")); + push_line(&mut output, format_args!("skill: {}", name.as_str())); + } + _ => {} + } + output +} + +pub(crate) struct IronHubService { + skill_management: Arc, + extension_management: Arc, + runtime_http_egress: Arc, + scope: ResourceScope, + manifest_url: String, +} + +impl IronHubService { + pub(crate) fn new( + skill_management: Arc, + extension_management: Arc, + runtime_http_egress: Arc, + scope: ResourceScope, + ) -> Self { + Self { + skill_management, + extension_management, + runtime_http_egress, + scope, + manifest_url: resolve_manifest_url(), + } + } + + pub(crate) async fn execute( + &self, + command: IronHubCommand, + ) -> Result { + match command { + IronHubCommand::Search { query } => self.search(&query).await, + IronHubCommand::List { kind } => self.list(kind).await, + IronHubCommand::Info { name } => self.info(&name).await, + IronHubCommand::Install { name, options } => self.install(&name, options).await, + } + } + + async fn search(&self, query: &str) -> Result { + let manifest = self.fetch_manifest_cached().await?; + let query = query.trim().to_ascii_lowercase(); + let tools = manifest + .tools + .iter() + .filter(|entry| entry_matches(&entry.name, &entry.description, &query)) + .map(tool_summary) + .collect::, _>>()?; + let skills = manifest + .skills + .iter() + .filter(|entry| entry_matches(&entry.name, &entry.description, &query)) + .map(skill_summary) + .collect::, _>>()?; + Ok(response_with_payload( + None, + LifecyclePhase::Discovered, + LifecycleProductPayload::CatalogSearch { + count: tools.len() + skills.len(), + tools, + skills, + }, + )) + } + + async fn list( + &self, + kind: Option, + ) -> Result { + let manifest = self.fetch_manifest_cached().await?; + match kind { + Some(IronHubEntryKind::Skill) => { + let skills = manifest + .skills + .iter() + .map(skill_summary) + .collect::, _>>()?; + Ok(response_with_payload( + None, + LifecyclePhase::Discovered, + LifecycleProductPayload::SkillSearch { + count: skills.len(), + limit: skills.len(), + truncated: false, + skills, + }, + )) + } + None => { + let tools = manifest + .tools + .iter() + .map(tool_summary) + .collect::, _>>()?; + let skills = manifest + .skills + .iter() + .map(skill_summary) + .collect::, _>>()?; + Ok(response_with_payload( + None, + LifecyclePhase::Discovered, + LifecycleProductPayload::CatalogSearch { + count: tools.len() + skills.len(), + tools, + skills, + }, + )) + } + Some(IronHubEntryKind::Tool) => { + let tools = manifest + .tools + .iter() + .map(tool_summary) + .collect::, _>>()?; + Ok(response_with_payload( + None, + LifecyclePhase::Discovered, + LifecycleProductPayload::ExtensionSearch { + count: tools.len(), + extensions: tools, + }, + )) + } + } + } + + async fn info(&self, name: &str) -> Result { + validate_hub_name(name)?; + let manifest = self.fetch_manifest_cached().await?; + let kind = classify(&manifest, name, None)?; + let response = match kind { + IronHubEntryKind::Tool => { + let tool = manifest + .find_tool(name) + .ok_or_else(|| catalog_error("tool not found"))?; + response_with_payload( + Some(package_ref(LifecyclePackageKind::Extension, &tool.name)?), + LifecyclePhase::Discovered, + LifecycleProductPayload::ExtensionSearch { + extensions: vec![tool_summary(tool)?], + count: 1, + }, + ) + } + IronHubEntryKind::Skill => { + let skill = manifest + .find_skill(name) + .ok_or_else(|| catalog_error("skill not found"))?; + response_with_payload( + Some(package_ref(LifecyclePackageKind::Skill, &skill.name)?), + LifecyclePhase::Discovered, + LifecycleProductPayload::SkillSearch { + skills: vec![skill_summary(skill)?], + count: 1, + limit: 1, + truncated: false, + }, + ) + } + }; + Ok(response) + } + + async fn install( + &self, + name: &str, + options: IronHubInstallOptions, + ) -> Result { + validate_hub_name(name)?; + let manifest = self.fetch_manifest_cached().await?; + let (kind, provenance, artifact_digest) = + classify_gate_and_digest(&manifest, name, options.kind, &options)?; + let lock_key = format!("{}:{name}", kind.as_str()); + let lock = install_lock(&lock_key); + let _guard = lock.lock().await; + match kind { + IronHubEntryKind::Skill => { + let entry = manifest + .find_skill(name) + .ok_or_else(|| catalog_error("skill not found"))?; + let content = self + .download_verified(&entry.skill_md, MAX_METADATA_BYTES) + .await?; + let content = + String::from_utf8(content).map_err(|error| IronHubCommandError::Install { + reason: format!("skill markdown is not UTF-8: {error}"), + })?; + let result = self + .skill_management + .install_from_ironhub(Some(&entry.name), &content, &entry.skill_md.url) + .await + .map_err(|error| IronHubCommandError::Install { + reason: error.to_string(), + })?; + let mut response = response_with_payload( + Some(package_ref(LifecyclePackageKind::Skill, &result.name)?), + LifecyclePhase::Installed, + LifecycleProductPayload::SkillInstall { + installed: true, + name: LifecyclePackageId::new(result.name).map_err(product_error)?, + }, + ); + response.message = Some(install_message( + IronHubEntryKind::Skill, + name, + entry.version.as_str(), + provenance, + &artifact_digest, + )); + Ok(response) + } + IronHubEntryKind::Tool => { + let entry = manifest + .find_tool(name) + .ok_or_else(|| catalog_error("tool not found"))?; + let wasm = self.download_verified(&entry.wasm, MAX_WASM_BYTES).await?; + let capabilities = self + .download_verified(&entry.capabilities, MAX_METADATA_BYTES) + .await?; + let package = ironhub_tool_package(entry, &wasm, &capabilities)?; + let mut response = self + .extension_management + .install_available_package(package, options.force) + .await + .map_err(IronHubCommandError::Product)?; + response.message = Some(install_message( + IronHubEntryKind::Tool, + name, + entry.version.as_str(), + provenance, + &artifact_digest, + )); + Ok(response) + } + } + } + + async fn fetch_manifest_cached(&self) -> Result { + let now = Instant::now(); + if let Some(hit) = manifest_cache_get(&self.manifest_url, now) { + return Ok((*hit).clone()); + } + let manifest = Arc::new(self.fetch_manifest().await?); + manifest_cache_put(&self.manifest_url, Arc::clone(&manifest), now); + Ok((*manifest).clone()) + } + + async fn fetch_manifest(&self) -> Result { + validate_artifact_url("hub-manifest", "manifest_url", &self.manifest_url)?; + let envelope = self + .download_url(&self.manifest_url, MAX_SIGNED_MANIFEST_BYTES) + .await?; + let bytes = + verify_signed_manifest(&envelope).map_err(|reason| IronHubCommandError::Catalog { + reason: format!("signed manifest verification failed: {reason}"), + })?; + if bytes.len() > usize::try_from(MAX_MANIFEST_BYTES).unwrap_or(usize::MAX) { + return Err(IronHubCommandError::Catalog { + reason: "manifest exceeds size cap".to_string(), + }); + } + serde_json::from_slice(&bytes).map_err(|error| IronHubCommandError::Catalog { + reason: format!("manifest parse failed: {error}"), + }) + } + + async fn download_verified( + &self, + artifact: &IronHubArtifact, + max_bytes: u64, + ) -> Result, IronHubCommandError> { + validate_artifact(artifact, max_bytes)?; + let bytes = self.download_url(&artifact.url, max_bytes).await?; + let actual = sha256_digest_token(&bytes); + if !actual.eq_ignore_ascii_case(&artifact.sha256) { + return Err(IronHubCommandError::Install { + reason: format!( + "checksum mismatch for {}: expected {}, got {}", + artifact.url, artifact.sha256, actual + ), + }); + } + Ok(bytes) + } + + async fn download_url( + &self, + url: &str, + max_bytes: u64, + ) -> Result, IronHubCommandError> { + let request = RuntimeHttpEgressRequest { + runtime: RuntimeKind::FirstParty, + scope: self.scope.clone(), + capability_id: CapabilityId::new("builtin.ironhub_fetch").map_err(invalid_input)?, + method: NetworkMethod::Get, + url: url.to_string(), + headers: Vec::new(), + body: Vec::new(), + network_policy: network_policy_for_url(url, max_bytes)?, + credential_injections: Vec::new(), + response_body_limit: Some(max_bytes), + save_body_to: None, + timeout_ms: Some(30_000), + }; + let response = self + .runtime_http_egress + .execute(request) + .await + .map_err(|error| IronHubCommandError::Catalog { + reason: error.stable_runtime_reason().to_string(), + })?; + if !(200..300).contains(&response.status) { + return Err(IronHubCommandError::Catalog { + reason: format!("download returned HTTP {}", response.status), + }); + } + if response.body.len() > usize::try_from(max_bytes).unwrap_or(usize::MAX) { + return Err(IronHubCommandError::Catalog { + reason: "download exceeds size cap".to_string(), + }); + } + Ok(response.body) + } +} + +fn ironhub_tool_package( + entry: &IronHubToolEntry, + wasm: &[u8], + capabilities: &[u8], +) -> Result { + validate_hub_name(&entry.name)?; + let manifest_toml = generic_tool_manifest(entry); + let root = VirtualPath::new(format!("/system/extensions/{}", entry.name)) + .map_err(|error| install_error(error.to_string()))?; + let host_ports = ironclaw_host_runtime::default_host_port_catalog() + .map_err(|error| install_error(error.to_string()))?; + let contracts = ironclaw_host_runtime::default_host_api_contract_registry() + .map_err(|error| install_error(error.to_string()))?; + let manifest = ExtensionManifest::parse_with_optional_host_api_contracts( + &manifest_toml, + ManifestSource::RegistryInstalled, + &host_ports, + &contracts, + ) + .map_err(|error| install_error(error.to_string()))?; + let package = ExtensionPackage::from_manifest_toml(manifest, root, &manifest_toml) + .map_err(|error| install_error(error.to_string()))?; + let package_ref = package_ref(LifecyclePackageKind::Extension, &entry.name)?; + Ok(AvailableExtensionPackage { + package_ref, + manifest_toml, + package, + assets: vec![ + bytes_asset("manifest.toml", manifest_toml_bytes(entry).as_slice()), + bytes_asset(&format!("wasm/{}_tool.wasm", entry.name), wasm), + bytes_asset("legacy/capabilities.json", capabilities), + bytes_asset( + &format!("schemas/{}/invoke.input.v1.json", entry.name), + GENERIC_TOOL_INPUT_SCHEMA, + ), + bytes_asset( + &format!("schemas/{}/raw_output.v1.json", entry.name), + GENERIC_TOOL_OUTPUT_SCHEMA, + ), + ], + }) +} + +fn manifest_toml_bytes(entry: &IronHubToolEntry) -> Vec { + generic_tool_manifest(entry).into_bytes() +} + +fn generic_tool_manifest(entry: &IronHubToolEntry) -> String { + format!( + r#"schema_version = "reborn.extension_manifest.v2" +id = "{id}" +name = "{name}" +version = "{version}" +description = "{description}" +trust = "third_party" + +[runtime] +kind = "wasm" +module = "wasm/{id}_tool.wasm" + +[[capabilities]] +id = "{id}.invoke" +description = "{description}" +effects = ["dispatch_capability", "network"] +default_permission = "ask" +visibility = "model" +input_schema_ref = "schemas/{id}/invoke.input.v1.json" +output_schema_ref = "schemas/{id}/raw_output.v1.json" +required_host_ports = ["host.runtime.http_egress"] +"#, + id = toml_escape(&entry.name), + name = toml_escape(&entry.name), + version = toml_escape(&entry.version), + description = toml_escape(&entry.description), + ) +} + +fn bytes_asset(path: &str, bytes: &[u8]) -> AvailableExtensionAsset { + AvailableExtensionAsset { + path: path.to_string(), + content: AvailableExtensionAssetContent::Bytes(bytes.to_vec()), + } +} + +fn verify_signed_manifest(envelope_bytes: &[u8]) -> Result, String> { + verify_signed_manifest_with_keys(envelope_bytes, MANIFEST_VERIFY_KEYS) +} + +fn verify_signed_manifest_with_keys( + envelope_bytes: &[u8], + verify_keys: &[(&str, &str)], +) -> Result, String> { + let env: SignedManifestEnvelope = serde_json::from_slice(envelope_bytes) + .map_err(|error| format!("envelope parse failed: {error}"))?; + if env.v != 1 { + return Err(format!("unsupported signed-manifest version {}", env.v)); + } + let key_hex = verify_keys + .iter() + .find(|(id, _)| *id == env.key_id) + .map(|(_, key)| *key) + .ok_or_else(|| format!("unknown manifest signing key_id '{}'", env.key_id))?; + let verifying_key = verifying_key_from_hex(key_hex)?; + let manifest_bytes = URL_SAFE_NO_PAD + .decode(env.manifest_b64.as_bytes()) + .map_err(|error| format!("manifest_b64 decode failed: {error}"))?; + let sig_bytes = URL_SAFE_NO_PAD + .decode(env.sig.as_bytes()) + .map_err(|error| format!("signature decode failed: {error}"))?; + let signature = Signature::from_slice(&sig_bytes) + .map_err(|error| format!("signature malformed: {error}"))?; + verifying_key + .verify_strict(&manifest_bytes, &signature) + .map_err(|_| "manifest signature verification failed".to_string())?; + Ok(manifest_bytes) +} + +fn verifying_key_from_hex(hex: &str) -> Result { + let raw = hex::decode(hex).map_err(|error| format!("verify key is not valid hex: {error}"))?; + let raw: [u8; 32] = raw + .try_into() + .map_err(|_| "verify key must be 32 bytes".to_string())?; + VerifyingKey::from_bytes(&raw).map_err(|error| format!("invalid verify key: {error}")) +} + +fn classify_gate_and_digest( + manifest: &IronHubManifest, + name: &str, + hint: Option, + options: &IronHubInstallOptions, +) -> Result<(IronHubEntryKind, IronHubProvenance, String), IronHubCommandError> { + let kind = classify(manifest, name, hint)?; + let (version, provenance, artifact_digest) = match kind { + IronHubEntryKind::Tool => { + let entry = manifest + .find_tool(name) + .ok_or_else(|| catalog_error("tool not found"))?; + ( + entry.version.as_str(), + entry.provenance, + tool_artifact_digest(entry), + ) + } + IronHubEntryKind::Skill => { + let entry = manifest + .find_skill(name) + .ok_or_else(|| catalog_error("skill not found"))?; + ( + entry.version.as_str(), + entry.provenance, + skill_artifact_digest(entry), + ) + } + }; + if let Some(expected) = &options.expected_version + && expected != version + { + return Err(IronHubCommandError::InvalidInput { + reason: format!( + "catalog version for '{name}' changed: expected {expected}, current {version}" + ), + }); + } + if let Some(expected) = &options.expected_artifact_digest + && !expected.eq_ignore_ascii_case(&artifact_digest) + { + return Err(IronHubCommandError::InvalidInput { + reason: format!( + "artifact digest for '{name}' changed: expected {expected}, current {artifact_digest}" + ), + }); + } + if provenance.is_community_unverified() && !options.acknowledge_unverified { + return Err(IronHubCommandError::InvalidInput { + reason: format!( + "'{name}' is UNVERIFIED community content (trust tier: {}). Re-run with acknowledgement to install at your own risk.", + provenance.as_wire() + ), + }); + } + Ok((kind, provenance, artifact_digest)) +} + +fn classify( + manifest: &IronHubManifest, + name: &str, + hint: Option, +) -> Result { + let in_tools = manifest.find_tool(name).is_some(); + let in_skills = manifest.find_skill(name).is_some(); + match (hint, in_tools, in_skills) { + (Some(IronHubEntryKind::Tool), true, _) => Ok(IronHubEntryKind::Tool), + (Some(IronHubEntryKind::Tool), false, _) => Err(invalid_input(format!( + "'{name}' is not a tool in this IronHub catalog" + ))), + (Some(IronHubEntryKind::Skill), _, true) => Ok(IronHubEntryKind::Skill), + (Some(IronHubEntryKind::Skill), _, false) => Err(invalid_input(format!( + "'{name}' is not a skill in this IronHub catalog" + ))), + (None, true, false) => Ok(IronHubEntryKind::Tool), + (None, false, true) => Ok(IronHubEntryKind::Skill), + (None, true, true) => Err(invalid_input(format!( + "'{name}' exists as both a tool and a skill; specify a kind" + ))), + (None, false, false) => Err(invalid_input(format!( + "'{name}' is not in this IronHub catalog" + ))), + } +} + +fn tool_artifact_digest(entry: &IronHubToolEntry) -> String { + sha256_digest_token(format!("{}:{}", entry.wasm.sha256, entry.capabilities.sha256).as_bytes()) +} + +fn skill_artifact_digest(entry: &IronHubSkillEntry) -> String { + sha256_digest_token(entry.skill_md.sha256.as_bytes()) +} + +fn validate_artifact( + artifact: &IronHubArtifact, + max_bytes: u64, +) -> Result<(), IronHubCommandError> { + validate_artifact_url("artifact", "url", &artifact.url)?; + if artifact.size_bytes > max_bytes { + return Err(IronHubCommandError::Catalog { + reason: format!("artifact exceeds {} byte cap", max_bytes), + }); + } + if artifact.sha256.len() != 64 || !artifact.sha256.bytes().all(|byte| byte.is_ascii_hexdigit()) + { + return Err(IronHubCommandError::Catalog { + reason: "artifact sha256 must be 64 hex characters".to_string(), + }); + } + Ok(()) +} + +fn validate_artifact_url( + manifest_name: &str, + field: &'static str, + url: &str, +) -> Result<(), IronHubCommandError> { + let parsed = url::Url::parse(url).map_err(|error| IronHubCommandError::Catalog { + reason: format!("{manifest_name}.{field} invalid URL: {error}"), + })?; + if parsed.scheme() != "https" { + return Err(IronHubCommandError::Catalog { + reason: format!("{manifest_name}.{field} must use https"), + }); + } + let host = parsed + .host_str() + .ok_or_else(|| IronHubCommandError::Catalog { + reason: format!("{manifest_name}.{field} host is missing"), + })?; + if host_is_disallowed_target(host) || !is_allowed_artifact_host(host) { + return Err(IronHubCommandError::Catalog { + reason: format!("{manifest_name}.{field} host '{host}' is not allowed"), + }); + } + Ok(()) +} + +fn network_policy_for_url(url: &str, max_bytes: u64) -> Result { + validate_artifact_url("download", "url", url)?; + let parsed = url::Url::parse(url).map_err(|error| IronHubCommandError::Catalog { + reason: format!("invalid URL: {error}"), + })?; + let host = parsed + .host_str() + .ok_or_else(|| catalog_error("URL host is missing"))?; + Ok(NetworkPolicy { + allowed_targets: vec![NetworkTargetPattern { + scheme: Some(NetworkScheme::Https), + host_pattern: host.to_ascii_lowercase(), + port: parsed.port(), + }], + deny_private_ip_ranges: true, + max_egress_bytes: Some(max_bytes), + }) +} + +fn is_allowed_artifact_host(host: &str) -> bool { + const ALLOWED: &[&str] = &[ + "hub.ironclaw.com", + "github.com", + "objects.githubusercontent.com", + "github-releases.githubusercontent.com", + "raw.githubusercontent.com", + ]; + ALLOWED + .iter() + .any(|allowed| host.eq_ignore_ascii_case(allowed)) + || host.ends_with(".githubusercontent.com") + || extra_artifact_hosts() + .iter() + .any(|allowed| host.eq_ignore_ascii_case(allowed)) +} + +fn extra_artifact_hosts() -> Vec { + std::env::var("IRONHUB_EXTRA_ARTIFACT_HOSTS") + .ok() + .unwrap_or_default() + .split(',') + .map(str::trim) + .map(str::to_ascii_lowercase) + .filter(|host| !host.is_empty() && !host_is_disallowed_target(host)) + .collect() +} + +fn host_is_disallowed_target(host: &str) -> bool { + let host = host.strip_suffix('.').unwrap_or(host); + let ip_form = host + .strip_prefix('[') + .and_then(|value| value.strip_suffix(']')) + .unwrap_or(host); + if ip_form.parse::().is_ok() || host == "localhost" { + return true; + } + const INTERNAL_SUFFIXES: &[&str] = &[ + ".localhost", + ".local", + ".internal", + ".intranet", + ".lan", + ".home", + ".corp", + ".private", + ]; + INTERNAL_SUFFIXES + .iter() + .any(|suffix| host.ends_with(suffix)) + || !host.contains('.') +} + +fn validate_hub_name(name: &str) -> Result<(), IronHubCommandError> { + let valid = !name.is_empty() + && name + .chars() + .all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-' || ch == '_'); + if valid { + Ok(()) + } else { + Err(invalid_input( + "name must be non-empty and contain only lowercase letters, digits, '-', '_'", + )) + } +} + +fn tool_summary( + entry: &IronHubToolEntry, +) -> Result { + Ok(ironclaw_product_workflow::LifecycleExtensionSummary { + package_ref: package_ref(LifecyclePackageKind::Extension, &entry.name)?, + name: entry.name.clone(), + version: entry.version.clone(), + description: format!("{} [{}]", entry.description, entry.provenance.trust_label()), + source: ironclaw_product_workflow::LifecycleExtensionSource::Registry, + runtime_kind: ironclaw_product_workflow::LifecycleExtensionRuntimeKind::WasmTool, + visible_capability_ids: vec![format!("{}.invoke", entry.name)], + visible_read_only_capability_ids: Vec::new(), + credential_requirements: Vec::new(), + onboarding: None, + }) +} + +fn skill_summary( + entry: &IronHubSkillEntry, +) -> Result { + Ok(ironclaw_product_workflow::LifecycleSkillSummary { + name: LifecyclePackageId::new(entry.name.clone()).map_err(product_error)?, + version: entry.version.clone(), + description: format!("{} [{}]", entry.description, entry.provenance.trust_label()), + source: ironclaw_product_workflow::LifecycleSkillSource::Installed, + keywords: Vec::new(), + tags: Vec::new(), + requires_skills: Vec::new(), + }) +} + +fn entry_matches(name: &str, description: &str, query: &str) -> bool { + query.is_empty() + || name.to_ascii_lowercase().contains(query) + || description.to_ascii_lowercase().contains(query) +} + +fn package_ref( + kind: LifecyclePackageKind, + id: &str, +) -> Result { + LifecyclePackageRef::new(kind, id).map_err(product_error) +} + +fn resolve_manifest_url() -> String { + std::env::var("IRONHUB_MANIFEST_URL") + .ok() + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| DEFAULT_IRONHUB_MANIFEST_URL.to_string()) +} + +fn manifest_cache_get(url: &str, now: Instant) -> Option> { + let guard = MANIFEST_CACHE + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let entry = guard.get(url)?; + (now.duration_since(entry.fetched_at) <= MANIFEST_CACHE_TTL) + .then(|| Arc::clone(&entry.manifest)) +} + +fn manifest_cache_put(url: &str, manifest: Arc, now: Instant) { + let mut guard = MANIFEST_CACHE + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if guard.len() >= MANIFEST_CACHE_MAX_ENTRIES && !guard.contains_key(url) { + guard.retain(|_, entry| now.duration_since(entry.fetched_at) <= MANIFEST_CACHE_TTL); + if guard.len() >= MANIFEST_CACHE_MAX_ENTRIES + && let Some(victim) = guard.keys().next().cloned() + { + guard.remove(&victim); + } + } + guard.insert( + url.to_string(), + CachedManifest { + manifest, + fetched_at: now, + }, + ); +} + +fn install_lock(key: &str) -> Arc> { + let mut guard = INSTALL_LOCKS + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + guard + .entry(key.to_string()) + .or_insert_with(|| Arc::new(AsyncMutex::new(()))) + .clone() +} + +fn install_message( + kind: IronHubEntryKind, + name: &str, + version: &str, + provenance: IronHubProvenance, + artifact_digest: &str, +) -> String { + format!( + "installed {} '{}' {} from IronHub; provenance={}, artifact_digest={}", + kind.as_str(), + name, + version, + provenance.as_wire(), + artifact_digest + ) +} + +fn phase_label(phase: LifecyclePhase) -> &'static str { + match phase { + LifecyclePhase::Discovered => "discovered", + LifecyclePhase::Installing => "installing", + LifecyclePhase::Installed => "installed", + LifecyclePhase::Configured => "configured", + LifecyclePhase::Activating => "activating", + LifecyclePhase::Active => "active", + LifecyclePhase::Disabled => "disabled", + LifecyclePhase::UpgradeRequired => "upgrade_required", + LifecyclePhase::Failed => "failed", + LifecyclePhase::Removing => "removing", + LifecyclePhase::Removed => "removed", + LifecyclePhase::UnsupportedOrLegacy => "unsupported_or_legacy", + } +} + +fn package_kind_label(kind: LifecyclePackageKind) -> &'static str { + match kind { + LifecyclePackageKind::Extension => "extension", + LifecyclePackageKind::Skill => "skill", + LifecyclePackageKind::Mcp => "mcp", + LifecyclePackageKind::Wasm => "wasm", + } +} + +fn terminal_safe(value: &str) -> String { + value.chars().flat_map(char::escape_default).collect() +} + +fn push_line(output: &mut String, args: std::fmt::Arguments<'_>) { + use std::fmt::Write as _; + let _ = output.write_fmt(args); + output.push('\n'); +} + +fn toml_escape(value: &str) -> String { + value.replace('\\', "\\\\").replace('"', "\\\"") +} + +fn invalid_input(error: impl std::fmt::Display) -> IronHubCommandError { + IronHubCommandError::InvalidInput { + reason: error.to_string(), + } +} + +fn catalog_error(reason: impl Into) -> IronHubCommandError { + IronHubCommandError::Catalog { + reason: reason.into(), + } +} + +fn install_error(reason: impl Into) -> IronHubCommandError { + IronHubCommandError::Install { + reason: reason.into(), + } +} + +fn product_error(error: impl std::fmt::Display) -> IronHubCommandError { + IronHubCommandError::Product(ProductWorkflowError::InvalidBindingRequest { + reason: error.to_string(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn signed_manifest_verifies_known_test_vector() { + let envelope = br#"{"v":1,"key_id":"test-vector","manifest_b64":"eyJ2ZXJzaW9uIjoiMSIsImdlbmVyYXRlZF9hdCI6IjIwMjYtMDEtMDFUMDA6MDA6MDBaIiwicmVsZWFzZV90YWciOiJ0ZXN0IiwicmVwbyI6Im5lYXJhaS9pcm9uaHViIiwidG9vbHMiOltdLCJza2lsbHMiOltdfQ","sig":"KjsUDgi1enj3iTPNQI6gU1Bwxf01hIUItlFvX9PxgWNybPPrJNIV7vFG-G8hJOalFMwFs5zQHrxbtFDZAlgtBg"}"#; + let manifest = verify_signed_manifest_with_keys( + envelope, + &[( + "test-vector", + "ca46572f4dcd485599cdf95442934a3e3c86e2cae766a85fbffc8d6540959928", + )], + ) + .expect("signed manifest verifies"); + + assert_eq!( + manifest, + br#"{"version":"1","generated_at":"2026-01-01T00:00:00Z","release_tag":"test","repo":"nearai/ironhub","tools":[],"skills":[]}"# + ); + } + + #[test] + fn missing_provenance_defaults_to_unverified() { + let manifest: IronHubManifest = serde_json::from_str( + r#"{ + "version": "1", + "generated_at": "2026-01-01T00:00:00Z", + "release_tag": "test", + "repo": "nearai/ironhub", + "tools": [{ + "name": "community-tool", + "crate_name": "community-tool", + "version": "0.1.0", + "description": "community", + "wasm": { + "url": "https://hub.ironclaw.com/community-tool.wasm", + "size_bytes": 1, + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + "capabilities": { + "url": "https://hub.ironclaw.com/community-tool.capabilities.json", + "size_bytes": 1, + "sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + } + }], + "skills": [{ + "name": "community-skill", + "version": "0.1.0", + "description": "community", + "skill_md": { + "url": "https://hub.ironclaw.com/community-skill/SKILL.md", + "size_bytes": 1, + "sha256": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + } + }] + }"#, + ) + .expect("manifest parses"); + + assert_eq!(manifest.tools[0].provenance, IronHubProvenance::New); + assert_eq!(manifest.skills[0].provenance, IronHubProvenance::New); + } + + #[test] + fn unverified_install_requires_acknowledgement() { + let manifest = IronHubManifest { + version: "1".to_string(), + generated_at: "2026-01-01T00:00:00Z".to_string(), + release_tag: "test".to_string(), + repo: "nearai/ironhub".to_string(), + tools: Vec::new(), + skills: vec![IronHubSkillEntry { + name: "community-skill".to_string(), + trunk: String::new(), + version: "0.1.0".to_string(), + description: String::new(), + provenance: IronHubProvenance::New, + skill_md: IronHubArtifact { + url: "https://hub.ironclaw.com/community-skill/SKILL.md".to_string(), + size_bytes: 1, + sha256: "c".repeat(64), + }, + }], + }; + + let blocked = classify_gate_and_digest( + &manifest, + "community-skill", + Some(IronHubEntryKind::Skill), + &IronHubInstallOptions::default(), + ) + .expect_err("unverified content requires acknowledgement"); + assert!(blocked.to_string().contains("UNVERIFIED community content")); + + let allowed = classify_gate_and_digest( + &manifest, + "community-skill", + Some(IronHubEntryKind::Skill), + &IronHubInstallOptions { + acknowledge_unverified: true, + ..IronHubInstallOptions::default() + }, + ) + .expect("acknowledged unverified content can proceed"); + assert_eq!(allowed.0, IronHubEntryKind::Skill); + assert_eq!(allowed.1, IronHubProvenance::New); + } + + #[test] + fn renderer_includes_tools_and_skills_in_mixed_search() { + let response = response_with_payload( + None, + LifecyclePhase::Discovered, + LifecycleProductPayload::CatalogSearch { + count: 2, + tools: vec![ + tool_summary(&IronHubToolEntry { + name: "web".to_string(), + crate_name: "web-tool".to_string(), + version: "0.1.0".to_string(), + description: "web tool".to_string(), + provenance: IronHubProvenance::Official, + wasm: IronHubArtifact { + url: "https://hub.ironclaw.com/web.wasm".to_string(), + size_bytes: 1, + sha256: "a".repeat(64), + }, + capabilities: IronHubArtifact { + url: "https://hub.ironclaw.com/web.capabilities.json".to_string(), + size_bytes: 1, + sha256: "b".repeat(64), + }, + }) + .expect("tool summary"), + ], + skills: vec![ + skill_summary(&IronHubSkillEntry { + name: "reviewer".to_string(), + trunk: String::new(), + version: "0.2.0".to_string(), + description: "review skill".to_string(), + provenance: IronHubProvenance::Verified, + skill_md: IronHubArtifact { + url: "https://hub.ironclaw.com/reviewer/SKILL.md".to_string(), + size_bytes: 1, + sha256: "c".repeat(64), + }, + }) + .expect("skill summary"), + ], + }, + ); + + let rendered = render_reborn_ironhub_response("search", &response); + assert!(rendered.contains("- tool web 0.1.0")); + assert!(rendered.contains("- skill reviewer 0.2.0")); + } + + #[test] + fn artifact_digest_binds_both_tool_artifacts() { + let tool = IronHubToolEntry { + name: "web".to_string(), + crate_name: "web-tool".to_string(), + version: "0.1.0".to_string(), + description: String::new(), + provenance: IronHubProvenance::Official, + wasm: IronHubArtifact { + url: "https://hub.ironclaw.com/web.wasm".to_string(), + size_bytes: 1, + sha256: "a".repeat(64), + }, + capabilities: IronHubArtifact { + url: "https://hub.ironclaw.com/web.capabilities.json".to_string(), + size_bytes: 1, + sha256: "b".repeat(64), + }, + }; + assert_eq!( + tool_artifact_digest(&tool), + sha256_digest_token(format!("{}:{}", "a".repeat(64), "b".repeat(64)).as_bytes()) + ); + } + + #[test] + fn artifact_url_rejects_internal_hosts_even_when_extra() { + assert!(host_is_disallowed_target("localhost")); + assert!(host_is_disallowed_target("10.0.0.1")); + assert!(host_is_disallowed_target("service.internal")); + } +} diff --git a/crates/ironclaw_reborn_composition/src/lib.rs b/crates/ironclaw_reborn_composition/src/lib.rs index 989b6ff4314..9aa826d2d2b 100644 --- a/crates/ironclaw_reborn_composition/src/lib.rs +++ b/crates/ironclaw_reborn_composition/src/lib.rs @@ -38,6 +38,7 @@ mod factory; mod google_oauth; mod gsuite; mod input; +mod ironhub; mod lifecycle; #[cfg(feature = "root-llm-provider")] mod llm_catalog; @@ -150,6 +151,11 @@ pub use ironclaw_skills::{ ManagedSkillSource as RebornSkillSource, SkillSummary as RebornSkillSummary, skill_summary_json as reborn_skill_summary_json, }; +pub use ironhub::{ + IronHubCommand, IronHubCommandError, IronHubEntryKind, IronHubInstallOptions, + IronHubInstallOutcome, IronHubManifest, IronHubProvenance, execute_reborn_ironhub_command, + render_reborn_ironhub_response, +}; #[cfg(feature = "root-llm-provider")] pub use llm_catalog::{ RebornLlmCatalogError, resolve_against_registry, resolve_llm_selection_against_catalog, diff --git a/crates/ironclaw_reborn_composition/src/lifecycle.rs b/crates/ironclaw_reborn_composition/src/lifecycle.rs index 42a7c13a064..b1674cc1ea3 100644 --- a/crates/ironclaw_reborn_composition/src/lifecycle.rs +++ b/crates/ironclaw_reborn_composition/src/lifecycle.rs @@ -87,6 +87,26 @@ impl RebornLocalSkillManagementPort { .await?) } + pub(crate) async fn install_from_ironhub( + &self, + name: Option<&str>, + content: &str, + source_url: &str, + ) -> Result { + let context = self.skill_context()?; + Ok(install_skill( + &context, + SkillInstallRequest { + name, + content, + files: &[], + source: SkillInstallSource::InstalledUrl, + source_url: Some(source_url), + }, + ) + .await?) + } + async fn remove( &self, name: &str, @@ -397,8 +417,8 @@ fn skill_summary( description: skill.description, source: match skill.source { ironclaw_skills::ManagedSkillSource::System => LifecycleSkillSource::System, - ironclaw_skills::ManagedSkillSource::User - | ironclaw_skills::ManagedSkillSource::Installed => LifecycleSkillSource::User, + ironclaw_skills::ManagedSkillSource::User => LifecycleSkillSource::User, + ironclaw_skills::ManagedSkillSource::Installed => LifecycleSkillSource::Installed, }, keywords: skill.keywords, tags: skill.tags, From 8d3c53747ba8cdcf08f31915e12c7eddc0928b3e Mon Sep 17 00:00:00 2001 From: IronClaw Agent Date: Fri, 5 Jun 2026 02:48:32 +0300 Subject: [PATCH 2/9] refactor(reborn): tighten IronHub composition --- .../src/lifecycle.rs | 1 + .../src/available_extensions.rs | 5 +- .../src/extension_lifecycle.rs | 13 +- .../src/extension_lifecycle_command.rs | 1 + .../src/ironhub.rs | 1605 ----------------- .../src/ironhub/capabilities.rs | 218 +++ .../src/ironhub/catalog.rs | 324 ++++ .../src/ironhub/errors.rs | 27 + .../src/ironhub/mod.rs | 22 + .../src/ironhub/model.rs | 189 ++ .../src/ironhub/package.rs | 101 ++ .../src/ironhub/render.rs | 147 ++ .../src/ironhub/service.rs | 458 +++++ .../src/ironhub/tests.rs | 201 +++ crates/ironclaw_reborn_composition/src/lib.rs | 5 +- .../src/lifecycle.rs | 2 +- 16 files changed, 1704 insertions(+), 1615 deletions(-) delete mode 100644 crates/ironclaw_reborn_composition/src/ironhub.rs create mode 100644 crates/ironclaw_reborn_composition/src/ironhub/capabilities.rs create mode 100644 crates/ironclaw_reborn_composition/src/ironhub/catalog.rs create mode 100644 crates/ironclaw_reborn_composition/src/ironhub/errors.rs create mode 100644 crates/ironclaw_reborn_composition/src/ironhub/mod.rs create mode 100644 crates/ironclaw_reborn_composition/src/ironhub/model.rs create mode 100644 crates/ironclaw_reborn_composition/src/ironhub/package.rs create mode 100644 crates/ironclaw_reborn_composition/src/ironhub/render.rs create mode 100644 crates/ironclaw_reborn_composition/src/ironhub/service.rs create mode 100644 crates/ironclaw_reborn_composition/src/ironhub/tests.rs diff --git a/crates/ironclaw_product_workflow/src/lifecycle.rs b/crates/ironclaw_product_workflow/src/lifecycle.rs index 9563f2f7436..29f1084c936 100644 --- a/crates/ironclaw_product_workflow/src/lifecycle.rs +++ b/crates/ironclaw_product_workflow/src/lifecycle.rs @@ -392,6 +392,7 @@ pub enum LifecycleExtensionCredentialSetup { #[serde(rename_all = "snake_case")] pub enum LifecycleExtensionSource { HostBundled, + Installed, Registry, } diff --git a/crates/ironclaw_reborn_composition/src/available_extensions.rs b/crates/ironclaw_reborn_composition/src/available_extensions.rs index 1adaadb92b3..7cbd19a2962 100644 --- a/crates/ironclaw_reborn_composition/src/available_extensions.rs +++ b/crates/ironclaw_reborn_composition/src/available_extensions.rs @@ -83,9 +83,8 @@ impl AvailableExtensionPackage { description: self.package.manifest.description.clone(), source: match self.package.manifest.source { ManifestSource::HostBundled => LifecycleExtensionSource::HostBundled, - ManifestSource::InstalledLocal | ManifestSource::RegistryInstalled => { - LifecycleExtensionSource::Registry - } + ManifestSource::InstalledLocal => LifecycleExtensionSource::Installed, + ManifestSource::RegistryInstalled => LifecycleExtensionSource::Registry, }, runtime_kind: runtime_kind(&self.package.manifest.runtime), visible_capability_ids, diff --git a/crates/ironclaw_reborn_composition/src/extension_lifecycle.rs b/crates/ironclaw_reborn_composition/src/extension_lifecycle.rs index 1ed56f57496..effce7a7370 100644 --- a/crates/ironclaw_reborn_composition/src/extension_lifecycle.rs +++ b/crates/ironclaw_reborn_composition/src/extension_lifecycle.rs @@ -292,6 +292,7 @@ impl RebornLocalExtensionManagementPort { ) -> Result { let package_ref = available.package_ref.clone(); let plan = prepare_install(&available)?; + let _operation_guard = self.operation_lock.lock().await; if force && self .installation_store @@ -300,9 +301,8 @@ impl RebornLocalExtensionManagementPort { .map_err(map_extension_installation_error)? .is_some() { - self.remove(package_ref.clone()).await?; + self.remove_locked(package_ref.clone()).await?; } - let _operation_guard = self.operation_lock.lock().await; if !force { self.ensure_not_installed(&available.package.id, plan.installation.installation_id()) .await?; @@ -486,8 +486,15 @@ impl RebornLocalExtensionManagementPort { &self, package_ref: LifecyclePackageRef, ) -> Result { - let (extension_id, installation_id) = extension_ids_from_package_ref(&package_ref)?; let _operation_guard = self.operation_lock.lock().await; + self.remove_locked(package_ref).await + } + + async fn remove_locked( + &self, + package_ref: LifecyclePackageRef, + ) -> Result { + let (extension_id, installation_id) = extension_ids_from_package_ref(&package_ref)?; let installation = self .load_installation(&extension_id, &installation_id) .await?; diff --git a/crates/ironclaw_reborn_composition/src/extension_lifecycle_command.rs b/crates/ironclaw_reborn_composition/src/extension_lifecycle_command.rs index 697ae84a469..855956df2e6 100644 --- a/crates/ironclaw_reborn_composition/src/extension_lifecycle_command.rs +++ b/crates/ironclaw_reborn_composition/src/extension_lifecycle_command.rs @@ -181,6 +181,7 @@ fn phase_label(phase: LifecyclePhase) -> &'static str { fn extension_source_label(source: LifecycleExtensionSource) -> &'static str { match source { LifecycleExtensionSource::HostBundled => "host_bundled", + LifecycleExtensionSource::Installed => "installed", LifecycleExtensionSource::Registry => "registry", } } diff --git a/crates/ironclaw_reborn_composition/src/ironhub.rs b/crates/ironclaw_reborn_composition/src/ironhub.rs deleted file mode 100644 index 880525048c3..00000000000 --- a/crates/ironclaw_reborn_composition/src/ironhub.rs +++ /dev/null @@ -1,1605 +0,0 @@ -use std::collections::HashMap; -use std::sync::{Arc, LazyLock}; -use std::time::{Duration, Instant}; - -use async_trait::async_trait; -use base64::Engine; -use base64::engine::general_purpose::URL_SAFE_NO_PAD; -use ed25519_dalek::{Signature, VerifyingKey}; -use ironclaw_extensions::{ - CapabilityManifest, CapabilityVisibility, ExtensionError, ExtensionManifest, ExtensionPackage, - ManifestSource, -}; -use ironclaw_host_api::{ - CapabilityId, CapabilityProfileSchemaRef, EffectKind, HostPortId, InvocationId, NetworkMethod, - NetworkPolicy, NetworkScheme, NetworkTargetPattern, PermissionMode, ResourceEstimate, - ResourceProfile, ResourceScope, ResourceUsage, RuntimeDispatchErrorKind, RuntimeHttpEgress, - RuntimeHttpEgressRequest, RuntimeKind, UserId, VirtualPath, sha256_digest_token, -}; -use ironclaw_host_runtime::{ - FirstPartyCapabilityError, FirstPartyCapabilityHandler, FirstPartyCapabilityRegistry, - FirstPartyCapabilityRequest, FirstPartyCapabilityResult, -}; -use ironclaw_product_workflow::{ - LifecyclePackageId, LifecyclePackageKind, LifecyclePackageRef, LifecyclePhase, - LifecycleProductPayload, LifecycleProductResponse, ProductWorkflowError, -}; -use serde::{Deserialize, Serialize}; -use thiserror::Error; -use tokio::sync::Mutex as AsyncMutex; - -use crate::available_extensions::{ - AvailableExtensionAsset, AvailableExtensionAssetContent, AvailableExtensionPackage, -}; -use crate::extension_lifecycle::RebornLocalExtensionManagementPort; -use crate::factory::RebornServices; -use crate::lifecycle::{RebornLocalSkillManagementPort, response_with_payload}; - -pub(crate) const DEFAULT_IRONHUB_MANIFEST_URL: &str = - "https://hub.ironclaw.com/api/catalog/manifest.json"; - -const MANIFEST_VERIFY_KEYS: &[(&str, &str)] = &[( - "5895a21abea89672", - "f64d2d3a3228b16ca59450364d26b278071a1a425544f242504033341d8459bd", -)]; -const MAX_MANIFEST_BYTES: u64 = 1024 * 1024; -const MAX_SIGNED_MANIFEST_BYTES: u64 = MAX_MANIFEST_BYTES * 2; -const MAX_METADATA_BYTES: u64 = 1024 * 1024; -const MAX_WASM_BYTES: u64 = 16 * 1024 * 1024; -const MANIFEST_CACHE_TTL: Duration = Duration::from_secs(60); -const MANIFEST_CACHE_MAX_ENTRIES: usize = 64; -const GENERIC_TOOL_INPUT_SCHEMA: &[u8] = br#"{"type":"object","additionalProperties":true}"#; -const GENERIC_TOOL_OUTPUT_SCHEMA: &[u8] = - br#"{"description":"Raw JSON output from the installed IronHub tool"}"#; -pub(crate) const IRONHUB_SEARCH_CAPABILITY_ID: &str = "builtin.ironhub_search"; -pub(crate) const IRONHUB_INFO_CAPABILITY_ID: &str = "builtin.ironhub_info"; -pub(crate) const IRONHUB_INSTALL_CAPABILITY_ID: &str = "builtin.ironhub_install"; -const IRONHUB_CAPABILITY_IDS: [&str; 3] = [ - IRONHUB_SEARCH_CAPABILITY_ID, - IRONHUB_INFO_CAPABILITY_ID, - IRONHUB_INSTALL_CAPABILITY_ID, -]; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum IronHubEntryKind { - Tool, - Skill, -} - -impl IronHubEntryKind { - fn as_str(self) -> &'static str { - match self { - Self::Tool => "tool", - Self::Skill => "skill", - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum IronHubProvenance { - #[serde(alias = "repo")] - Official, - Trusted, - Verified, - #[default] - #[serde(alias = "community")] - New, -} - -impl IronHubProvenance { - pub fn as_wire(self) -> &'static str { - match self { - Self::Official => "official", - Self::Trusted => "trusted", - Self::Verified => "verified", - Self::New => "new", - } - } - - pub fn is_community_unverified(self) -> bool { - matches!(self, Self::New) - } - - pub fn trust_label(self) -> &'static str { - match self { - Self::Official => "NEAR-vetted (official)", - Self::Trusted => "community, trusted publisher", - Self::Verified => "community, verified publisher", - Self::New => "UNVERIFIED community (new author)", - } - } -} - -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct IronHubManifest { - pub version: String, - pub generated_at: String, - pub release_tag: String, - pub repo: String, - #[serde(default)] - pub tools: Vec, - #[serde(default)] - pub skills: Vec, -} - -impl IronHubManifest { - fn find_tool(&self, name: &str) -> Option<&IronHubToolEntry> { - self.tools.iter().find(|entry| entry.name == name) - } - - fn find_skill(&self, name: &str) -> Option<&IronHubSkillEntry> { - self.skills.iter().find(|entry| entry.name == name) - } -} - -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct IronHubToolEntry { - pub name: String, - pub crate_name: String, - pub version: String, - #[serde(default)] - pub description: String, - #[serde(default)] - pub provenance: IronHubProvenance, - pub wasm: IronHubArtifact, - pub capabilities: IronHubArtifact, -} - -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct IronHubSkillEntry { - pub name: String, - #[serde(default)] - pub trunk: String, - #[serde(default)] - pub version: String, - #[serde(default)] - pub description: String, - #[serde(default)] - pub provenance: IronHubProvenance, - pub skill_md: IronHubArtifact, -} - -#[derive(Debug, Clone, Deserialize, Serialize)] -pub struct IronHubArtifact { - pub url: String, - pub size_bytes: u64, - pub sha256: String, -} - -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub struct IronHubInstallOptions { - pub kind: Option, - pub force: bool, - pub acknowledge_unverified: bool, - pub expected_version: Option, - pub expected_artifact_digest: Option, -} - -#[derive(Debug, Clone, PartialEq, Eq, Serialize)] -pub struct IronHubInstallOutcome { - pub kind: IronHubEntryKind, - pub name: String, - pub version: String, - pub release_tag: String, - pub provenance: IronHubProvenance, - pub artifact_digest: String, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum IronHubCommand { - Search { - query: String, - }, - List { - kind: Option, - }, - Info { - name: String, - }, - Install { - name: String, - options: IronHubInstallOptions, - }, -} - -#[derive(Debug, Error)] -pub enum IronHubCommandError { - #[error("IronHub is available only for local-dev Reborn services")] - LocalRuntimeUnavailable, - #[error("IronHub runtime HTTP egress is unavailable")] - RuntimeHttpEgressUnavailable, - #[error("invalid IronHub input: {reason}")] - InvalidInput { reason: String }, - #[error("IronHub catalog failed: {reason}")] - Catalog { reason: String }, - #[error("IronHub install failed: {reason}")] - Install { reason: String }, - #[error("IronHub lifecycle failed: {0}")] - Product(#[from] ProductWorkflowError), -} - -#[derive(Debug, Deserialize)] -struct SignedManifestEnvelope { - v: u8, - key_id: String, - manifest_b64: String, - sig: String, -} - -struct CachedManifest { - manifest: Arc, - fetched_at: Instant, -} - -static MANIFEST_CACHE: LazyLock>> = - LazyLock::new(|| std::sync::Mutex::new(HashMap::new())); -static INSTALL_LOCKS: LazyLock>>>> = - LazyLock::new(|| std::sync::Mutex::new(HashMap::new())); - -pub async fn execute_reborn_ironhub_command( - services: &RebornServices, - command: IronHubCommand, -) -> Result { - let local_runtime = services - .local_runtime - .as_ref() - .ok_or(IronHubCommandError::LocalRuntimeUnavailable)?; - let extension_management = local_runtime - .extension_management - .as_ref() - .ok_or(IronHubCommandError::LocalRuntimeUnavailable)?; - let runtime_http_egress = local_runtime - .runtime_http_egress - .as_ref() - .ok_or(IronHubCommandError::RuntimeHttpEgressUnavailable)?; - let scope = ResourceScope::local_default( - UserId::new("reborn-cli").map_err(invalid_input)?, - InvocationId::new(), - ) - .map_err(invalid_input)?; - let service = IronHubService::new( - Arc::clone(&local_runtime.skill_management), - Arc::clone(extension_management), - Arc::clone(runtime_http_egress), - scope, - ); - service.execute(command).await -} - -pub(crate) fn extend_builtin_first_party_package( - mut package: ExtensionPackage, -) -> Result { - package - .manifest - .capabilities - .extend(capability_manifests()?); - ExtensionPackage::from_manifest(package.manifest, package.root) -} - -pub(crate) fn insert_handlers( - registry: &mut FirstPartyCapabilityRegistry, - skill_management: Arc, - extension_management: Arc, -) -> Result<(), ironclaw_host_api::HostApiError> { - let handler = Arc::new(IronHubCapabilityHandler { - skill_management, - extension_management, - }); - for capability_id in IRONHUB_CAPABILITY_IDS { - registry.insert_handler(CapabilityId::new(capability_id)?, handler.clone()); - } - Ok(()) -} - -fn capability_manifests() -> Result, ExtensionError> { - Ok(vec![ - capability_manifest( - IRONHUB_SEARCH_CAPABILITY_ID, - "Search the signed IronHub catalog for tools and skills", - vec![EffectKind::Network], - PermissionMode::Allow, - )?, - capability_manifest( - IRONHUB_INFO_CAPABILITY_ID, - "Inspect one signed IronHub catalog entry", - vec![EffectKind::Network], - PermissionMode::Allow, - )?, - capability_manifest( - IRONHUB_INSTALL_CAPABILITY_ID, - "Install a tool or skill from the signed IronHub catalog into Reborn local-dev state", - vec![EffectKind::Network, EffectKind::WriteFilesystem], - PermissionMode::Ask, - )?, - ]) -} - -fn capability_manifest( - id: &str, - description: &str, - effects: Vec, - default_permission: PermissionMode, -) -> Result { - let schema_name = id.strip_prefix("builtin.").unwrap_or(id).replace('.', "-"); - Ok(CapabilityManifest { - id: CapabilityId::new(id)?, - implements: Vec::new(), - description: description.to_string(), - effects, - default_permission, - visibility: CapabilityVisibility::Model, - input_schema_ref: CapabilityProfileSchemaRef::new(format!( - "schemas/builtin/{schema_name}.input.v1.json" - ))?, - output_schema_ref: CapabilityProfileSchemaRef::new(format!( - "schemas/builtin/{schema_name}.output.v1.json" - ))?, - prompt_doc_ref: None, - required_host_ports: vec![HostPortId::new("host.runtime.http_egress")?], - runtime_credentials: Vec::new(), - resource_profile: Some(ResourceProfile { - default_estimate: ResourceEstimate { - wall_clock_ms: Some(1_000), - output_bytes: Some(32 * 1024), - ..ResourceEstimate::default() - }, - hard_ceiling: None, - }), - }) -} - -struct IronHubCapabilityHandler { - skill_management: Arc, - extension_management: Arc, -} - -#[derive(Debug, Deserialize)] -struct SearchInput { - #[serde(default)] - query: String, -} - -#[derive(Debug, Deserialize)] -struct InfoInput { - name: String, -} - -#[derive(Debug, Deserialize)] -struct InstallInput { - name: String, - #[serde(default)] - kind: Option, - #[serde(default)] - force: bool, - #[serde(default)] - acknowledge_unverified: bool, - #[serde(default)] - expected_version: Option, - #[serde(default)] - expected_artifact_digest: Option, -} - -#[async_trait] -impl FirstPartyCapabilityHandler for IronHubCapabilityHandler { - async fn dispatch( - &self, - request: FirstPartyCapabilityRequest, - ) -> Result { - let started = Instant::now(); - let Some(runtime_http_egress) = request.services.runtime_http_egress.clone() else { - return Err(FirstPartyCapabilityError::new( - RuntimeDispatchErrorKind::Executor, - )); - }; - let service = IronHubService::new( - Arc::clone(&self.skill_management), - Arc::clone(&self.extension_management), - runtime_http_egress, - request.scope, - ); - let command = match request.capability_id.as_str() { - IRONHUB_SEARCH_CAPABILITY_ID => { - let input: SearchInput = parse_capability_input(request.input)?; - IronHubCommand::Search { query: input.query } - } - IRONHUB_INFO_CAPABILITY_ID => { - let input: InfoInput = parse_capability_input(request.input)?; - IronHubCommand::Info { name: input.name } - } - IRONHUB_INSTALL_CAPABILITY_ID => { - let input: InstallInput = parse_capability_input(request.input)?; - IronHubCommand::Install { - name: input.name, - options: IronHubInstallOptions { - kind: input.kind, - force: input.force, - acknowledge_unverified: input.acknowledge_unverified, - expected_version: input.expected_version, - expected_artifact_digest: input.expected_artifact_digest, - }, - } - } - _ => { - return Err(FirstPartyCapabilityError::new( - RuntimeDispatchErrorKind::UndeclaredCapability, - )); - } - }; - let response = service.execute(command).await.map_err(capability_error)?; - let output = serde_json::to_value(response) - .map_err(|_| FirstPartyCapabilityError::new(RuntimeDispatchErrorKind::OutputDecode))?; - Ok(FirstPartyCapabilityResult::new( - output, - ResourceUsage { - wall_clock_ms: started.elapsed().as_millis().try_into().unwrap_or(u64::MAX), - ..ResourceUsage::default() - }, - )) - } -} - -fn parse_capability_input(input: serde_json::Value) -> Result -where - T: for<'de> Deserialize<'de>, -{ - serde_json::from_value(input) - .map_err(|_| FirstPartyCapabilityError::new(RuntimeDispatchErrorKind::InputEncode)) -} - -fn capability_error(error: IronHubCommandError) -> FirstPartyCapabilityError { - let kind = match error { - IronHubCommandError::InvalidInput { .. } => RuntimeDispatchErrorKind::InputEncode, - IronHubCommandError::LocalRuntimeUnavailable - | IronHubCommandError::RuntimeHttpEgressUnavailable => RuntimeDispatchErrorKind::Executor, - IronHubCommandError::Catalog { .. } - | IronHubCommandError::Install { .. } - | IronHubCommandError::Product(_) => RuntimeDispatchErrorKind::OperationFailed, - }; - FirstPartyCapabilityError::new(kind) -} - -pub fn render_reborn_ironhub_response(label: &str, response: &LifecycleProductResponse) -> String { - let mut output = String::new(); - push_line(&mut output, format_args!("IronHub {label}")); - push_line( - &mut output, - format_args!("phase: {}", phase_label(response.phase)), - ); - if let Some(package_ref) = &response.package_ref { - push_line( - &mut output, - format_args!( - "package: {}/{}", - package_kind_label(package_ref.kind), - package_ref.id.as_str() - ), - ); - } - if let Some(message) = &response.message { - push_line( - &mut output, - format_args!("message: {}", terminal_safe(message)), - ); - } - match response.payload.as_ref() { - Some(LifecycleProductPayload::ExtensionSearch { extensions, count }) => { - push_line(&mut output, format_args!("count: {count}")); - for extension in extensions { - push_line( - &mut output, - format_args!( - "- tool {} {} ({})", - extension.package_ref.id.as_str(), - terminal_safe(&extension.version), - terminal_safe(&extension.description) - ), - ); - } - } - Some(LifecycleProductPayload::CatalogSearch { - tools, - skills, - count, - }) => { - push_line(&mut output, format_args!("count: {count}")); - for tool in tools { - push_line( - &mut output, - format_args!( - "- tool {} {} ({})", - tool.package_ref.id.as_str(), - terminal_safe(&tool.version), - terminal_safe(&tool.description) - ), - ); - } - for skill in skills { - push_line( - &mut output, - format_args!( - "- skill {} {} ({})", - skill.name.as_str(), - terminal_safe(&skill.version), - terminal_safe(&skill.description) - ), - ); - } - } - Some(LifecycleProductPayload::SkillSearch { - skills, - count, - truncated, - .. - }) => { - push_line(&mut output, format_args!("count: {count}")); - push_line(&mut output, format_args!("truncated: {truncated}")); - for skill in skills { - push_line( - &mut output, - format_args!( - "- skill {} {} ({})", - skill.name.as_str(), - terminal_safe(&skill.version), - terminal_safe(&skill.description) - ), - ); - } - } - Some(LifecycleProductPayload::ExtensionInstall { - installed, - visible_capability_ids, - }) => { - push_line(&mut output, format_args!("installed: {installed}")); - for id in visible_capability_ids { - push_line( - &mut output, - format_args!("visible_capability: {}", terminal_safe(id)), - ); - } - } - Some(LifecycleProductPayload::SkillInstall { installed, name }) => { - push_line(&mut output, format_args!("installed: {installed}")); - push_line(&mut output, format_args!("skill: {}", name.as_str())); - } - _ => {} - } - output -} - -pub(crate) struct IronHubService { - skill_management: Arc, - extension_management: Arc, - runtime_http_egress: Arc, - scope: ResourceScope, - manifest_url: String, -} - -impl IronHubService { - pub(crate) fn new( - skill_management: Arc, - extension_management: Arc, - runtime_http_egress: Arc, - scope: ResourceScope, - ) -> Self { - Self { - skill_management, - extension_management, - runtime_http_egress, - scope, - manifest_url: resolve_manifest_url(), - } - } - - pub(crate) async fn execute( - &self, - command: IronHubCommand, - ) -> Result { - match command { - IronHubCommand::Search { query } => self.search(&query).await, - IronHubCommand::List { kind } => self.list(kind).await, - IronHubCommand::Info { name } => self.info(&name).await, - IronHubCommand::Install { name, options } => self.install(&name, options).await, - } - } - - async fn search(&self, query: &str) -> Result { - let manifest = self.fetch_manifest_cached().await?; - let query = query.trim().to_ascii_lowercase(); - let tools = manifest - .tools - .iter() - .filter(|entry| entry_matches(&entry.name, &entry.description, &query)) - .map(tool_summary) - .collect::, _>>()?; - let skills = manifest - .skills - .iter() - .filter(|entry| entry_matches(&entry.name, &entry.description, &query)) - .map(skill_summary) - .collect::, _>>()?; - Ok(response_with_payload( - None, - LifecyclePhase::Discovered, - LifecycleProductPayload::CatalogSearch { - count: tools.len() + skills.len(), - tools, - skills, - }, - )) - } - - async fn list( - &self, - kind: Option, - ) -> Result { - let manifest = self.fetch_manifest_cached().await?; - match kind { - Some(IronHubEntryKind::Skill) => { - let skills = manifest - .skills - .iter() - .map(skill_summary) - .collect::, _>>()?; - Ok(response_with_payload( - None, - LifecyclePhase::Discovered, - LifecycleProductPayload::SkillSearch { - count: skills.len(), - limit: skills.len(), - truncated: false, - skills, - }, - )) - } - None => { - let tools = manifest - .tools - .iter() - .map(tool_summary) - .collect::, _>>()?; - let skills = manifest - .skills - .iter() - .map(skill_summary) - .collect::, _>>()?; - Ok(response_with_payload( - None, - LifecyclePhase::Discovered, - LifecycleProductPayload::CatalogSearch { - count: tools.len() + skills.len(), - tools, - skills, - }, - )) - } - Some(IronHubEntryKind::Tool) => { - let tools = manifest - .tools - .iter() - .map(tool_summary) - .collect::, _>>()?; - Ok(response_with_payload( - None, - LifecyclePhase::Discovered, - LifecycleProductPayload::ExtensionSearch { - count: tools.len(), - extensions: tools, - }, - )) - } - } - } - - async fn info(&self, name: &str) -> Result { - validate_hub_name(name)?; - let manifest = self.fetch_manifest_cached().await?; - let kind = classify(&manifest, name, None)?; - let response = match kind { - IronHubEntryKind::Tool => { - let tool = manifest - .find_tool(name) - .ok_or_else(|| catalog_error("tool not found"))?; - response_with_payload( - Some(package_ref(LifecyclePackageKind::Extension, &tool.name)?), - LifecyclePhase::Discovered, - LifecycleProductPayload::ExtensionSearch { - extensions: vec![tool_summary(tool)?], - count: 1, - }, - ) - } - IronHubEntryKind::Skill => { - let skill = manifest - .find_skill(name) - .ok_or_else(|| catalog_error("skill not found"))?; - response_with_payload( - Some(package_ref(LifecyclePackageKind::Skill, &skill.name)?), - LifecyclePhase::Discovered, - LifecycleProductPayload::SkillSearch { - skills: vec![skill_summary(skill)?], - count: 1, - limit: 1, - truncated: false, - }, - ) - } - }; - Ok(response) - } - - async fn install( - &self, - name: &str, - options: IronHubInstallOptions, - ) -> Result { - validate_hub_name(name)?; - let manifest = self.fetch_manifest_cached().await?; - let (kind, provenance, artifact_digest) = - classify_gate_and_digest(&manifest, name, options.kind, &options)?; - let lock_key = format!("{}:{name}", kind.as_str()); - let lock = install_lock(&lock_key); - let _guard = lock.lock().await; - match kind { - IronHubEntryKind::Skill => { - let entry = manifest - .find_skill(name) - .ok_or_else(|| catalog_error("skill not found"))?; - let content = self - .download_verified(&entry.skill_md, MAX_METADATA_BYTES) - .await?; - let content = - String::from_utf8(content).map_err(|error| IronHubCommandError::Install { - reason: format!("skill markdown is not UTF-8: {error}"), - })?; - let result = self - .skill_management - .install_from_ironhub(Some(&entry.name), &content, &entry.skill_md.url) - .await - .map_err(|error| IronHubCommandError::Install { - reason: error.to_string(), - })?; - let mut response = response_with_payload( - Some(package_ref(LifecyclePackageKind::Skill, &result.name)?), - LifecyclePhase::Installed, - LifecycleProductPayload::SkillInstall { - installed: true, - name: LifecyclePackageId::new(result.name).map_err(product_error)?, - }, - ); - response.message = Some(install_message( - IronHubEntryKind::Skill, - name, - entry.version.as_str(), - provenance, - &artifact_digest, - )); - Ok(response) - } - IronHubEntryKind::Tool => { - let entry = manifest - .find_tool(name) - .ok_or_else(|| catalog_error("tool not found"))?; - let wasm = self.download_verified(&entry.wasm, MAX_WASM_BYTES).await?; - let capabilities = self - .download_verified(&entry.capabilities, MAX_METADATA_BYTES) - .await?; - let package = ironhub_tool_package(entry, &wasm, &capabilities)?; - let mut response = self - .extension_management - .install_available_package(package, options.force) - .await - .map_err(IronHubCommandError::Product)?; - response.message = Some(install_message( - IronHubEntryKind::Tool, - name, - entry.version.as_str(), - provenance, - &artifact_digest, - )); - Ok(response) - } - } - } - - async fn fetch_manifest_cached(&self) -> Result { - let now = Instant::now(); - if let Some(hit) = manifest_cache_get(&self.manifest_url, now) { - return Ok((*hit).clone()); - } - let manifest = Arc::new(self.fetch_manifest().await?); - manifest_cache_put(&self.manifest_url, Arc::clone(&manifest), now); - Ok((*manifest).clone()) - } - - async fn fetch_manifest(&self) -> Result { - validate_artifact_url("hub-manifest", "manifest_url", &self.manifest_url)?; - let envelope = self - .download_url(&self.manifest_url, MAX_SIGNED_MANIFEST_BYTES) - .await?; - let bytes = - verify_signed_manifest(&envelope).map_err(|reason| IronHubCommandError::Catalog { - reason: format!("signed manifest verification failed: {reason}"), - })?; - if bytes.len() > usize::try_from(MAX_MANIFEST_BYTES).unwrap_or(usize::MAX) { - return Err(IronHubCommandError::Catalog { - reason: "manifest exceeds size cap".to_string(), - }); - } - serde_json::from_slice(&bytes).map_err(|error| IronHubCommandError::Catalog { - reason: format!("manifest parse failed: {error}"), - }) - } - - async fn download_verified( - &self, - artifact: &IronHubArtifact, - max_bytes: u64, - ) -> Result, IronHubCommandError> { - validate_artifact(artifact, max_bytes)?; - let bytes = self.download_url(&artifact.url, max_bytes).await?; - let actual = sha256_digest_token(&bytes); - if !actual.eq_ignore_ascii_case(&artifact.sha256) { - return Err(IronHubCommandError::Install { - reason: format!( - "checksum mismatch for {}: expected {}, got {}", - artifact.url, artifact.sha256, actual - ), - }); - } - Ok(bytes) - } - - async fn download_url( - &self, - url: &str, - max_bytes: u64, - ) -> Result, IronHubCommandError> { - let request = RuntimeHttpEgressRequest { - runtime: RuntimeKind::FirstParty, - scope: self.scope.clone(), - capability_id: CapabilityId::new("builtin.ironhub_fetch").map_err(invalid_input)?, - method: NetworkMethod::Get, - url: url.to_string(), - headers: Vec::new(), - body: Vec::new(), - network_policy: network_policy_for_url(url, max_bytes)?, - credential_injections: Vec::new(), - response_body_limit: Some(max_bytes), - save_body_to: None, - timeout_ms: Some(30_000), - }; - let response = self - .runtime_http_egress - .execute(request) - .await - .map_err(|error| IronHubCommandError::Catalog { - reason: error.stable_runtime_reason().to_string(), - })?; - if !(200..300).contains(&response.status) { - return Err(IronHubCommandError::Catalog { - reason: format!("download returned HTTP {}", response.status), - }); - } - if response.body.len() > usize::try_from(max_bytes).unwrap_or(usize::MAX) { - return Err(IronHubCommandError::Catalog { - reason: "download exceeds size cap".to_string(), - }); - } - Ok(response.body) - } -} - -fn ironhub_tool_package( - entry: &IronHubToolEntry, - wasm: &[u8], - capabilities: &[u8], -) -> Result { - validate_hub_name(&entry.name)?; - let manifest_toml = generic_tool_manifest(entry); - let root = VirtualPath::new(format!("/system/extensions/{}", entry.name)) - .map_err(|error| install_error(error.to_string()))?; - let host_ports = ironclaw_host_runtime::default_host_port_catalog() - .map_err(|error| install_error(error.to_string()))?; - let contracts = ironclaw_host_runtime::default_host_api_contract_registry() - .map_err(|error| install_error(error.to_string()))?; - let manifest = ExtensionManifest::parse_with_optional_host_api_contracts( - &manifest_toml, - ManifestSource::RegistryInstalled, - &host_ports, - &contracts, - ) - .map_err(|error| install_error(error.to_string()))?; - let package = ExtensionPackage::from_manifest_toml(manifest, root, &manifest_toml) - .map_err(|error| install_error(error.to_string()))?; - let package_ref = package_ref(LifecyclePackageKind::Extension, &entry.name)?; - Ok(AvailableExtensionPackage { - package_ref, - manifest_toml, - package, - assets: vec![ - bytes_asset("manifest.toml", manifest_toml_bytes(entry).as_slice()), - bytes_asset(&format!("wasm/{}_tool.wasm", entry.name), wasm), - bytes_asset("legacy/capabilities.json", capabilities), - bytes_asset( - &format!("schemas/{}/invoke.input.v1.json", entry.name), - GENERIC_TOOL_INPUT_SCHEMA, - ), - bytes_asset( - &format!("schemas/{}/raw_output.v1.json", entry.name), - GENERIC_TOOL_OUTPUT_SCHEMA, - ), - ], - }) -} - -fn manifest_toml_bytes(entry: &IronHubToolEntry) -> Vec { - generic_tool_manifest(entry).into_bytes() -} - -fn generic_tool_manifest(entry: &IronHubToolEntry) -> String { - format!( - r#"schema_version = "reborn.extension_manifest.v2" -id = "{id}" -name = "{name}" -version = "{version}" -description = "{description}" -trust = "third_party" - -[runtime] -kind = "wasm" -module = "wasm/{id}_tool.wasm" - -[[capabilities]] -id = "{id}.invoke" -description = "{description}" -effects = ["dispatch_capability", "network"] -default_permission = "ask" -visibility = "model" -input_schema_ref = "schemas/{id}/invoke.input.v1.json" -output_schema_ref = "schemas/{id}/raw_output.v1.json" -required_host_ports = ["host.runtime.http_egress"] -"#, - id = toml_escape(&entry.name), - name = toml_escape(&entry.name), - version = toml_escape(&entry.version), - description = toml_escape(&entry.description), - ) -} - -fn bytes_asset(path: &str, bytes: &[u8]) -> AvailableExtensionAsset { - AvailableExtensionAsset { - path: path.to_string(), - content: AvailableExtensionAssetContent::Bytes(bytes.to_vec()), - } -} - -fn verify_signed_manifest(envelope_bytes: &[u8]) -> Result, String> { - verify_signed_manifest_with_keys(envelope_bytes, MANIFEST_VERIFY_KEYS) -} - -fn verify_signed_manifest_with_keys( - envelope_bytes: &[u8], - verify_keys: &[(&str, &str)], -) -> Result, String> { - let env: SignedManifestEnvelope = serde_json::from_slice(envelope_bytes) - .map_err(|error| format!("envelope parse failed: {error}"))?; - if env.v != 1 { - return Err(format!("unsupported signed-manifest version {}", env.v)); - } - let key_hex = verify_keys - .iter() - .find(|(id, _)| *id == env.key_id) - .map(|(_, key)| *key) - .ok_or_else(|| format!("unknown manifest signing key_id '{}'", env.key_id))?; - let verifying_key = verifying_key_from_hex(key_hex)?; - let manifest_bytes = URL_SAFE_NO_PAD - .decode(env.manifest_b64.as_bytes()) - .map_err(|error| format!("manifest_b64 decode failed: {error}"))?; - let sig_bytes = URL_SAFE_NO_PAD - .decode(env.sig.as_bytes()) - .map_err(|error| format!("signature decode failed: {error}"))?; - let signature = Signature::from_slice(&sig_bytes) - .map_err(|error| format!("signature malformed: {error}"))?; - verifying_key - .verify_strict(&manifest_bytes, &signature) - .map_err(|_| "manifest signature verification failed".to_string())?; - Ok(manifest_bytes) -} - -fn verifying_key_from_hex(hex: &str) -> Result { - let raw = hex::decode(hex).map_err(|error| format!("verify key is not valid hex: {error}"))?; - let raw: [u8; 32] = raw - .try_into() - .map_err(|_| "verify key must be 32 bytes".to_string())?; - VerifyingKey::from_bytes(&raw).map_err(|error| format!("invalid verify key: {error}")) -} - -fn classify_gate_and_digest( - manifest: &IronHubManifest, - name: &str, - hint: Option, - options: &IronHubInstallOptions, -) -> Result<(IronHubEntryKind, IronHubProvenance, String), IronHubCommandError> { - let kind = classify(manifest, name, hint)?; - let (version, provenance, artifact_digest) = match kind { - IronHubEntryKind::Tool => { - let entry = manifest - .find_tool(name) - .ok_or_else(|| catalog_error("tool not found"))?; - ( - entry.version.as_str(), - entry.provenance, - tool_artifact_digest(entry), - ) - } - IronHubEntryKind::Skill => { - let entry = manifest - .find_skill(name) - .ok_or_else(|| catalog_error("skill not found"))?; - ( - entry.version.as_str(), - entry.provenance, - skill_artifact_digest(entry), - ) - } - }; - if let Some(expected) = &options.expected_version - && expected != version - { - return Err(IronHubCommandError::InvalidInput { - reason: format!( - "catalog version for '{name}' changed: expected {expected}, current {version}" - ), - }); - } - if let Some(expected) = &options.expected_artifact_digest - && !expected.eq_ignore_ascii_case(&artifact_digest) - { - return Err(IronHubCommandError::InvalidInput { - reason: format!( - "artifact digest for '{name}' changed: expected {expected}, current {artifact_digest}" - ), - }); - } - if provenance.is_community_unverified() && !options.acknowledge_unverified { - return Err(IronHubCommandError::InvalidInput { - reason: format!( - "'{name}' is UNVERIFIED community content (trust tier: {}). Re-run with acknowledgement to install at your own risk.", - provenance.as_wire() - ), - }); - } - Ok((kind, provenance, artifact_digest)) -} - -fn classify( - manifest: &IronHubManifest, - name: &str, - hint: Option, -) -> Result { - let in_tools = manifest.find_tool(name).is_some(); - let in_skills = manifest.find_skill(name).is_some(); - match (hint, in_tools, in_skills) { - (Some(IronHubEntryKind::Tool), true, _) => Ok(IronHubEntryKind::Tool), - (Some(IronHubEntryKind::Tool), false, _) => Err(invalid_input(format!( - "'{name}' is not a tool in this IronHub catalog" - ))), - (Some(IronHubEntryKind::Skill), _, true) => Ok(IronHubEntryKind::Skill), - (Some(IronHubEntryKind::Skill), _, false) => Err(invalid_input(format!( - "'{name}' is not a skill in this IronHub catalog" - ))), - (None, true, false) => Ok(IronHubEntryKind::Tool), - (None, false, true) => Ok(IronHubEntryKind::Skill), - (None, true, true) => Err(invalid_input(format!( - "'{name}' exists as both a tool and a skill; specify a kind" - ))), - (None, false, false) => Err(invalid_input(format!( - "'{name}' is not in this IronHub catalog" - ))), - } -} - -fn tool_artifact_digest(entry: &IronHubToolEntry) -> String { - sha256_digest_token(format!("{}:{}", entry.wasm.sha256, entry.capabilities.sha256).as_bytes()) -} - -fn skill_artifact_digest(entry: &IronHubSkillEntry) -> String { - sha256_digest_token(entry.skill_md.sha256.as_bytes()) -} - -fn validate_artifact( - artifact: &IronHubArtifact, - max_bytes: u64, -) -> Result<(), IronHubCommandError> { - validate_artifact_url("artifact", "url", &artifact.url)?; - if artifact.size_bytes > max_bytes { - return Err(IronHubCommandError::Catalog { - reason: format!("artifact exceeds {} byte cap", max_bytes), - }); - } - if artifact.sha256.len() != 64 || !artifact.sha256.bytes().all(|byte| byte.is_ascii_hexdigit()) - { - return Err(IronHubCommandError::Catalog { - reason: "artifact sha256 must be 64 hex characters".to_string(), - }); - } - Ok(()) -} - -fn validate_artifact_url( - manifest_name: &str, - field: &'static str, - url: &str, -) -> Result<(), IronHubCommandError> { - let parsed = url::Url::parse(url).map_err(|error| IronHubCommandError::Catalog { - reason: format!("{manifest_name}.{field} invalid URL: {error}"), - })?; - if parsed.scheme() != "https" { - return Err(IronHubCommandError::Catalog { - reason: format!("{manifest_name}.{field} must use https"), - }); - } - let host = parsed - .host_str() - .ok_or_else(|| IronHubCommandError::Catalog { - reason: format!("{manifest_name}.{field} host is missing"), - })?; - if host_is_disallowed_target(host) || !is_allowed_artifact_host(host) { - return Err(IronHubCommandError::Catalog { - reason: format!("{manifest_name}.{field} host '{host}' is not allowed"), - }); - } - Ok(()) -} - -fn network_policy_for_url(url: &str, max_bytes: u64) -> Result { - validate_artifact_url("download", "url", url)?; - let parsed = url::Url::parse(url).map_err(|error| IronHubCommandError::Catalog { - reason: format!("invalid URL: {error}"), - })?; - let host = parsed - .host_str() - .ok_or_else(|| catalog_error("URL host is missing"))?; - Ok(NetworkPolicy { - allowed_targets: vec![NetworkTargetPattern { - scheme: Some(NetworkScheme::Https), - host_pattern: host.to_ascii_lowercase(), - port: parsed.port(), - }], - deny_private_ip_ranges: true, - max_egress_bytes: Some(max_bytes), - }) -} - -fn is_allowed_artifact_host(host: &str) -> bool { - const ALLOWED: &[&str] = &[ - "hub.ironclaw.com", - "github.com", - "objects.githubusercontent.com", - "github-releases.githubusercontent.com", - "raw.githubusercontent.com", - ]; - ALLOWED - .iter() - .any(|allowed| host.eq_ignore_ascii_case(allowed)) - || host.ends_with(".githubusercontent.com") - || extra_artifact_hosts() - .iter() - .any(|allowed| host.eq_ignore_ascii_case(allowed)) -} - -fn extra_artifact_hosts() -> Vec { - std::env::var("IRONHUB_EXTRA_ARTIFACT_HOSTS") - .ok() - .unwrap_or_default() - .split(',') - .map(str::trim) - .map(str::to_ascii_lowercase) - .filter(|host| !host.is_empty() && !host_is_disallowed_target(host)) - .collect() -} - -fn host_is_disallowed_target(host: &str) -> bool { - let host = host.strip_suffix('.').unwrap_or(host); - let ip_form = host - .strip_prefix('[') - .and_then(|value| value.strip_suffix(']')) - .unwrap_or(host); - if ip_form.parse::().is_ok() || host == "localhost" { - return true; - } - const INTERNAL_SUFFIXES: &[&str] = &[ - ".localhost", - ".local", - ".internal", - ".intranet", - ".lan", - ".home", - ".corp", - ".private", - ]; - INTERNAL_SUFFIXES - .iter() - .any(|suffix| host.ends_with(suffix)) - || !host.contains('.') -} - -fn validate_hub_name(name: &str) -> Result<(), IronHubCommandError> { - let valid = !name.is_empty() - && name - .chars() - .all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-' || ch == '_'); - if valid { - Ok(()) - } else { - Err(invalid_input( - "name must be non-empty and contain only lowercase letters, digits, '-', '_'", - )) - } -} - -fn tool_summary( - entry: &IronHubToolEntry, -) -> Result { - Ok(ironclaw_product_workflow::LifecycleExtensionSummary { - package_ref: package_ref(LifecyclePackageKind::Extension, &entry.name)?, - name: entry.name.clone(), - version: entry.version.clone(), - description: format!("{} [{}]", entry.description, entry.provenance.trust_label()), - source: ironclaw_product_workflow::LifecycleExtensionSource::Registry, - runtime_kind: ironclaw_product_workflow::LifecycleExtensionRuntimeKind::WasmTool, - visible_capability_ids: vec![format!("{}.invoke", entry.name)], - visible_read_only_capability_ids: Vec::new(), - credential_requirements: Vec::new(), - onboarding: None, - }) -} - -fn skill_summary( - entry: &IronHubSkillEntry, -) -> Result { - Ok(ironclaw_product_workflow::LifecycleSkillSummary { - name: LifecyclePackageId::new(entry.name.clone()).map_err(product_error)?, - version: entry.version.clone(), - description: format!("{} [{}]", entry.description, entry.provenance.trust_label()), - source: ironclaw_product_workflow::LifecycleSkillSource::Installed, - keywords: Vec::new(), - tags: Vec::new(), - requires_skills: Vec::new(), - }) -} - -fn entry_matches(name: &str, description: &str, query: &str) -> bool { - query.is_empty() - || name.to_ascii_lowercase().contains(query) - || description.to_ascii_lowercase().contains(query) -} - -fn package_ref( - kind: LifecyclePackageKind, - id: &str, -) -> Result { - LifecyclePackageRef::new(kind, id).map_err(product_error) -} - -fn resolve_manifest_url() -> String { - std::env::var("IRONHUB_MANIFEST_URL") - .ok() - .filter(|value| !value.trim().is_empty()) - .unwrap_or_else(|| DEFAULT_IRONHUB_MANIFEST_URL.to_string()) -} - -fn manifest_cache_get(url: &str, now: Instant) -> Option> { - let guard = MANIFEST_CACHE - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - let entry = guard.get(url)?; - (now.duration_since(entry.fetched_at) <= MANIFEST_CACHE_TTL) - .then(|| Arc::clone(&entry.manifest)) -} - -fn manifest_cache_put(url: &str, manifest: Arc, now: Instant) { - let mut guard = MANIFEST_CACHE - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - if guard.len() >= MANIFEST_CACHE_MAX_ENTRIES && !guard.contains_key(url) { - guard.retain(|_, entry| now.duration_since(entry.fetched_at) <= MANIFEST_CACHE_TTL); - if guard.len() >= MANIFEST_CACHE_MAX_ENTRIES - && let Some(victim) = guard.keys().next().cloned() - { - guard.remove(&victim); - } - } - guard.insert( - url.to_string(), - CachedManifest { - manifest, - fetched_at: now, - }, - ); -} - -fn install_lock(key: &str) -> Arc> { - let mut guard = INSTALL_LOCKS - .lock() - .unwrap_or_else(|poisoned| poisoned.into_inner()); - guard - .entry(key.to_string()) - .or_insert_with(|| Arc::new(AsyncMutex::new(()))) - .clone() -} - -fn install_message( - kind: IronHubEntryKind, - name: &str, - version: &str, - provenance: IronHubProvenance, - artifact_digest: &str, -) -> String { - format!( - "installed {} '{}' {} from IronHub; provenance={}, artifact_digest={}", - kind.as_str(), - name, - version, - provenance.as_wire(), - artifact_digest - ) -} - -fn phase_label(phase: LifecyclePhase) -> &'static str { - match phase { - LifecyclePhase::Discovered => "discovered", - LifecyclePhase::Installing => "installing", - LifecyclePhase::Installed => "installed", - LifecyclePhase::Configured => "configured", - LifecyclePhase::Activating => "activating", - LifecyclePhase::Active => "active", - LifecyclePhase::Disabled => "disabled", - LifecyclePhase::UpgradeRequired => "upgrade_required", - LifecyclePhase::Failed => "failed", - LifecyclePhase::Removing => "removing", - LifecyclePhase::Removed => "removed", - LifecyclePhase::UnsupportedOrLegacy => "unsupported_or_legacy", - } -} - -fn package_kind_label(kind: LifecyclePackageKind) -> &'static str { - match kind { - LifecyclePackageKind::Extension => "extension", - LifecyclePackageKind::Skill => "skill", - LifecyclePackageKind::Mcp => "mcp", - LifecyclePackageKind::Wasm => "wasm", - } -} - -fn terminal_safe(value: &str) -> String { - value.chars().flat_map(char::escape_default).collect() -} - -fn push_line(output: &mut String, args: std::fmt::Arguments<'_>) { - use std::fmt::Write as _; - let _ = output.write_fmt(args); - output.push('\n'); -} - -fn toml_escape(value: &str) -> String { - value.replace('\\', "\\\\").replace('"', "\\\"") -} - -fn invalid_input(error: impl std::fmt::Display) -> IronHubCommandError { - IronHubCommandError::InvalidInput { - reason: error.to_string(), - } -} - -fn catalog_error(reason: impl Into) -> IronHubCommandError { - IronHubCommandError::Catalog { - reason: reason.into(), - } -} - -fn install_error(reason: impl Into) -> IronHubCommandError { - IronHubCommandError::Install { - reason: reason.into(), - } -} - -fn product_error(error: impl std::fmt::Display) -> IronHubCommandError { - IronHubCommandError::Product(ProductWorkflowError::InvalidBindingRequest { - reason: error.to_string(), - }) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn signed_manifest_verifies_known_test_vector() { - let envelope = br#"{"v":1,"key_id":"test-vector","manifest_b64":"eyJ2ZXJzaW9uIjoiMSIsImdlbmVyYXRlZF9hdCI6IjIwMjYtMDEtMDFUMDA6MDA6MDBaIiwicmVsZWFzZV90YWciOiJ0ZXN0IiwicmVwbyI6Im5lYXJhaS9pcm9uaHViIiwidG9vbHMiOltdLCJza2lsbHMiOltdfQ","sig":"KjsUDgi1enj3iTPNQI6gU1Bwxf01hIUItlFvX9PxgWNybPPrJNIV7vFG-G8hJOalFMwFs5zQHrxbtFDZAlgtBg"}"#; - let manifest = verify_signed_manifest_with_keys( - envelope, - &[( - "test-vector", - "ca46572f4dcd485599cdf95442934a3e3c86e2cae766a85fbffc8d6540959928", - )], - ) - .expect("signed manifest verifies"); - - assert_eq!( - manifest, - br#"{"version":"1","generated_at":"2026-01-01T00:00:00Z","release_tag":"test","repo":"nearai/ironhub","tools":[],"skills":[]}"# - ); - } - - #[test] - fn missing_provenance_defaults_to_unverified() { - let manifest: IronHubManifest = serde_json::from_str( - r#"{ - "version": "1", - "generated_at": "2026-01-01T00:00:00Z", - "release_tag": "test", - "repo": "nearai/ironhub", - "tools": [{ - "name": "community-tool", - "crate_name": "community-tool", - "version": "0.1.0", - "description": "community", - "wasm": { - "url": "https://hub.ironclaw.com/community-tool.wasm", - "size_bytes": 1, - "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" - }, - "capabilities": { - "url": "https://hub.ironclaw.com/community-tool.capabilities.json", - "size_bytes": 1, - "sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" - } - }], - "skills": [{ - "name": "community-skill", - "version": "0.1.0", - "description": "community", - "skill_md": { - "url": "https://hub.ironclaw.com/community-skill/SKILL.md", - "size_bytes": 1, - "sha256": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" - } - }] - }"#, - ) - .expect("manifest parses"); - - assert_eq!(manifest.tools[0].provenance, IronHubProvenance::New); - assert_eq!(manifest.skills[0].provenance, IronHubProvenance::New); - } - - #[test] - fn unverified_install_requires_acknowledgement() { - let manifest = IronHubManifest { - version: "1".to_string(), - generated_at: "2026-01-01T00:00:00Z".to_string(), - release_tag: "test".to_string(), - repo: "nearai/ironhub".to_string(), - tools: Vec::new(), - skills: vec![IronHubSkillEntry { - name: "community-skill".to_string(), - trunk: String::new(), - version: "0.1.0".to_string(), - description: String::new(), - provenance: IronHubProvenance::New, - skill_md: IronHubArtifact { - url: "https://hub.ironclaw.com/community-skill/SKILL.md".to_string(), - size_bytes: 1, - sha256: "c".repeat(64), - }, - }], - }; - - let blocked = classify_gate_and_digest( - &manifest, - "community-skill", - Some(IronHubEntryKind::Skill), - &IronHubInstallOptions::default(), - ) - .expect_err("unverified content requires acknowledgement"); - assert!(blocked.to_string().contains("UNVERIFIED community content")); - - let allowed = classify_gate_and_digest( - &manifest, - "community-skill", - Some(IronHubEntryKind::Skill), - &IronHubInstallOptions { - acknowledge_unverified: true, - ..IronHubInstallOptions::default() - }, - ) - .expect("acknowledged unverified content can proceed"); - assert_eq!(allowed.0, IronHubEntryKind::Skill); - assert_eq!(allowed.1, IronHubProvenance::New); - } - - #[test] - fn renderer_includes_tools_and_skills_in_mixed_search() { - let response = response_with_payload( - None, - LifecyclePhase::Discovered, - LifecycleProductPayload::CatalogSearch { - count: 2, - tools: vec![ - tool_summary(&IronHubToolEntry { - name: "web".to_string(), - crate_name: "web-tool".to_string(), - version: "0.1.0".to_string(), - description: "web tool".to_string(), - provenance: IronHubProvenance::Official, - wasm: IronHubArtifact { - url: "https://hub.ironclaw.com/web.wasm".to_string(), - size_bytes: 1, - sha256: "a".repeat(64), - }, - capabilities: IronHubArtifact { - url: "https://hub.ironclaw.com/web.capabilities.json".to_string(), - size_bytes: 1, - sha256: "b".repeat(64), - }, - }) - .expect("tool summary"), - ], - skills: vec![ - skill_summary(&IronHubSkillEntry { - name: "reviewer".to_string(), - trunk: String::new(), - version: "0.2.0".to_string(), - description: "review skill".to_string(), - provenance: IronHubProvenance::Verified, - skill_md: IronHubArtifact { - url: "https://hub.ironclaw.com/reviewer/SKILL.md".to_string(), - size_bytes: 1, - sha256: "c".repeat(64), - }, - }) - .expect("skill summary"), - ], - }, - ); - - let rendered = render_reborn_ironhub_response("search", &response); - assert!(rendered.contains("- tool web 0.1.0")); - assert!(rendered.contains("- skill reviewer 0.2.0")); - } - - #[test] - fn artifact_digest_binds_both_tool_artifacts() { - let tool = IronHubToolEntry { - name: "web".to_string(), - crate_name: "web-tool".to_string(), - version: "0.1.0".to_string(), - description: String::new(), - provenance: IronHubProvenance::Official, - wasm: IronHubArtifact { - url: "https://hub.ironclaw.com/web.wasm".to_string(), - size_bytes: 1, - sha256: "a".repeat(64), - }, - capabilities: IronHubArtifact { - url: "https://hub.ironclaw.com/web.capabilities.json".to_string(), - size_bytes: 1, - sha256: "b".repeat(64), - }, - }; - assert_eq!( - tool_artifact_digest(&tool), - sha256_digest_token(format!("{}:{}", "a".repeat(64), "b".repeat(64)).as_bytes()) - ); - } - - #[test] - fn artifact_url_rejects_internal_hosts_even_when_extra() { - assert!(host_is_disallowed_target("localhost")); - assert!(host_is_disallowed_target("10.0.0.1")); - assert!(host_is_disallowed_target("service.internal")); - } -} diff --git a/crates/ironclaw_reborn_composition/src/ironhub/capabilities.rs b/crates/ironclaw_reborn_composition/src/ironhub/capabilities.rs new file mode 100644 index 00000000000..9c908458d0a --- /dev/null +++ b/crates/ironclaw_reborn_composition/src/ironhub/capabilities.rs @@ -0,0 +1,218 @@ +use std::sync::Arc; +use std::time::Instant; + +use async_trait::async_trait; +use ironclaw_extensions::{ + CapabilityManifest, CapabilityVisibility, ExtensionError, ExtensionPackage, +}; +use ironclaw_host_api::{ + CapabilityId, CapabilityProfileSchemaRef, EffectKind, HostPortId, PermissionMode, + ResourceEstimate, ResourceProfile, ResourceUsage, RuntimeDispatchErrorKind, +}; +use ironclaw_host_runtime::{ + FirstPartyCapabilityError, FirstPartyCapabilityHandler, FirstPartyCapabilityRegistry, + FirstPartyCapabilityRequest, FirstPartyCapabilityResult, +}; +use serde::Deserialize; + +use crate::extension_lifecycle::RebornLocalExtensionManagementPort; +use crate::lifecycle::RebornLocalSkillManagementPort; + +use super::model::{ + IRONHUB_CAPABILITY_IDS, IRONHUB_INFO_CAPABILITY_ID, IRONHUB_INSTALL_CAPABILITY_ID, + IRONHUB_SEARCH_CAPABILITY_ID, IronHubCommand, IronHubCommandError, IronHubEntryKind, + IronHubInstallOptions, +}; +use super::service::IronHubService; + +pub(crate) fn extend_builtin_first_party_package( + mut package: ExtensionPackage, +) -> Result { + package + .manifest + .capabilities + .extend(capability_manifests()?); + ExtensionPackage::from_manifest(package.manifest, package.root) +} + +pub(crate) fn insert_handlers( + registry: &mut FirstPartyCapabilityRegistry, + skill_management: Arc, + extension_management: Arc, +) -> Result<(), ironclaw_host_api::HostApiError> { + let handler = Arc::new(IronHubCapabilityHandler { + skill_management, + extension_management, + }); + for capability_id in IRONHUB_CAPABILITY_IDS { + registry.insert_handler(CapabilityId::new(capability_id)?, handler.clone()); + } + Ok(()) +} + +fn capability_manifests() -> Result, ExtensionError> { + Ok(vec![ + capability_manifest( + IRONHUB_SEARCH_CAPABILITY_ID, + "Search the signed IronHub catalog for tools and skills", + vec![EffectKind::Network], + PermissionMode::Allow, + )?, + capability_manifest( + IRONHUB_INFO_CAPABILITY_ID, + "Inspect one signed IronHub catalog entry", + vec![EffectKind::Network], + PermissionMode::Allow, + )?, + capability_manifest( + IRONHUB_INSTALL_CAPABILITY_ID, + "Install a tool or skill from the signed IronHub catalog into Reborn local-dev state", + vec![EffectKind::Network, EffectKind::WriteFilesystem], + PermissionMode::Ask, + )?, + ]) +} + +fn capability_manifest( + id: &str, + description: &str, + effects: Vec, + default_permission: PermissionMode, +) -> Result { + let schema_name = id.strip_prefix("builtin.").unwrap_or(id).replace('.', "-"); + Ok(CapabilityManifest { + id: CapabilityId::new(id)?, + implements: Vec::new(), + description: description.to_string(), + effects, + default_permission, + visibility: CapabilityVisibility::Model, + input_schema_ref: CapabilityProfileSchemaRef::new(format!( + "schemas/builtin/{schema_name}.input.v1.json" + ))?, + output_schema_ref: CapabilityProfileSchemaRef::new(format!( + "schemas/builtin/{schema_name}.output.v1.json" + ))?, + prompt_doc_ref: None, + required_host_ports: vec![HostPortId::new("host.runtime.http_egress")?], + runtime_credentials: Vec::new(), + resource_profile: Some(ResourceProfile { + default_estimate: ResourceEstimate { + wall_clock_ms: Some(1_000), + output_bytes: Some(32 * 1024), + ..ResourceEstimate::default() + }, + hard_ceiling: None, + }), + }) +} + +struct IronHubCapabilityHandler { + skill_management: Arc, + extension_management: Arc, +} + +#[derive(Debug, Deserialize)] +struct SearchInput { + #[serde(default)] + query: String, +} + +#[derive(Debug, Deserialize)] +struct InfoInput { + name: String, +} + +#[derive(Debug, Deserialize)] +struct InstallInput { + name: String, + #[serde(default)] + kind: Option, + #[serde(default)] + force: bool, + #[serde(default)] + acknowledge_unverified: bool, + #[serde(default)] + expected_version: Option, + #[serde(default)] + expected_artifact_digest: Option, +} + +#[async_trait] +impl FirstPartyCapabilityHandler for IronHubCapabilityHandler { + async fn dispatch( + &self, + request: FirstPartyCapabilityRequest, + ) -> Result { + let started = Instant::now(); + let Some(runtime_http_egress) = request.services.runtime_http_egress.clone() else { + return Err(FirstPartyCapabilityError::new( + RuntimeDispatchErrorKind::Executor, + )); + }; + let service = IronHubService::new( + Arc::clone(&self.skill_management), + Arc::clone(&self.extension_management), + runtime_http_egress, + request.scope, + ); + let command = match request.capability_id.as_str() { + IRONHUB_SEARCH_CAPABILITY_ID => { + let input: SearchInput = parse_capability_input(request.input)?; + IronHubCommand::Search { query: input.query } + } + IRONHUB_INFO_CAPABILITY_ID => { + let input: InfoInput = parse_capability_input(request.input)?; + IronHubCommand::Info { name: input.name } + } + IRONHUB_INSTALL_CAPABILITY_ID => { + let input: InstallInput = parse_capability_input(request.input)?; + IronHubCommand::Install { + name: input.name, + options: IronHubInstallOptions { + kind: input.kind, + force: input.force, + acknowledge_unverified: input.acknowledge_unverified, + expected_version: input.expected_version, + expected_artifact_digest: input.expected_artifact_digest, + }, + } + } + _ => { + return Err(FirstPartyCapabilityError::new( + RuntimeDispatchErrorKind::UndeclaredCapability, + )); + } + }; + let response = service.execute(command).await.map_err(capability_error)?; + let output = serde_json::to_value(response) + .map_err(|_| FirstPartyCapabilityError::new(RuntimeDispatchErrorKind::OutputDecode))?; + Ok(FirstPartyCapabilityResult::new( + output, + ResourceUsage { + wall_clock_ms: started.elapsed().as_millis().try_into().unwrap_or(u64::MAX), + ..ResourceUsage::default() + }, + )) + } +} + +fn parse_capability_input(input: serde_json::Value) -> Result +where + T: for<'de> Deserialize<'de>, +{ + serde_json::from_value(input) + .map_err(|_| FirstPartyCapabilityError::new(RuntimeDispatchErrorKind::InputEncode)) +} + +fn capability_error(error: IronHubCommandError) -> FirstPartyCapabilityError { + let kind = match error { + IronHubCommandError::InvalidInput { .. } => RuntimeDispatchErrorKind::InputEncode, + IronHubCommandError::LocalRuntimeUnavailable + | IronHubCommandError::RuntimeHttpEgressUnavailable => RuntimeDispatchErrorKind::Executor, + IronHubCommandError::Catalog { .. } + | IronHubCommandError::Install { .. } + | IronHubCommandError::Product(_) => RuntimeDispatchErrorKind::OperationFailed, + }; + FirstPartyCapabilityError::new(kind) +} diff --git a/crates/ironclaw_reborn_composition/src/ironhub/catalog.rs b/crates/ironclaw_reborn_composition/src/ironhub/catalog.rs new file mode 100644 index 00000000000..8586245cbcf --- /dev/null +++ b/crates/ironclaw_reborn_composition/src/ironhub/catalog.rs @@ -0,0 +1,324 @@ +use base64::Engine; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use ed25519_dalek::{Signature, VerifyingKey}; +use ironclaw_host_api::{NetworkPolicy, NetworkScheme, NetworkTargetPattern, sha256_digest_token}; +use ironclaw_product_workflow::{LifecyclePackageId, LifecyclePackageKind, LifecyclePackageRef}; + +use super::errors::{catalog_error, invalid_input, product_error}; +use super::model::{ + IronHubArtifact, IronHubCommandError, IronHubEntryKind, IronHubInstallOptions, IronHubManifest, + IronHubProvenance, IronHubSkillEntry, IronHubToolEntry, MANIFEST_VERIFY_KEYS, + SignedManifestEnvelope, +}; + +pub(super) fn verify_signed_manifest(envelope_bytes: &[u8]) -> Result, String> { + verify_signed_manifest_with_keys(envelope_bytes, MANIFEST_VERIFY_KEYS) +} + +pub(super) fn verify_signed_manifest_with_keys( + envelope_bytes: &[u8], + verify_keys: &[(&str, &str)], +) -> Result, String> { + let env: SignedManifestEnvelope = serde_json::from_slice(envelope_bytes) + .map_err(|error| format!("envelope parse failed: {error}"))?; + if env.v != 1 { + return Err(format!("unsupported signed-manifest version {}", env.v)); + } + let key_hex = verify_keys + .iter() + .find(|(id, _)| *id == env.key_id) + .map(|(_, key)| *key) + .ok_or_else(|| format!("unknown manifest signing key_id '{}'", env.key_id))?; + let verifying_key = verifying_key_from_hex(key_hex)?; + let manifest_bytes = URL_SAFE_NO_PAD + .decode(env.manifest_b64.as_bytes()) + .map_err(|error| format!("manifest_b64 decode failed: {error}"))?; + let sig_bytes = URL_SAFE_NO_PAD + .decode(env.sig.as_bytes()) + .map_err(|error| format!("signature decode failed: {error}"))?; + let signature = Signature::from_slice(&sig_bytes) + .map_err(|error| format!("signature malformed: {error}"))?; + verifying_key + .verify_strict(&manifest_bytes, &signature) + .map_err(|_| "manifest signature verification failed".to_string())?; + Ok(manifest_bytes) +} + +fn verifying_key_from_hex(hex: &str) -> Result { + let raw = hex::decode(hex).map_err(|error| format!("verify key is not valid hex: {error}"))?; + let raw: [u8; 32] = raw + .try_into() + .map_err(|_| "verify key must be 32 bytes".to_string())?; + VerifyingKey::from_bytes(&raw).map_err(|error| format!("invalid verify key: {error}")) +} + +pub(super) fn classify_gate_and_digest( + manifest: &IronHubManifest, + name: &str, + hint: Option, + options: &IronHubInstallOptions, +) -> Result<(IronHubEntryKind, IronHubProvenance, String), IronHubCommandError> { + let kind = classify(manifest, name, hint)?; + let (version, provenance, artifact_digest) = match kind { + IronHubEntryKind::Tool => { + let entry = manifest + .find_tool(name) + .ok_or_else(|| catalog_error("tool not found"))?; + ( + entry.version.as_str(), + entry.provenance, + tool_artifact_digest(entry), + ) + } + IronHubEntryKind::Skill => { + let entry = manifest + .find_skill(name) + .ok_or_else(|| catalog_error("skill not found"))?; + ( + entry.version.as_str(), + entry.provenance, + skill_artifact_digest(entry), + ) + } + }; + if let Some(expected) = &options.expected_version + && expected != version + { + return Err(IronHubCommandError::InvalidInput { + reason: format!( + "catalog version for '{name}' changed: expected {expected}, current {version}" + ), + }); + } + if let Some(expected) = &options.expected_artifact_digest + && !expected.eq_ignore_ascii_case(&artifact_digest) + { + return Err(IronHubCommandError::InvalidInput { + reason: format!( + "artifact digest for '{name}' changed: expected {expected}, current {artifact_digest}" + ), + }); + } + if provenance.is_community_unverified() && !options.acknowledge_unverified { + return Err(IronHubCommandError::InvalidInput { + reason: format!( + "'{name}' is UNVERIFIED community content (trust tier: {}). Re-run with acknowledgement to install at your own risk.", + provenance.as_wire() + ), + }); + } + Ok((kind, provenance, artifact_digest)) +} + +pub(super) fn classify( + manifest: &IronHubManifest, + name: &str, + hint: Option, +) -> Result { + let in_tools = manifest.find_tool(name).is_some(); + let in_skills = manifest.find_skill(name).is_some(); + match (hint, in_tools, in_skills) { + (Some(IronHubEntryKind::Tool), true, _) => Ok(IronHubEntryKind::Tool), + (Some(IronHubEntryKind::Tool), false, _) => Err(invalid_input(format!( + "'{name}' is not a tool in this IronHub catalog" + ))), + (Some(IronHubEntryKind::Skill), _, true) => Ok(IronHubEntryKind::Skill), + (Some(IronHubEntryKind::Skill), _, false) => Err(invalid_input(format!( + "'{name}' is not a skill in this IronHub catalog" + ))), + (None, true, false) => Ok(IronHubEntryKind::Tool), + (None, false, true) => Ok(IronHubEntryKind::Skill), + (None, true, true) => Err(invalid_input(format!( + "'{name}' exists as both a tool and a skill; specify a kind" + ))), + (None, false, false) => Err(invalid_input(format!( + "'{name}' is not in this IronHub catalog" + ))), + } +} + +pub(super) fn tool_artifact_digest(entry: &IronHubToolEntry) -> String { + sha256_digest_token(format!("{}:{}", entry.wasm.sha256, entry.capabilities.sha256).as_bytes()) +} + +fn skill_artifact_digest(entry: &IronHubSkillEntry) -> String { + sha256_digest_token(entry.skill_md.sha256.as_bytes()) +} + +pub(super) fn validate_artifact( + artifact: &IronHubArtifact, + max_bytes: u64, +) -> Result<(), IronHubCommandError> { + validate_artifact_url("artifact", "url", &artifact.url)?; + if artifact.size_bytes > max_bytes { + return Err(IronHubCommandError::Catalog { + reason: format!("artifact exceeds {} byte cap", max_bytes), + }); + } + if artifact.sha256.len() != 64 || !artifact.sha256.bytes().all(|byte| byte.is_ascii_hexdigit()) + { + return Err(IronHubCommandError::Catalog { + reason: "artifact sha256 must be 64 hex characters".to_string(), + }); + } + Ok(()) +} + +pub(super) fn validate_artifact_url( + manifest_name: &str, + field: &'static str, + url: &str, +) -> Result<(), IronHubCommandError> { + let parsed = url::Url::parse(url).map_err(|error| IronHubCommandError::Catalog { + reason: format!("{manifest_name}.{field} invalid URL: {error}"), + })?; + if parsed.scheme() != "https" { + return Err(IronHubCommandError::Catalog { + reason: format!("{manifest_name}.{field} must use https"), + }); + } + let host = parsed + .host_str() + .ok_or_else(|| IronHubCommandError::Catalog { + reason: format!("{manifest_name}.{field} host is missing"), + })?; + if host_is_disallowed_target(host) || !is_allowed_artifact_host(host) { + return Err(IronHubCommandError::Catalog { + reason: format!("{manifest_name}.{field} host '{host}' is not allowed"), + }); + } + Ok(()) +} + +pub(super) fn network_policy_for_url( + url: &str, + max_bytes: u64, +) -> Result { + validate_artifact_url("download", "url", url)?; + let parsed = url::Url::parse(url).map_err(|error| IronHubCommandError::Catalog { + reason: format!("invalid URL: {error}"), + })?; + let host = parsed + .host_str() + .ok_or_else(|| catalog_error("URL host is missing"))?; + Ok(NetworkPolicy { + allowed_targets: vec![NetworkTargetPattern { + scheme: Some(NetworkScheme::Https), + host_pattern: host.to_ascii_lowercase(), + port: parsed.port(), + }], + deny_private_ip_ranges: true, + max_egress_bytes: Some(max_bytes), + }) +} + +fn is_allowed_artifact_host(host: &str) -> bool { + const ALLOWED: &[&str] = &[ + "hub.ironclaw.com", + "github.com", + "objects.githubusercontent.com", + "github-releases.githubusercontent.com", + "raw.githubusercontent.com", + ]; + ALLOWED + .iter() + .any(|allowed| host.eq_ignore_ascii_case(allowed)) + || host.ends_with(".githubusercontent.com") + || extra_artifact_hosts() + .iter() + .any(|allowed| host.eq_ignore_ascii_case(allowed)) +} + +fn extra_artifact_hosts() -> Vec { + std::env::var("IRONHUB_EXTRA_ARTIFACT_HOSTS") + .ok() + .unwrap_or_default() + .split(',') + .map(str::trim) + .map(str::to_ascii_lowercase) + .filter(|host| !host.is_empty() && !host_is_disallowed_target(host)) + .collect() +} + +pub(super) fn host_is_disallowed_target(host: &str) -> bool { + let host = host.strip_suffix('.').unwrap_or(host); + let ip_form = host + .strip_prefix('[') + .and_then(|value| value.strip_suffix(']')) + .unwrap_or(host); + if ip_form.parse::().is_ok() || host == "localhost" { + return true; + } + const INTERNAL_SUFFIXES: &[&str] = &[ + ".localhost", + ".local", + ".internal", + ".intranet", + ".lan", + ".home", + ".corp", + ".private", + ]; + INTERNAL_SUFFIXES + .iter() + .any(|suffix| host.ends_with(suffix)) + || !host.contains('.') +} + +pub(super) fn validate_hub_name(name: &str) -> Result<(), IronHubCommandError> { + let valid = !name.is_empty() + && name + .chars() + .all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-' || ch == '_'); + if valid { + Ok(()) + } else { + Err(invalid_input( + "name must be non-empty and contain only lowercase letters, digits, '-', '_'", + )) + } +} + +pub(super) fn tool_summary( + entry: &IronHubToolEntry, +) -> Result { + Ok(ironclaw_product_workflow::LifecycleExtensionSummary { + package_ref: package_ref(LifecyclePackageKind::Extension, &entry.name)?, + name: entry.name.clone(), + version: entry.version.clone(), + description: format!("{} [{}]", entry.description, entry.provenance.trust_label()), + source: ironclaw_product_workflow::LifecycleExtensionSource::Registry, + runtime_kind: ironclaw_product_workflow::LifecycleExtensionRuntimeKind::WasmTool, + visible_capability_ids: vec![format!("{}.invoke", entry.name)], + visible_read_only_capability_ids: Vec::new(), + credential_requirements: Vec::new(), + onboarding: None, + }) +} + +pub(super) fn skill_summary( + entry: &IronHubSkillEntry, +) -> Result { + Ok(ironclaw_product_workflow::LifecycleSkillSummary { + name: LifecyclePackageId::new(entry.name.clone()).map_err(product_error)?, + version: entry.version.clone(), + description: format!("{} [{}]", entry.description, entry.provenance.trust_label()), + source: ironclaw_product_workflow::LifecycleSkillSource::Installed, + keywords: Vec::new(), + tags: Vec::new(), + requires_skills: Vec::new(), + }) +} + +pub(super) fn entry_matches(name: &str, description: &str, query: &str) -> bool { + query.is_empty() + || name.to_ascii_lowercase().contains(query) + || description.to_ascii_lowercase().contains(query) +} + +pub(super) fn package_ref( + kind: LifecyclePackageKind, + id: &str, +) -> Result { + LifecyclePackageRef::new(kind, id).map_err(product_error) +} diff --git a/crates/ironclaw_reborn_composition/src/ironhub/errors.rs b/crates/ironclaw_reborn_composition/src/ironhub/errors.rs new file mode 100644 index 00000000000..96e28c3c27b --- /dev/null +++ b/crates/ironclaw_reborn_composition/src/ironhub/errors.rs @@ -0,0 +1,27 @@ +use ironclaw_product_workflow::ProductWorkflowError; + +use super::model::IronHubCommandError; + +pub(super) fn invalid_input(error: impl std::fmt::Display) -> IronHubCommandError { + IronHubCommandError::InvalidInput { + reason: error.to_string(), + } +} + +pub(super) fn catalog_error(reason: impl Into) -> IronHubCommandError { + IronHubCommandError::Catalog { + reason: reason.into(), + } +} + +pub(super) fn install_error(reason: impl Into) -> IronHubCommandError { + IronHubCommandError::Install { + reason: reason.into(), + } +} + +pub(super) fn product_error(error: impl std::fmt::Display) -> IronHubCommandError { + IronHubCommandError::Product(ProductWorkflowError::InvalidBindingRequest { + reason: error.to_string(), + }) +} diff --git a/crates/ironclaw_reborn_composition/src/ironhub/mod.rs b/crates/ironclaw_reborn_composition/src/ironhub/mod.rs new file mode 100644 index 00000000000..0cb5b11e66d --- /dev/null +++ b/crates/ironclaw_reborn_composition/src/ironhub/mod.rs @@ -0,0 +1,22 @@ +mod capabilities; +mod catalog; +mod errors; +mod model; +mod package; +mod render; +mod service; + +#[cfg(test)] +mod tests; + +pub(crate) use capabilities::{extend_builtin_first_party_package, insert_handlers}; +#[cfg(test)] +pub(crate) use model::{ + IRONHUB_INFO_CAPABILITY_ID, IRONHUB_INSTALL_CAPABILITY_ID, IRONHUB_SEARCH_CAPABILITY_ID, +}; +pub use model::{ + IronHubCommand, IronHubCommandError, IronHubEntryKind, IronHubInstallOptions, IronHubManifest, + IronHubProvenance, +}; +pub use render::render_reborn_ironhub_response; +pub use service::execute_reborn_ironhub_command; diff --git a/crates/ironclaw_reborn_composition/src/ironhub/model.rs b/crates/ironclaw_reborn_composition/src/ironhub/model.rs new file mode 100644 index 00000000000..f6e745d4f3d --- /dev/null +++ b/crates/ironclaw_reborn_composition/src/ironhub/model.rs @@ -0,0 +1,189 @@ +use std::time::Duration; + +use ironclaw_product_workflow::ProductWorkflowError; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +pub(crate) const DEFAULT_IRONHUB_MANIFEST_URL: &str = + "https://hub.ironclaw.com/api/catalog/manifest.json"; + +pub(super) const MANIFEST_VERIFY_KEYS: &[(&str, &str)] = &[( + "5895a21abea89672", + "f64d2d3a3228b16ca59450364d26b278071a1a425544f242504033341d8459bd", +)]; +pub(super) const MAX_MANIFEST_BYTES: u64 = 1024 * 1024; +pub(super) const MAX_SIGNED_MANIFEST_BYTES: u64 = MAX_MANIFEST_BYTES * 2; +pub(super) const MAX_METADATA_BYTES: u64 = 1024 * 1024; +pub(super) const MAX_WASM_BYTES: u64 = 16 * 1024 * 1024; +pub(super) const MANIFEST_CACHE_TTL: Duration = Duration::from_secs(60); +pub(super) const MANIFEST_CACHE_MAX_ENTRIES: usize = 64; +pub(super) const GENERIC_TOOL_INPUT_SCHEMA: &[u8] = + br#"{"type":"object","additionalProperties":true}"#; +pub(super) const GENERIC_TOOL_OUTPUT_SCHEMA: &[u8] = + br#"{"description":"Raw JSON output from the installed IronHub tool"}"#; +pub(crate) const IRONHUB_SEARCH_CAPABILITY_ID: &str = "builtin.ironhub_search"; +pub(crate) const IRONHUB_INFO_CAPABILITY_ID: &str = "builtin.ironhub_info"; +pub(crate) const IRONHUB_INSTALL_CAPABILITY_ID: &str = "builtin.ironhub_install"; +pub(super) const IRONHUB_CAPABILITY_IDS: [&str; 3] = [ + IRONHUB_SEARCH_CAPABILITY_ID, + IRONHUB_INFO_CAPABILITY_ID, + IRONHUB_INSTALL_CAPABILITY_ID, +]; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum IronHubEntryKind { + Tool, + Skill, +} + +impl IronHubEntryKind { + pub(super) fn as_str(self) -> &'static str { + match self { + Self::Tool => "tool", + Self::Skill => "skill", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum IronHubProvenance { + #[serde(alias = "repo")] + Official, + Trusted, + Verified, + #[default] + #[serde(alias = "community")] + New, +} + +impl IronHubProvenance { + pub fn as_wire(self) -> &'static str { + match self { + Self::Official => "official", + Self::Trusted => "trusted", + Self::Verified => "verified", + Self::New => "new", + } + } + + pub fn is_community_unverified(self) -> bool { + matches!(self, Self::New) + } + + pub(super) fn trust_label(self) -> &'static str { + match self { + Self::Official => "NEAR-vetted (official)", + Self::Trusted => "community, trusted publisher", + Self::Verified => "community, verified publisher", + Self::New => "UNVERIFIED community (new author)", + } + } +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct IronHubManifest { + pub version: String, + pub generated_at: String, + pub release_tag: String, + pub repo: String, + #[serde(default)] + pub tools: Vec, + #[serde(default)] + pub skills: Vec, +} + +impl IronHubManifest { + pub(super) fn find_tool(&self, name: &str) -> Option<&IronHubToolEntry> { + self.tools.iter().find(|entry| entry.name == name) + } + + pub(super) fn find_skill(&self, name: &str) -> Option<&IronHubSkillEntry> { + self.skills.iter().find(|entry| entry.name == name) + } +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct IronHubToolEntry { + pub name: String, + pub crate_name: String, + pub version: String, + #[serde(default)] + pub description: String, + #[serde(default)] + pub provenance: IronHubProvenance, + pub wasm: IronHubArtifact, + pub capabilities: IronHubArtifact, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct IronHubSkillEntry { + pub name: String, + #[serde(default)] + pub trunk: String, + #[serde(default)] + pub version: String, + #[serde(default)] + pub description: String, + #[serde(default)] + pub provenance: IronHubProvenance, + pub skill_md: IronHubArtifact, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct IronHubArtifact { + pub url: String, + pub size_bytes: u64, + pub sha256: String, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct IronHubInstallOptions { + pub kind: Option, + pub force: bool, + pub acknowledge_unverified: bool, + pub expected_version: Option, + pub expected_artifact_digest: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum IronHubCommand { + Search { + query: String, + }, + List { + kind: Option, + }, + Info { + name: String, + }, + Install { + name: String, + options: IronHubInstallOptions, + }, +} + +#[derive(Debug, Error)] +pub enum IronHubCommandError { + #[error("IronHub is available only for local-dev Reborn services")] + LocalRuntimeUnavailable, + #[error("IronHub runtime HTTP egress is unavailable")] + RuntimeHttpEgressUnavailable, + #[error("invalid IronHub input: {reason}")] + InvalidInput { reason: String }, + #[error("IronHub catalog failed: {reason}")] + Catalog { reason: String }, + #[error("IronHub install failed: {reason}")] + Install { reason: String }, + #[error("IronHub lifecycle failed: {0}")] + Product(#[from] ProductWorkflowError), +} + +#[derive(Debug, Deserialize)] +pub(super) struct SignedManifestEnvelope { + pub(super) v: u8, + pub(super) key_id: String, + pub(super) manifest_b64: String, + pub(super) sig: String, +} diff --git a/crates/ironclaw_reborn_composition/src/ironhub/package.rs b/crates/ironclaw_reborn_composition/src/ironhub/package.rs new file mode 100644 index 00000000000..1cc2c8d1e19 --- /dev/null +++ b/crates/ironclaw_reborn_composition/src/ironhub/package.rs @@ -0,0 +1,101 @@ +use ironclaw_extensions::{ExtensionManifest, ExtensionPackage, ManifestSource}; +use ironclaw_host_api::VirtualPath; +use ironclaw_product_workflow::LifecyclePackageKind; + +use crate::available_extensions::{ + AvailableExtensionAsset, AvailableExtensionAssetContent, AvailableExtensionPackage, +}; + +use super::catalog::{package_ref, validate_hub_name}; +use super::errors::install_error; +use super::model::{ + GENERIC_TOOL_INPUT_SCHEMA, GENERIC_TOOL_OUTPUT_SCHEMA, IronHubCommandError, IronHubToolEntry, +}; + +pub(super) fn ironhub_tool_package( + entry: &IronHubToolEntry, + wasm: &[u8], + capabilities: &[u8], +) -> Result { + validate_hub_name(&entry.name)?; + let manifest_toml = generic_tool_manifest(entry); + let root = VirtualPath::new(format!("/system/extensions/{}", entry.name)) + .map_err(|error| install_error(error.to_string()))?; + let host_ports = ironclaw_host_runtime::default_host_port_catalog() + .map_err(|error| install_error(error.to_string()))?; + let contracts = ironclaw_host_runtime::default_host_api_contract_registry() + .map_err(|error| install_error(error.to_string()))?; + let manifest = ExtensionManifest::parse_with_optional_host_api_contracts( + &manifest_toml, + ManifestSource::RegistryInstalled, + &host_ports, + &contracts, + ) + .map_err(|error| install_error(error.to_string()))?; + let package = ExtensionPackage::from_manifest_toml(manifest, root, &manifest_toml) + .map_err(|error| install_error(error.to_string()))?; + let package_ref = package_ref(LifecyclePackageKind::Extension, &entry.name)?; + Ok(AvailableExtensionPackage { + package_ref, + manifest_toml, + package, + assets: vec![ + bytes_asset("manifest.toml", manifest_toml_bytes(entry).as_slice()), + bytes_asset(&format!("wasm/{}_tool.wasm", entry.name), wasm), + bytes_asset("legacy/capabilities.json", capabilities), + bytes_asset( + &format!("schemas/{}/invoke.input.v1.json", entry.name), + GENERIC_TOOL_INPUT_SCHEMA, + ), + bytes_asset( + &format!("schemas/{}/raw_output.v1.json", entry.name), + GENERIC_TOOL_OUTPUT_SCHEMA, + ), + ], + }) +} + +fn manifest_toml_bytes(entry: &IronHubToolEntry) -> Vec { + generic_tool_manifest(entry).into_bytes() +} + +fn generic_tool_manifest(entry: &IronHubToolEntry) -> String { + format!( + r#"schema_version = "reborn.extension_manifest.v2" +id = "{id}" +name = "{name}" +version = "{version}" +description = "{description}" +trust = "third_party" + +[runtime] +kind = "wasm" +module = "wasm/{id}_tool.wasm" + +[[capabilities]] +id = "{id}.invoke" +description = "{description}" +effects = ["dispatch_capability", "network"] +default_permission = "ask" +visibility = "model" +input_schema_ref = "schemas/{id}/invoke.input.v1.json" +output_schema_ref = "schemas/{id}/raw_output.v1.json" +required_host_ports = ["host.runtime.http_egress"] +"#, + id = toml_escape(&entry.name), + name = toml_escape(&entry.name), + version = toml_escape(&entry.version), + description = toml_escape(&entry.description), + ) +} + +fn bytes_asset(path: &str, bytes: &[u8]) -> AvailableExtensionAsset { + AvailableExtensionAsset { + path: path.to_string(), + content: AvailableExtensionAssetContent::Bytes(bytes.to_vec()), + } +} + +fn toml_escape(value: &str) -> String { + value.replace('\\', "\\\\").replace('"', "\\\"") +} diff --git a/crates/ironclaw_reborn_composition/src/ironhub/render.rs b/crates/ironclaw_reborn_composition/src/ironhub/render.rs new file mode 100644 index 00000000000..485057fdefa --- /dev/null +++ b/crates/ironclaw_reborn_composition/src/ironhub/render.rs @@ -0,0 +1,147 @@ +use ironclaw_product_workflow::{ + LifecyclePackageKind, LifecyclePhase, LifecycleProductPayload, LifecycleProductResponse, +}; + +pub fn render_reborn_ironhub_response(label: &str, response: &LifecycleProductResponse) -> String { + let mut output = String::new(); + push_line(&mut output, format_args!("IronHub {label}")); + push_line( + &mut output, + format_args!("phase: {}", phase_label(response.phase)), + ); + if let Some(package_ref) = &response.package_ref { + push_line( + &mut output, + format_args!( + "package: {}/{}", + package_kind_label(package_ref.kind), + package_ref.id.as_str() + ), + ); + } + if let Some(message) = &response.message { + push_line( + &mut output, + format_args!("message: {}", terminal_safe(message)), + ); + } + match response.payload.as_ref() { + Some(LifecycleProductPayload::ExtensionSearch { extensions, count }) => { + push_line(&mut output, format_args!("count: {count}")); + for extension in extensions { + push_line( + &mut output, + format_args!( + "- tool {} {} ({})", + extension.package_ref.id.as_str(), + terminal_safe(&extension.version), + terminal_safe(&extension.description) + ), + ); + } + } + Some(LifecycleProductPayload::CatalogSearch { + tools, + skills, + count, + }) => { + push_line(&mut output, format_args!("count: {count}")); + for tool in tools { + push_line( + &mut output, + format_args!( + "- tool {} {} ({})", + tool.package_ref.id.as_str(), + terminal_safe(&tool.version), + terminal_safe(&tool.description) + ), + ); + } + for skill in skills { + push_line( + &mut output, + format_args!( + "- skill {} {} ({})", + skill.name.as_str(), + terminal_safe(&skill.version), + terminal_safe(&skill.description) + ), + ); + } + } + Some(LifecycleProductPayload::SkillSearch { + skills, + count, + truncated, + .. + }) => { + push_line(&mut output, format_args!("count: {count}")); + push_line(&mut output, format_args!("truncated: {truncated}")); + for skill in skills { + push_line( + &mut output, + format_args!( + "- skill {} {} ({})", + skill.name.as_str(), + terminal_safe(&skill.version), + terminal_safe(&skill.description) + ), + ); + } + } + Some(LifecycleProductPayload::ExtensionInstall { + installed, + visible_capability_ids, + }) => { + push_line(&mut output, format_args!("installed: {installed}")); + for id in visible_capability_ids { + push_line( + &mut output, + format_args!("visible_capability: {}", terminal_safe(id)), + ); + } + } + Some(LifecycleProductPayload::SkillInstall { installed, name }) => { + push_line(&mut output, format_args!("installed: {installed}")); + push_line(&mut output, format_args!("skill: {}", name.as_str())); + } + _ => {} + } + output +} + +fn phase_label(phase: LifecyclePhase) -> &'static str { + match phase { + LifecyclePhase::Discovered => "discovered", + LifecyclePhase::Installing => "installing", + LifecyclePhase::Installed => "installed", + LifecyclePhase::Configured => "configured", + LifecyclePhase::Activating => "activating", + LifecyclePhase::Active => "active", + LifecyclePhase::Disabled => "disabled", + LifecyclePhase::UpgradeRequired => "upgrade_required", + LifecyclePhase::Failed => "failed", + LifecyclePhase::Removing => "removing", + LifecyclePhase::Removed => "removed", + LifecyclePhase::UnsupportedOrLegacy => "unsupported_or_legacy", + } +} + +fn package_kind_label(kind: LifecyclePackageKind) -> &'static str { + match kind { + LifecyclePackageKind::Extension => "extension", + LifecyclePackageKind::Skill => "skill", + LifecyclePackageKind::Mcp => "mcp", + LifecyclePackageKind::Wasm => "wasm", + } +} + +fn terminal_safe(value: &str) -> String { + value.chars().flat_map(char::escape_default).collect() +} + +fn push_line(output: &mut String, args: std::fmt::Arguments<'_>) { + use std::fmt::Write as _; + let _ = output.write_fmt(args); + output.push('\n'); +} diff --git a/crates/ironclaw_reborn_composition/src/ironhub/service.rs b/crates/ironclaw_reborn_composition/src/ironhub/service.rs new file mode 100644 index 00000000000..f0e45894b50 --- /dev/null +++ b/crates/ironclaw_reborn_composition/src/ironhub/service.rs @@ -0,0 +1,458 @@ +use std::collections::HashMap; +use std::sync::{Arc, LazyLock}; +use std::time::Instant; + +use ironclaw_host_api::{ + CapabilityId, InvocationId, NetworkMethod, ResourceScope, RuntimeHttpEgress, + RuntimeHttpEgressRequest, RuntimeKind, UserId, sha256_digest_token, +}; +use ironclaw_product_workflow::{ + LifecyclePackageId, LifecyclePackageKind, LifecyclePhase, LifecycleProductPayload, + LifecycleProductResponse, +}; +use tokio::sync::Mutex as AsyncMutex; + +use crate::extension_lifecycle::RebornLocalExtensionManagementPort; +use crate::factory::RebornServices; +use crate::lifecycle::{RebornLocalSkillManagementPort, response_with_payload}; + +use super::catalog::{ + classify, classify_gate_and_digest, entry_matches, network_policy_for_url, package_ref, + skill_summary, tool_summary, validate_artifact, validate_artifact_url, validate_hub_name, + verify_signed_manifest, +}; +use super::errors::{catalog_error, invalid_input, product_error}; +use super::model::{ + DEFAULT_IRONHUB_MANIFEST_URL, IronHubArtifact, IronHubCommand, IronHubCommandError, + IronHubEntryKind, IronHubInstallOptions, IronHubManifest, IronHubProvenance, + MANIFEST_CACHE_MAX_ENTRIES, MANIFEST_CACHE_TTL, MAX_MANIFEST_BYTES, MAX_METADATA_BYTES, + MAX_SIGNED_MANIFEST_BYTES, MAX_WASM_BYTES, +}; +use super::package::ironhub_tool_package; + +struct CachedManifest { + manifest: Arc, + fetched_at: Instant, +} + +static MANIFEST_CACHE: LazyLock>> = + LazyLock::new(|| std::sync::Mutex::new(HashMap::new())); +static INSTALL_LOCKS: LazyLock>>>> = + LazyLock::new(|| std::sync::Mutex::new(HashMap::new())); + +pub async fn execute_reborn_ironhub_command( + services: &RebornServices, + command: IronHubCommand, +) -> Result { + let local_runtime = services + .local_runtime + .as_ref() + .ok_or(IronHubCommandError::LocalRuntimeUnavailable)?; + let extension_management = local_runtime + .extension_management + .as_ref() + .ok_or(IronHubCommandError::LocalRuntimeUnavailable)?; + let runtime_http_egress = local_runtime + .runtime_http_egress + .as_ref() + .ok_or(IronHubCommandError::RuntimeHttpEgressUnavailable)?; + let scope = ResourceScope::local_default( + UserId::new("reborn-cli").map_err(invalid_input)?, + InvocationId::new(), + ) + .map_err(invalid_input)?; + let service = IronHubService::new( + Arc::clone(&local_runtime.skill_management), + Arc::clone(extension_management), + Arc::clone(runtime_http_egress), + scope, + ); + service.execute(command).await +} + +pub(crate) struct IronHubService { + skill_management: Arc, + extension_management: Arc, + runtime_http_egress: Arc, + scope: ResourceScope, + manifest_url: String, +} + +impl IronHubService { + pub(crate) fn new( + skill_management: Arc, + extension_management: Arc, + runtime_http_egress: Arc, + scope: ResourceScope, + ) -> Self { + Self { + skill_management, + extension_management, + runtime_http_egress, + scope, + manifest_url: resolve_manifest_url(), + } + } + + pub(crate) async fn execute( + &self, + command: IronHubCommand, + ) -> Result { + match command { + IronHubCommand::Search { query } => self.search(&query).await, + IronHubCommand::List { kind } => self.list(kind).await, + IronHubCommand::Info { name } => self.info(&name).await, + IronHubCommand::Install { name, options } => self.install(&name, options).await, + } + } + + async fn search(&self, query: &str) -> Result { + let manifest = self.fetch_manifest_cached().await?; + let query = query.trim().to_ascii_lowercase(); + let tools = manifest + .tools + .iter() + .filter(|entry| entry_matches(&entry.name, &entry.description, &query)) + .map(tool_summary) + .collect::, _>>()?; + let skills = manifest + .skills + .iter() + .filter(|entry| entry_matches(&entry.name, &entry.description, &query)) + .map(skill_summary) + .collect::, _>>()?; + Ok(response_with_payload( + None, + LifecyclePhase::Discovered, + LifecycleProductPayload::CatalogSearch { + count: tools.len() + skills.len(), + tools, + skills, + }, + )) + } + + async fn list( + &self, + kind: Option, + ) -> Result { + let manifest = self.fetch_manifest_cached().await?; + match kind { + Some(IronHubEntryKind::Skill) => { + let skills = manifest + .skills + .iter() + .map(skill_summary) + .collect::, _>>()?; + Ok(response_with_payload( + None, + LifecyclePhase::Discovered, + LifecycleProductPayload::SkillSearch { + count: skills.len(), + limit: skills.len(), + truncated: false, + skills, + }, + )) + } + None => { + let tools = manifest + .tools + .iter() + .map(tool_summary) + .collect::, _>>()?; + let skills = manifest + .skills + .iter() + .map(skill_summary) + .collect::, _>>()?; + Ok(response_with_payload( + None, + LifecyclePhase::Discovered, + LifecycleProductPayload::CatalogSearch { + count: tools.len() + skills.len(), + tools, + skills, + }, + )) + } + Some(IronHubEntryKind::Tool) => { + let tools = manifest + .tools + .iter() + .map(tool_summary) + .collect::, _>>()?; + Ok(response_with_payload( + None, + LifecyclePhase::Discovered, + LifecycleProductPayload::ExtensionSearch { + count: tools.len(), + extensions: tools, + }, + )) + } + } + } + + async fn info(&self, name: &str) -> Result { + validate_hub_name(name)?; + let manifest = self.fetch_manifest_cached().await?; + let kind = classify(&manifest, name, None)?; + let response = match kind { + IronHubEntryKind::Tool => { + let tool = manifest + .find_tool(name) + .ok_or_else(|| catalog_error("tool not found"))?; + response_with_payload( + Some(package_ref(LifecyclePackageKind::Extension, &tool.name)?), + LifecyclePhase::Discovered, + LifecycleProductPayload::ExtensionSearch { + extensions: vec![tool_summary(tool)?], + count: 1, + }, + ) + } + IronHubEntryKind::Skill => { + let skill = manifest + .find_skill(name) + .ok_or_else(|| catalog_error("skill not found"))?; + response_with_payload( + Some(package_ref(LifecyclePackageKind::Skill, &skill.name)?), + LifecyclePhase::Discovered, + LifecycleProductPayload::SkillSearch { + skills: vec![skill_summary(skill)?], + count: 1, + limit: 1, + truncated: false, + }, + ) + } + }; + Ok(response) + } + + async fn install( + &self, + name: &str, + options: IronHubInstallOptions, + ) -> Result { + validate_hub_name(name)?; + let manifest = self.fetch_manifest_cached().await?; + let (kind, provenance, artifact_digest) = + classify_gate_and_digest(&manifest, name, options.kind, &options)?; + let lock_key = format!("{}:{name}", kind.as_str()); + let lock = install_lock(&lock_key); + let _guard = lock.lock().await; + match kind { + IronHubEntryKind::Skill => { + let entry = manifest + .find_skill(name) + .ok_or_else(|| catalog_error("skill not found"))?; + let content = self + .download_verified(&entry.skill_md, MAX_METADATA_BYTES) + .await?; + let content = + String::from_utf8(content).map_err(|error| IronHubCommandError::Install { + reason: format!("skill markdown is not UTF-8: {error}"), + })?; + let result = self + .skill_management + .install_from_url(Some(&entry.name), &content, &entry.skill_md.url) + .await + .map_err(|error| IronHubCommandError::Install { + reason: error.to_string(), + })?; + let mut response = response_with_payload( + Some(package_ref(LifecyclePackageKind::Skill, &result.name)?), + LifecyclePhase::Installed, + LifecycleProductPayload::SkillInstall { + installed: true, + name: LifecyclePackageId::new(result.name).map_err(product_error)?, + }, + ); + response.message = Some(install_message( + IronHubEntryKind::Skill, + name, + entry.version.as_str(), + provenance, + &artifact_digest, + )); + Ok(response) + } + IronHubEntryKind::Tool => { + let entry = manifest + .find_tool(name) + .ok_or_else(|| catalog_error("tool not found"))?; + let wasm = self.download_verified(&entry.wasm, MAX_WASM_BYTES).await?; + let capabilities = self + .download_verified(&entry.capabilities, MAX_METADATA_BYTES) + .await?; + let package = ironhub_tool_package(entry, &wasm, &capabilities)?; + let mut response = self + .extension_management + .install_available_package(package, options.force) + .await + .map_err(IronHubCommandError::Product)?; + response.message = Some(install_message( + IronHubEntryKind::Tool, + name, + entry.version.as_str(), + provenance, + &artifact_digest, + )); + Ok(response) + } + } + } + + async fn fetch_manifest_cached(&self) -> Result { + let now = Instant::now(); + if let Some(hit) = manifest_cache_get(&self.manifest_url, now) { + return Ok((*hit).clone()); + } + let manifest = Arc::new(self.fetch_manifest().await?); + manifest_cache_put(&self.manifest_url, Arc::clone(&manifest), now); + Ok((*manifest).clone()) + } + + async fn fetch_manifest(&self) -> Result { + validate_artifact_url("hub-manifest", "manifest_url", &self.manifest_url)?; + let envelope = self + .download_url(&self.manifest_url, MAX_SIGNED_MANIFEST_BYTES) + .await?; + let bytes = + verify_signed_manifest(&envelope).map_err(|reason| IronHubCommandError::Catalog { + reason: format!("signed manifest verification failed: {reason}"), + })?; + if bytes.len() > usize::try_from(MAX_MANIFEST_BYTES).unwrap_or(usize::MAX) { + return Err(IronHubCommandError::Catalog { + reason: "manifest exceeds size cap".to_string(), + }); + } + serde_json::from_slice(&bytes).map_err(|error| IronHubCommandError::Catalog { + reason: format!("manifest parse failed: {error}"), + }) + } + + async fn download_verified( + &self, + artifact: &IronHubArtifact, + max_bytes: u64, + ) -> Result, IronHubCommandError> { + validate_artifact(artifact, max_bytes)?; + let bytes = self.download_url(&artifact.url, max_bytes).await?; + let actual = sha256_digest_token(&bytes); + if !actual.eq_ignore_ascii_case(&artifact.sha256) { + return Err(IronHubCommandError::Install { + reason: format!( + "checksum mismatch for {}: expected {}, got {}", + artifact.url, artifact.sha256, actual + ), + }); + } + Ok(bytes) + } + + async fn download_url( + &self, + url: &str, + max_bytes: u64, + ) -> Result, IronHubCommandError> { + let request = RuntimeHttpEgressRequest { + runtime: RuntimeKind::FirstParty, + scope: self.scope.clone(), + capability_id: CapabilityId::new("builtin.ironhub_fetch").map_err(invalid_input)?, + method: NetworkMethod::Get, + url: url.to_string(), + headers: Vec::new(), + body: Vec::new(), + network_policy: network_policy_for_url(url, max_bytes)?, + credential_injections: Vec::new(), + response_body_limit: Some(max_bytes), + save_body_to: None, + timeout_ms: Some(30_000), + }; + let response = self + .runtime_http_egress + .execute(request) + .await + .map_err(|error| IronHubCommandError::Catalog { + reason: error.stable_runtime_reason().to_string(), + })?; + if !(200..300).contains(&response.status) { + return Err(IronHubCommandError::Catalog { + reason: format!("download returned HTTP {}", response.status), + }); + } + if response.body.len() > usize::try_from(max_bytes).unwrap_or(usize::MAX) { + return Err(IronHubCommandError::Catalog { + reason: "download exceeds size cap".to_string(), + }); + } + Ok(response.body) + } +} + +fn resolve_manifest_url() -> String { + std::env::var("IRONHUB_MANIFEST_URL") + .ok() + .filter(|value| !value.trim().is_empty()) + .unwrap_or_else(|| DEFAULT_IRONHUB_MANIFEST_URL.to_string()) +} + +fn manifest_cache_get(url: &str, now: Instant) -> Option> { + let guard = MANIFEST_CACHE + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + let entry = guard.get(url)?; + (now.duration_since(entry.fetched_at) <= MANIFEST_CACHE_TTL) + .then(|| Arc::clone(&entry.manifest)) +} + +fn manifest_cache_put(url: &str, manifest: Arc, now: Instant) { + let mut guard = MANIFEST_CACHE + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if guard.len() >= MANIFEST_CACHE_MAX_ENTRIES && !guard.contains_key(url) { + guard.retain(|_, entry| now.duration_since(entry.fetched_at) <= MANIFEST_CACHE_TTL); + if guard.len() >= MANIFEST_CACHE_MAX_ENTRIES + && let Some(victim) = guard.keys().next().cloned() + { + guard.remove(&victim); + } + } + guard.insert( + url.to_string(), + CachedManifest { + manifest, + fetched_at: now, + }, + ); +} + +fn install_lock(key: &str) -> Arc> { + let mut guard = INSTALL_LOCKS + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + guard + .entry(key.to_string()) + .or_insert_with(|| Arc::new(AsyncMutex::new(()))) + .clone() +} + +fn install_message( + kind: IronHubEntryKind, + name: &str, + version: &str, + provenance: IronHubProvenance, + artifact_digest: &str, +) -> String { + format!( + "installed {} '{}' {} from IronHub; provenance={}, artifact_digest={}", + kind.as_str(), + name, + version, + provenance.as_wire(), + artifact_digest + ) +} diff --git a/crates/ironclaw_reborn_composition/src/ironhub/tests.rs b/crates/ironclaw_reborn_composition/src/ironhub/tests.rs new file mode 100644 index 00000000000..607471ba652 --- /dev/null +++ b/crates/ironclaw_reborn_composition/src/ironhub/tests.rs @@ -0,0 +1,201 @@ +use ironclaw_host_api::sha256_digest_token; +use ironclaw_product_workflow::{LifecyclePhase, LifecycleProductPayload}; + +use crate::lifecycle::response_with_payload; + +use super::catalog::{ + classify_gate_and_digest, host_is_disallowed_target, skill_summary, tool_artifact_digest, + tool_summary, verify_signed_manifest_with_keys, +}; +use super::model::{ + IronHubArtifact, IronHubEntryKind, IronHubInstallOptions, IronHubManifest, IronHubProvenance, + IronHubSkillEntry, IronHubToolEntry, +}; +use super::render::render_reborn_ironhub_response; + +#[test] +fn signed_manifest_verifies_known_test_vector() { + let envelope = br#"{"v":1,"key_id":"test-vector","manifest_b64":"eyJ2ZXJzaW9uIjoiMSIsImdlbmVyYXRlZF9hdCI6IjIwMjYtMDEtMDFUMDA6MDA6MDBaIiwicmVsZWFzZV90YWciOiJ0ZXN0IiwicmVwbyI6Im5lYXJhaS9pcm9uaHViIiwidG9vbHMiOltdLCJza2lsbHMiOltdfQ","sig":"KjsUDgi1enj3iTPNQI6gU1Bwxf01hIUItlFvX9PxgWNybPPrJNIV7vFG-G8hJOalFMwFs5zQHrxbtFDZAlgtBg"}"#; + let manifest = verify_signed_manifest_with_keys( + envelope, + &[( + "test-vector", + "ca46572f4dcd485599cdf95442934a3e3c86e2cae766a85fbffc8d6540959928", + )], + ) + .expect("signed manifest verifies"); + + assert_eq!( + manifest, + br#"{"version":"1","generated_at":"2026-01-01T00:00:00Z","release_tag":"test","repo":"nearai/ironhub","tools":[],"skills":[]}"# + ); +} + +#[test] +fn missing_provenance_defaults_to_unverified() { + let manifest: IronHubManifest = serde_json::from_str( + r#"{ + "version": "1", + "generated_at": "2026-01-01T00:00:00Z", + "release_tag": "test", + "repo": "nearai/ironhub", + "tools": [{ + "name": "community-tool", + "crate_name": "community-tool", + "version": "0.1.0", + "description": "community", + "wasm": { + "url": "https://hub.ironclaw.com/community-tool.wasm", + "size_bytes": 1, + "sha256": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + }, + "capabilities": { + "url": "https://hub.ironclaw.com/community-tool.capabilities.json", + "size_bytes": 1, + "sha256": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + } + }], + "skills": [{ + "name": "community-skill", + "version": "0.1.0", + "description": "community", + "skill_md": { + "url": "https://hub.ironclaw.com/community-skill/SKILL.md", + "size_bytes": 1, + "sha256": "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc" + } + }] + }"#, + ) + .expect("manifest parses"); + + assert_eq!(manifest.tools[0].provenance, IronHubProvenance::New); + assert_eq!(manifest.skills[0].provenance, IronHubProvenance::New); +} + +#[test] +fn unverified_install_requires_acknowledgement() { + let manifest = IronHubManifest { + version: "1".to_string(), + generated_at: "2026-01-01T00:00:00Z".to_string(), + release_tag: "test".to_string(), + repo: "nearai/ironhub".to_string(), + tools: Vec::new(), + skills: vec![IronHubSkillEntry { + name: "community-skill".to_string(), + trunk: String::new(), + version: "0.1.0".to_string(), + description: String::new(), + provenance: IronHubProvenance::New, + skill_md: IronHubArtifact { + url: "https://hub.ironclaw.com/community-skill/SKILL.md".to_string(), + size_bytes: 1, + sha256: "c".repeat(64), + }, + }], + }; + + let blocked = classify_gate_and_digest( + &manifest, + "community-skill", + Some(IronHubEntryKind::Skill), + &IronHubInstallOptions::default(), + ) + .expect_err("unverified content requires acknowledgement"); + assert!(blocked.to_string().contains("UNVERIFIED community content")); + + let allowed = classify_gate_and_digest( + &manifest, + "community-skill", + Some(IronHubEntryKind::Skill), + &IronHubInstallOptions { + acknowledge_unverified: true, + ..IronHubInstallOptions::default() + }, + ) + .expect("acknowledged unverified content can proceed"); + assert_eq!(allowed.0, IronHubEntryKind::Skill); + assert_eq!(allowed.1, IronHubProvenance::New); +} + +#[test] +fn renderer_includes_tools_and_skills_in_mixed_search() { + let response = response_with_payload( + None, + LifecyclePhase::Discovered, + LifecycleProductPayload::CatalogSearch { + count: 2, + tools: vec![ + tool_summary(&IronHubToolEntry { + name: "web".to_string(), + crate_name: "web-tool".to_string(), + version: "0.1.0".to_string(), + description: "web tool".to_string(), + provenance: IronHubProvenance::Official, + wasm: IronHubArtifact { + url: "https://hub.ironclaw.com/web.wasm".to_string(), + size_bytes: 1, + sha256: "a".repeat(64), + }, + capabilities: IronHubArtifact { + url: "https://hub.ironclaw.com/web.capabilities.json".to_string(), + size_bytes: 1, + sha256: "b".repeat(64), + }, + }) + .expect("tool summary"), + ], + skills: vec![ + skill_summary(&IronHubSkillEntry { + name: "reviewer".to_string(), + trunk: String::new(), + version: "0.2.0".to_string(), + description: "review skill".to_string(), + provenance: IronHubProvenance::Verified, + skill_md: IronHubArtifact { + url: "https://hub.ironclaw.com/reviewer/SKILL.md".to_string(), + size_bytes: 1, + sha256: "c".repeat(64), + }, + }) + .expect("skill summary"), + ], + }, + ); + + let rendered = render_reborn_ironhub_response("search", &response); + assert!(rendered.contains("- tool web 0.1.0")); + assert!(rendered.contains("- skill reviewer 0.2.0")); +} + +#[test] +fn artifact_digest_binds_both_tool_artifacts() { + let tool = IronHubToolEntry { + name: "web".to_string(), + crate_name: "web-tool".to_string(), + version: "0.1.0".to_string(), + description: String::new(), + provenance: IronHubProvenance::Official, + wasm: IronHubArtifact { + url: "https://hub.ironclaw.com/web.wasm".to_string(), + size_bytes: 1, + sha256: "a".repeat(64), + }, + capabilities: IronHubArtifact { + url: "https://hub.ironclaw.com/web.capabilities.json".to_string(), + size_bytes: 1, + sha256: "b".repeat(64), + }, + }; + assert_eq!( + tool_artifact_digest(&tool), + sha256_digest_token(format!("{}:{}", "a".repeat(64), "b".repeat(64)).as_bytes()) + ); +} + +#[test] +fn artifact_url_rejects_internal_hosts_even_when_extra() { + assert!(host_is_disallowed_target("localhost")); + assert!(host_is_disallowed_target("10.0.0.1")); + assert!(host_is_disallowed_target("service.internal")); +} diff --git a/crates/ironclaw_reborn_composition/src/lib.rs b/crates/ironclaw_reborn_composition/src/lib.rs index 9aa826d2d2b..0b56d227838 100644 --- a/crates/ironclaw_reborn_composition/src/lib.rs +++ b/crates/ironclaw_reborn_composition/src/lib.rs @@ -152,9 +152,8 @@ pub use ironclaw_skills::{ skill_summary_json as reborn_skill_summary_json, }; pub use ironhub::{ - IronHubCommand, IronHubCommandError, IronHubEntryKind, IronHubInstallOptions, - IronHubInstallOutcome, IronHubManifest, IronHubProvenance, execute_reborn_ironhub_command, - render_reborn_ironhub_response, + IronHubCommand, IronHubCommandError, IronHubEntryKind, IronHubInstallOptions, IronHubManifest, + IronHubProvenance, execute_reborn_ironhub_command, render_reborn_ironhub_response, }; #[cfg(feature = "root-llm-provider")] pub use llm_catalog::{ diff --git a/crates/ironclaw_reborn_composition/src/lifecycle.rs b/crates/ironclaw_reborn_composition/src/lifecycle.rs index b1674cc1ea3..d33a437ec14 100644 --- a/crates/ironclaw_reborn_composition/src/lifecycle.rs +++ b/crates/ironclaw_reborn_composition/src/lifecycle.rs @@ -87,7 +87,7 @@ impl RebornLocalSkillManagementPort { .await?) } - pub(crate) async fn install_from_ironhub( + pub(crate) async fn install_from_url( &self, name: Option<&str>, content: &str, From 5377a8dbbcfba773b05cd7a34b26d8a759dfb6a2 Mon Sep 17 00:00:00 2001 From: IronClaw Agent Date: Fri, 5 Jun 2026 10:30:28 +0300 Subject: [PATCH 3/9] fix(reborn): mark IronHub catalog skills as registry sourced Co-authored-by: neo-sky --- .../src/lifecycle.rs | 1 + .../src/ironhub/catalog.rs | 2 +- .../src/ironhub/tests.rs | 33 ++++++++++--------- 3 files changed, 19 insertions(+), 17 deletions(-) diff --git a/crates/ironclaw_product_workflow/src/lifecycle.rs b/crates/ironclaw_product_workflow/src/lifecycle.rs index 29f1084c936..5c90cd95c7c 100644 --- a/crates/ironclaw_product_workflow/src/lifecycle.rs +++ b/crates/ironclaw_product_workflow/src/lifecycle.rs @@ -434,6 +434,7 @@ pub enum LifecycleSkillSource { System, User, Installed, + Registry, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] diff --git a/crates/ironclaw_reborn_composition/src/ironhub/catalog.rs b/crates/ironclaw_reborn_composition/src/ironhub/catalog.rs index 8586245cbcf..80e48228f67 100644 --- a/crates/ironclaw_reborn_composition/src/ironhub/catalog.rs +++ b/crates/ironclaw_reborn_composition/src/ironhub/catalog.rs @@ -303,7 +303,7 @@ pub(super) fn skill_summary( name: LifecyclePackageId::new(entry.name.clone()).map_err(product_error)?, version: entry.version.clone(), description: format!("{} [{}]", entry.description, entry.provenance.trust_label()), - source: ironclaw_product_workflow::LifecycleSkillSource::Installed, + source: ironclaw_product_workflow::LifecycleSkillSource::Registry, keywords: Vec::new(), tags: Vec::new(), requires_skills: Vec::new(), diff --git a/crates/ironclaw_reborn_composition/src/ironhub/tests.rs b/crates/ironclaw_reborn_composition/src/ironhub/tests.rs index 607471ba652..0acb4eb68a5 100644 --- a/crates/ironclaw_reborn_composition/src/ironhub/tests.rs +++ b/crates/ironclaw_reborn_composition/src/ironhub/tests.rs @@ -1,5 +1,5 @@ use ironclaw_host_api::sha256_digest_token; -use ironclaw_product_workflow::{LifecyclePhase, LifecycleProductPayload}; +use ironclaw_product_workflow::{LifecyclePhase, LifecycleProductPayload, LifecycleSkillSource}; use crate::lifecycle::response_with_payload; @@ -120,6 +120,21 @@ fn unverified_install_requires_acknowledgement() { #[test] fn renderer_includes_tools_and_skills_in_mixed_search() { + let skill = skill_summary(&IronHubSkillEntry { + name: "reviewer".to_string(), + trunk: String::new(), + version: "0.2.0".to_string(), + description: "review skill".to_string(), + provenance: IronHubProvenance::Verified, + skill_md: IronHubArtifact { + url: "https://hub.ironclaw.com/reviewer/SKILL.md".to_string(), + size_bytes: 1, + sha256: "c".repeat(64), + }, + }) + .expect("skill summary"); + assert_eq!(skill.source, LifecycleSkillSource::Registry); + let response = response_with_payload( None, LifecyclePhase::Discovered, @@ -145,21 +160,7 @@ fn renderer_includes_tools_and_skills_in_mixed_search() { }) .expect("tool summary"), ], - skills: vec![ - skill_summary(&IronHubSkillEntry { - name: "reviewer".to_string(), - trunk: String::new(), - version: "0.2.0".to_string(), - description: "review skill".to_string(), - provenance: IronHubProvenance::Verified, - skill_md: IronHubArtifact { - url: "https://hub.ironclaw.com/reviewer/SKILL.md".to_string(), - size_bytes: 1, - sha256: "c".repeat(64), - }, - }) - .expect("skill summary"), - ], + skills: vec![skill], }, ); From bd8cee89c50dc321f5859be4b29f3c924901b9ef Mon Sep 17 00:00:00 2001 From: IronClaw Agent Date: Fri, 5 Jun 2026 11:21:44 +0300 Subject: [PATCH 4/9] fix(reborn): harden IronHub install review findings Co-authored-by: neo-sky --- .../src/first_party_tools/schemas.rs | 28 +- .../src/commands/ironhub.rs | 9 +- crates/ironclaw_reborn_cli/tests/smoke.rs | 64 +++ .../src/extension_lifecycle_capabilities.rs | 5 + .../src/ironhub/capabilities.rs | 11 +- .../src/ironhub/catalog.rs | 6 +- .../src/ironhub/mod.rs | 5 +- .../src/ironhub/model.rs | 59 +-- .../src/ironhub/package.rs | 8 +- .../src/ironhub/service.rs | 114 ++++- .../src/ironhub/tests.rs | 411 +++++++++++++++++- crates/ironclaw_reborn_composition/src/lib.rs | 4 +- .../src/lifecycle.rs | 16 + 13 files changed, 680 insertions(+), 60 deletions(-) diff --git a/crates/ironclaw_host_runtime/src/first_party_tools/schemas.rs b/crates/ironclaw_host_runtime/src/first_party_tools/schemas.rs index b88f62d170c..375198d45eb 100644 --- a/crates/ironclaw_host_runtime/src/first_party_tools/schemas.rs +++ b/crates/ironclaw_host_runtime/src/first_party_tools/schemas.rs @@ -273,7 +273,8 @@ pub(crate) fn resolve_builtin_input_schema_ref(reference: &str) -> Option "schemas/builtin/ironhub_info.input.v1.json" => json!({ "type": "object", "properties": { - "name": { "type": "string", "description": "IronHub tool or skill name." } + "name": { "type": "string", "description": "IronHub tool or skill name." }, + "kind": { "type": "string", "enum": ["tool", "skill"], "description": "Disambiguate when a name exists as both a tool and a skill." } }, "required": ["name"], "additionalProperties": false @@ -284,7 +285,6 @@ pub(crate) fn resolve_builtin_input_schema_ref(reference: &str) -> Option "name": { "type": "string", "description": "IronHub tool or skill name." }, "kind": { "type": "string", "enum": ["tool", "skill"], "description": "Disambiguate when a name exists as both a tool and a skill." }, "force": { "type": "boolean", "description": "Replace an already installed package.", "default": false }, - "acknowledge_unverified": { "type": "boolean", "description": "Required for unverified community content.", "default": false }, "expected_version": { "type": "string", "description": "Optional catalog version pin for signed install intents." }, "expected_artifact_digest": { "type": "string", "description": "Optional artifact digest pin for signed install intents." } }, @@ -442,3 +442,27 @@ fn response_body_limit_schema(require_save_to: bool) -> Value { "description": description }) } + +#[cfg(test)] +mod tests { + use super::resolve_builtin_input_schema_ref; + + #[test] + fn ironhub_model_visible_schemas_preserve_host_trust_boundary() { + let info = resolve_builtin_input_schema_ref("schemas/builtin/ironhub_info.input.v1.json") + .expect("ironhub info schema"); + assert_eq!( + info["properties"]["kind"]["enum"], + serde_json::json!(["tool", "skill"]) + ); + + let install = + resolve_builtin_input_schema_ref("schemas/builtin/ironhub_install.input.v1.json") + .expect("ironhub install schema"); + assert_eq!( + install["properties"].get("acknowledge_unverified"), + None, + "model-visible IronHub install must not self-acknowledge unverified community content" + ); + } +} diff --git a/crates/ironclaw_reborn_cli/src/commands/ironhub.rs b/crates/ironclaw_reborn_cli/src/commands/ironhub.rs index f0b83060e6c..145af412558 100644 --- a/crates/ironclaw_reborn_cli/src/commands/ironhub.rs +++ b/crates/ironclaw_reborn_cli/src/commands/ironhub.rs @@ -56,6 +56,10 @@ struct IronHubInfoCommand { /// Tool or skill name. name: String, + /// Disambiguate when a name exists as both a tool and a skill. + #[arg(long, value_enum)] + kind: Option, + /// Output the lifecycle response as JSON. #[arg(long)] json: bool, @@ -116,7 +120,10 @@ impl IronHubCommand { "list", ), IronHubSubcommand::Info(command) => ( - RebornIronHubCommand::Info { name: command.name }, + RebornIronHubCommand::Info { + name: command.name, + kind: command.kind.map(Into::into), + }, command.json, "info", ), diff --git a/crates/ironclaw_reborn_cli/tests/smoke.rs b/crates/ironclaw_reborn_cli/tests/smoke.rs index 9126bf5cc2c..4de09e672a1 100644 --- a/crates/ironclaw_reborn_cli/tests/smoke.rs +++ b/crates/ironclaw_reborn_cli/tests/smoke.rs @@ -86,6 +86,70 @@ fn ironhub_help_mentions_catalog_commands() { assert!(stdout.contains("--confirm-host-access"), "stdout: {stdout}"); } +#[test] +fn ironhub_install_help_mentions_safety_and_replacement_flags() { + let output = Command::new(reborn_bin()) + .args(["ironhub", "install", "--help"]) + .output() + .expect("ironclaw-reborn ironhub install --help should run"); + + assert!( + output.status.success(), + "stderr: {}", + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8_lossy(&output.stdout); + assert!(stdout.contains("--kind"), "stdout: {stdout}"); + assert!(stdout.contains("--force"), "stdout: {stdout}"); + assert!( + stdout.contains("--acknowledge-unverified"), + "stdout: {stdout}" + ); + assert!(stdout.contains("--expected-version"), "stdout: {stdout}"); + assert!( + stdout.contains("--expected-artifact-digest"), + "stdout: {stdout}" + ); + assert!(stdout.contains("--json"), "stdout: {stdout}"); +} + +#[test] +fn ironhub_install_uses_reborn_home_without_touching_v1_state() { + let temp = tempfile::tempdir().expect("tempdir"); + let reborn_home = temp.path().join("reborn-home"); + let v1_home = temp.path().join("v1-home"); + + let output = Command::new(reborn_bin()) + .args(["ironhub", "install", "catalog-helper", "--kind", "skill"]) + .env_clear() + .env("IRONCLAW_REBORN_HOME", &reborn_home) + .env("IRONCLAW_BASE_DIR", &v1_home) + .env( + "IRONHUB_MANIFEST_URL", + "http://hub.ironclaw.com/manifest.json", + ) + .output() + .expect("ironclaw-reborn ironhub install should run"); + + assert!( + !output.status.success(), + "invalid manifest URL should fail before install" + ); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("hub-manifest.manifest_url must use https"), + "stderr: {stderr}" + ); + assert!( + reborn_home.join("local-dev").exists(), + "ironhub install should initialize Reborn local-dev state" + ); + assert!( + !v1_home.exists(), + "ironhub install must not create or read v1 state" + ); +} + #[test] fn profile_list_shows_supported_profiles_without_reborn_home() { let output = Command::new(reborn_bin()) diff --git a/crates/ironclaw_reborn_composition/src/extension_lifecycle_capabilities.rs b/crates/ironclaw_reborn_composition/src/extension_lifecycle_capabilities.rs index d12f4db9c47..4e8fe9788bb 100644 --- a/crates/ironclaw_reborn_composition/src/extension_lifecycle_capabilities.rs +++ b/crates/ironclaw_reborn_composition/src/extension_lifecycle_capabilities.rs @@ -323,6 +323,11 @@ mod tests { install.parameters_schema["required"], serde_json::json!(["name"]) ); + assert_eq!( + install.parameters_schema["properties"].get("acknowledge_unverified"), + None, + "model-visible IronHub install must not self-acknowledge unverified community content" + ); assert!( install.effects.contains(&EffectKind::Network), "IronHub install downloads signed catalog artifacts through runtime HTTP egress" diff --git a/crates/ironclaw_reborn_composition/src/ironhub/capabilities.rs b/crates/ironclaw_reborn_composition/src/ironhub/capabilities.rs index 9c908458d0a..7d53b201ed6 100644 --- a/crates/ironclaw_reborn_composition/src/ironhub/capabilities.rs +++ b/crates/ironclaw_reborn_composition/src/ironhub/capabilities.rs @@ -121,6 +121,8 @@ struct SearchInput { #[derive(Debug, Deserialize)] struct InfoInput { name: String, + #[serde(default)] + kind: Option, } #[derive(Debug, Deserialize)] @@ -131,8 +133,6 @@ struct InstallInput { #[serde(default)] force: bool, #[serde(default)] - acknowledge_unverified: bool, - #[serde(default)] expected_version: Option, #[serde(default)] expected_artifact_digest: Option, @@ -163,7 +163,10 @@ impl FirstPartyCapabilityHandler for IronHubCapabilityHandler { } IRONHUB_INFO_CAPABILITY_ID => { let input: InfoInput = parse_capability_input(request.input)?; - IronHubCommand::Info { name: input.name } + IronHubCommand::Info { + name: input.name, + kind: input.kind, + } } IRONHUB_INSTALL_CAPABILITY_ID => { let input: InstallInput = parse_capability_input(request.input)?; @@ -172,7 +175,7 @@ impl FirstPartyCapabilityHandler for IronHubCapabilityHandler { options: IronHubInstallOptions { kind: input.kind, force: input.force, - acknowledge_unverified: input.acknowledge_unverified, + acknowledge_unverified: false, expected_version: input.expected_version, expected_artifact_digest: input.expected_artifact_digest, }, diff --git a/crates/ironclaw_reborn_composition/src/ironhub/catalog.rs b/crates/ironclaw_reborn_composition/src/ironhub/catalog.rs index 80e48228f67..409f7a47836 100644 --- a/crates/ironclaw_reborn_composition/src/ironhub/catalog.rs +++ b/crates/ironclaw_reborn_composition/src/ironhub/catalog.rs @@ -7,12 +7,12 @@ use ironclaw_product_workflow::{LifecyclePackageId, LifecyclePackageKind, Lifecy use super::errors::{catalog_error, invalid_input, product_error}; use super::model::{ IronHubArtifact, IronHubCommandError, IronHubEntryKind, IronHubInstallOptions, IronHubManifest, - IronHubProvenance, IronHubSkillEntry, IronHubToolEntry, MANIFEST_VERIFY_KEYS, - SignedManifestEnvelope, + IronHubProvenance, IronHubSkillEntry, IronHubToolEntry, SignedManifestEnvelope, }; +#[cfg(not(test))] pub(super) fn verify_signed_manifest(envelope_bytes: &[u8]) -> Result, String> { - verify_signed_manifest_with_keys(envelope_bytes, MANIFEST_VERIFY_KEYS) + verify_signed_manifest_with_keys(envelope_bytes, super::model::MANIFEST_VERIFY_KEYS) } pub(super) fn verify_signed_manifest_with_keys( diff --git a/crates/ironclaw_reborn_composition/src/ironhub/mod.rs b/crates/ironclaw_reborn_composition/src/ironhub/mod.rs index 0cb5b11e66d..3dac58e39be 100644 --- a/crates/ironclaw_reborn_composition/src/ironhub/mod.rs +++ b/crates/ironclaw_reborn_composition/src/ironhub/mod.rs @@ -14,9 +14,6 @@ pub(crate) use capabilities::{extend_builtin_first_party_package, insert_handler pub(crate) use model::{ IRONHUB_INFO_CAPABILITY_ID, IRONHUB_INSTALL_CAPABILITY_ID, IRONHUB_SEARCH_CAPABILITY_ID, }; -pub use model::{ - IronHubCommand, IronHubCommandError, IronHubEntryKind, IronHubInstallOptions, IronHubManifest, - IronHubProvenance, -}; +pub use model::{IronHubCommand, IronHubCommandError, IronHubEntryKind, IronHubInstallOptions}; pub use render::render_reborn_ironhub_response; pub use service::execute_reborn_ironhub_command; diff --git a/crates/ironclaw_reborn_composition/src/ironhub/model.rs b/crates/ironclaw_reborn_composition/src/ironhub/model.rs index f6e745d4f3d..a8ab4bf53f5 100644 --- a/crates/ironclaw_reborn_composition/src/ironhub/model.rs +++ b/crates/ironclaw_reborn_composition/src/ironhub/model.rs @@ -48,7 +48,7 @@ impl IronHubEntryKind { #[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] -pub enum IronHubProvenance { +pub(super) enum IronHubProvenance { #[serde(alias = "repo")] Official, Trusted, @@ -59,7 +59,7 @@ pub enum IronHubProvenance { } impl IronHubProvenance { - pub fn as_wire(self) -> &'static str { + pub(super) fn as_wire(self) -> &'static str { match self { Self::Official => "official", Self::Trusted => "trusted", @@ -68,7 +68,7 @@ impl IronHubProvenance { } } - pub fn is_community_unverified(self) -> bool { + pub(super) fn is_community_unverified(self) -> bool { matches!(self, Self::New) } @@ -83,15 +83,15 @@ impl IronHubProvenance { } #[derive(Debug, Clone, Deserialize, Serialize)] -pub struct IronHubManifest { - pub version: String, - pub generated_at: String, - pub release_tag: String, - pub repo: String, +pub(super) struct IronHubManifest { + pub(super) version: String, + pub(super) generated_at: String, + pub(super) release_tag: String, + pub(super) repo: String, #[serde(default)] - pub tools: Vec, + pub(super) tools: Vec, #[serde(default)] - pub skills: Vec, + pub(super) skills: Vec, } impl IronHubManifest { @@ -105,37 +105,37 @@ impl IronHubManifest { } #[derive(Debug, Clone, Deserialize, Serialize)] -pub struct IronHubToolEntry { - pub name: String, - pub crate_name: String, - pub version: String, +pub(super) struct IronHubToolEntry { + pub(super) name: String, + pub(super) crate_name: String, + pub(super) version: String, #[serde(default)] - pub description: String, + pub(super) description: String, #[serde(default)] - pub provenance: IronHubProvenance, - pub wasm: IronHubArtifact, - pub capabilities: IronHubArtifact, + pub(super) provenance: IronHubProvenance, + pub(super) wasm: IronHubArtifact, + pub(super) capabilities: IronHubArtifact, } #[derive(Debug, Clone, Deserialize, Serialize)] -pub struct IronHubSkillEntry { - pub name: String, +pub(super) struct IronHubSkillEntry { + pub(super) name: String, #[serde(default)] - pub trunk: String, + pub(super) trunk: String, #[serde(default)] - pub version: String, + pub(super) version: String, #[serde(default)] - pub description: String, + pub(super) description: String, #[serde(default)] - pub provenance: IronHubProvenance, - pub skill_md: IronHubArtifact, + pub(super) provenance: IronHubProvenance, + pub(super) skill_md: IronHubArtifact, } #[derive(Debug, Clone, Deserialize, Serialize)] -pub struct IronHubArtifact { - pub url: String, - pub size_bytes: u64, - pub sha256: String, +pub(super) struct IronHubArtifact { + pub(super) url: String, + pub(super) size_bytes: u64, + pub(super) sha256: String, } #[derive(Debug, Clone, Default, PartialEq, Eq)] @@ -157,6 +157,7 @@ pub enum IronHubCommand { }, Info { name: String, + kind: Option, }, Install { name: String, diff --git a/crates/ironclaw_reborn_composition/src/ironhub/package.rs b/crates/ironclaw_reborn_composition/src/ironhub/package.rs index 1cc2c8d1e19..c665cde4d24 100644 --- a/crates/ironclaw_reborn_composition/src/ironhub/package.rs +++ b/crates/ironclaw_reborn_composition/src/ironhub/package.rs @@ -72,7 +72,13 @@ trust = "third_party" kind = "wasm" module = "wasm/{id}_tool.wasm" -[[capabilities]] +[[host_api]] +id = "ironclaw.capability_provider/v1" +section = "capability_provider.tools" + +[capability_provider.tools] + +[[capability_provider.tools.capabilities]] id = "{id}.invoke" description = "{description}" effects = ["dispatch_capability", "network"] diff --git a/crates/ironclaw_reborn_composition/src/ironhub/service.rs b/crates/ironclaw_reborn_composition/src/ironhub/service.rs index f0e45894b50..235896921f1 100644 --- a/crates/ironclaw_reborn_composition/src/ironhub/service.rs +++ b/crates/ironclaw_reborn_composition/src/ironhub/service.rs @@ -2,9 +2,11 @@ use std::collections::HashMap; use std::sync::{Arc, LazyLock}; use std::time::Instant; +use chrono::{DateTime, Utc}; +use ironclaw_common::hashing::sha256_hex; use ironclaw_host_api::{ CapabilityId, InvocationId, NetworkMethod, ResourceScope, RuntimeHttpEgress, - RuntimeHttpEgressRequest, RuntimeKind, UserId, sha256_digest_token, + RuntimeHttpEgressRequest, RuntimeKind, UserId, }; use ironclaw_product_workflow::{ LifecyclePackageId, LifecyclePackageKind, LifecyclePhase, LifecycleProductPayload, @@ -16,10 +18,11 @@ use crate::extension_lifecycle::RebornLocalExtensionManagementPort; use crate::factory::RebornServices; use crate::lifecycle::{RebornLocalSkillManagementPort, response_with_payload}; +#[cfg(not(test))] +use super::catalog::verify_signed_manifest; use super::catalog::{ classify, classify_gate_and_digest, entry_matches, network_policy_for_url, package_ref, skill_summary, tool_summary, validate_artifact, validate_artifact_url, validate_hub_name, - verify_signed_manifest, }; use super::errors::{catalog_error, invalid_input, product_error}; use super::model::{ @@ -37,6 +40,10 @@ struct CachedManifest { static MANIFEST_CACHE: LazyLock>> = LazyLock::new(|| std::sync::Mutex::new(HashMap::new())); +static MANIFEST_FETCH_LOCKS: LazyLock>>>> = + LazyLock::new(|| std::sync::Mutex::new(HashMap::new())); +static MANIFEST_LAST_SEEN: LazyLock>>> = + LazyLock::new(|| std::sync::Mutex::new(HashMap::new())); static INSTALL_LOCKS: LazyLock>>>> = LazyLock::new(|| std::sync::Mutex::new(HashMap::new())); @@ -76,6 +83,8 @@ pub(crate) struct IronHubService { runtime_http_egress: Arc, scope: ResourceScope, manifest_url: String, + #[cfg(test)] + manifest_verify_keys: &'static [(&'static str, &'static str)], } impl IronHubService { @@ -91,9 +100,26 @@ impl IronHubService { runtime_http_egress, scope, manifest_url: resolve_manifest_url(), + #[cfg(test)] + manifest_verify_keys: super::model::MANIFEST_VERIFY_KEYS, } } + #[cfg(test)] + pub(crate) fn with_manifest_url(mut self, manifest_url: impl Into) -> Self { + self.manifest_url = manifest_url.into(); + self + } + + #[cfg(test)] + pub(crate) fn with_manifest_verify_keys( + mut self, + manifest_verify_keys: &'static [(&'static str, &'static str)], + ) -> Self { + self.manifest_verify_keys = manifest_verify_keys; + self + } + pub(crate) async fn execute( &self, command: IronHubCommand, @@ -101,7 +127,7 @@ impl IronHubService { match command { IronHubCommand::Search { query } => self.search(&query).await, IronHubCommand::List { kind } => self.list(kind).await, - IronHubCommand::Info { name } => self.info(&name).await, + IronHubCommand::Info { name, kind } => self.info(&name, kind).await, IronHubCommand::Install { name, options } => self.install(&name, options).await, } } @@ -194,10 +220,14 @@ impl IronHubService { } } - async fn info(&self, name: &str) -> Result { + async fn info( + &self, + name: &str, + hint: Option, + ) -> Result { validate_hub_name(name)?; let manifest = self.fetch_manifest_cached().await?; - let kind = classify(&manifest, name, None)?; + let kind = classify(&manifest, name, hint)?; let response = match kind { IronHubEntryKind::Tool => { let tool = manifest @@ -255,6 +285,14 @@ impl IronHubService { String::from_utf8(content).map_err(|error| IronHubCommandError::Install { reason: format!("skill markdown is not UTF-8: {error}"), })?; + if options.force { + self.skill_management + .remove_if_installed(&entry.name) + .await + .map_err(|error| IronHubCommandError::Install { + reason: error.to_string(), + })?; + } let result = self .skill_management .install_from_url(Some(&entry.name), &content, &entry.skill_md.url) @@ -306,6 +344,12 @@ impl IronHubService { } async fn fetch_manifest_cached(&self) -> Result { + let now = Instant::now(); + if let Some(hit) = manifest_cache_get(&self.manifest_url, now) { + return Ok((*hit).clone()); + } + let fetch_lock = manifest_fetch_lock(&self.manifest_url); + let _fetch_guard = fetch_lock.lock().await; let now = Instant::now(); if let Some(hit) = manifest_cache_get(&self.manifest_url, now) { return Ok((*hit).clone()); @@ -320,18 +364,25 @@ impl IronHubService { let envelope = self .download_url(&self.manifest_url, MAX_SIGNED_MANIFEST_BYTES) .await?; - let bytes = - verify_signed_manifest(&envelope).map_err(|reason| IronHubCommandError::Catalog { - reason: format!("signed manifest verification failed: {reason}"), - })?; + #[cfg(not(test))] + let verified_manifest = verify_signed_manifest(&envelope); + #[cfg(test)] + let verified_manifest = + super::catalog::verify_signed_manifest_with_keys(&envelope, self.manifest_verify_keys); + let bytes = verified_manifest.map_err(|reason| IronHubCommandError::Catalog { + reason: format!("signed manifest verification failed: {reason}"), + })?; if bytes.len() > usize::try_from(MAX_MANIFEST_BYTES).unwrap_or(usize::MAX) { return Err(IronHubCommandError::Catalog { reason: "manifest exceeds size cap".to_string(), }); } - serde_json::from_slice(&bytes).map_err(|error| IronHubCommandError::Catalog { - reason: format!("manifest parse failed: {error}"), - }) + let manifest: IronHubManifest = + serde_json::from_slice(&bytes).map_err(|error| IronHubCommandError::Catalog { + reason: format!("manifest parse failed: {error}"), + })?; + enforce_manifest_monotonic(&self.manifest_url, &manifest)?; + Ok(manifest) } async fn download_verified( @@ -341,7 +392,7 @@ impl IronHubService { ) -> Result, IronHubCommandError> { validate_artifact(artifact, max_bytes)?; let bytes = self.download_url(&artifact.url, max_bytes).await?; - let actual = sha256_digest_token(&bytes); + let actual = sha256_hex(&bytes); if !actual.eq_ignore_ascii_case(&artifact.sha256) { return Err(IronHubCommandError::Install { reason: format!( @@ -430,6 +481,43 @@ fn manifest_cache_put(url: &str, manifest: Arc, now: Instant) { ); } +fn manifest_fetch_lock(url: &str) -> Arc> { + let mut guard = MANIFEST_FETCH_LOCKS + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + guard + .entry(url.to_string()) + .or_insert_with(|| Arc::new(AsyncMutex::new(()))) + .clone() +} + +fn enforce_manifest_monotonic( + url: &str, + manifest: &IronHubManifest, +) -> Result<(), IronHubCommandError> { + let generated_at = DateTime::parse_from_rfc3339(&manifest.generated_at) + .map_err(|error| IronHubCommandError::Catalog { + reason: format!("manifest generated_at is not RFC3339: {error}"), + })? + .with_timezone(&Utc); + let mut guard = MANIFEST_LAST_SEEN + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if let Some(previous) = guard.get(url) + && generated_at < *previous + { + return Err(IronHubCommandError::Catalog { + reason: format!( + "signed manifest replay rejected: generated_at {} is older than last seen {}", + generated_at.to_rfc3339(), + previous.to_rfc3339() + ), + }); + } + guard.insert(url.to_string(), generated_at); + Ok(()) +} + fn install_lock(key: &str) -> Arc> { let mut guard = INSTALL_LOCKS .lock() diff --git a/crates/ironclaw_reborn_composition/src/ironhub/tests.rs b/crates/ironclaw_reborn_composition/src/ironhub/tests.rs index 0acb4eb68a5..ec01c630164 100644 --- a/crates/ironclaw_reborn_composition/src/ironhub/tests.rs +++ b/crates/ironclaw_reborn_composition/src/ironhub/tests.rs @@ -1,6 +1,20 @@ -use ironclaw_host_api::sha256_digest_token; +use std::collections::{HashMap, VecDeque}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use async_trait::async_trait; +use base64::Engine; +use base64::engine::general_purpose::URL_SAFE_NO_PAD; +use ed25519_dalek::{Signer, SigningKey}; +use ironclaw_common::hashing::sha256_hex; +use ironclaw_host_api::{ + InvocationId, NetworkScheme, ResourceScope, RuntimeHttpEgress, RuntimeHttpEgressError, + RuntimeHttpEgressRequest, RuntimeHttpEgressResponse, RuntimeKind, UserId, sha256_digest_token, +}; use ironclaw_product_workflow::{LifecyclePhase, LifecycleProductPayload, LifecycleSkillSource}; +use crate::RebornBuildInput; +use crate::factory::build_reborn_services; use crate::lifecycle::response_with_payload; use super::catalog::{ @@ -12,6 +26,7 @@ use super::model::{ IronHubSkillEntry, IronHubToolEntry, }; use super::render::render_reborn_ironhub_response; +use super::service::IronHubService; #[test] fn signed_manifest_verifies_known_test_vector() { @@ -200,3 +215,397 @@ fn artifact_url_rejects_internal_hosts_even_when_extra() { assert!(host_is_disallowed_target("10.0.0.1")); assert!(host_is_disallowed_target("service.internal")); } + +#[tokio::test] +async fn search_rejects_untrusted_signed_manifest_from_runtime_egress() { + let dir = tempfile::tempdir().expect("tempdir"); + let manifest_url = "https://hub.ironclaw.com/tests/reject/manifest.json"; + let egress = Arc::new(RecordingIronHubEgress::new([( + manifest_url, + b"not signed".to_vec(), + )])); + let service = ironhub_service(dir.path().join("local-dev"), egress, manifest_url).await; + + let error = service + .execute(super::model::IronHubCommand::Search { + query: String::new(), + }) + .await + .expect_err("bad signed manifest should be rejected"); + + assert!( + error + .to_string() + .contains("signed manifest verification failed"), + "{error}" + ); +} + +#[tokio::test] +async fn install_rejects_artifact_sha256_mismatch_before_reborn_write() { + let dir = tempfile::tempdir().expect("tempdir"); + let root = dir.path().join("local-dev"); + let manifest_url = "https://hub.ironclaw.com/tests/mismatch/manifest.json"; + let skill_url = "https://hub.ironclaw.com/tests/mismatch/SKILL.md"; + let manifest = signed_manifest(skill_manifest_json( + "checksum-skill", + "2026-01-01T00:00:00Z", + skill_url, + &sha256_hex(b"expected skill"), + IronHubProvenance::Official, + )); + let egress = Arc::new(RecordingIronHubEgress::new([ + (manifest_url, manifest), + (skill_url, b"corrupted skill".to_vec()), + ])); + let service = ironhub_service(root.clone(), egress, manifest_url).await; + + let error = service + .execute(super::model::IronHubCommand::Install { + name: "checksum-skill".to_string(), + options: IronHubInstallOptions { + kind: Some(IronHubEntryKind::Skill), + ..IronHubInstallOptions::default() + }, + }) + .await + .expect_err("checksum mismatch should fail before installing"); + + assert!(error.to_string().contains("checksum mismatch"), "{error}"); + assert!( + !root.join("skills/checksum-skill/SKILL.md").exists(), + "corrupted skill should not be materialized" + ); +} + +#[tokio::test] +async fn install_skill_and_tool_materialize_into_reborn_management() { + let dir = tempfile::tempdir().expect("tempdir"); + let root = dir.path().join("local-dev"); + let manifest_url = "https://hub.ironclaw.com/tests/install/manifest.json"; + let skill_url = "https://hub.ironclaw.com/tests/install/SKILL.md"; + let wasm_url = "https://hub.ironclaw.com/tests/install/tool.wasm"; + let capabilities_url = "https://hub.ironclaw.com/tests/install/capabilities.json"; + let skill_bytes = b"# installed skill\n"; + let wasm_bytes = b"\0asm"; + let capabilities_bytes = br#"{"capabilities":[]}"#; + let manifest = signed_manifest(mixed_manifest_json(MixedManifestFixture { + skill_name: "installed-skill", + tool_name: "installed-tool", + generated_at: "2026-01-02T00:00:00Z", + skill_url, + skill_sha: &sha256_hex(skill_bytes), + wasm_url, + wasm_sha: &sha256_hex(wasm_bytes), + capabilities_url, + capabilities_sha: &sha256_hex(capabilities_bytes), + })); + let egress = Arc::new(RecordingIronHubEgress::new([ + (manifest_url, manifest), + (skill_url, skill_bytes.to_vec()), + (wasm_url, wasm_bytes.to_vec()), + (capabilities_url, capabilities_bytes.to_vec()), + ])); + let service = ironhub_service(root.clone(), egress, manifest_url).await; + + let skill = service + .execute(super::model::IronHubCommand::Install { + name: "installed-skill".to_string(), + options: IronHubInstallOptions { + kind: Some(IronHubEntryKind::Skill), + ..IronHubInstallOptions::default() + }, + }) + .await + .expect("skill install succeeds"); + assert_eq!(skill.phase, LifecyclePhase::Installed); + assert!(root.join("skills/installed-skill/SKILL.md").exists()); + + let tool = service + .execute(super::model::IronHubCommand::Install { + name: "installed-tool".to_string(), + options: IronHubInstallOptions { + kind: Some(IronHubEntryKind::Tool), + ..IronHubInstallOptions::default() + }, + }) + .await + .expect("tool install succeeds"); + assert_eq!(tool.phase, LifecyclePhase::Installed); + assert!( + root.join("system/extensions/installed-tool/manifest.toml") + .exists() + ); +} + +#[tokio::test] +async fn fetch_manifest_uses_runtime_egress_host_policy() { + let dir = tempfile::tempdir().expect("tempdir"); + let manifest_url = "https://hub.ironclaw.com/tests/policy/manifest.json"; + let egress = Arc::new(RecordingIronHubEgress::new([( + manifest_url, + signed_manifest(empty_manifest_json("2026-01-03T00:00:00Z")), + )])); + let service = ironhub_service( + dir.path().join("local-dev"), + egress.clone() as Arc, + manifest_url, + ) + .await; + + service + .execute(super::model::IronHubCommand::Search { + query: String::new(), + }) + .await + .expect("manifest fetch succeeds"); + + let request = egress.single_request(); + assert_eq!(request.runtime, RuntimeKind::FirstParty); + assert_eq!(request.url, manifest_url); + assert_eq!( + request.response_body_limit, + Some(super::model::MAX_SIGNED_MANIFEST_BYTES) + ); + assert_eq!(request.network_policy.allowed_targets.len(), 1); + let target = &request.network_policy.allowed_targets[0]; + assert_eq!(target.scheme, Some(NetworkScheme::Https)); + assert_eq!(target.host_pattern, "hub.ironclaw.com"); + assert!(request.network_policy.deny_private_ip_ranges); + assert_eq!( + request.network_policy.max_egress_bytes, + Some(super::model::MAX_SIGNED_MANIFEST_BYTES) + ); +} + +#[tokio::test] +async fn concurrent_manifest_cache_miss_fetches_once() { + let dir = tempfile::tempdir().expect("tempdir"); + let manifest_url = "https://hub.ironclaw.com/tests/singleflight/manifest.json"; + let egress = Arc::new( + RecordingIronHubEgress::new([( + manifest_url, + signed_manifest(empty_manifest_json("2026-01-04T00:00:00Z")), + )]) + .with_delay(Duration::from_millis(50)), + ); + let service = Arc::new( + ironhub_service( + dir.path().join("local-dev"), + egress.clone() as Arc, + manifest_url, + ) + .await, + ); + + let first = { + let service = Arc::clone(&service); + tokio::spawn(async move { + service + .execute(super::model::IronHubCommand::Search { + query: String::new(), + }) + .await + }) + }; + let second = { + let service = Arc::clone(&service); + tokio::spawn(async move { + service + .execute(super::model::IronHubCommand::Search { + query: String::new(), + }) + .await + }) + }; + + first.await.expect("first task").expect("first search"); + second.await.expect("second task").expect("second search"); + assert_eq!(egress.request_count(), 1); +} + +async fn ironhub_service( + local_dev_root: std::path::PathBuf, + egress: Arc, + manifest_url: &str, +) -> IronHubService { + let services = build_reborn_services(RebornBuildInput::local_dev( + "ironhub-test-owner", + local_dev_root, + )) + .await + .expect("local-dev services build"); + let local_runtime = services.local_runtime.expect("local runtime substrate"); + IronHubService::new( + Arc::clone(&local_runtime.skill_management), + local_runtime + .extension_management + .as_ref() + .expect("extension management") + .clone(), + egress, + ResourceScope::local_default( + UserId::new("ironhub-test-user").expect("user"), + InvocationId::new(), + ) + .expect("scope"), + ) + .with_manifest_url(manifest_url) + .with_manifest_verify_keys(test_manifest_verify_keys()) +} + +fn signed_manifest(manifest_json: String) -> Vec { + let signing_key = test_signing_key(); + let signature = signing_key.sign(manifest_json.as_bytes()); + serde_json::json!({ + "v": 1, + "key_id": "ironhub-test-key", + "manifest_b64": URL_SAFE_NO_PAD.encode(manifest_json.as_bytes()), + "sig": URL_SAFE_NO_PAD.encode(signature.to_bytes()), + }) + .to_string() + .into_bytes() +} + +fn test_manifest_verify_keys() -> &'static [(&'static str, &'static str)] { + let signing_key = test_signing_key(); + let verify_key = hex::encode(signing_key.verifying_key().to_bytes()); + let verify_key: &'static str = Box::leak(verify_key.into_boxed_str()); + Box::leak(vec![("ironhub-test-key", verify_key)].into_boxed_slice()) +} + +fn test_signing_key() -> SigningKey { + SigningKey::from_bytes(&[7_u8; 32]) +} + +fn empty_manifest_json(generated_at: &str) -> String { + format!( + r#"{{"version":"1","generated_at":"{generated_at}","release_tag":"test","repo":"nearai/ironhub","tools":[],"skills":[]}}"# + ) +} + +fn skill_manifest_json( + name: &str, + generated_at: &str, + skill_url: &str, + skill_sha: &str, + provenance: IronHubProvenance, +) -> String { + format!( + r#"{{"version":"1","generated_at":"{generated_at}","release_tag":"test","repo":"nearai/ironhub","tools":[],"skills":[{{"name":"{name}","version":"0.1.0","description":"test skill","provenance":"{}","skill_md":{{"url":"{skill_url}","size_bytes":1048576,"sha256":"{skill_sha}"}}}}]}}"#, + provenance.as_wire() + ) +} + +struct MixedManifestFixture<'a> { + skill_name: &'a str, + tool_name: &'a str, + generated_at: &'a str, + skill_url: &'a str, + skill_sha: &'a str, + wasm_url: &'a str, + wasm_sha: &'a str, + capabilities_url: &'a str, + capabilities_sha: &'a str, +} + +fn mixed_manifest_json(fixture: MixedManifestFixture<'_>) -> String { + let MixedManifestFixture { + skill_name, + tool_name, + generated_at, + skill_url, + skill_sha, + wasm_url, + wasm_sha, + capabilities_url, + capabilities_sha, + } = fixture; + format!( + r#"{{"version":"1","generated_at":"{generated_at}","release_tag":"test","repo":"nearai/ironhub","tools":[{{"name":"{tool_name}","crate_name":"{tool_name}","version":"0.1.0","description":"test tool","provenance":"official","wasm":{{"url":"{wasm_url}","size_bytes":1048576,"sha256":"{wasm_sha}"}},"capabilities":{{"url":"{capabilities_url}","size_bytes":1048576,"sha256":"{capabilities_sha}"}}}}],"skills":[{{"name":"{skill_name}","version":"0.1.0","description":"test skill","provenance":"official","skill_md":{{"url":"{skill_url}","size_bytes":1048576,"sha256":"{skill_sha}"}}}}]}}"# + ) +} + +#[derive(Debug, Clone)] +struct RecordedEgressRequest { + runtime: RuntimeKind, + url: String, + response_body_limit: Option, + network_policy: ironclaw_host_api::NetworkPolicy, +} + +struct RecordingIronHubEgress { + responses: Mutex>>>, + requests: Mutex>, + delay: Option, +} + +impl RecordingIronHubEgress { + fn new(responses: [(&str, Vec); N]) -> Self { + let responses = responses + .into_iter() + .map(|(url, body)| (url.to_string(), VecDeque::from([body]))) + .collect(); + Self { + responses: Mutex::new(responses), + requests: Mutex::new(Vec::new()), + delay: None, + } + } + + fn with_delay(mut self, delay: Duration) -> Self { + self.delay = Some(delay); + self + } + + fn single_request(&self) -> RecordedEgressRequest { + let requests = self.requests.lock().expect("requests lock"); + assert_eq!(requests.len(), 1); + requests[0].clone() + } + + fn request_count(&self) -> usize { + self.requests.lock().expect("requests lock").len() + } +} + +#[async_trait] +impl RuntimeHttpEgress for RecordingIronHubEgress { + async fn execute( + &self, + request: RuntimeHttpEgressRequest, + ) -> Result { + self.requests + .lock() + .expect("requests lock") + .push(RecordedEgressRequest { + runtime: request.runtime, + url: request.url.clone(), + response_body_limit: request.response_body_limit, + network_policy: request.network_policy.clone(), + }); + if let Some(delay) = self.delay { + tokio::time::sleep(delay).await; + } + let body = self + .responses + .lock() + .expect("responses lock") + .get_mut(&request.url) + .and_then(VecDeque::pop_front) + .ok_or_else(|| RuntimeHttpEgressError::Request { + reason: format!("unexpected IronHub test URL {}", request.url), + request_bytes: 0, + response_bytes: 0, + })?; + Ok(RuntimeHttpEgressResponse { + status: 200, + headers: Vec::new(), + body, + saved_body: None, + request_bytes: 0, + response_bytes: 0, + redaction_applied: false, + }) + } +} diff --git a/crates/ironclaw_reborn_composition/src/lib.rs b/crates/ironclaw_reborn_composition/src/lib.rs index 0b56d227838..85027f045e0 100644 --- a/crates/ironclaw_reborn_composition/src/lib.rs +++ b/crates/ironclaw_reborn_composition/src/lib.rs @@ -152,8 +152,8 @@ pub use ironclaw_skills::{ skill_summary_json as reborn_skill_summary_json, }; pub use ironhub::{ - IronHubCommand, IronHubCommandError, IronHubEntryKind, IronHubInstallOptions, IronHubManifest, - IronHubProvenance, execute_reborn_ironhub_command, render_reborn_ironhub_response, + IronHubCommand, IronHubCommandError, IronHubEntryKind, IronHubInstallOptions, + execute_reborn_ironhub_command, render_reborn_ironhub_response, }; #[cfg(feature = "root-llm-provider")] pub use llm_catalog::{ diff --git a/crates/ironclaw_reborn_composition/src/lifecycle.rs b/crates/ironclaw_reborn_composition/src/lifecycle.rs index d33a437ec14..ca0301d493f 100644 --- a/crates/ironclaw_reborn_composition/src/lifecycle.rs +++ b/crates/ironclaw_reborn_composition/src/lifecycle.rs @@ -114,6 +114,22 @@ impl RebornLocalSkillManagementPort { let context = self.skill_context()?; Ok(remove_skill(&context, SkillRemoveRequest { name }).await?) } + + pub(crate) async fn remove_if_installed( + &self, + name: &str, + ) -> Result { + let context = self.skill_context()?; + match remove_skill(&context, SkillRemoveRequest { name }).await { + Ok(result) => Ok(result), + Err(error) if error.kind() == SkillManagementErrorKind::NotFound => { + Ok(ironclaw_skills::SkillRemoveResult { + name: name.to_string(), + }) + } + Err(error) => Err(error.into()), + } + } } #[derive(Debug, thiserror::Error)] From d4f430a5d9736def520dbf2c688a49bc0ac9d58f Mon Sep 17 00:00:00 2001 From: IronClaw Agent Date: Fri, 5 Jun 2026 11:55:50 +0300 Subject: [PATCH 5/9] refactor(reborn): simplify IronHub package installation Co-authored-by: neo-sky --- .../src/available_extensions.rs | 6 +- .../src/extension_lifecycle.rs | 11 ++- .../src/ironhub/package.rs | 83 ++++++++++++++----- .../src/ironhub/service.rs | 10 +-- 4 files changed, 76 insertions(+), 34 deletions(-) diff --git a/crates/ironclaw_reborn_composition/src/available_extensions.rs b/crates/ironclaw_reborn_composition/src/available_extensions.rs index 7cbd19a2962..3e56b608c33 100644 --- a/crates/ironclaw_reborn_composition/src/available_extensions.rs +++ b/crates/ironclaw_reborn_composition/src/available_extensions.rs @@ -48,19 +48,19 @@ const WEB_ACCESS_MANIFEST: &str = const NEARAI_MCP_MANIFEST: &str = include_str!("../../ironclaw_first_party_extensions/assets/nearai-mcp/manifest.toml"); -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, PartialEq, Eq)] pub(crate) struct AvailableExtensionAsset { pub(crate) path: String, pub(crate) content: AvailableExtensionAssetContent, } -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, PartialEq, Eq)] pub(crate) enum AvailableExtensionAssetContent { Bytes(Vec), Filesystem(VirtualPath), } -#[derive(Debug, Clone)] +#[derive(Debug)] pub(crate) struct AvailableExtensionPackage { pub(crate) package_ref: LifecyclePackageRef, pub(crate) manifest_toml: String, diff --git a/crates/ironclaw_reborn_composition/src/extension_lifecycle.rs b/crates/ironclaw_reborn_composition/src/extension_lifecycle.rs index effce7a7370..1d11a6d9c18 100644 --- a/crates/ironclaw_reborn_composition/src/extension_lifecycle.rs +++ b/crates/ironclaw_reborn_composition/src/extension_lifecycle.rs @@ -281,17 +281,16 @@ impl RebornLocalExtensionManagementPort { package_ref: LifecyclePackageRef, ) -> Result { let available = self.catalog.resolve(&package_ref)?; - self.install_available_package(available.clone(), false) - .await + self.install_available_package(available, false).await } pub(crate) async fn install_available_package( &self, - available: AvailableExtensionPackage, + available: &AvailableExtensionPackage, force: bool, ) -> Result { let package_ref = available.package_ref.clone(); - let plan = prepare_install(&available)?; + let plan = prepare_install(available)?; let _operation_guard = self.operation_lock.lock().await; if force && self @@ -310,7 +309,7 @@ impl RebornLocalExtensionManagementPort { self.register_lifecycle_package(&available.package).await?; if let Err(error) = - materialize_available_extension(self.filesystem.as_ref(), &available).await + materialize_available_extension(self.filesystem.as_ref(), available).await { if let Err(rollback_error) = self.rollback_lifecycle_install(&available.package.id).await @@ -344,7 +343,7 @@ impl RebornLocalExtensionManagementPort { LifecyclePhase::Installed, LifecycleProductPayload::ExtensionInstall { installed: true, - visible_capability_ids: visible_capability_ids(&available) + visible_capability_ids: visible_capability_ids(available) .map(|id| id.as_str().to_string()) .collect(), }, diff --git a/crates/ironclaw_reborn_composition/src/ironhub/package.rs b/crates/ironclaw_reborn_composition/src/ironhub/package.rs index c665cde4d24..393205232fa 100644 --- a/crates/ironclaw_reborn_composition/src/ironhub/package.rs +++ b/crates/ironclaw_reborn_composition/src/ironhub/package.rs @@ -35,12 +35,13 @@ pub(super) fn ironhub_tool_package( let package = ExtensionPackage::from_manifest_toml(manifest, root, &manifest_toml) .map_err(|error| install_error(error.to_string()))?; let package_ref = package_ref(LifecyclePackageKind::Extension, &entry.name)?; + let manifest_asset = bytes_asset("manifest.toml", manifest_toml.as_bytes()); Ok(AvailableExtensionPackage { package_ref, manifest_toml, package, assets: vec![ - bytes_asset("manifest.toml", manifest_toml_bytes(entry).as_slice()), + manifest_asset, bytes_asset(&format!("wasm/{}_tool.wasm", entry.name), wasm), bytes_asset("legacy/capabilities.json", capabilities), bytes_asset( @@ -55,22 +56,18 @@ pub(super) fn ironhub_tool_package( }) } -fn manifest_toml_bytes(entry: &IronHubToolEntry) -> Vec { - generic_tool_manifest(entry).into_bytes() -} - fn generic_tool_manifest(entry: &IronHubToolEntry) -> String { format!( r#"schema_version = "reborn.extension_manifest.v2" -id = "{id}" -name = "{name}" -version = "{version}" -description = "{description}" +id = {id} +name = {name} +version = {version} +description = {description} trust = "third_party" [runtime] kind = "wasm" -module = "wasm/{id}_tool.wasm" +module = {module} [[host_api]] id = "ironclaw.capability_provider/v1" @@ -79,19 +76,23 @@ section = "capability_provider.tools" [capability_provider.tools] [[capability_provider.tools.capabilities]] -id = "{id}.invoke" -description = "{description}" +id = {capability_id} +description = {description} effects = ["dispatch_capability", "network"] default_permission = "ask" visibility = "model" -input_schema_ref = "schemas/{id}/invoke.input.v1.json" -output_schema_ref = "schemas/{id}/raw_output.v1.json" +input_schema_ref = {input_schema_ref} +output_schema_ref = {output_schema_ref} required_host_ports = ["host.runtime.http_egress"] "#, - id = toml_escape(&entry.name), - name = toml_escape(&entry.name), - version = toml_escape(&entry.version), - description = toml_escape(&entry.description), + id = toml_string(&entry.name), + name = toml_string(&entry.name), + version = toml_string(&entry.version), + description = toml_string(&entry.description), + module = toml_string(format!("wasm/{}_tool.wasm", entry.name)), + capability_id = toml_string(format!("{}.invoke", entry.name)), + input_schema_ref = toml_string(format!("schemas/{}/invoke.input.v1.json", entry.name)), + output_schema_ref = toml_string(format!("schemas/{}/raw_output.v1.json", entry.name)), ) } @@ -102,6 +103,48 @@ fn bytes_asset(path: &str, bytes: &[u8]) -> AvailableExtensionAsset { } } -fn toml_escape(value: &str) -> String { - value.replace('\\', "\\\\").replace('"', "\\\"") +fn toml_string(value: impl Into) -> String { + toml::Value::String(value.into()).to_string() +} + +#[cfg(test)] +mod tests { + use super::generic_tool_manifest; + use crate::ironhub::model::{IronHubArtifact, IronHubProvenance, IronHubToolEntry}; + + #[test] + fn generic_tool_manifest_uses_toml_escaping_for_catalog_strings() { + let manifest = generic_tool_manifest(&IronHubToolEntry { + name: "quote_tool".to_string(), + crate_name: "quote_tool".to_string(), + version: "0.1.0".to_string(), + description: "quote \" slash \\ newline\nok".to_string(), + provenance: IronHubProvenance::Official, + wasm: IronHubArtifact { + url: "https://hub.ironclaw.com/quote_tool.wasm".to_string(), + size_bytes: 1, + sha256: "a".repeat(64), + }, + capabilities: IronHubArtifact { + url: "https://hub.ironclaw.com/quote_tool.capabilities.json".to_string(), + size_bytes: 1, + sha256: "b".repeat(64), + }, + }); + + let parsed: toml::Value = toml::from_str(&manifest).expect("manifest TOML parses"); + assert_eq!(parsed["id"].as_str(), Some("quote_tool")); + assert_eq!( + parsed["description"].as_str(), + Some("quote \" slash \\ newline\nok") + ); + assert_eq!( + parsed["runtime"]["module"].as_str(), + Some("wasm/quote_tool_tool.wasm") + ); + assert_eq!( + parsed["capability_provider"]["tools"]["capabilities"][0]["id"].as_str(), + Some("quote_tool.invoke") + ); + } } diff --git a/crates/ironclaw_reborn_composition/src/ironhub/service.rs b/crates/ironclaw_reborn_composition/src/ironhub/service.rs index 235896921f1..fb8874fe54e 100644 --- a/crates/ironclaw_reborn_composition/src/ironhub/service.rs +++ b/crates/ironclaw_reborn_composition/src/ironhub/service.rs @@ -328,7 +328,7 @@ impl IronHubService { let package = ironhub_tool_package(entry, &wasm, &capabilities)?; let mut response = self .extension_management - .install_available_package(package, options.force) + .install_available_package(&package, options.force) .await .map_err(IronHubCommandError::Product)?; response.message = Some(install_message( @@ -343,20 +343,20 @@ impl IronHubService { } } - async fn fetch_manifest_cached(&self) -> Result { + async fn fetch_manifest_cached(&self) -> Result, IronHubCommandError> { let now = Instant::now(); if let Some(hit) = manifest_cache_get(&self.manifest_url, now) { - return Ok((*hit).clone()); + return Ok(hit); } let fetch_lock = manifest_fetch_lock(&self.manifest_url); let _fetch_guard = fetch_lock.lock().await; let now = Instant::now(); if let Some(hit) = manifest_cache_get(&self.manifest_url, now) { - return Ok((*hit).clone()); + return Ok(hit); } let manifest = Arc::new(self.fetch_manifest().await?); manifest_cache_put(&self.manifest_url, Arc::clone(&manifest), now); - Ok((*manifest).clone()) + Ok(manifest) } async fn fetch_manifest(&self) -> Result { From 54e033f4005de5f711ce392b72f670c1ceabd329 Mon Sep 17 00:00:00 2001 From: serrrfirat Date: Fri, 5 Jun 2026 20:18:55 +0300 Subject: [PATCH 6/9] fix: keep wasm staged credentials for dispatch --- .../src/egress/credential.rs | 11 ++- .../ironclaw_host_runtime/src/obligations.rs | 18 +++++ .../tests/runtime_http_egress_contract.rs | 69 +++++++++++++++++++ 3 files changed, 95 insertions(+), 3 deletions(-) diff --git a/crates/ironclaw_host_runtime/src/egress/credential.rs b/crates/ironclaw_host_runtime/src/egress/credential.rs index 2349c7af48c..3d0c8535daa 100644 --- a/crates/ironclaw_host_runtime/src/egress/credential.rs +++ b/crates/ironclaw_host_runtime/src/egress/credential.rs @@ -1,6 +1,6 @@ use ironclaw_host_api::{ CapabilityId, RuntimeCredentialInjection, RuntimeCredentialSource, RuntimeCredentialTarget, - RuntimeHttpEgressError, RuntimeHttpEgressRequest, SecretHandle, + RuntimeHttpEgressError, RuntimeHttpEgressRequest, RuntimeKind, SecretHandle, }; use ironclaw_network::is_rfc3986_unreserved_segment; use ironclaw_safety::redaction_values_for_secret; @@ -52,7 +52,7 @@ impl<'a> CredentialSourceStrategy<'a> { ) -> Result<(), RuntimeHttpEgressError> { match self { Self::SecretStoreLease => Err(RuntimeHttpEgressError::Credential { - // Production egress accepts one-shot staged obligations only; + // Production egress accepts staged obligations only; // direct store leases are retained behind crate-local tests for // legacy mapping coverage, not as a runtime path. reason: "direct secret-store leases are unavailable for production runtime egress" @@ -234,7 +234,12 @@ fn staged_secret_for_injection( let Some(secret_injections) = secret_injections else { return missing_runtime_credential(injection.required); }; - match secret_injections.take(&request.scope, capability_id, &injection.handle) { + let material = if request.runtime == RuntimeKind::Wasm { + secret_injections.get(&request.scope, capability_id, &injection.handle) + } else { + secret_injections.take(&request.scope, capability_id, &injection.handle) + }; + match material { Ok(Some(material)) => Ok(Some(material)), Ok(None) => missing_runtime_credential(injection.required), Err(_) => Err(RuntimeHttpEgressError::Credential { diff --git a/crates/ironclaw_host_runtime/src/obligations.rs b/crates/ironclaw_host_runtime/src/obligations.rs index fb70e0c86f1..8a0ca1d924e 100644 --- a/crates/ironclaw_host_runtime/src/obligations.rs +++ b/crates/ironclaw_host_runtime/src/obligations.rs @@ -145,6 +145,24 @@ impl RuntimeSecretInjectionStore { .map(|entry| entry.material)) } + pub(crate) fn get( + &self, + scope: &ResourceScope, + capability_id: &CapabilityId, + handle: &SecretHandle, + ) -> Result, RuntimeSecretInjectionStoreError> { + let now = Instant::now(); + let mut secrets = self.lock()?; + prune_expired_entries(&mut secrets, now); + Ok(secrets + .get(&RuntimeSecretInjectionKey::new( + scope, + capability_id, + handle, + )) + .map(|entry| entry.material.clone())) + } + /// Discard all staged secrets for a scoped capability before process ownership exists. /// /// Background process lifecycle cleanup is guarded by a single-active-handoff diff --git a/crates/ironclaw_host_runtime/tests/runtime_http_egress_contract.rs b/crates/ironclaw_host_runtime/tests/runtime_http_egress_contract.rs index e0d8df855e9..ad98c0d7ff8 100644 --- a/crates/ironclaw_host_runtime/tests/runtime_http_egress_contract.rs +++ b/crates/ironclaw_host_runtime/tests/runtime_http_egress_contract.rs @@ -266,6 +266,75 @@ async fn host_http_egress_consumes_staged_obligation_secret_once() { assert_eq!(network_recorder.lock().unwrap().len(), 1); } +#[tokio::test] +async fn wasm_host_http_egress_reuses_staged_obligation_secret_within_dispatch() { + let network = RecordingNetwork::ok(NetworkHttpResponse { + status: 200, + headers: vec![], + body: br#"{"ok":true}"#.to_vec(), + usage: NetworkUsage { + request_bytes: 5, + response_bytes: 11, + resolved_ip: None, + }, + }); + let network_recorder = network.requests.clone(); + let scope = sample_scope(); + let capability_id = sample_capability_id(); + let handle = SecretHandle::new("api-token").unwrap(); + let services = test_obligation_services(); + stage_policy(&services, &scope, &capability_id, sample_policy()).await; + stage_secret( + &services, + &scope, + &capability_id, + &handle, + "sk-staged-wasm-secret", + ) + .await; + let service = services.host_http_egress(network); + let request = RuntimeHttpEgressRequest { + runtime: RuntimeKind::Wasm, + scope, + capability_id: capability_id.clone(), + method: NetworkMethod::Post, + url: "https://api.example.test/v1/run".to_string(), + headers: vec![], + body: b"hello".to_vec(), + network_policy: sample_policy(), + credential_injections: vec![RuntimeCredentialInjection { + handle, + source: RuntimeCredentialSource::StagedObligation { capability_id }, + target: RuntimeCredentialTarget::Header { + name: "authorization".to_string(), + prefix: Some("Bearer ".to_string()), + }, + required: true, + }], + response_body_limit: Some(4096), + save_body_to: None, + timeout_ms: None, + }; + + service + .execute(request.clone()) + .await + .expect("first WASM request should use staged credential"); + service + .execute(request) + .await + .expect("second WASM request in the same dispatch should reuse staged credential"); + + let requests = network_recorder.lock().unwrap(); + assert_eq!(requests.len(), 2); + assert!(requests.iter().all(|request| { + request + .headers + .iter() + .any(|(name, value)| name == "authorization" && value == "Bearer sk-staged-wasm-secret") + })); +} + #[test] fn host_http_egress_records_injected_credentials_in_zeroizing_network_request() { let network = RecordingNetwork::ok(NetworkHttpResponse { From 28092234661aa0ec25c48a92b8bf79bb86d88552 Mon Sep 17 00:00:00 2001 From: serrrfirat Date: Sat, 6 Jun 2026 10:10:24 +0300 Subject: [PATCH 7/9] fix(ci): align trace replay error kind test --- tests/support_unit_tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/support_unit_tests.rs b/tests/support_unit_tests.rs index 80e8b99d8be..c2e1414bee0 100644 --- a/tests/support_unit_tests.rs +++ b/tests/support_unit_tests.rs @@ -587,7 +587,7 @@ mod reborn_support_tests { .await .expect_err("unadvertised scripted capability should fail"); - assert_eq!(error.kind, HostManagedModelErrorKind::InvalidOutput); + assert_eq!(error.kind, HostManagedModelErrorKind::InvalidRequest); assert!( error .safe_summary From ffd6bf9ae4e3066bf7a675464bf8537c2594ea78 Mon Sep 17 00:00:00 2001 From: serrrfirat Date: Sat, 6 Jun 2026 10:26:11 +0300 Subject: [PATCH 8/9] fix(ci): align reborn hook test builders --- crates/ironclaw_reborn/tests/hooks_integration.rs | 10 +++++----- crates/ironclaw_reborn/tests/loop_driver_host.rs | 1 + 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/crates/ironclaw_reborn/tests/hooks_integration.rs b/crates/ironclaw_reborn/tests/hooks_integration.rs index fc414256b2f..32761d3c55b 100644 --- a/crates/ironclaw_reborn/tests/hooks_integration.rs +++ b/crates/ironclaw_reborn/tests/hooks_integration.rs @@ -80,8 +80,8 @@ use ironclaw_reborn::hook_gate_refs::{ InMemoryHookGateRouter, RouterBackedHookGateRefFactory, hook_gate_arguments_digest, }; use ironclaw_reborn::loop_driver_host::{ - EventTriggeredHookSubscription, RebornLoopDriverHostFactory, RebornLoopDriverHostRequest, - TextOnlyLoopHostConfig, + EventTriggeredHookSubscription, RebornLoopDriverHostError, RebornLoopDriverHostFactory, + RebornLoopDriverHostRequest, TextOnlyLoopHostConfig, }; use ironclaw_threads::{ AcceptInboundMessageRequest, EnsureThreadRequest, InMemorySessionThreadService, MessageContent, @@ -649,7 +649,7 @@ fn predicate_deny_dispatcher() -> Arc { /// builder-factory path can attach a security-audit sink before sealing. This /// is the only dispatcher-installation path that consumes /// `RebornLoopDriverHostFactory::with_hook_security_audit_sink`. -fn predicate_deny_builder() -> HookDispatcherBuilder { +fn predicate_deny_builder() -> Result { let hook_id = HookId::derive( &ExtensionId::new("integration-tests").expect("valid ExtensionId in test"), "0.0.1", @@ -665,7 +665,7 @@ fn predicate_deny_builder() -> HookDispatcherBuilder { let evaluator = Arc::new(PredicateEvaluator::new()); let hook = PredicateBackedBeforeCapabilityHook::new(hook_id, spec, evaluator); - HookDispatcherBuilder::new(HookRegistry::new()) + Ok(HookDispatcherBuilder::new(HookRegistry::new()) .install_installed_before_capability( hook_id, HookPhase::Policy, @@ -673,7 +673,7 @@ fn predicate_deny_builder() -> HookDispatcherBuilder { HookBindingScope::Global, Box::new(hook), ) - .expect("Installed-tier predicate hook installs at policy phase") + .expect("Installed-tier predicate hook installs at policy phase")) } fn selective_deny_dispatcher(target: &str) -> Arc { diff --git a/crates/ironclaw_reborn/tests/loop_driver_host.rs b/crates/ironclaw_reborn/tests/loop_driver_host.rs index b8a6d518b1b..903885bb411 100644 --- a/crates/ironclaw_reborn/tests/loop_driver_host.rs +++ b/crates/ironclaw_reborn/tests/loop_driver_host.rs @@ -2666,6 +2666,7 @@ async fn build_runtime_host_with_optional_hooks( model_policy_guard: None, model_budget_accountant: None, safety_context: None, + hook_security_audit_sink: None, turn_event_sink: None, hook_dispatcher_builder_factory: hook_factory, }) From 075deecf4435e0ae72ae6be2d909d5e4033b9116 Mon Sep 17 00:00:00 2001 From: serrrfirat Date: Wed, 17 Jun 2026 12:09:11 +0300 Subject: [PATCH 9/9] fix ironhub egress and force reinstall rollback --- .../src/available_extensions.rs | 91 +++- .../src/extension_lifecycle.rs | 452 +++++++++++++++++- .../src/factory.rs | 56 ++- .../src/ironhub/capabilities.rs | 6 +- .../src/ironhub/service.rs | 153 +++++- .../src/ironhub/tests.rs | 14 +- ...ronclaw__cli__tests__long_help_output.snap | 8 +- 7 files changed, 708 insertions(+), 72 deletions(-) diff --git a/crates/ironclaw_reborn_composition/src/available_extensions.rs b/crates/ironclaw_reborn_composition/src/available_extensions.rs index 3e56b608c33..c40ea9ae015 100644 --- a/crates/ironclaw_reborn_composition/src/available_extensions.rs +++ b/crates/ironclaw_reborn_composition/src/available_extensions.rs @@ -1,3 +1,5 @@ +use std::collections::BTreeMap; + use ironclaw_extensions::{ CapabilityDeclV2, CapabilityVisibility, ExtensionAssetPath, ExtensionManifest, ExtensionPackage, ExtensionRuntime, ManifestSource, @@ -323,15 +325,28 @@ impl AvailableExtensionCatalog { } } + #[cfg(test)] pub(crate) async fn from_filesystem_root( fs: &F, root: &VirtualPath, ) -> Result + where + F: RootFilesystem + ?Sized, + { + let manifest_sources = BTreeMap::new(); + Self::from_filesystem_root_with_manifest_sources(fs, root, &manifest_sources).await + } + + pub(crate) async fn from_filesystem_root_with_manifest_sources( + fs: &F, + root: &VirtualPath, + manifest_sources: &BTreeMap, + ) -> Result where F: RootFilesystem + ?Sized, { Ok(Self::from_packages( - load_filesystem_packages(fs, root).await?, + load_filesystem_packages(fs, root, manifest_sources).await?, )) } @@ -1322,6 +1337,7 @@ where async fn load_filesystem_packages( fs: &F, root: &VirtualPath, + manifest_sources: &BTreeMap, ) -> Result, ProductWorkflowError> where F: RootFilesystem + ?Sized, @@ -1380,9 +1396,13 @@ where reason: format!("available extension manifest is not UTF-8: {error}"), } })?; + let manifest_source = manifest_sources + .get(&entry.name) + .copied() + .unwrap_or(ManifestSource::InstalledLocal); let manifest = ExtensionManifest::parse_with_optional_host_api_contracts( &manifest_toml, - ManifestSource::HostBundled, + manifest_source, &host_ports, &contracts, ) @@ -1461,7 +1481,7 @@ fn visible_capabilities( #[cfg(test)] mod tests { use std::{ - collections::{HashMap, HashSet}, + collections::{BTreeMap, HashMap, HashSet}, sync::{Arc, Mutex}, time::SystemTime, }; @@ -1904,6 +1924,71 @@ mod tests { ); } + #[tokio::test] + async fn filesystem_catalog_preserves_persisted_manifest_source() { + static REGISTRY_MANIFEST: &str = r#" +schema_version = "reborn.extension_manifest.v2" +id = "fixture" +name = "Fixture" +version = "0.1.0" +description = "fixture extension" +trust = "third_party" + +[runtime] +kind = "wasm" +module = "wasm/fixture.wasm" + +[[host_api]] +id = "ironclaw.capability_provider/v1" +section = "capability_provider.tools" + +[capability_provider.tools] + +[[capability_provider.tools.capabilities]] +id = "fixture.search" +description = "Search" +effects = ["network"] +default_permission = "ask" +visibility = "model" +input_schema_ref = "schemas/search.input.json" +output_schema_ref = "schemas/search.output.json" +"#; + let fs = InMemoryBackend::default(); + fs.write_file( + &VirtualPath::new("/system/extensions/fixture/manifest.toml").unwrap(), + REGISTRY_MANIFEST.as_bytes(), + ) + .await + .unwrap(); + fs.write_file( + &VirtualPath::new("/system/extensions/fixture/wasm/fixture.wasm").unwrap(), + b"wasm", + ) + .await + .unwrap(); + let mut manifest_sources = BTreeMap::new(); + manifest_sources.insert("fixture".to_string(), ManifestSource::RegistryInstalled); + + let catalog = AvailableExtensionCatalog::from_filesystem_root_with_manifest_sources( + &fs, + &VirtualPath::new("/system/extensions").unwrap(), + &manifest_sources, + ) + .await + .unwrap(); + let results = catalog.search("fixture").collect::>(); + + assert_eq!(results.len(), 1); + assert_eq!( + results[0].package.manifest.source, + ManifestSource::RegistryInstalled + ); + assert_eq!( + results[0].summary().source, + ironclaw_product_workflow::LifecycleExtensionSource::Registry + ); + } + #[tokio::test] async fn filesystem_catalog_skips_extension_dirs_without_manifest() { let fs = InMemoryBackend::default(); diff --git a/crates/ironclaw_reborn_composition/src/extension_lifecycle.rs b/crates/ironclaw_reborn_composition/src/extension_lifecycle.rs index 1d11a6d9c18..afa1b547341 100644 --- a/crates/ironclaw_reborn_composition/src/extension_lifecycle.rs +++ b/crates/ironclaw_reborn_composition/src/extension_lifecycle.rs @@ -6,7 +6,7 @@ use ironclaw_extensions::{ ExtensionLifecycleService, ExtensionManifestRecord, ExtensionManifestRef, ExtensionPackage, ManifestHash, ManifestSource, }; -use ironclaw_filesystem::RootFilesystem; +use ironclaw_filesystem::{FileType, FilesystemError, RootFilesystem}; use ironclaw_host_api::{ CapabilityDescriptor, CapabilityId, EffectKind, ExtensionId, ResourceScope, RuntimeCredentialRequirement, RuntimeHttpEgress, VirtualPath, sha256_digest_token, @@ -292,20 +292,19 @@ impl RebornLocalExtensionManagementPort { let package_ref = available.package_ref.clone(); let plan = prepare_install(available)?; let _operation_guard = self.operation_lock.lock().await; - if force - && self - .installation_store - .get_installation(plan.installation.installation_id()) - .await - .map_err(map_extension_installation_error)? - .is_some() - { - self.remove_locked(package_ref.clone()).await?; - } - if !force { - self.ensure_not_installed(&available.package.id, plan.installation.installation_id()) - .await?; + let replacement = if force { + self.forced_replacement_state(available, plan.installation.installation_id()) + .await? + } else { + None + }; + if let Some(replacement) = replacement { + return self + .replace_available_package(available, plan, replacement) + .await; } + self.ensure_not_installed(&available.package.id, plan.installation.installation_id()) + .await?; self.register_lifecycle_package(&available.package).await?; if let Err(error) = @@ -350,6 +349,146 @@ impl RebornLocalExtensionManagementPort { )) } + async fn forced_replacement_state( + &self, + available: &AvailableExtensionPackage, + installation_id: &ExtensionInstallationId, + ) -> Result, ProductWorkflowError> { + let Some(installation) = self + .installation_store + .get_installation(installation_id) + .await + .map_err(map_extension_installation_error)? + else { + return Ok(None); + }; + if installation.extension_id() != &available.package.id { + return Err(ProductWorkflowError::InvalidBindingRequest { + reason: format!( + "installation {} does not belong to extension {}", + installation_id.as_str(), + available.package.id.as_str() + ), + }); + } + let manifest = self + .installation_store + .get_manifest(&available.package.id) + .await + .map_err(map_extension_installation_error)? + .ok_or_else(|| ProductWorkflowError::InvalidBindingRequest { + reason: format!( + "extension {} manifest is not installed", + available.package.id.as_str() + ), + })?; + let previous_state = installation.activation_state(); + let lifecycle_package = self.lifecycle_package(&available.package.id).await?; + let files = self + .backup_materialized_extension_files(&available.package.id) + .await?; + Ok(Some(ForcedExtensionReplacement { + manifest, + installation, + lifecycle_package, + previous_state, + files, + })) + } + + async fn replace_available_package( + &self, + available: &AvailableExtensionPackage, + plan: ExtensionInstallPlan, + replacement: ForcedExtensionReplacement, + ) -> Result { + let package_ref = available.package_ref.clone(); + if let Err(error) = self + .installation_store + .set_activation_state( + replacement.installation.installation_id(), + ExtensionActivationState::Disabled, + ) + .await + { + return Err(map_extension_installation_error(error)); + } + if let Err(error) = self + .active_extensions + .unpublish(&replacement.lifecycle_package) + { + if let Err(restore_error) = self + .installation_store + .set_activation_state( + replacement.installation.installation_id(), + replacement.previous_state, + ) + .await + .map_err(map_extension_installation_error) + { + return Err(compensation_failure( + "extension force install failed to unpublish active package and activation restore failed", + error, + restore_error, + )); + } + return Err(error); + } + if let Err(error) = self.replace_lifecycle_package(&available.package).await { + if let Err(restore_error) = self.restore_active_publication( + &replacement.lifecycle_package, + replacement.previous_state, + ) { + return Err(compensation_failure( + "extension force install failed to update lifecycle package and active publication restore failed", + error, + restore_error, + )); + } + if let Err(restore_error) = self + .installation_store + .set_activation_state( + replacement.installation.installation_id(), + replacement.previous_state, + ) + .await + .map_err(map_extension_installation_error) + { + return Err(compensation_failure( + "extension force install failed to update lifecycle package and activation restore failed", + error, + restore_error, + )); + } + return Err(error); + } + if let Err(error) = self + .clear_materialized_extension_files(&replacement.files.root) + .await + { + return self.rollback_forced_replacement(replacement, error).await; + } + if let Err(error) = + materialize_available_extension(self.filesystem.as_ref(), available).await + { + return self.rollback_forced_replacement(replacement, error).await; + } + if let Err(error) = self.persist_replacement_plan(plan).await { + return self.rollback_forced_replacement(replacement, error).await; + } + + Ok(response_with_payload( + Some(package_ref), + LifecyclePhase::Installed, + LifecycleProductPayload::ExtensionInstall { + installed: true, + visible_capability_ids: visible_capability_ids(available) + .map(|id| id.as_str().to_string()) + .collect(), + }, + )) + } + pub(crate) async fn activate( &self, package_ref: LifecyclePackageRef, @@ -787,6 +926,18 @@ impl RebornLocalExtensionManagementPort { .map_err(map_extension_error) } + async fn replace_lifecycle_package( + &self, + package: &ExtensionPackage, + ) -> Result<(), ProductWorkflowError> { + self.lifecycle_service + .lock() + .await + .update(package.clone()) + .await + .map_err(map_extension_error) + } + async fn rollback_lifecycle_install( &self, extension_id: &ExtensionId, @@ -804,10 +955,17 @@ impl RebornLocalExtensionManagementPort { previous_state: ExtensionActivationState, ) -> Result<(), ProductWorkflowError> { let mut lifecycle = self.lifecycle_service.lock().await; - lifecycle - .install(package.clone()) - .await - .map_err(map_extension_error)?; + if lifecycle.registry().get_extension(&package.id).is_some() { + lifecycle + .update(package.clone()) + .await + .map_err(map_extension_error)?; + } else { + lifecycle + .install(package.clone()) + .await + .map_err(map_extension_error)?; + } match previous_state { ExtensionActivationState::Enabled => { lifecycle @@ -825,6 +983,97 @@ impl RebornLocalExtensionManagementPort { Ok(()) } + async fn backup_materialized_extension_files( + &self, + extension_id: &ExtensionId, + ) -> Result { + let root = VirtualPath::new(format!("/system/extensions/{}", extension_id.as_str())) + .map_err(|error| ProductWorkflowError::InvalidBindingRequest { + reason: format!("invalid extension root path: {error}"), + })?; + let mut files = Vec::new(); + let mut pending = vec![root.clone()]; + while let Some(directory) = pending.pop() { + let entries = match self.filesystem.list_dir(&directory).await { + Ok(entries) => entries, + Err(FilesystemError::NotFound { .. }) + | Err(FilesystemError::MountNotFound { .. }) + if directory == root => + { + Vec::new() + } + Err(error) => { + return Err(ProductWorkflowError::Transient { + reason: format!("failed to inspect extension files for backup: {error}"), + }); + } + }; + for entry in entries { + match entry.file_type { + FileType::File => { + let bytes = + self.filesystem + .read_file(&entry.path) + .await + .map_err(|error| ProductWorkflowError::Transient { + reason: format!( + "failed to read extension file for backup: {error}" + ), + })?; + files.push((entry.path, bytes)); + } + FileType::Directory => pending.push(entry.path), + FileType::Symlink | FileType::Other => { + return Err(ProductWorkflowError::Transient { + reason: format!( + "extension file backup rejected non-file entry {}", + entry.path.as_str() + ), + }); + } + } + } + } + Ok(ExtensionFileBackup { root, files }) + } + + async fn restore_materialized_extension_files( + &self, + backup: &ExtensionFileBackup, + ) -> Result<(), ProductWorkflowError> { + self.clear_materialized_extension_files(&backup.root) + .await?; + for (path, bytes) in &backup.files { + self.filesystem + .write_file(path, bytes) + .await + .map_err(|error| ProductWorkflowError::Transient { + reason: format!( + "failed to restore extension file {}: {error}", + path.as_str() + ), + })?; + } + Ok(()) + } + + async fn clear_materialized_extension_files( + &self, + root: &VirtualPath, + ) -> Result<(), ProductWorkflowError> { + match self.filesystem.delete(root).await { + Ok(()) + | Err(FilesystemError::NotFound { .. }) + | Err(FilesystemError::MountNotFound { .. }) => {} + Err(error) => { + return Err(ProductWorkflowError::Transient { + reason: format!("failed to clear replaced extension files: {error}"), + }); + } + } + Ok(()) + } + async fn restore_installation( &self, installation: &ExtensionInstallation, @@ -884,6 +1133,63 @@ impl RebornLocalExtensionManagementPort { Ok(()) } + async fn persist_replacement_plan( + &self, + plan: ExtensionInstallPlan, + ) -> Result<(), ProductWorkflowError> { + self.installation_store + .upsert_manifest_and_installation(plan.manifest_record, plan.installation) + .await + .map_err(map_extension_installation_error) + } + + async fn rollback_forced_replacement( + &self, + replacement: ForcedExtensionReplacement, + original_error: ProductWorkflowError, + ) -> Result { + if let Err(restore_error) = self + .restore_materialized_extension_files(&replacement.files) + .await + { + return Err(compensation_failure( + "extension force install failed and file restore failed", + original_error, + restore_error, + )); + } + if let Err(restore_error) = self + .restore_lifecycle_package(&replacement.lifecycle_package, replacement.previous_state) + .await + { + return Err(compensation_failure( + "extension force install failed and lifecycle restore failed", + original_error, + restore_error, + )); + } + if let Err(restore_error) = self + .restore_active_publication(&replacement.lifecycle_package, replacement.previous_state) + { + return Err(compensation_failure( + "extension force install failed and active publication restore failed", + original_error, + restore_error, + )); + } + if let Err(restore_error) = self + .restore_installation_records(replacement.manifest, replacement.installation) + .await + { + return Err(compensation_failure( + "extension force install failed and installation restore failed", + original_error, + restore_error, + )); + } + Err(original_error) + } + async fn delete_materialized_extension_files( &self, extension_id: &ExtensionId, @@ -913,6 +1219,19 @@ struct ExtensionInstallPlan { installation: ExtensionInstallation, } +struct ForcedExtensionReplacement { + manifest: ExtensionManifestRecord, + installation: ExtensionInstallation, + lifecycle_package: ExtensionPackage, + previous_state: ExtensionActivationState, + files: ExtensionFileBackup, +} + +struct ExtensionFileBackup { + root: VirtualPath, + files: Vec<(VirtualPath, Vec)>, +} + fn prepare_install( available: &AvailableExtensionPackage, ) -> Result { @@ -2347,6 +2666,103 @@ mod tests { ); } + #[tokio::test] + async fn extension_force_install_materialization_failure_restores_existing_install() { + let initial = fixture_extension_package(); + let old_hash = available_manifest_hash(&initial).expect("initial hash"); + let (_dir, storage_root, port, active_registry, installation_store) = + extension_management_port_fixture_with_catalog_and_service( + AvailableExtensionCatalog::from_packages(vec![initial]), + ExtensionLifecycleService::new(ExtensionRegistry::new()), + ); + let package_ref = LifecyclePackageRef::new(LifecyclePackageKind::Extension, "fixture") + .expect("valid ref"); + port.install(package_ref.clone()) + .await + .expect("install extension"); + port.activate(package_ref, ExtensionActivationMode::Static) + .await + .expect("activate extension"); + let wasm_path = storage_root.join("system/extensions/fixture/wasm/fixture.wasm"); + std::fs::write(&wasm_path, b"existing-live-module").expect("rewrite installed module"); + let mut replacement = + fixture_extension_package_with_description("Replacement fixture extension"); + replacement.assets[1].content = AvailableExtensionAssetContent::Filesystem( + VirtualPath::new("/system/extensions/missing/fixture.wasm").unwrap(), + ); + + let error = port + .install_available_package(&replacement, true) + .await + .expect_err("failed force install restores previous extension"); + + assert!(matches!(error, ProductWorkflowError::Transient { .. })); + let extension_id = ExtensionId::new("fixture").expect("valid extension id"); + let installation_id = ExtensionInstallationId::new("fixture").expect("valid installation"); + assert!( + active_registry + .snapshot() + .get_extension(&extension_id) + .is_some() + ); + assert!( + std::fs::read_to_string(storage_root.join("system/extensions/fixture/manifest.toml")) + .expect("manifest restored") + .contains("Lifecycle fixture extension") + ); + assert_eq!( + std::fs::read(wasm_path).expect("wasm restored"), + b"existing-live-module" + ); + let installation = installation_store + .get_installation(&installation_id) + .await + .expect("read installation") + .expect("installation remains"); + assert_eq!( + installation.activation_state(), + ExtensionActivationState::Enabled + ); + let manifest = installation_store + .get_manifest(&extension_id) + .await + .expect("read manifest") + .expect("manifest remains"); + assert_eq!(manifest.manifest_hash(), Some(&old_hash)); + } + + #[tokio::test] + async fn extension_force_install_removes_obsolete_materialized_files() { + let (_dir, storage_root, port, _active_registry, _installation_store) = + extension_management_port_fixture_with_catalog_and_service( + AvailableExtensionCatalog::from_packages(vec![fixture_extension_package()]), + ExtensionLifecycleService::new(ExtensionRegistry::new()), + ); + let package_ref = LifecyclePackageRef::new(LifecyclePackageKind::Extension, "fixture") + .expect("valid ref"); + port.install(package_ref).await.expect("install extension"); + let obsolete_path = storage_root.join("system/extensions/fixture/schemas/old.json"); + std::fs::create_dir_all(obsolete_path.parent().expect("schema parent")) + .expect("create obsolete parent"); + std::fs::write(&obsolete_path, br#"{"stale":true}"#).expect("write obsolete file"); + let replacement = + fixture_extension_package_with_description("Replacement fixture extension"); + + port.install_available_package(&replacement, true) + .await + .expect("force install replacement"); + + assert!( + !obsolete_path.exists(), + "force install should clear obsolete old package files" + ); + assert!( + std::fs::read_to_string(storage_root.join("system/extensions/fixture/manifest.toml")) + .expect("manifest materialized") + .contains("Replacement fixture extension") + ); + } + #[tokio::test] async fn extension_activate_rejects_lifecycle_package_without_installation() { let dir = tempfile::tempdir().expect("tempdir"); diff --git a/crates/ironclaw_reborn_composition/src/factory.rs b/crates/ironclaw_reborn_composition/src/factory.rs index e95d4811ecb..651e2c7ceda 100644 --- a/crates/ironclaw_reborn_composition/src/factory.rs +++ b/crates/ironclaw_reborn_composition/src/factory.rs @@ -1,5 +1,6 @@ // arch-exempt: large_file, needs Reborn composition helper extraction, plan #4469 use std::{ + collections::BTreeMap, path::{Path, PathBuf}, sync::Arc, }; @@ -27,7 +28,7 @@ use ironclaw_events::{ DurableAuditLog, DurableEventLog, InMemoryDurableAuditLog, InMemoryDurableEventLog, }; use ironclaw_extensions::{ - ExtensionInstallationStore, ExtensionLifecycleService, ExtensionRegistry, + ExtensionInstallationStore, ExtensionLifecycleService, ExtensionRegistry, ManifestSource, }; #[cfg(not(feature = "libsql"))] use ironclaw_filesystem::InMemoryBackend; @@ -750,21 +751,6 @@ async fn build_local_dev(input: RebornBuildInput) -> Result = filesystem.clone(); let extension_installation_store: Arc = Arc::new( FilesystemExtensionInstallationStore::load(extension_filesystem.clone()) @@ -773,6 +759,25 @@ async fn build_local_dev(input: RebornBuildInput) -> Result Result Result, RebornBuildError> { + let manifests = installation_store.list_manifests().await.map_err(|error| { + RebornBuildError::InvalidConfig { + reason: format!("extension installation manifests could not be loaded: {error}"), + } + })?; + Ok(manifests + .into_iter() + .map(|record| { + ( + record.manifest().id.as_str().to_string(), + record.manifest().source, + ) + }) + .collect()) +} + #[cfg(feature = "libsql")] fn build_local_dev_store_graph( input: RebornLocalDevStoreGraphInput, diff --git a/crates/ironclaw_reborn_composition/src/ironhub/capabilities.rs b/crates/ironclaw_reborn_composition/src/ironhub/capabilities.rs index 7d53b201ed6..ab17d5fd8e2 100644 --- a/crates/ironclaw_reborn_composition/src/ironhub/capabilities.rs +++ b/crates/ironclaw_reborn_composition/src/ironhub/capabilities.rs @@ -150,13 +150,15 @@ impl FirstPartyCapabilityHandler for IronHubCapabilityHandler { RuntimeDispatchErrorKind::Executor, )); }; - let service = IronHubService::new( + let capability_id = request.capability_id.clone(); + let service = IronHubService::new_with_runtime_egress( Arc::clone(&self.skill_management), Arc::clone(&self.extension_management), runtime_http_egress, + capability_id.clone(), request.scope, ); - let command = match request.capability_id.as_str() { + let command = match capability_id.as_str() { IRONHUB_SEARCH_CAPABILITY_ID => { let input: SearchInput = parse_capability_input(request.input)?; IronHubCommand::Search { query: input.query } diff --git a/crates/ironclaw_reborn_composition/src/ironhub/service.rs b/crates/ironclaw_reborn_composition/src/ironhub/service.rs index fb8874fe54e..3c072b6a9a9 100644 --- a/crates/ironclaw_reborn_composition/src/ironhub/service.rs +++ b/crates/ironclaw_reborn_composition/src/ironhub/service.rs @@ -5,8 +5,12 @@ use std::time::Instant; use chrono::{DateTime, Utc}; use ironclaw_common::hashing::sha256_hex; use ironclaw_host_api::{ - CapabilityId, InvocationId, NetworkMethod, ResourceScope, RuntimeHttpEgress, - RuntimeHttpEgressRequest, RuntimeKind, UserId, + CapabilityId, ExtensionId, InvocationId, NetworkMethod, ResourceScope, RuntimeHttpEgress, + RuntimeHttpEgressError, RuntimeHttpEgressRequest, RuntimeHttpEgressResponse, RuntimeKind, + TrustClass, UserId, +}; +use ironclaw_host_runtime::{ + BUILTIN_FIRST_PARTY_PROVIDER, HostRuntimeHttpEgressPort, HostRuntimeHttpEgressRequest, }; use ironclaw_product_workflow::{ LifecyclePackageId, LifecyclePackageKind, LifecyclePhase, LifecycleProductPayload, @@ -16,7 +20,9 @@ use tokio::sync::Mutex as AsyncMutex; use crate::extension_lifecycle::RebornLocalExtensionManagementPort; use crate::factory::RebornServices; -use crate::lifecycle::{RebornLocalSkillManagementPort, response_with_payload}; +use crate::lifecycle::{ + RebornLocalSkillManagementError, RebornLocalSkillManagementPort, response_with_payload, +}; #[cfg(not(test))] use super::catalog::verify_signed_manifest; @@ -59,8 +65,8 @@ pub async fn execute_reborn_ironhub_command( .extension_management .as_ref() .ok_or(IronHubCommandError::LocalRuntimeUnavailable)?; - let runtime_http_egress = local_runtime - .runtime_http_egress + let host_runtime_http_egress = local_runtime + .host_runtime_http_egress .as_ref() .ok_or(IronHubCommandError::RuntimeHttpEgressUnavailable)?; let scope = ResourceScope::local_default( @@ -68,19 +74,66 @@ pub async fn execute_reborn_ironhub_command( InvocationId::new(), ) .map_err(invalid_input)?; - let service = IronHubService::new( + let capability_id = CapabilityId::new("builtin.ironhub_fetch").map_err(invalid_input)?; + let service = IronHubService::new_with_host_egress( Arc::clone(&local_runtime.skill_management), Arc::clone(extension_management), - Arc::clone(runtime_http_egress), + host_runtime_http_egress.clone(), + capability_id, scope, ); service.execute(command).await } +enum IronHubEgress { + Host { + port: HostRuntimeHttpEgressPort, + capability_id: CapabilityId, + }, + Runtime { + egress: Arc, + capability_id: CapabilityId, + }, +} + +impl IronHubEgress { + fn capability_id(&self) -> CapabilityId { + match self { + Self::Host { capability_id, .. } | Self::Runtime { capability_id, .. } => { + capability_id.clone() + } + } + } + + async fn execute( + &self, + request: RuntimeHttpEgressRequest, + ) -> Result { + match self { + Self::Host { port, .. } => { + port.execute(HostRuntimeHttpEgressRequest { + extension_id: ExtensionId::new(BUILTIN_FIRST_PARTY_PROVIDER).map_err( + |error| RuntimeHttpEgressError::Request { + reason: format!("invalid builtin provider id: {error}"), + request_bytes: 0, + response_bytes: 0, + }, + )?, + trust: TrustClass::FirstParty, + request, + credentials: Vec::new(), + }) + .await + } + Self::Runtime { egress, .. } => egress.execute(request).await, + } + } +} + pub(crate) struct IronHubService { skill_management: Arc, extension_management: Arc, - runtime_http_egress: Arc, + egress: IronHubEgress, scope: ResourceScope, manifest_url: String, #[cfg(test)] @@ -88,16 +141,52 @@ pub(crate) struct IronHubService { } impl IronHubService { - pub(crate) fn new( + pub(crate) fn new_with_host_egress( + skill_management: Arc, + extension_management: Arc, + host_runtime_http_egress: HostRuntimeHttpEgressPort, + capability_id: CapabilityId, + scope: ResourceScope, + ) -> Self { + Self::new( + skill_management, + extension_management, + IronHubEgress::Host { + port: host_runtime_http_egress, + capability_id, + }, + scope, + ) + } + + pub(crate) fn new_with_runtime_egress( skill_management: Arc, extension_management: Arc, runtime_http_egress: Arc, + capability_id: CapabilityId, + scope: ResourceScope, + ) -> Self { + Self::new( + skill_management, + extension_management, + IronHubEgress::Runtime { + egress: runtime_http_egress, + capability_id, + }, + scope, + ) + } + + fn new( + skill_management: Arc, + extension_management: Arc, + egress: IronHubEgress, scope: ResourceScope, ) -> Self { Self { skill_management, extension_management, - runtime_http_egress, + egress, scope, manifest_url: resolve_manifest_url(), #[cfg(test)] @@ -285,21 +374,25 @@ impl IronHubService { String::from_utf8(content).map_err(|error| IronHubCommandError::Install { reason: format!("skill markdown is not UTF-8: {error}"), })?; - if options.force { + let mut result = self + .skill_management + .install_from_url(Some(&entry.name), &content, &entry.skill_md.url) + .await; + if options.force && matches!(result, Err(ref error) if is_skill_conflict(error)) { self.skill_management .remove_if_installed(&entry.name) .await .map_err(|error| IronHubCommandError::Install { reason: error.to_string(), })?; + result = self + .skill_management + .install_from_url(Some(&entry.name), &content, &entry.skill_md.url) + .await; } - let result = self - .skill_management - .install_from_url(Some(&entry.name), &content, &entry.skill_md.url) - .await - .map_err(|error| IronHubCommandError::Install { - reason: error.to_string(), - })?; + let result = result.map_err(|error| IronHubCommandError::Install { + reason: error.to_string(), + })?; let mut response = response_with_payload( Some(package_ref(LifecyclePackageKind::Skill, &result.name)?), LifecyclePhase::Installed, @@ -412,7 +505,7 @@ impl IronHubService { let request = RuntimeHttpEgressRequest { runtime: RuntimeKind::FirstParty, scope: self.scope.clone(), - capability_id: CapabilityId::new("builtin.ironhub_fetch").map_err(invalid_input)?, + capability_id: self.egress.capability_id(), method: NetworkMethod::Get, url: url.to_string(), headers: Vec::new(), @@ -423,13 +516,13 @@ impl IronHubService { save_body_to: None, timeout_ms: Some(30_000), }; - let response = self - .runtime_http_egress - .execute(request) - .await - .map_err(|error| IronHubCommandError::Catalog { - reason: error.stable_runtime_reason().to_string(), - })?; + let response = + self.egress + .execute(request) + .await + .map_err(|error| IronHubCommandError::Catalog { + reason: error.stable_runtime_reason().to_string(), + })?; if !(200..300).contains(&response.status) { return Err(IronHubCommandError::Catalog { reason: format!("download returned HTTP {}", response.status), @@ -444,6 +537,14 @@ impl IronHubService { } } +fn is_skill_conflict(error: &RebornLocalSkillManagementError) -> bool { + matches!( + error, + RebornLocalSkillManagementError::Skill(error) + if error.kind() == ironclaw_skills::SkillManagementErrorKind::Conflict + ) +} + fn resolve_manifest_url() -> String { std::env::var("IRONHUB_MANIFEST_URL") .ok() diff --git a/crates/ironclaw_reborn_composition/src/ironhub/tests.rs b/crates/ironclaw_reborn_composition/src/ironhub/tests.rs index ec01c630164..4e7f228d0ac 100644 --- a/crates/ironclaw_reborn_composition/src/ironhub/tests.rs +++ b/crates/ironclaw_reborn_composition/src/ironhub/tests.rs @@ -8,8 +8,9 @@ use base64::engine::general_purpose::URL_SAFE_NO_PAD; use ed25519_dalek::{Signer, SigningKey}; use ironclaw_common::hashing::sha256_hex; use ironclaw_host_api::{ - InvocationId, NetworkScheme, ResourceScope, RuntimeHttpEgress, RuntimeHttpEgressError, - RuntimeHttpEgressRequest, RuntimeHttpEgressResponse, RuntimeKind, UserId, sha256_digest_token, + CapabilityId, InvocationId, NetworkScheme, ResourceScope, RuntimeHttpEgress, + RuntimeHttpEgressError, RuntimeHttpEgressRequest, RuntimeHttpEgressResponse, RuntimeKind, + UserId, sha256_digest_token, }; use ironclaw_product_workflow::{LifecyclePhase, LifecycleProductPayload, LifecycleSkillSource}; @@ -363,6 +364,10 @@ async fn fetch_manifest_uses_runtime_egress_host_policy() { let request = egress.single_request(); assert_eq!(request.runtime, RuntimeKind::FirstParty); assert_eq!(request.url, manifest_url); + assert_eq!( + request.capability_id, + CapabilityId::new(super::model::IRONHUB_SEARCH_CAPABILITY_ID).expect("capability id") + ); assert_eq!( request.response_body_limit, Some(super::model::MAX_SIGNED_MANIFEST_BYTES) @@ -436,7 +441,7 @@ async fn ironhub_service( .await .expect("local-dev services build"); let local_runtime = services.local_runtime.expect("local runtime substrate"); - IronHubService::new( + IronHubService::new_with_runtime_egress( Arc::clone(&local_runtime.skill_management), local_runtime .extension_management @@ -444,6 +449,7 @@ async fn ironhub_service( .expect("extension management") .clone(), egress, + CapabilityId::new(super::model::IRONHUB_SEARCH_CAPABILITY_ID).expect("capability id"), ResourceScope::local_default( UserId::new("ironhub-test-user").expect("user"), InvocationId::new(), @@ -529,6 +535,7 @@ fn mixed_manifest_json(fixture: MixedManifestFixture<'_>) -> String { #[derive(Debug, Clone)] struct RecordedEgressRequest { runtime: RuntimeKind, + capability_id: CapabilityId, url: String, response_body_limit: Option, network_policy: ironclaw_host_api::NetworkPolicy, @@ -580,6 +587,7 @@ impl RuntimeHttpEgress for RecordingIronHubEgress { .expect("requests lock") .push(RecordedEgressRequest { runtime: request.runtime, + capability_id: request.capability_id.clone(), url: request.url.clone(), response_body_limit: request.response_body_limit, network_policy: request.network_policy.clone(), diff --git a/src/cli/snapshots/ironclaw__cli__tests__long_help_output.snap b/src/cli/snapshots/ironclaw__cli__tests__long_help_output.snap index 79ffe904c5e..a406ddb0a94 100644 --- a/src/cli/snapshots/ironclaw__cli__tests__long_help_output.snap +++ b/src/cli/snapshots/ironclaw__cli__tests__long_help_output.snap @@ -63,22 +63,22 @@ Options: --auto-approve Auto-approve tool execution (shell, file writes, HTTP, etc.) - + Skips interactive approval prompts for standard tools. Destructive operations still require explicit approval. Other safeguards remain active: rate limits, hooks, authentication gates. --deployment-mode Deployment mode: where IronClaw is running and who owns the machine boundary. - + Wire names: `local_single_user`, `hosted_multi_tenant`, `enterprise_dedicated`. Falls back to `IRONCLAW_DEPLOYMENT_MODE`, then to `local_single_user`. --runtime-profile Requested runtime profile (#3045). - + Wire names: `secure_default`, `local_safe`, `local_dev`, `local_yolo`, `hosted_safe`, `hosted_dev`, `hosted_yolo_tenant_scoped`, `enterprise_safe`, `enterprise_dev`, `enterprise_yolo_dedicated`, `sandboxed`, `experiment`. Falls back to `IRONCLAW_RUNTIME_PROFILE`, then to `secure_default`. --yolo-disclosure Acknowledge the disclosure required by `*_yolo*` profiles. - + Yolo profiles intentionally reduce approvals inside their authority boundary. The CLI must capture explicit operator confirmation — without this flag (or `IRONCLAW_YOLO_DISCLOSURE=true`), any yolo profile selection fails closed. -h, --help