From 1accc2afa5c2bf4625ccd058f0a7baab4f3b89b3 Mon Sep 17 00:00:00 2001 From: neo-sky Date: Tue, 23 Jun 2026 23:35:35 -0400 Subject: [PATCH 1/8] feat(ironhub): install from a private org-scoped manifest source Adds an optional private_manifest_url to IronHubInstallOptions so install resolves an org-scoped signed manifest instead of the public catalog, threaded through the agent install capability and a new --private-manifest-url flag. Private manifests are one-shot and uniquely tokened, so they fetch and verify directly rather than entering the public catalog's cache, single-flight, and replay-monotonic machinery. Agent half of IronHub private spaces. --- .../src/commands/ironhub.rs | 72 +++++++++++-------- .../src/ironhub/capabilities.rs | 3 + .../src/ironhub/model.rs | 1 + .../src/ironhub/service.rs | 39 +++++----- .../src/ironhub/tests.rs | 42 +++++++++++ 5 files changed, 110 insertions(+), 47 deletions(-) diff --git a/crates/ironclaw_reborn_cli/src/commands/ironhub.rs b/crates/ironclaw_reborn_cli/src/commands/ironhub.rs index 145af412558..0ff8220e008 100644 --- a/crates/ironclaw_reborn_cli/src/commands/ironhub.rs +++ b/crates/ironclaw_reborn_cli/src/commands/ironhub.rs @@ -10,8 +10,11 @@ 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)] + #[arg( + long = "confirm-host-access", + global = true, + help = "Confirm trusted-laptop host filesystem access for local-dev-yolo." + )] confirm_host_access: bool, #[command(subcommand)] @@ -20,78 +23,84 @@ pub(crate) struct IronHubCommand { #[derive(Debug, Subcommand)] enum IronHubSubcommand { - /// Search the signed IronHub catalog. + #[command(about = "Search the signed IronHub catalog.")] Search(IronHubSearchCommand), - /// List available IronHub tools or skills. + #[command(about = "List available IronHub tools or skills.")] List(IronHubListCommand), - /// Show one IronHub catalog entry. + #[command(about = "Show one IronHub catalog entry.")] Info(IronHubInfoCommand), - /// Install an IronHub tool or skill into Reborn local-dev state. + #[command(about = "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. + #[arg(help = "Optional query by name or description. Omit to list all entries.")] query: Option, - /// Output the lifecycle response as JSON. - #[arg(long)] + #[arg(long, help = "Output the lifecycle response as JSON.")] json: bool, } #[derive(Debug, Args)] struct IronHubListCommand { - /// Limit results to tools or skills. - #[arg(long, value_enum)] + #[arg(long, value_enum, help = "Limit results to tools or skills.")] kind: Option, - /// Output the lifecycle response as JSON. - #[arg(long)] + #[arg(long, help = "Output the lifecycle response as JSON.")] json: bool, } #[derive(Debug, Args)] struct IronHubInfoCommand { - /// Tool or skill name. + #[arg(help = "Tool or skill name.")] name: String, - /// Disambiguate when a name exists as both a tool and a skill. - #[arg(long, value_enum)] + #[arg( + long, + value_enum, + help = "Disambiguate when a name exists as both a tool and a skill." + )] kind: Option, - /// Output the lifecycle response as JSON. - #[arg(long)] + #[arg(long, help = "Output the lifecycle response as JSON.")] json: bool, } #[derive(Debug, Args)] struct IronHubInstallCommand { - /// Tool or skill name. + #[arg(help = "Tool or skill name.")] name: String, - /// Disambiguate when a name exists as both a tool and a skill. - #[arg(long, value_enum)] + #[arg( + long, + value_enum, + help = "Disambiguate when a name exists as both a tool and a skill." + )] kind: Option, - /// Replace an already installed package. - #[arg(long)] + #[arg(long, help = "Replace an already installed package.")] force: bool, - /// Acknowledge installing unverified community content. - #[arg(long)] + #[arg(long, help = "Acknowledge installing unverified community content.")] acknowledge_unverified: bool, - /// Require the catalog entry to still have this version. - #[arg(long)] + #[arg(long, help = "Require the catalog entry to still have this version.")] expected_version: Option, - /// Require the catalog entry to still have this artifact digest. - #[arg(long)] + #[arg( + long, + help = "Require the catalog entry to still have this artifact digest." + )] expected_artifact_digest: Option, - /// Output the lifecycle response as JSON. - #[arg(long)] + #[arg( + long, + help = "Install from a private org-scoped signed manifest URL instead of the public catalog." + )] + private_manifest_url: Option, + + #[arg(long, help = "Output the lifecycle response as JSON.")] json: bool, } @@ -136,6 +145,7 @@ impl IronHubCommand { acknowledge_unverified: command.acknowledge_unverified, expected_version: command.expected_version, expected_artifact_digest: command.expected_artifact_digest, + private_manifest_url: command.private_manifest_url, }, }, command.json, diff --git a/crates/ironclaw_reborn_composition/src/ironhub/capabilities.rs b/crates/ironclaw_reborn_composition/src/ironhub/capabilities.rs index ab17d5fd8e2..e03578f8b43 100644 --- a/crates/ironclaw_reborn_composition/src/ironhub/capabilities.rs +++ b/crates/ironclaw_reborn_composition/src/ironhub/capabilities.rs @@ -136,6 +136,8 @@ struct InstallInput { expected_version: Option, #[serde(default)] expected_artifact_digest: Option, + #[serde(default)] + private_manifest_url: Option, } #[async_trait] @@ -180,6 +182,7 @@ impl FirstPartyCapabilityHandler for IronHubCapabilityHandler { acknowledge_unverified: false, expected_version: input.expected_version, expected_artifact_digest: input.expected_artifact_digest, + private_manifest_url: input.private_manifest_url, }, } } diff --git a/crates/ironclaw_reborn_composition/src/ironhub/model.rs b/crates/ironclaw_reborn_composition/src/ironhub/model.rs index a8ab4bf53f5..59ad410a686 100644 --- a/crates/ironclaw_reborn_composition/src/ironhub/model.rs +++ b/crates/ironclaw_reborn_composition/src/ironhub/model.rs @@ -145,6 +145,7 @@ pub struct IronHubInstallOptions { pub acknowledge_unverified: bool, pub expected_version: Option, pub expected_artifact_digest: Option, + pub private_manifest_url: Option, } #[derive(Debug, Clone, PartialEq, Eq)] diff --git a/crates/ironclaw_reborn_composition/src/ironhub/service.rs b/crates/ironclaw_reborn_composition/src/ironhub/service.rs index 3c072b6a9a9..290fbd725da 100644 --- a/crates/ironclaw_reborn_composition/src/ironhub/service.rs +++ b/crates/ironclaw_reborn_composition/src/ironhub/service.rs @@ -222,7 +222,7 @@ impl IronHubService { } async fn search(&self, query: &str) -> Result { - let manifest = self.fetch_manifest_cached().await?; + let manifest = self.fetch_manifest_cached(&self.manifest_url).await?; let query = query.trim().to_ascii_lowercase(); let tools = manifest .tools @@ -251,7 +251,7 @@ impl IronHubService { &self, kind: Option, ) -> Result { - let manifest = self.fetch_manifest_cached().await?; + let manifest = self.fetch_manifest_cached(&self.manifest_url).await?; match kind { Some(IronHubEntryKind::Skill) => { let skills = manifest @@ -315,7 +315,7 @@ impl IronHubService { hint: Option, ) -> Result { validate_hub_name(name)?; - let manifest = self.fetch_manifest_cached().await?; + let manifest = self.fetch_manifest_cached(&self.manifest_url).await?; let kind = classify(&manifest, name, hint)?; let response = match kind { IronHubEntryKind::Tool => { @@ -356,7 +356,10 @@ impl IronHubService { options: IronHubInstallOptions, ) -> Result { validate_hub_name(name)?; - let manifest = self.fetch_manifest_cached().await?; + let manifest = match options.private_manifest_url.as_deref() { + Some(private_url) => Arc::new(self.download_and_verify_manifest(private_url).await?), + None => self.fetch_manifest_cached(&self.manifest_url).await?, + }; let (kind, provenance, artifact_digest) = classify_gate_and_digest(&manifest, name, options.kind, &options)?; let lock_key = format!("{}:{name}", kind.as_str()); @@ -436,27 +439,32 @@ impl IronHubService { } } - async fn fetch_manifest_cached(&self) -> Result, IronHubCommandError> { + async fn fetch_manifest_cached( + &self, + url: &str, + ) -> Result, IronHubCommandError> { let now = Instant::now(); - if let Some(hit) = manifest_cache_get(&self.manifest_url, now) { + if let Some(hit) = manifest_cache_get(url, now) { return Ok(hit); } - let fetch_lock = manifest_fetch_lock(&self.manifest_url); + let fetch_lock = manifest_fetch_lock(url); let _fetch_guard = fetch_lock.lock().await; let now = Instant::now(); - if let Some(hit) = manifest_cache_get(&self.manifest_url, now) { + if let Some(hit) = manifest_cache_get(url, now) { return Ok(hit); } - let manifest = Arc::new(self.fetch_manifest().await?); - manifest_cache_put(&self.manifest_url, Arc::clone(&manifest), now); + let manifest = Arc::new(self.download_and_verify_manifest(url).await?); + enforce_manifest_monotonic(url, &manifest)?; + manifest_cache_put(url, Arc::clone(&manifest), now); Ok(manifest) } - 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?; + async fn download_and_verify_manifest( + &self, + url: &str, + ) -> Result { + validate_artifact_url("hub-manifest", "manifest_url", url)?; + let envelope = self.download_url(url, MAX_SIGNED_MANIFEST_BYTES).await?; #[cfg(not(test))] let verified_manifest = verify_signed_manifest(&envelope); #[cfg(test)] @@ -474,7 +482,6 @@ impl IronHubService { 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) } diff --git a/crates/ironclaw_reborn_composition/src/ironhub/tests.rs b/crates/ironclaw_reborn_composition/src/ironhub/tests.rs index 4e7f228d0ac..310037e6bbd 100644 --- a/crates/ironclaw_reborn_composition/src/ironhub/tests.rs +++ b/crates/ironclaw_reborn_composition/src/ironhub/tests.rs @@ -339,6 +339,48 @@ async fn install_skill_and_tool_materialize_into_reborn_management() { ); } +#[tokio::test] +async fn install_resolves_skill_from_private_manifest_url() { + let dir = tempfile::tempdir().expect("tempdir"); + let root = dir.path().join("local-dev"); + let public_manifest_url = "https://hub.ironclaw.com/tests/private/public-manifest.json"; + let private_manifest_url = + "https://hub.ironclaw.com/tests/private/org-manifest.json?token=test-capability-token"; + let skill_url = "https://hub.ironclaw.com/tests/private/SKILL.md"; + let skill_bytes = b"# private skill\n"; + let private_manifest = signed_manifest(skill_manifest_json( + "private-skill", + "2026-01-04T00:00:00Z", + skill_url, + &sha256_hex(skill_bytes), + IronHubProvenance::Official, + )); + let egress = Arc::new(RecordingIronHubEgress::new([ + ( + public_manifest_url, + signed_manifest(empty_manifest_json("2026-01-04T00:00:00Z")), + ), + (private_manifest_url, private_manifest), + (skill_url, skill_bytes.to_vec()), + ])); + let service = ironhub_service(root.clone(), egress, public_manifest_url).await; + + let installed = service + .execute(super::model::IronHubCommand::Install { + name: "private-skill".to_string(), + options: IronHubInstallOptions { + kind: Some(IronHubEntryKind::Skill), + private_manifest_url: Some(private_manifest_url.to_string()), + ..IronHubInstallOptions::default() + }, + }) + .await + .expect("install resolves the skill from the private manifest"); + + assert_eq!(installed.phase, LifecyclePhase::Installed); + assert!(root.join("skills/private-skill/SKILL.md").exists()); +} + #[tokio::test] async fn fetch_manifest_uses_runtime_egress_host_policy() { let dir = tempfile::tempdir().expect("tempdir"); From e125294da634cbc1c12da4703a454ea67c38f0d4 Mon Sep 17 00:00:00 2001 From: neo-sky Date: Tue, 23 Jun 2026 23:35:47 -0400 Subject: [PATCH 2/8] fix: migrate extension fixtures to capability_provider/v1 host_api The binding validator rejects legacy top-level [[capabilities]] for InstalledLocal manifest sources, breaking the extension CLI fixtures and the available_extensions filesystem-catalog fixture. Move both to the [[host_api]] ironclaw.capability_provider/v1 form, and switch the available_extensions fixture in-memory parse to parse_with_host_api_contracts so it accepts the new shape. --- crates/ironclaw_reborn_cli/tests/extension.rs | 8 +++++++- .../src/available_extensions.rs | 15 ++++++++++++--- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/crates/ironclaw_reborn_cli/tests/extension.rs b/crates/ironclaw_reborn_cli/tests/extension.rs index a712ebf9669..ff7c2627e93 100644 --- a/crates/ironclaw_reborn_cli/tests/extension.rs +++ b/crates/ironclaw_reborn_cli/tests/extension.rs @@ -197,7 +197,13 @@ transport = "stdio" command = "zztest-mcp-server" args = ["--stdio"] -[[capabilities]] +[[host_api]] +id = "ironclaw.capability_provider/v1" +section = "capability_provider.tools" + +[capability_provider.tools] + +[[capability_provider.tools.capabilities]] id = "{extension_id}.search_issues" description = "Search GitHub issues" effects = ["network", "dispatch_capability"] diff --git a/crates/ironclaw_reborn_composition/src/available_extensions.rs b/crates/ironclaw_reborn_composition/src/available_extensions.rs index c40ea9ae015..0eb7ee24b53 100644 --- a/crates/ironclaw_reborn_composition/src/available_extensions.rs +++ b/crates/ironclaw_reborn_composition/src/available_extensions.rs @@ -2149,7 +2149,13 @@ trust = "third_party" kind = "wasm" module = "wasm/fixture.wasm" -[[capabilities]] +[[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"] @@ -2158,7 +2164,7 @@ visibility = "model" input_schema_ref = "schemas/search.input.json" output_schema_ref = "schemas/search.output.json" -[[capabilities]] +[[capability_provider.tools.capabilities]] id = "fixture.write" description = "Write" effects = ["external_write"] @@ -2167,10 +2173,13 @@ visibility = "model" input_schema_ref = "schemas/write.input.json" output_schema_ref = "schemas/write.output.json" "#; - let manifest = ExtensionManifest::parse( + let contracts = ironclaw_host_runtime::default_host_api_contract_registry() + .expect("default host api contracts"); + let manifest = ExtensionManifest::parse_with_host_api_contracts( MANIFEST, ManifestSource::HostBundled, &HostPortCatalog::empty(), + &contracts, ) .expect("manifest"); let package = ExtensionPackage::from_manifest( From 3ae89801709029178fe73e9bdc2a408c437d76df Mon Sep 17 00:00:00 2001 From: neo-sky Date: Wed, 24 Jun 2026 00:07:17 -0400 Subject: [PATCH 3/8] feat(ironhub): add private provenance tier for org-scoped artifacts A private artifact is the org's own signed content, not untrusted community content, so it must not trip the unverified-install gate. Add an IronHubProvenance::Private tier (wire "private") that is never classified community-unverified, and assert the private-manifest install test installs without acknowledgement. --- crates/ironclaw_reborn_composition/src/ironhub/model.rs | 3 +++ crates/ironclaw_reborn_composition/src/ironhub/tests.rs | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/ironclaw_reborn_composition/src/ironhub/model.rs b/crates/ironclaw_reborn_composition/src/ironhub/model.rs index 59ad410a686..885d981ec9b 100644 --- a/crates/ironclaw_reborn_composition/src/ironhub/model.rs +++ b/crates/ironclaw_reborn_composition/src/ironhub/model.rs @@ -53,6 +53,7 @@ pub(super) enum IronHubProvenance { Official, Trusted, Verified, + Private, #[default] #[serde(alias = "community")] New, @@ -64,6 +65,7 @@ impl IronHubProvenance { Self::Official => "official", Self::Trusted => "trusted", Self::Verified => "verified", + Self::Private => "private", Self::New => "new", } } @@ -77,6 +79,7 @@ impl IronHubProvenance { Self::Official => "NEAR-vetted (official)", Self::Trusted => "community, trusted publisher", Self::Verified => "community, verified publisher", + Self::Private => "private (your organization)", Self::New => "UNVERIFIED community (new author)", } } diff --git a/crates/ironclaw_reborn_composition/src/ironhub/tests.rs b/crates/ironclaw_reborn_composition/src/ironhub/tests.rs index 310037e6bbd..6d41a218a90 100644 --- a/crates/ironclaw_reborn_composition/src/ironhub/tests.rs +++ b/crates/ironclaw_reborn_composition/src/ironhub/tests.rs @@ -353,7 +353,7 @@ async fn install_resolves_skill_from_private_manifest_url() { "2026-01-04T00:00:00Z", skill_url, &sha256_hex(skill_bytes), - IronHubProvenance::Official, + IronHubProvenance::Private, )); let egress = Arc::new(RecordingIronHubEgress::new([ ( From fde1dcbb2c89dafa984304a425a24e097ede5581 Mon Sep 17 00:00:00 2001 From: neo-sky Date: Wed, 24 Jun 2026 14:06:37 -0400 Subject: [PATCH 4/8] feat(ironhub): allow private artifacts from the configured catalog host validate_artifact_url also accepts a URL whose host matches the configured catalog host, so private install works from the hub's own origin on any deployment. SSRF blocklist still applies. --- .../src/ironhub/catalog.rs | 11 +++-- .../src/ironhub/service.rs | 17 ++++++-- .../src/ironhub/tests.rs | 42 +++++++++++++++++++ 3 files changed, 64 insertions(+), 6 deletions(-) diff --git a/crates/ironclaw_reborn_composition/src/ironhub/catalog.rs b/crates/ironclaw_reborn_composition/src/ironhub/catalog.rs index 409f7a47836..1e5873c456e 100644 --- a/crates/ironclaw_reborn_composition/src/ironhub/catalog.rs +++ b/crates/ironclaw_reborn_composition/src/ironhub/catalog.rs @@ -148,8 +148,9 @@ fn skill_artifact_digest(entry: &IronHubSkillEntry) -> String { pub(super) fn validate_artifact( artifact: &IronHubArtifact, max_bytes: u64, + catalog_host: Option<&str>, ) -> Result<(), IronHubCommandError> { - validate_artifact_url("artifact", "url", &artifact.url)?; + validate_artifact_url("artifact", "url", &artifact.url, catalog_host)?; if artifact.size_bytes > max_bytes { return Err(IronHubCommandError::Catalog { reason: format!("artifact exceeds {} byte cap", max_bytes), @@ -168,6 +169,7 @@ pub(super) fn validate_artifact_url( manifest_name: &str, field: &'static str, url: &str, + catalog_host: Option<&str>, ) -> Result<(), IronHubCommandError> { let parsed = url::Url::parse(url).map_err(|error| IronHubCommandError::Catalog { reason: format!("{manifest_name}.{field} invalid URL: {error}"), @@ -182,7 +184,9 @@ pub(super) fn validate_artifact_url( .ok_or_else(|| IronHubCommandError::Catalog { reason: format!("{manifest_name}.{field} host is missing"), })?; - if host_is_disallowed_target(host) || !is_allowed_artifact_host(host) { + let host_allowed = is_allowed_artifact_host(host) + || catalog_host.is_some_and(|allowed| host.eq_ignore_ascii_case(allowed)); + if host_is_disallowed_target(host) || !host_allowed { return Err(IronHubCommandError::Catalog { reason: format!("{manifest_name}.{field} host '{host}' is not allowed"), }); @@ -193,8 +197,9 @@ pub(super) fn validate_artifact_url( pub(super) fn network_policy_for_url( url: &str, max_bytes: u64, + catalog_host: Option<&str>, ) -> Result { - validate_artifact_url("download", "url", url)?; + validate_artifact_url("download", "url", url, catalog_host)?; let parsed = url::Url::parse(url).map_err(|error| IronHubCommandError::Catalog { reason: format!("invalid URL: {error}"), })?; diff --git a/crates/ironclaw_reborn_composition/src/ironhub/service.rs b/crates/ironclaw_reborn_composition/src/ironhub/service.rs index 290fbd725da..6d0f8fa8b12 100644 --- a/crates/ironclaw_reborn_composition/src/ironhub/service.rs +++ b/crates/ironclaw_reborn_composition/src/ironhub/service.rs @@ -459,11 +459,22 @@ impl IronHubService { Ok(manifest) } + fn catalog_host(&self) -> Option { + url::Url::parse(&self.manifest_url) + .ok() + .and_then(|parsed| parsed.host_str().map(str::to_string)) + } + async fn download_and_verify_manifest( &self, url: &str, ) -> Result { - validate_artifact_url("hub-manifest", "manifest_url", url)?; + validate_artifact_url( + "hub-manifest", + "manifest_url", + url, + self.catalog_host().as_deref(), + )?; let envelope = self.download_url(url, MAX_SIGNED_MANIFEST_BYTES).await?; #[cfg(not(test))] let verified_manifest = verify_signed_manifest(&envelope); @@ -490,7 +501,7 @@ impl IronHubService { artifact: &IronHubArtifact, max_bytes: u64, ) -> Result, IronHubCommandError> { - validate_artifact(artifact, max_bytes)?; + validate_artifact(artifact, max_bytes, self.catalog_host().as_deref())?; let bytes = self.download_url(&artifact.url, max_bytes).await?; let actual = sha256_hex(&bytes); if !actual.eq_ignore_ascii_case(&artifact.sha256) { @@ -517,7 +528,7 @@ impl IronHubService { url: url.to_string(), headers: Vec::new(), body: Vec::new(), - network_policy: network_policy_for_url(url, max_bytes)?, + network_policy: network_policy_for_url(url, max_bytes, self.catalog_host().as_deref())?, credential_injections: Vec::new(), response_body_limit: Some(max_bytes), save_body_to: None, diff --git a/crates/ironclaw_reborn_composition/src/ironhub/tests.rs b/crates/ironclaw_reborn_composition/src/ironhub/tests.rs index 6d41a218a90..39830fcaa6a 100644 --- a/crates/ironclaw_reborn_composition/src/ironhub/tests.rs +++ b/crates/ironclaw_reborn_composition/src/ironhub/tests.rs @@ -381,6 +381,48 @@ async fn install_resolves_skill_from_private_manifest_url() { assert!(root.join("skills/private-skill/SKILL.md").exists()); } +#[tokio::test] +async fn install_allows_private_artifacts_on_configured_catalog_host() { + let dir = tempfile::tempdir().expect("tempdir"); + let root = dir.path().join("local-dev"); + let catalog_url = "https://private-hub.example.com/api/catalog/manifest.json"; + let private_manifest_url = + "https://private-hub.example.com/api/private-artifacts/manifest.json?token=test-token"; + let skill_url = "https://private-hub.example.com/api/private-artifacts/SKILL.md"; + let skill_bytes = b"# scoped skill\n"; + let private_manifest = signed_manifest(skill_manifest_json( + "scoped-skill", + "2026-01-05T00:00:00Z", + skill_url, + &sha256_hex(skill_bytes), + IronHubProvenance::Official, + )); + let egress = Arc::new(RecordingIronHubEgress::new([ + ( + catalog_url, + signed_manifest(empty_manifest_json("2026-01-05T00:00:00Z")), + ), + (private_manifest_url, private_manifest), + (skill_url, skill_bytes.to_vec()), + ])); + let service = ironhub_service(root.clone(), egress, catalog_url).await; + + let installed = service + .execute(super::model::IronHubCommand::Install { + name: "scoped-skill".to_string(), + options: IronHubInstallOptions { + kind: Some(IronHubEntryKind::Skill), + private_manifest_url: Some(private_manifest_url.to_string()), + ..IronHubInstallOptions::default() + }, + }) + .await + .expect("install succeeds when private artifacts share the configured catalog host"); + + assert_eq!(installed.phase, LifecyclePhase::Installed); + assert!(root.join("skills/scoped-skill/SKILL.md").exists()); +} + #[tokio::test] async fn fetch_manifest_uses_runtime_egress_host_policy() { let dir = tempfile::tempdir().expect("tempdir"); From f6c318f8289adcd21a419b20d426638695f24236 Mon Sep 17 00:00:00 2001 From: neo-sky Date: Thu, 25 Jun 2026 00:42:43 -0400 Subject: [PATCH 5/8] feat(reborn): port IronHub deep-link register/install gateway Rebuilds the v1-only IronHub deep-link surface natively on Reborn: a public HMAC-verified POST /api/ironhub/register webhook and a bearer plus HMAC-verified install-delivery route, both reusing the existing IronHubService for catalog and install. The shared key threads from IRONHUB_AGENT_SHARED_KEY through RebornRuntimeInput into build_webui_services, and the register webhook mounts only when a key is configured. Gated webui-v2-beta; agent_link HMAC vectors and the webui_v2 descriptor contract pass. --- crates/ironclaw_product_workflow/src/lib.rs | 8 +- .../src/reborn_services.rs | 56 +++++++ .../src/reborn_services/ironhub_link.rs | 72 +++++++++ .../ironclaw_reborn_cli/src/commands/serve.rs | 8 + crates/ironclaw_reborn_cli/src/runtime/mod.rs | 8 + crates/ironclaw_reborn_composition/Cargo.toml | 2 + .../src/ironhub/agent_link.rs | 138 +++++++++++++++++ .../src/ironhub/link_service.rs | 140 ++++++++++++++++++ .../src/ironhub/mod.rs | 6 + .../src/ironhub_link_serve.rs | 107 +++++++++++++ crates/ironclaw_reborn_composition/src/lib.rs | 4 + .../src/runtime.rs | 23 +++ .../src/runtime_input.rs | 16 ++ .../ironclaw_reborn_composition/src/webui.rs | 16 ++ crates/ironclaw_webui_v2/src/descriptors.rs | 17 +++ crates/ironclaw_webui_v2/src/handlers.rs | 13 +- crates/ironclaw_webui_v2/src/lib.rs | 6 +- crates/ironclaw_webui_v2/src/router.rs | 7 +- .../tests/webui_v2_descriptors_contract.rs | 20 ++- 19 files changed, 659 insertions(+), 8 deletions(-) create mode 100644 crates/ironclaw_product_workflow/src/reborn_services/ironhub_link.rs create mode 100644 crates/ironclaw_reborn_composition/src/ironhub/agent_link.rs create mode 100644 crates/ironclaw_reborn_composition/src/ironhub/link_service.rs create mode 100644 crates/ironclaw_reborn_composition/src/ironhub_link_serve.rs diff --git a/crates/ironclaw_product_workflow/src/lib.rs b/crates/ironclaw_product_workflow/src/lib.rs index f672f20f96b..97efa5563bf 100644 --- a/crates/ironclaw_product_workflow/src/lib.rs +++ b/crates/ironclaw_product_workflow/src/lib.rs @@ -135,9 +135,11 @@ pub use ironclaw_product_adapters::{ pub use reborn_services::{ AUTOMATION_LIST_DEFAULT_PAGE_SIZE, AUTOMATION_LIST_MAX_PAGE_SIZE, AutomationProductFacade, CodexLoginStart, ConnectableChannelsProductFacade, ExtensionCredentialSetupService, - ExtensionCredentialStatusRequest, ExtensionCredentialSubmitRequest, LlmActiveSelection, - LlmConfigService, LlmConfigServiceError, LlmConfigSnapshot, LlmModelsResult, LlmProbeRequest, - LlmProbeResult, LlmProviderView, NearAiAuthProvider, NearAiLoginRequest, NearAiLoginStart, + ExtensionCredentialStatusRequest, ExtensionCredentialSubmitRequest, + IronhubInstallDeliveryRequest, IronhubInstallDeliveryResult, IronhubLinkError, + IronhubLinkService, IronhubRegisterRequest, LlmActiveSelection, LlmConfigService, + LlmConfigServiceError, LlmConfigSnapshot, LlmModelsResult, LlmProbeRequest, LlmProbeResult, + LlmProviderView, NearAiAuthProvider, NearAiLoginRequest, NearAiLoginStart, NearAiWalletLoginRequest, NearAiWalletLoginResult, ProductAgentBoundCaller, RebornAutomationInfo, RebornAutomationRunStatus, RebornAutomationSource, RebornAutomationState, RebornCancelRunResponse, RebornChannelConnectAction, RebornChannelConnectStrategy, diff --git a/crates/ironclaw_product_workflow/src/reborn_services.rs b/crates/ironclaw_product_workflow/src/reborn_services.rs index 3ba791d1911..bf305a3e8b8 100644 --- a/crates/ironclaw_product_workflow/src/reborn_services.rs +++ b/crates/ironclaw_product_workflow/src/reborn_services.rs @@ -53,11 +53,16 @@ mod error; mod extension_onboarding; mod extension_setup_credentials; mod extensions; +mod ironhub_link; mod lifecycle_setup; mod llm_config; mod types; pub use error::{RebornServicesError, RebornServicesErrorCode, RebornServicesErrorKind}; +pub use ironhub_link::{ + IronhubInstallDeliveryRequest, IronhubInstallDeliveryResult, IronhubLinkError, + IronhubLinkService, IronhubRegisterRequest, +}; pub use llm_config::{ CodexLoginStart, LlmActiveSelection, LlmConfigService, LlmConfigServiceError, LlmConfigSnapshot, LlmModelsResult, LlmProbeRequest, LlmProbeResult, LlmProviderView, @@ -465,6 +470,22 @@ pub trait RebornServicesApi: Send + Sync { let _ = caller; Err(llm_config::llm_config_unavailable()) } + + async fn ironhub_register( + &self, + request: IronhubRegisterRequest, + ) -> Result<(), RebornServicesError> { + let _ = request; + Err(ironhub_link::ironhub_link_unavailable()) + } + + async fn ironhub_deliver_install( + &self, + request: IronhubInstallDeliveryRequest, + ) -> Result { + let _ = request; + Err(ironhub_link::ironhub_link_unavailable()) + } } /// Default facade implementation composed at the WebUI boundary. @@ -482,6 +503,7 @@ pub struct RebornServices { skill_activation_recorder: Option>, skill_activation_clearer: Option>, llm_config: Option>, + ironhub_link: Option>, } impl RebornServices { @@ -504,6 +526,7 @@ impl RebornServices { skill_activation_recorder: None, skill_activation_clearer: None, llm_config: None, + ironhub_link: None, } } @@ -517,6 +540,11 @@ impl RebornServices { self } + pub fn with_ironhub_link_service(mut self, ironhub_link: Arc) -> Self { + self.ironhub_link = Some(ironhub_link); + self + } + pub fn with_lifecycle_product_facade( mut self, lifecycle_facade: Arc, @@ -1270,6 +1298,34 @@ impl RebornServicesApi for RebornServices { .await .map_err(llm_config::map_llm_config_error) } + + async fn ironhub_register( + &self, + request: IronhubRegisterRequest, + ) -> Result<(), RebornServicesError> { + let service = self + .ironhub_link + .as_ref() + .ok_or_else(ironhub_link::ironhub_link_unavailable)?; + service + .register(request) + .await + .map_err(ironhub_link::map_ironhub_link_error) + } + + async fn ironhub_deliver_install( + &self, + request: IronhubInstallDeliveryRequest, + ) -> Result { + let service = self + .ironhub_link + .as_ref() + .ok_or_else(ironhub_link::ironhub_link_unavailable)?; + service + .deliver_install(request) + .await + .map_err(ironhub_link::map_ironhub_link_error) + } } fn automation_unavailable() -> RebornServicesError { diff --git a/crates/ironclaw_product_workflow/src/reborn_services/ironhub_link.rs b/crates/ironclaw_product_workflow/src/reborn_services/ironhub_link.rs new file mode 100644 index 00000000000..be9be9f6e7c --- /dev/null +++ b/crates/ironclaw_product_workflow/src/reborn_services/ironhub_link.rs @@ -0,0 +1,72 @@ +use async_trait::async_trait; +use serde::{Deserialize, Serialize}; + +use super::error::{RebornServicesError, RebornServicesErrorCode}; + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +pub struct IronhubRegisterRequest { + pub uid: String, + pub aid: String, + pub ts: u64, + pub nonce: String, + pub sig: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +pub struct IronhubInstallDeliveryRequest { + pub slug: String, + pub version: String, + pub uid: String, + pub aid: String, + pub ts: u64, + pub nonce: String, + pub artifact_digest: String, + pub sig: String, + #[serde(default)] + pub kind: Option, + #[serde(default)] + pub private_manifest_url: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct IronhubInstallDeliveryResult { + pub installed: bool, + pub slug: String, + pub message: String, +} + +#[derive(Debug, thiserror::Error)] +pub enum IronhubLinkError { + #[error("invalid agent-link signature")] + InvalidSignature, + #[error("agent-link timestamp outside the accepted window")] + StaleTimestamp, + #[error("ironhub install failed: {reason}")] + Install { reason: String }, + #[error("ironhub link service is unavailable")] + Unavailable, +} + +#[async_trait] +pub trait IronhubLinkService: Send + Sync { + async fn register(&self, request: IronhubRegisterRequest) -> Result<(), IronhubLinkError>; + + async fn deliver_install( + &self, + request: IronhubInstallDeliveryRequest, + ) -> Result; +} + +pub(super) fn ironhub_link_unavailable() -> RebornServicesError { + RebornServicesError::service_unavailable(false) +} + +pub(super) fn map_ironhub_link_error(error: IronhubLinkError) -> RebornServicesError { + match error { + IronhubLinkError::InvalidSignature | IronhubLinkError::StaleTimestamp => { + RebornServicesError::from_status(RebornServicesErrorCode::Forbidden, 403, false) + } + IronhubLinkError::Install { .. } => RebornServicesError::internal_invariant(), + IronhubLinkError::Unavailable => RebornServicesError::service_unavailable(false), + } +} diff --git a/crates/ironclaw_reborn_cli/src/commands/serve.rs b/crates/ironclaw_reborn_cli/src/commands/serve.rs index 9413cc04be5..e65127657ce 100644 --- a/crates/ironclaw_reborn_cli/src/commands/serve.rs +++ b/crates/ironclaw_reborn_cli/src/commands/serve.rs @@ -18,6 +18,8 @@ use ironclaw_reborn_composition::{ use ironclaw_reborn_composition::{ build_slack_host_beta_mounts, build_webui_services_with_slack_host_beta_mounts, }; +#[cfg(feature = "webui-v2-beta")] +use ironclaw_reborn_composition::{IronhubRegisterRouteState, ironhub_register_route_mount}; use ironclaw_reborn_config::IdentitySection; use ironclaw_reborn_webui_ingress::{ EnvBearerAuthenticator, RebornWebuiServeOptions, serve_webui_v2, @@ -442,6 +444,12 @@ impl ServeCommand { if let Some(nearai_mount) = runtime.nearai_login_callback_mount() { serve_config = serve_config.with_public_route_mount(nearai_mount); } + #[cfg(feature = "webui-v2-beta")] + if runtime.ironhub_register_enabled() { + let register_state = IronhubRegisterRouteState::new(Arc::clone(&bundle.api)); + serve_config = serve_config + .with_public_route_mount(ironhub_register_route_mount(register_state)); + } if let Some(mount) = public_mount { serve_config = serve_config.with_public_route_mount(mount); } diff --git a/crates/ironclaw_reborn_cli/src/runtime/mod.rs b/crates/ironclaw_reborn_cli/src/runtime/mod.rs index c9b35729563..88bc086d48d 100644 --- a/crates/ironclaw_reborn_cli/src/runtime/mod.rs +++ b/crates/ironclaw_reborn_cli/src/runtime/mod.rs @@ -365,6 +365,14 @@ pub(crate) fn build_runtime_input_with_options( } } + #[cfg(feature = "webui-v2-beta")] + if let Ok(shared_key) = std::env::var("IRONHUB_AGENT_SHARED_KEY") { + let shared_key = shared_key.trim(); + if !shared_key.is_empty() { + runtime_input = runtime_input.with_ironhub_agent_shared_key(shared_key.to_string()); + } + } + Ok(runtime_input) } diff --git a/crates/ironclaw_reborn_composition/Cargo.toml b/crates/ironclaw_reborn_composition/Cargo.toml index 56476746485..84176d7a726 100644 --- a/crates/ironclaw_reborn_composition/Cargo.toml +++ b/crates/ironclaw_reborn_composition/Cargo.toml @@ -84,6 +84,8 @@ deadpool-postgres = { version = "0.14", optional = true } ed25519-dalek = "2.2.0" futures = "0.3" hex = "0.4.3" +hmac = "0.12" +sha2 = "0.10" ironclaw_auth = { path = "../ironclaw_auth" } ironclaw_common = { path = "../ironclaw_common" } ironclaw_capabilities = { path = "../ironclaw_capabilities" } diff --git a/crates/ironclaw_reborn_composition/src/ironhub/agent_link.rs b/crates/ironclaw_reborn_composition/src/ironhub/agent_link.rs new file mode 100644 index 00000000000..c1af23a5f84 --- /dev/null +++ b/crates/ironclaw_reborn_composition/src/ironhub/agent_link.rs @@ -0,0 +1,138 @@ +use hmac::{Hmac, Mac}; +use sha2::Sha256; + +type HmacSha256 = Hmac; + +pub(super) struct RegisterChallenge<'a> { + pub uid: &'a str, + pub aid: &'a str, + pub ts: u64, + pub nonce: &'a str, +} + +impl RegisterChallenge<'_> { + pub(super) fn payload(&self) -> String { + format!( + "register:{}:{}:{}:{}", + self.uid, self.aid, self.ts, self.nonce + ) + } +} + +pub(super) struct InstallDelivery<'a> { + pub slug: &'a str, + pub version: &'a str, + pub uid: &'a str, + pub aid: &'a str, + pub ts: u64, + pub nonce: &'a str, + pub artifact_digest: &'a str, +} + +impl InstallDelivery<'_> { + pub(super) fn payload(&self) -> String { + format!( + "install:{}:{}:{}:{}:{}:{}:{}", + self.slug, self.version, self.uid, self.aid, self.ts, self.nonce, self.artifact_digest + ) + } +} + +pub(super) fn verify_signature(shared_key: &str, payload: &str, sig_hex: &str) -> bool { + let Ok(expected) = hex::decode(sig_hex) else { + return false; + }; + let Ok(mut mac) = HmacSha256::new_from_slice(shared_key.as_bytes()) else { + return false; + }; + mac.update(payload.as_bytes()); + mac.verify_slice(&expected).is_ok() +} + +#[cfg(test)] +mod tests { + use super::*; + + const SHARED_KEY: &str = "ihub_sk_E2ETestSharedKey0000000000000000000000000"; + const REGISTER_SIG: &str = "7e69b8cd66138589a2ae1320d6ba894870462efeb295acf80336ddd00e0953b5"; + const INSTALL_SIG: &str = "f00a213648f926e263d25b02b5fcd2731d371355a5728c3c07a3c7eb817be2ea"; + + fn register() -> RegisterChallenge<'static> { + RegisterChallenge { + uid: "user-1", + aid: "aid-1", + ts: 1_700_000_000, + nonce: "nonce-abc", + } + } + + fn install() -> InstallDelivery<'static> { + InstallDelivery { + slug: "my-skill", + version: "1.0.0", + uid: "user-1", + aid: "aid-1", + ts: 1_700_000_000, + nonce: "nonce-abc", + artifact_digest: "sha256:deadbeef", + } + } + + #[test] + fn register_payload_matches_hub_format() { + assert_eq!( + register().payload(), + "register:user-1:aid-1:1700000000:nonce-abc" + ); + } + + #[test] + fn install_payload_matches_hub_format() { + assert_eq!( + install().payload(), + "install:my-skill:1.0.0:user-1:aid-1:1700000000:nonce-abc:sha256:deadbeef" + ); + } + + #[test] + fn verifies_hub_register_signature() { + assert!(verify_signature( + SHARED_KEY, + ®ister().payload(), + REGISTER_SIG + )); + } + + #[test] + fn verifies_hub_install_signature() { + assert!(verify_signature( + SHARED_KEY, + &install().payload(), + INSTALL_SIG + )); + } + + #[test] + fn rejects_tampered_signature() { + let tampered = format!("00{}", ®ISTER_SIG[2..]); + assert!(!verify_signature( + SHARED_KEY, + ®ister().payload(), + &tampered + )); + } + + #[test] + fn rejects_wrong_shared_key() { + assert!(!verify_signature( + "ihub_sk_wrong", + ®ister().payload(), + REGISTER_SIG + )); + } + + #[test] + fn rejects_non_hex_signature() { + assert!(!verify_signature(SHARED_KEY, ®ister().payload(), "zzzz")); + } +} diff --git a/crates/ironclaw_reborn_composition/src/ironhub/link_service.rs b/crates/ironclaw_reborn_composition/src/ironhub/link_service.rs new file mode 100644 index 00000000000..b217e4a25b2 --- /dev/null +++ b/crates/ironclaw_reborn_composition/src/ironhub/link_service.rs @@ -0,0 +1,140 @@ +use std::sync::Arc; + +use async_trait::async_trait; +use ironclaw_host_api::{CapabilityId, InvocationId, ResourceScope, UserId}; +use ironclaw_host_runtime::HostRuntimeHttpEgressPort; +use ironclaw_product_workflow::{ + IronhubInstallDeliveryRequest, IronhubInstallDeliveryResult, IronhubLinkError, + IronhubLinkService, IronhubRegisterRequest, LifecyclePhase, +}; + +use crate::extension_lifecycle::RebornLocalExtensionManagementPort; +use crate::lifecycle::RebornLocalSkillManagementPort; + +use super::agent_link::{InstallDelivery, RegisterChallenge, verify_signature}; +use super::model::{IronHubCommand, IronHubEntryKind, IronHubInstallOptions}; +use super::service::IronHubService; + +const MAX_TIMESTAMP_DRIFT_SECS: i64 = 300; + +pub(crate) struct RebornIronhubLinkService { + skill_management: Arc, + extension_management: Arc, + host_runtime_http_egress: HostRuntimeHttpEgressPort, + shared_key: String, +} + +impl RebornIronhubLinkService { + pub(crate) fn new( + skill_management: Arc, + extension_management: Arc, + host_runtime_http_egress: HostRuntimeHttpEgressPort, + shared_key: String, + ) -> Self { + Self { + skill_management, + extension_management, + host_runtime_http_egress, + shared_key, + } + } + + fn install_service(&self) -> Result { + let scope = ResourceScope::local_default( + UserId::new("reborn-ironhub-link").map_err(internal)?, + InvocationId::new(), + ) + .map_err(internal)?; + let capability_id = CapabilityId::new("builtin.ironhub_install").map_err(internal)?; + Ok(IronHubService::new_with_host_egress( + Arc::clone(&self.skill_management), + Arc::clone(&self.extension_management), + self.host_runtime_http_egress.clone(), + capability_id, + scope, + )) + } +} + +fn internal(error: impl std::fmt::Display) -> IronhubLinkError { + IronhubLinkError::Install { + reason: error.to_string(), + } +} + +fn timestamp_fresh(ts: u64) -> bool { + let drift = chrono::Utc::now().timestamp() - ts as i64; + drift.abs() <= MAX_TIMESTAMP_DRIFT_SECS +} + +fn install_kind(kind: Option<&str>) -> Option { + match kind { + Some("tool") => Some(IronHubEntryKind::Tool), + Some("skill") => Some(IronHubEntryKind::Skill), + _ => None, + } +} + +#[async_trait] +impl IronhubLinkService for RebornIronhubLinkService { + async fn register(&self, request: IronhubRegisterRequest) -> Result<(), IronhubLinkError> { + if !timestamp_fresh(request.ts) { + return Err(IronhubLinkError::StaleTimestamp); + } + let challenge = RegisterChallenge { + uid: &request.uid, + aid: &request.aid, + ts: request.ts, + nonce: &request.nonce, + }; + if verify_signature(&self.shared_key, &challenge.payload(), &request.sig) { + Ok(()) + } else { + Err(IronhubLinkError::InvalidSignature) + } + } + + async fn deliver_install( + &self, + request: IronhubInstallDeliveryRequest, + ) -> Result { + if !timestamp_fresh(request.ts) { + return Err(IronhubLinkError::StaleTimestamp); + } + let delivery = InstallDelivery { + slug: &request.slug, + version: &request.version, + uid: &request.uid, + aid: &request.aid, + ts: request.ts, + nonce: &request.nonce, + artifact_digest: &request.artifact_digest, + }; + if !verify_signature(&self.shared_key, &delivery.payload(), &request.sig) { + return Err(IronhubLinkError::InvalidSignature); + } + + let options = IronHubInstallOptions { + kind: install_kind(request.kind.as_deref()), + force: false, + acknowledge_unverified: false, + expected_version: Some(request.version), + expected_artifact_digest: Some(request.artifact_digest), + private_manifest_url: request.private_manifest_url, + }; + let response = self + .install_service()? + .execute(IronHubCommand::Install { + name: request.slug.clone(), + options, + }) + .await + .map_err(internal)?; + + Ok(IronhubInstallDeliveryResult { + installed: matches!(response.phase, LifecyclePhase::Installed), + slug: request.slug, + message: response.message.unwrap_or_default(), + }) + } +} diff --git a/crates/ironclaw_reborn_composition/src/ironhub/mod.rs b/crates/ironclaw_reborn_composition/src/ironhub/mod.rs index 3dac58e39be..8ff51d28f66 100644 --- a/crates/ironclaw_reborn_composition/src/ironhub/mod.rs +++ b/crates/ironclaw_reborn_composition/src/ironhub/mod.rs @@ -1,6 +1,10 @@ +#[cfg(feature = "webui-v2-beta")] +mod agent_link; mod capabilities; mod catalog; mod errors; +#[cfg(feature = "webui-v2-beta")] +mod link_service; mod model; mod package; mod render; @@ -10,6 +14,8 @@ mod service; mod tests; pub(crate) use capabilities::{extend_builtin_first_party_package, insert_handlers}; +#[cfg(feature = "webui-v2-beta")] +pub(crate) use link_service::RebornIronhubLinkService; #[cfg(test)] pub(crate) use model::{ IRONHUB_INFO_CAPABILITY_ID, IRONHUB_INSTALL_CAPABILITY_ID, IRONHUB_SEARCH_CAPABILITY_ID, diff --git a/crates/ironclaw_reborn_composition/src/ironhub_link_serve.rs b/crates/ironclaw_reborn_composition/src/ironhub_link_serve.rs new file mode 100644 index 00000000000..8982fc94eb2 --- /dev/null +++ b/crates/ironclaw_reborn_composition/src/ironhub_link_serve.rs @@ -0,0 +1,107 @@ +use std::num::{NonZeroU32, NonZeroU64}; +use std::sync::Arc; + +use axum::Router; +use axum::body::Bytes; +use axum::extract::State; +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use axum::routing::post; +use ironclaw_host_api::NetworkMethod; +use ironclaw_host_api::ingress::{ + AllowedEffectPath, AuditTraceClass, BodyLimitPolicy, CorsPolicy, IngressAuthPolicy, + IngressAuthScheme, IngressPolicy, IngressPolicyParts, IngressRouteDescriptor, + IngressScopeSource, ListenerClass, RateLimitPolicy, RateLimitScope, StreamingMode, + WebSocketOriginPolicy, +}; +use ironclaw_product_workflow::{IronhubRegisterRequest, RebornServicesApi}; + +use crate::webui_serve::PublicRouteMount; + +pub(crate) const IRONHUB_REGISTER_PATH: &str = "/api/ironhub/register"; +const IRONHUB_REGISTER_ROUTE_ID: &str = "ironhub.register"; +// safety: 8 KiB is a non-zero literal. +const IRONHUB_REGISTER_BODY_LIMIT_BYTES: NonZeroU64 = NonZeroU64::new(8 * 1024).unwrap(); +// safety: 600 requests is a non-zero literal. +const IRONHUB_REGISTER_MAX_REQUESTS: NonZeroU32 = NonZeroU32::new(600).unwrap(); +// safety: 60 seconds is a non-zero literal. +const IRONHUB_REGISTER_RATE_WINDOW_SECONDS: NonZeroU32 = NonZeroU32::new(60).unwrap(); + +#[derive(Clone)] +pub struct IronhubRegisterRouteState { + api: Arc, +} + +impl IronhubRegisterRouteState { + pub fn new(api: Arc) -> Self { + Self { api } + } +} + +impl std::fmt::Debug for IronhubRegisterRouteState { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.debug_struct("IronhubRegisterRouteState").finish() + } +} + +pub fn ironhub_register_route_mount(state: IronhubRegisterRouteState) -> PublicRouteMount { + PublicRouteMount::new( + Router::new() + .route(IRONHUB_REGISTER_PATH, post(ironhub_register_handler)) + .with_state(state), + ironhub_register_route_descriptors(), + ) +} + +pub(crate) fn ironhub_register_route_descriptors() -> Vec { + let descriptor = IngressRouteDescriptor::new( + IRONHUB_REGISTER_ROUTE_ID, + NetworkMethod::Post, + IRONHUB_REGISTER_PATH, + ironhub_register_policy(), + ) + // safety: route id/path are crate-local literals and the policy is built by the sibling helper. + .expect("IronHub register route descriptor must validate at startup"); + vec![descriptor] +} + +fn ironhub_register_policy() -> IngressPolicy { + IngressPolicy::new(IngressPolicyParts { + listener_class: ListenerClass::PublicWebhook, + auth: IngressAuthPolicy::Required { + schemes: vec![IngressAuthScheme::WebhookSignature], + }, + scope_source: IngressScopeSource::HostResolved, + body_limit: BodyLimitPolicy::Limited { + max_bytes: IRONHUB_REGISTER_BODY_LIMIT_BYTES, + }, + rate_limit: RateLimitPolicy::Limited { + scope: RateLimitScope::Global, + max_requests: IRONHUB_REGISTER_MAX_REQUESTS, + window_seconds: IRONHUB_REGISTER_RATE_WINDOW_SECONDS, + }, + cors: CorsPolicy::NotApplicable, + websocket_origin: WebSocketOriginPolicy::NotApplicable, + streaming: StreamingMode::None, + audit: AuditTraceClass::PublicCallback, + effect_path: AllowedEffectPath::ProductWorkflow, + }) + // safety: policy combines validated constants and a host-resolved webhook-signature scope. + .expect("IronHub register ingress policy must validate") +} + +async fn ironhub_register_handler( + State(state): State, + body: Bytes, +) -> Response { + let request: IronhubRegisterRequest = match serde_json::from_slice(&body) { + Ok(request) => request, + Err(_) => return StatusCode::BAD_REQUEST.into_response(), + }; + match state.api.ironhub_register(request).await { + Ok(()) => StatusCode::OK.into_response(), + Err(error) => StatusCode::from_u16(error.status_code) + .unwrap_or(StatusCode::INTERNAL_SERVER_ERROR) + .into_response(), + } +} diff --git a/crates/ironclaw_reborn_composition/src/lib.rs b/crates/ironclaw_reborn_composition/src/lib.rs index 03999842395..354a760b990 100644 --- a/crates/ironclaw_reborn_composition/src/lib.rs +++ b/crates/ironclaw_reborn_composition/src/lib.rs @@ -41,6 +41,8 @@ mod gsuite; mod hooks; mod input; mod ironhub; +#[cfg(feature = "webui-v2-beta")] +mod ironhub_link_serve; mod lifecycle; #[cfg(feature = "root-llm-provider")] mod llm_catalog; @@ -167,6 +169,8 @@ pub use ironhub::{ IronHubCommand, IronHubCommandError, IronHubEntryKind, IronHubInstallOptions, execute_reborn_ironhub_command, render_reborn_ironhub_response, }; +#[cfg(feature = "webui-v2-beta")] +pub use ironhub_link_serve::{IronhubRegisterRouteState, ironhub_register_route_mount}; #[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/runtime.rs b/crates/ironclaw_reborn_composition/src/runtime.rs index 1e2fdfe3729..1da9d8a10d3 100644 --- a/crates/ironclaw_reborn_composition/src/runtime.rs +++ b/crates/ironclaw_reborn_composition/src/runtime.rs @@ -238,6 +238,10 @@ pub struct RebornRuntime { /// LLM-config settings service over `providers.json` / `config.toml`. #[cfg(feature = "root-llm-provider")] boot: Option, + /// Shared HMAC key for the IronHub deep-link register/install webhooks, + /// carried so the WebUI facade can compose the agent-link service. + #[cfg(feature = "webui-v2-beta")] + ironhub_agent_shared_key: Option, /// Hot-swap handle for the live LLM provider, when one was wired at boot. #[cfg(feature = "root-llm-provider")] llm_reload: Option, @@ -573,6 +577,21 @@ impl RebornRuntime { self.boot.as_ref() } + /// Shared HMAC key for the IronHub deep-link webhooks, when one was wired + /// at boot. The WebUI facade uses it to compose the agent-link service. + #[cfg(feature = "webui-v2-beta")] + pub(crate) fn webui_ironhub_agent_shared_key(&self) -> Option { + self.ironhub_agent_shared_key.clone() + } + + /// Whether the IronHub deep-link register/install webhooks are configured + /// (a shared HMAC key was wired at boot). The serve path uses this to + /// decide whether to mount the public register webhook. + #[cfg(feature = "webui-v2-beta")] + pub fn ironhub_register_enabled(&self) -> bool { + self.ironhub_agent_shared_key.is_some() + } + /// The runtime's NEAR AI session manager, when an LLM seam is wired. The /// LLM-config service uses it so a completed NEAR AI login applies to the /// live provider on reload. @@ -1347,6 +1366,8 @@ pub async fn build_reborn_runtime( llm, #[cfg(feature = "root-llm-provider")] boot, + #[cfg(feature = "webui-v2-beta")] + ironhub_agent_shared_key, runner, trigger_poller, trigger_fire_access_checker, @@ -1903,6 +1924,8 @@ pub async fn build_reborn_runtime( skill_execution_adapter, #[cfg(feature = "root-llm-provider")] boot, + #[cfg(feature = "webui-v2-beta")] + ironhub_agent_shared_key, #[cfg(feature = "root-llm-provider")] llm_reload, }) diff --git a/crates/ironclaw_reborn_composition/src/runtime_input.rs b/crates/ironclaw_reborn_composition/src/runtime_input.rs index b532cdf0b34..ea7a7bd0638 100644 --- a/crates/ironclaw_reborn_composition/src/runtime_input.rs +++ b/crates/ironclaw_reborn_composition/src/runtime_input.rs @@ -252,6 +252,12 @@ pub struct RebornRuntimeInput { /// settings surface can read/write `providers.json` + `config.toml`. #[cfg(feature = "root-llm-provider")] pub boot: Option, + /// Shared HMAC key the IronHub deep-link register/install webhooks verify + /// inbound hub signatures against. When present (and `webui-v2-beta` is on), + /// the WebUI facade composes the IronHub agent-link service and the serve + /// path mounts the public `/api/ironhub/register` webhook. + #[cfg(feature = "webui-v2-beta")] + pub ironhub_agent_shared_key: Option, pub runner: TurnRunnerSettings, pub trigger_poller: TriggerPollerSettings, pub trigger_fire_access_checker: Option>, @@ -305,6 +311,8 @@ impl RebornRuntimeInput { llm: None, #[cfg(feature = "root-llm-provider")] boot: None, + #[cfg(feature = "webui-v2-beta")] + ironhub_agent_shared_key: None, runner: TurnRunnerSettings::default(), trigger_poller: TriggerPollerSettings::default(), trigger_fire_access_checker: None, @@ -361,6 +369,14 @@ impl RebornRuntimeInput { self } + /// Supply the IronHub agent-link shared HMAC key. Enables the deep-link + /// register/install webhooks for this runtime. + #[cfg(feature = "webui-v2-beta")] + pub fn with_ironhub_agent_shared_key(mut self, shared_key: String) -> Self { + self.ironhub_agent_shared_key = Some(shared_key); + self + } + pub fn with_runner_settings(mut self, runner: TurnRunnerSettings) -> Self { self.runner = runner; self diff --git a/crates/ironclaw_reborn_composition/src/webui.rs b/crates/ironclaw_reborn_composition/src/webui.rs index c5cd6d403ca..82d006d33f6 100644 --- a/crates/ironclaw_reborn_composition/src/webui.rs +++ b/crates/ironclaw_reborn_composition/src/webui.rs @@ -110,6 +110,22 @@ pub(crate) fn build_webui_services_with_connectable_channels( lifecycle_facade.with_runtime_http_egress(runtime_http_egress.clone()); } api = api.with_lifecycle_product_facade(Arc::new(lifecycle_facade)); + + #[cfg(feature = "webui-v2-beta")] + if let (Some(extension_management), Some(host_runtime_http_egress), Some(shared_key)) = ( + &local_runtime.extension_management, + &local_runtime.host_runtime_http_egress, + runtime.webui_ironhub_agent_shared_key(), + ) { + api = api.with_ironhub_link_service(Arc::new( + crate::ironhub::RebornIronhubLinkService::new( + local_runtime.skill_management.clone(), + extension_management.clone(), + host_runtime_http_egress.clone(), + shared_key, + ), + )); + } } if let Some(product_auth) = &services.product_auth { api = api.with_extension_credentials(Arc::new(ProductAuthExtensionCredentialSetup::new( diff --git a/crates/ironclaw_webui_v2/src/descriptors.rs b/crates/ironclaw_webui_v2/src/descriptors.rs index 7e6f30e36dc..a0d3d7abf97 100644 --- a/crates/ironclaw_webui_v2/src/descriptors.rs +++ b/crates/ironclaw_webui_v2/src/descriptors.rs @@ -26,6 +26,7 @@ pub const WEBUI_V2_ROUTE_LIST_CONNECTABLE_CHANNELS: &str = "webui.v2.list_connec pub const WEBUI_V2_ROUTE_LIST_EXTENSIONS: &str = "webui.v2.list_extensions"; pub const WEBUI_V2_ROUTE_LIST_EXTENSION_REGISTRY: &str = "webui.v2.list_extension_registry"; pub const WEBUI_V2_ROUTE_INSTALL_EXTENSION: &str = "webui.v2.install_extension"; +pub const WEBUI_V2_ROUTE_IRONHUB_DELIVER_INSTALL: &str = "webui.v2.ironhub_deliver_install"; pub const WEBUI_V2_ROUTE_ACTIVATE_EXTENSION: &str = "webui.v2.activate_extension"; pub const WEBUI_V2_ROUTE_REMOVE_EXTENSION: &str = "webui.v2.remove_extension"; pub const WEBUI_V2_ROUTE_GET_EXTENSION_SETUP: &str = "webui.v2.get_extension_setup"; @@ -56,6 +57,7 @@ pub const WEBUI_V2_PATTERN_LIST_CONNECTABLE_CHANNELS: &str = "/api/webchat/v2/ch pub const WEBUI_V2_PATTERN_LIST_EXTENSIONS: &str = "/api/webchat/v2/extensions"; pub const WEBUI_V2_PATTERN_LIST_EXTENSION_REGISTRY: &str = "/api/webchat/v2/extensions/registry"; pub const WEBUI_V2_PATTERN_INSTALL_EXTENSION: &str = "/api/webchat/v2/extensions/install"; +pub const WEBUI_V2_PATTERN_IRONHUB_DELIVER_INSTALL: &str = "/api/webchat/v2/ironhub/install"; pub const WEBUI_V2_PATTERN_ACTIVATE_EXTENSION: &str = "/api/webchat/v2/extensions/{package_id}/activate"; pub const WEBUI_V2_PATTERN_REMOVE_EXTENSION: &str = @@ -93,6 +95,7 @@ pub fn webui_v2_routes() -> Vec { list_extensions_descriptor(), list_extension_registry_descriptor(), install_extension_descriptor(), + ironhub_deliver_install_descriptor(), activate_extension_descriptor(), remove_extension_descriptor(), get_extension_setup_descriptor(), @@ -311,6 +314,20 @@ fn install_extension_descriptor() -> IngressRouteDescriptor { ) } +fn ironhub_deliver_install_descriptor() -> IngressRouteDescriptor { + descriptor( + WEBUI_V2_ROUTE_IRONHUB_DELIVER_INSTALL, + NetworkMethod::Post, + WEBUI_V2_PATTERN_IRONHUB_DELIVER_INSTALL, + mutation_policy( + body_limit_kib(16), + mutation_rate_limit(), + AuditTraceClass::UserAction, + AllowedEffectPath::ProductWorkflow, + ), + ) +} + fn activate_extension_descriptor() -> IngressRouteDescriptor { descriptor( WEBUI_V2_ROUTE_ACTIVATE_EXTENSION, diff --git a/crates/ironclaw_webui_v2/src/handlers.rs b/crates/ironclaw_webui_v2/src/handlers.rs index 89727cd5758..ab35389bfc0 100644 --- a/crates/ironclaw_webui_v2/src/handlers.rs +++ b/crates/ironclaw_webui_v2/src/handlers.rs @@ -22,7 +22,8 @@ use axum::response::sse::{Event, KeepAlive, Sse}; use futures::SinkExt; use futures::stream::Stream; use ironclaw_product_workflow::{ - CodexLoginStart, LifecyclePackageKind, LifecyclePackageRef, LlmConfigSnapshot, LlmModelsResult, + CodexLoginStart, IronhubInstallDeliveryRequest, IronhubInstallDeliveryResult, + LifecyclePackageKind, LifecyclePackageRef, LlmConfigSnapshot, LlmModelsResult, LlmProbeRequest, LlmProbeResult, NearAiLoginRequest, NearAiLoginStart, NearAiWalletLoginRequest, NearAiWalletLoginResult, ProductWorkflowError, ProjectionCursor, RebornCancelRunResponse, RebornConnectableChannelListResponse, RebornCreateThreadResponse, @@ -459,6 +460,16 @@ pub async fn install_extension( Ok(Json(response)) } +/// `POST /api/webchat/v2/ironhub/install` +pub async fn ironhub_deliver_install( + State(state): State, + Extension(_caller): Extension, + Json(body): Json, +) -> Result, WebUiV2HttpError> { + let response = state.services().ironhub_deliver_install(body).await?; + Ok(Json(response)) +} + /// `POST /api/webchat/v2/extensions/{package_id}/activate` pub async fn activate_extension( State(state): State, diff --git a/crates/ironclaw_webui_v2/src/lib.rs b/crates/ironclaw_webui_v2/src/lib.rs index 0c45c3b5c0e..5a74da05ef4 100644 --- a/crates/ironclaw_webui_v2/src/lib.rs +++ b/crates/ironclaw_webui_v2/src/lib.rs @@ -60,7 +60,8 @@ pub use descriptors::{ WEBUI_V2_ROUTE_COMPLETE_NEARAI_WALLET_LOGIN, WEBUI_V2_ROUTE_CREATE_THREAD, WEBUI_V2_ROUTE_DELETE_LLM_PROVIDER, WEBUI_V2_ROUTE_GET_EXTENSION_SETUP, WEBUI_V2_ROUTE_GET_LLM_CONFIG, WEBUI_V2_ROUTE_GET_TIMELINE, WEBUI_V2_ROUTE_INSTALL_EXTENSION, - WEBUI_V2_ROUTE_LIST_AUTOMATIONS, WEBUI_V2_ROUTE_LIST_CONNECTABLE_CHANNELS, + WEBUI_V2_ROUTE_IRONHUB_DELIVER_INSTALL, WEBUI_V2_ROUTE_LIST_AUTOMATIONS, + WEBUI_V2_ROUTE_LIST_CONNECTABLE_CHANNELS, WEBUI_V2_ROUTE_LIST_EXTENSION_REGISTRY, WEBUI_V2_ROUTE_LIST_EXTENSIONS, WEBUI_V2_ROUTE_LIST_LLM_MODELS, WEBUI_V2_ROUTE_LIST_THREADS, WEBUI_V2_ROUTE_REMOVE_EXTENSION, WEBUI_V2_ROUTE_RESOLVE_GATE, WEBUI_V2_ROUTE_SEND_MESSAGE, WEBUI_V2_ROUTE_SET_ACTIVE_LLM, @@ -75,7 +76,8 @@ pub use error::{WebUiV2HttpError, WebUiV2HttpErrorBody}; pub use handlers::{ activate_extension, cancel_run, complete_nearai_wallet_login, create_thread, delete_llm_provider, get_extension_setup, get_llm_config, get_timeline, install_extension, - list_automations, list_connectable_channels, list_extension_registry, list_extensions, + ironhub_deliver_install, list_automations, list_connectable_channels, + list_extension_registry, list_extensions, list_llm_models, list_threads, remove_extension, resolve_gate, send_message, set_active_llm, setup_extension, start_codex_login, start_nearai_login, stream_events, stream_events_ws, test_llm_connection, upsert_llm_provider, diff --git a/crates/ironclaw_webui_v2/src/router.rs b/crates/ironclaw_webui_v2/src/router.rs index 37de33b48e8..85e1bdf127c 100644 --- a/crates/ironclaw_webui_v2/src/router.rs +++ b/crates/ironclaw_webui_v2/src/router.rs @@ -17,7 +17,8 @@ use crate::descriptors::{ WEBUI_V2_PATTERN_COMPLETE_NEARAI_WALLET_LOGIN, WEBUI_V2_PATTERN_CREATE_THREAD, WEBUI_V2_PATTERN_DELETE_LLM_PROVIDER, WEBUI_V2_PATTERN_GET_LLM_CONFIG, WEBUI_V2_PATTERN_GET_TIMELINE, WEBUI_V2_PATTERN_INSTALL_EXTENSION, - WEBUI_V2_PATTERN_LIST_AUTOMATIONS, WEBUI_V2_PATTERN_LIST_CONNECTABLE_CHANNELS, + WEBUI_V2_PATTERN_IRONHUB_DELIVER_INSTALL, WEBUI_V2_PATTERN_LIST_AUTOMATIONS, + WEBUI_V2_PATTERN_LIST_CONNECTABLE_CHANNELS, WEBUI_V2_PATTERN_LIST_EXTENSION_REGISTRY, WEBUI_V2_PATTERN_LIST_EXTENSIONS, WEBUI_V2_PATTERN_LIST_LLM_MODELS, WEBUI_V2_PATTERN_REMOVE_EXTENSION, WEBUI_V2_PATTERN_RESOLVE_GATE, WEBUI_V2_PATTERN_SEND_MESSAGE, WEBUI_V2_PATTERN_SET_ACTIVE_LLM, @@ -136,6 +137,10 @@ pub fn webui_v2_router_with_options(state: WebUiV2State, options: WebUiV2RouteOp WEBUI_V2_PATTERN_INSTALL_EXTENSION, post(handlers::install_extension), ) + .route( + WEBUI_V2_PATTERN_IRONHUB_DELIVER_INSTALL, + post(handlers::ironhub_deliver_install), + ) .route( WEBUI_V2_PATTERN_ACTIVATE_EXTENSION, post(handlers::activate_extension), diff --git a/crates/ironclaw_webui_v2/tests/webui_v2_descriptors_contract.rs b/crates/ironclaw_webui_v2/tests/webui_v2_descriptors_contract.rs index 460bb739bad..3010b5fe64d 100644 --- a/crates/ironclaw_webui_v2/tests/webui_v2_descriptors_contract.rs +++ b/crates/ironclaw_webui_v2/tests/webui_v2_descriptors_contract.rs @@ -22,7 +22,8 @@ use ironclaw_webui_v2::{ WEBUI_V2_ROUTE_COMPLETE_NEARAI_WALLET_LOGIN, WEBUI_V2_ROUTE_CREATE_THREAD, WEBUI_V2_ROUTE_DELETE_LLM_PROVIDER, WEBUI_V2_ROUTE_GET_EXTENSION_SETUP, WEBUI_V2_ROUTE_GET_LLM_CONFIG, WEBUI_V2_ROUTE_GET_TIMELINE, WEBUI_V2_ROUTE_INSTALL_EXTENSION, - WEBUI_V2_ROUTE_LIST_AUTOMATIONS, WEBUI_V2_ROUTE_LIST_CONNECTABLE_CHANNELS, + WEBUI_V2_ROUTE_IRONHUB_DELIVER_INSTALL, WEBUI_V2_ROUTE_LIST_AUTOMATIONS, + WEBUI_V2_ROUTE_LIST_CONNECTABLE_CHANNELS, WEBUI_V2_ROUTE_LIST_EXTENSION_REGISTRY, WEBUI_V2_ROUTE_LIST_EXTENSIONS, WEBUI_V2_ROUTE_LIST_LLM_MODELS, WEBUI_V2_ROUTE_LIST_THREADS, WEBUI_V2_ROUTE_REMOVE_EXTENSION, WEBUI_V2_ROUTE_RESOLVE_GATE, WEBUI_V2_ROUTE_SEND_MESSAGE, WEBUI_V2_ROUTE_SET_ACTIVE_LLM, @@ -282,6 +283,23 @@ fn expected_table() -> Vec { audit: AuditTraceClass::UserAction, effect_path: AllowedEffectPath::ProductWorkflow, }, + Expected { + route_id: WEBUI_V2_ROUTE_IRONHUB_DELIVER_INSTALL, + method: NetworkMethod::Post, + pattern: "/api/webchat/v2/ironhub/install", + listener_class: ListenerClass::LocalGateway, + auth_schemes: &[IngressAuthScheme::BearerToken], + scope_source: IngressScopeSource::AuthenticatedCaller, + body_limit: body_limit_kib(16), + rate_limit_max: 60, + rate_limit_window_seconds: 60, + rate_limit_scope: RateLimitScope::PerCaller, + cors: CorsPolicy::SameOriginOnly, + websocket_origin: WebSocketOriginPolicy::NotApplicable, + streaming: StreamingMode::None, + audit: AuditTraceClass::UserAction, + effect_path: AllowedEffectPath::ProductWorkflow, + }, Expected { route_id: WEBUI_V2_ROUTE_ACTIVATE_EXTENSION, method: NetworkMethod::Post, From acbd10d7effacd072634dd034da6ba67f12c6e3b Mon Sep 17 00:00:00 2001 From: neo-sky Date: Fri, 26 Jun 2026 13:25:24 -0400 Subject: [PATCH 6/8] Guard private-manifest replays; address review cleanups Private installs now run the same replay guard as the public path, with a handler-contract test; the rest are structural review cleanups. --- crates/ironclaw_reborn_cli/src/runtime/mod.rs | 4 +- .../src/ironhub/agent_link.rs | 105 ++++++++++-------- .../src/ironhub/catalog.rs | 9 ++ .../src/ironhub/link_service.rs | 54 ++++----- .../src/ironhub/mod.rs | 2 + .../src/ironhub/service.rs | 26 +++-- crates/ironclaw_reborn_composition/src/lib.rs | 2 + .../src/runtime.rs | 21 +++- .../src/runtime_input.rs | 7 +- .../ironclaw_reborn_composition/src/webui.rs | 2 +- crates/ironclaw_webui_v2/CLAUDE.md | 1 + .../tests/webui_v2_handlers_contract.rs | 48 +++++++- 12 files changed, 184 insertions(+), 97 deletions(-) diff --git a/crates/ironclaw_reborn_cli/src/runtime/mod.rs b/crates/ironclaw_reborn_cli/src/runtime/mod.rs index 88bc086d48d..204ccb21156 100644 --- a/crates/ironclaw_reborn_cli/src/runtime/mod.rs +++ b/crates/ironclaw_reborn_cli/src/runtime/mod.rs @@ -369,7 +369,9 @@ pub(crate) fn build_runtime_input_with_options( if let Ok(shared_key) = std::env::var("IRONHUB_AGENT_SHARED_KEY") { let shared_key = shared_key.trim(); if !shared_key.is_empty() { - runtime_input = runtime_input.with_ironhub_agent_shared_key(shared_key.to_string()); + runtime_input = runtime_input.with_ironhub_agent_shared_key( + ironclaw_reborn_composition::IronhubSharedKey::new(shared_key), + ); } } diff --git a/crates/ironclaw_reborn_composition/src/ironhub/agent_link.rs b/crates/ironclaw_reborn_composition/src/ironhub/agent_link.rs index c1af23a5f84..963a132a8e7 100644 --- a/crates/ironclaw_reborn_composition/src/ironhub/agent_link.rs +++ b/crates/ironclaw_reborn_composition/src/ironhub/agent_link.rs @@ -1,41 +1,46 @@ use hmac::{Hmac, Mac}; +use ironclaw_product_workflow::{IronhubInstallDeliveryRequest, IronhubRegisterRequest}; use sha2::Sha256; type HmacSha256 = Hmac; -pub(super) struct RegisterChallenge<'a> { - pub uid: &'a str, - pub aid: &'a str, - pub ts: u64, - pub nonce: &'a str, +#[derive(Clone)] +pub struct IronhubSharedKey(String); + +impl IronhubSharedKey { + pub fn new(value: impl Into) -> Self { + Self(value.into()) + } + + pub(super) fn as_str(&self) -> &str { + &self.0 + } } -impl RegisterChallenge<'_> { - pub(super) fn payload(&self) -> String { - format!( - "register:{}:{}:{}:{}", - self.uid, self.aid, self.ts, self.nonce - ) +impl std::fmt::Debug for IronhubSharedKey { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("IronhubSharedKey(redacted)") } } -pub(super) struct InstallDelivery<'a> { - pub slug: &'a str, - pub version: &'a str, - pub uid: &'a str, - pub aid: &'a str, - pub ts: u64, - pub nonce: &'a str, - pub artifact_digest: &'a str, +pub(super) fn register_payload(request: &IronhubRegisterRequest) -> String { + format!( + "register:{}:{}:{}:{}", + request.uid, request.aid, request.ts, request.nonce + ) } -impl InstallDelivery<'_> { - pub(super) fn payload(&self) -> String { - format!( - "install:{}:{}:{}:{}:{}:{}:{}", - self.slug, self.version, self.uid, self.aid, self.ts, self.nonce, self.artifact_digest - ) - } +pub(super) fn install_payload(request: &IronhubInstallDeliveryRequest) -> String { + format!( + "install:{}:{}:{}:{}:{}:{}:{}", + request.slug, + request.version, + request.uid, + request.aid, + request.ts, + request.nonce, + request.artifact_digest + ) } pub(super) fn verify_signature(shared_key: &str, payload: &str, sig_hex: &str) -> bool { @@ -57,31 +62,35 @@ mod tests { const REGISTER_SIG: &str = "7e69b8cd66138589a2ae1320d6ba894870462efeb295acf80336ddd00e0953b5"; const INSTALL_SIG: &str = "f00a213648f926e263d25b02b5fcd2731d371355a5728c3c07a3c7eb817be2ea"; - fn register() -> RegisterChallenge<'static> { - RegisterChallenge { - uid: "user-1", - aid: "aid-1", + fn register_request() -> IronhubRegisterRequest { + IronhubRegisterRequest { + uid: "user-1".to_string(), + aid: "aid-1".to_string(), ts: 1_700_000_000, - nonce: "nonce-abc", + nonce: "nonce-abc".to_string(), + sig: String::new(), } } - fn install() -> InstallDelivery<'static> { - InstallDelivery { - slug: "my-skill", - version: "1.0.0", - uid: "user-1", - aid: "aid-1", + fn install_request() -> IronhubInstallDeliveryRequest { + IronhubInstallDeliveryRequest { + slug: "my-skill".to_string(), + version: "1.0.0".to_string(), + uid: "user-1".to_string(), + aid: "aid-1".to_string(), ts: 1_700_000_000, - nonce: "nonce-abc", - artifact_digest: "sha256:deadbeef", + nonce: "nonce-abc".to_string(), + artifact_digest: "sha256:deadbeef".to_string(), + sig: String::new(), + kind: None, + private_manifest_url: None, } } #[test] fn register_payload_matches_hub_format() { assert_eq!( - register().payload(), + register_payload(®ister_request()), "register:user-1:aid-1:1700000000:nonce-abc" ); } @@ -89,7 +98,7 @@ mod tests { #[test] fn install_payload_matches_hub_format() { assert_eq!( - install().payload(), + install_payload(&install_request()), "install:my-skill:1.0.0:user-1:aid-1:1700000000:nonce-abc:sha256:deadbeef" ); } @@ -98,7 +107,7 @@ mod tests { fn verifies_hub_register_signature() { assert!(verify_signature( SHARED_KEY, - ®ister().payload(), + ®ister_payload(®ister_request()), REGISTER_SIG )); } @@ -107,7 +116,7 @@ mod tests { fn verifies_hub_install_signature() { assert!(verify_signature( SHARED_KEY, - &install().payload(), + &install_payload(&install_request()), INSTALL_SIG )); } @@ -117,7 +126,7 @@ mod tests { let tampered = format!("00{}", ®ISTER_SIG[2..]); assert!(!verify_signature( SHARED_KEY, - ®ister().payload(), + ®ister_payload(®ister_request()), &tampered )); } @@ -126,13 +135,17 @@ mod tests { fn rejects_wrong_shared_key() { assert!(!verify_signature( "ihub_sk_wrong", - ®ister().payload(), + ®ister_payload(®ister_request()), REGISTER_SIG )); } #[test] fn rejects_non_hex_signature() { - assert!(!verify_signature(SHARED_KEY, ®ister().payload(), "zzzz")); + assert!(!verify_signature( + SHARED_KEY, + ®ister_payload(®ister_request()), + "zzzz" + )); } } diff --git a/crates/ironclaw_reborn_composition/src/ironhub/catalog.rs b/crates/ironclaw_reborn_composition/src/ironhub/catalog.rs index 1e5873c456e..3b6dbdb3bf8 100644 --- a/crates/ironclaw_reborn_composition/src/ironhub/catalog.rs +++ b/crates/ironclaw_reborn_composition/src/ironhub/catalog.rs @@ -81,6 +81,15 @@ pub(super) fn classify_gate_and_digest( ) } }; + let provenance = if options.private_manifest_url.is_some() { + IronHubProvenance::Private + } else if matches!(provenance, IronHubProvenance::Private) { + return Err(invalid_input(format!( + "catalog entry '{name}' claims private provenance but was not installed from a private manifest" + ))); + } else { + provenance + }; if let Some(expected) = &options.expected_version && expected != version { diff --git a/crates/ironclaw_reborn_composition/src/ironhub/link_service.rs b/crates/ironclaw_reborn_composition/src/ironhub/link_service.rs index b217e4a25b2..15947cba177 100644 --- a/crates/ironclaw_reborn_composition/src/ironhub/link_service.rs +++ b/crates/ironclaw_reborn_composition/src/ironhub/link_service.rs @@ -8,20 +8,25 @@ use ironclaw_product_workflow::{ IronhubLinkService, IronhubRegisterRequest, LifecyclePhase, }; +use crate::RebornBuildError; use crate::extension_lifecycle::RebornLocalExtensionManagementPort; use crate::lifecycle::RebornLocalSkillManagementPort; -use super::agent_link::{InstallDelivery, RegisterChallenge, verify_signature}; +use super::agent_link::{IronhubSharedKey, install_payload, register_payload, verify_signature}; use super::model::{IronHubCommand, IronHubEntryKind, IronHubInstallOptions}; use super::service::IronHubService; const MAX_TIMESTAMP_DRIFT_SECS: i64 = 300; +const LINK_USER_ID: &str = "reborn-ironhub-link"; +const INSTALL_CAPABILITY_ID: &str = "builtin.ironhub_install"; pub(crate) struct RebornIronhubLinkService { skill_management: Arc, extension_management: Arc, host_runtime_http_egress: HostRuntimeHttpEgressPort, - shared_key: String, + shared_key: IronhubSharedKey, + link_user_id: UserId, + install_capability: CapabilityId, } impl RebornIronhubLinkService { @@ -29,33 +34,37 @@ impl RebornIronhubLinkService { skill_management: Arc, extension_management: Arc, host_runtime_http_egress: HostRuntimeHttpEgressPort, - shared_key: String, - ) -> Self { - Self { + shared_key: IronhubSharedKey, + ) -> Result { + Ok(Self { skill_management, extension_management, host_runtime_http_egress, shared_key, - } + link_user_id: UserId::new(LINK_USER_ID).map_err(invalid_config)?, + install_capability: CapabilityId::new(INSTALL_CAPABILITY_ID).map_err(invalid_config)?, + }) } fn install_service(&self) -> Result { - let scope = ResourceScope::local_default( - UserId::new("reborn-ironhub-link").map_err(internal)?, - InvocationId::new(), - ) - .map_err(internal)?; - let capability_id = CapabilityId::new("builtin.ironhub_install").map_err(internal)?; + let scope = ResourceScope::local_default(self.link_user_id.clone(), InvocationId::new()) + .map_err(internal)?; Ok(IronHubService::new_with_host_egress( Arc::clone(&self.skill_management), Arc::clone(&self.extension_management), self.host_runtime_http_egress.clone(), - capability_id, + self.install_capability.clone(), scope, )) } } +fn invalid_config(error: impl std::fmt::Display) -> RebornBuildError { + RebornBuildError::InvalidConfig { + reason: format!("ironhub link service: {error}"), + } +} + fn internal(error: impl std::fmt::Display) -> IronhubLinkError { IronhubLinkError::Install { reason: error.to_string(), @@ -81,13 +90,7 @@ impl IronhubLinkService for RebornIronhubLinkService { if !timestamp_fresh(request.ts) { return Err(IronhubLinkError::StaleTimestamp); } - let challenge = RegisterChallenge { - uid: &request.uid, - aid: &request.aid, - ts: request.ts, - nonce: &request.nonce, - }; - if verify_signature(&self.shared_key, &challenge.payload(), &request.sig) { + if verify_signature(self.shared_key.as_str(), ®ister_payload(&request), &request.sig) { Ok(()) } else { Err(IronhubLinkError::InvalidSignature) @@ -101,16 +104,7 @@ impl IronhubLinkService for RebornIronhubLinkService { if !timestamp_fresh(request.ts) { return Err(IronhubLinkError::StaleTimestamp); } - let delivery = InstallDelivery { - slug: &request.slug, - version: &request.version, - uid: &request.uid, - aid: &request.aid, - ts: request.ts, - nonce: &request.nonce, - artifact_digest: &request.artifact_digest, - }; - if !verify_signature(&self.shared_key, &delivery.payload(), &request.sig) { + if !verify_signature(self.shared_key.as_str(), &install_payload(&request), &request.sig) { return Err(IronhubLinkError::InvalidSignature); } diff --git a/crates/ironclaw_reborn_composition/src/ironhub/mod.rs b/crates/ironclaw_reborn_composition/src/ironhub/mod.rs index 8ff51d28f66..31612b83746 100644 --- a/crates/ironclaw_reborn_composition/src/ironhub/mod.rs +++ b/crates/ironclaw_reborn_composition/src/ironhub/mod.rs @@ -13,6 +13,8 @@ mod service; #[cfg(test)] mod tests; +#[cfg(feature = "webui-v2-beta")] +pub use agent_link::IronhubSharedKey; pub(crate) use capabilities::{extend_builtin_first_party_package, insert_handlers}; #[cfg(feature = "webui-v2-beta")] pub(crate) use link_service::RebornIronhubLinkService; diff --git a/crates/ironclaw_reborn_composition/src/ironhub/service.rs b/crates/ironclaw_reborn_composition/src/ironhub/service.rs index 6d0f8fa8b12..2f3e4f3609c 100644 --- a/crates/ironclaw_reborn_composition/src/ironhub/service.rs +++ b/crates/ironclaw_reborn_composition/src/ironhub/service.rs @@ -136,6 +136,7 @@ pub(crate) struct IronHubService { egress: IronHubEgress, scope: ResourceScope, manifest_url: String, + catalog_host: Option, #[cfg(test)] manifest_verify_keys: &'static [(&'static str, &'static str)], } @@ -183,12 +184,14 @@ impl IronHubService { egress: IronHubEgress, scope: ResourceScope, ) -> Self { + let manifest_url = resolve_manifest_url(); Self { skill_management, extension_management, egress, scope, - manifest_url: resolve_manifest_url(), + catalog_host: manifest_host(&manifest_url), + manifest_url, #[cfg(test)] manifest_verify_keys: super::model::MANIFEST_VERIFY_KEYS, } @@ -197,6 +200,7 @@ impl IronHubService { #[cfg(test)] pub(crate) fn with_manifest_url(mut self, manifest_url: impl Into) -> Self { self.manifest_url = manifest_url.into(); + self.catalog_host = manifest_host(&self.manifest_url); self } @@ -454,17 +458,10 @@ impl IronHubService { return Ok(hit); } let manifest = Arc::new(self.download_and_verify_manifest(url).await?); - enforce_manifest_monotonic(url, &manifest)?; manifest_cache_put(url, Arc::clone(&manifest), now); Ok(manifest) } - fn catalog_host(&self) -> Option { - url::Url::parse(&self.manifest_url) - .ok() - .and_then(|parsed| parsed.host_str().map(str::to_string)) - } - async fn download_and_verify_manifest( &self, url: &str, @@ -473,7 +470,7 @@ impl IronHubService { "hub-manifest", "manifest_url", url, - self.catalog_host().as_deref(), + self.catalog_host.as_deref(), )?; let envelope = self.download_url(url, MAX_SIGNED_MANIFEST_BYTES).await?; #[cfg(not(test))] @@ -493,6 +490,7 @@ impl IronHubService { serde_json::from_slice(&bytes).map_err(|error| IronHubCommandError::Catalog { reason: format!("manifest parse failed: {error}"), })?; + enforce_manifest_monotonic(url, &manifest)?; Ok(manifest) } @@ -501,7 +499,7 @@ impl IronHubService { artifact: &IronHubArtifact, max_bytes: u64, ) -> Result, IronHubCommandError> { - validate_artifact(artifact, max_bytes, self.catalog_host().as_deref())?; + validate_artifact(artifact, max_bytes, self.catalog_host.as_deref())?; let bytes = self.download_url(&artifact.url, max_bytes).await?; let actual = sha256_hex(&bytes); if !actual.eq_ignore_ascii_case(&artifact.sha256) { @@ -528,7 +526,7 @@ impl IronHubService { url: url.to_string(), headers: Vec::new(), body: Vec::new(), - network_policy: network_policy_for_url(url, max_bytes, self.catalog_host().as_deref())?, + network_policy: network_policy_for_url(url, max_bytes, self.catalog_host.as_deref())?, credential_injections: Vec::new(), response_body_limit: Some(max_bytes), save_body_to: None, @@ -570,6 +568,12 @@ fn resolve_manifest_url() -> String { .unwrap_or_else(|| DEFAULT_IRONHUB_MANIFEST_URL.to_string()) } +fn manifest_host(url: &str) -> Option { + url::Url::parse(url) + .ok() + .and_then(|parsed| parsed.host_str().map(str::to_string)) +} + fn manifest_cache_get(url: &str, now: Instant) -> Option> { let guard = MANIFEST_CACHE .lock() diff --git a/crates/ironclaw_reborn_composition/src/lib.rs b/crates/ironclaw_reborn_composition/src/lib.rs index 354a760b990..8a4b646cf62 100644 --- a/crates/ironclaw_reborn_composition/src/lib.rs +++ b/crates/ironclaw_reborn_composition/src/lib.rs @@ -170,6 +170,8 @@ pub use ironhub::{ execute_reborn_ironhub_command, render_reborn_ironhub_response, }; #[cfg(feature = "webui-v2-beta")] +pub use ironhub::IronhubSharedKey; +#[cfg(feature = "webui-v2-beta")] pub use ironhub_link_serve::{IronhubRegisterRouteState, ironhub_register_route_mount}; #[cfg(feature = "root-llm-provider")] pub use llm_catalog::{ diff --git a/crates/ironclaw_reborn_composition/src/runtime.rs b/crates/ironclaw_reborn_composition/src/runtime.rs index 1da9d8a10d3..c23be2796a1 100644 --- a/crates/ironclaw_reborn_composition/src/runtime.rs +++ b/crates/ironclaw_reborn_composition/src/runtime.rs @@ -241,7 +241,7 @@ pub struct RebornRuntime { /// Shared HMAC key for the IronHub deep-link register/install webhooks, /// carried so the WebUI facade can compose the agent-link service. #[cfg(feature = "webui-v2-beta")] - ironhub_agent_shared_key: Option, + ironhub_agent_shared_key: Option, /// Hot-swap handle for the live LLM provider, when one was wired at boot. #[cfg(feature = "root-llm-provider")] llm_reload: Option, @@ -580,16 +580,27 @@ impl RebornRuntime { /// Shared HMAC key for the IronHub deep-link webhooks, when one was wired /// at boot. The WebUI facade uses it to compose the agent-link service. #[cfg(feature = "webui-v2-beta")] - pub(crate) fn webui_ironhub_agent_shared_key(&self) -> Option { + pub(crate) fn webui_ironhub_agent_shared_key( + &self, + ) -> Option { self.ironhub_agent_shared_key.clone() } - /// Whether the IronHub deep-link register/install webhooks are configured - /// (a shared HMAC key was wired at boot). The serve path uses this to - /// decide whether to mount the public register webhook. + /// Whether the IronHub register/install webhooks have everything the link + /// service needs (shared key plus local-runtime extension + host egress), + /// matching the facade-attach gate so serve never mounts a webhook the + /// facade left unavailable. #[cfg(feature = "webui-v2-beta")] pub fn ironhub_register_enabled(&self) -> bool { self.ironhub_agent_shared_key.is_some() + && self + .services + .local_runtime + .as_ref() + .is_some_and(|local_runtime| { + local_runtime.extension_management.is_some() + && local_runtime.host_runtime_http_egress.is_some() + }) } /// The runtime's NEAR AI session manager, when an LLM seam is wired. The diff --git a/crates/ironclaw_reborn_composition/src/runtime_input.rs b/crates/ironclaw_reborn_composition/src/runtime_input.rs index ea7a7bd0638..a111e9d4a7a 100644 --- a/crates/ironclaw_reborn_composition/src/runtime_input.rs +++ b/crates/ironclaw_reborn_composition/src/runtime_input.rs @@ -257,7 +257,7 @@ pub struct RebornRuntimeInput { /// the WebUI facade composes the IronHub agent-link service and the serve /// path mounts the public `/api/ironhub/register` webhook. #[cfg(feature = "webui-v2-beta")] - pub ironhub_agent_shared_key: Option, + pub ironhub_agent_shared_key: Option, pub runner: TurnRunnerSettings, pub trigger_poller: TriggerPollerSettings, pub trigger_fire_access_checker: Option>, @@ -372,7 +372,10 @@ impl RebornRuntimeInput { /// Supply the IronHub agent-link shared HMAC key. Enables the deep-link /// register/install webhooks for this runtime. #[cfg(feature = "webui-v2-beta")] - pub fn with_ironhub_agent_shared_key(mut self, shared_key: String) -> Self { + pub fn with_ironhub_agent_shared_key( + mut self, + shared_key: crate::ironhub::IronhubSharedKey, + ) -> Self { self.ironhub_agent_shared_key = Some(shared_key); self } diff --git a/crates/ironclaw_reborn_composition/src/webui.rs b/crates/ironclaw_reborn_composition/src/webui.rs index 82d006d33f6..e0147c9b231 100644 --- a/crates/ironclaw_reborn_composition/src/webui.rs +++ b/crates/ironclaw_reborn_composition/src/webui.rs @@ -123,7 +123,7 @@ pub(crate) fn build_webui_services_with_connectable_channels( extension_management.clone(), host_runtime_http_egress.clone(), shared_key, - ), + )?, )); } } diff --git a/crates/ironclaw_webui_v2/CLAUDE.md b/crates/ironclaw_webui_v2/CLAUDE.md index 0b2385e4e4e..7f654869ba5 100644 --- a/crates/ironclaw_webui_v2/CLAUDE.md +++ b/crates/ironclaw_webui_v2/CLAUDE.md @@ -63,6 +63,7 @@ browser-reachable. | `webui.v2.list_extensions` | GET | `/api/webchat/v2/extensions` | None | `ProjectionOnly` | | `webui.v2.list_extension_registry` | GET | `/api/webchat/v2/extensions/registry` | None | `ProjectionOnly` | | `webui.v2.install_extension` | POST | `/api/webchat/v2/extensions/install` | None | `ProductWorkflow` | +| `webui.v2.ironhub_deliver_install` | POST | `/api/webchat/v2/ironhub/install` | None | `ProductWorkflow` | | `webui.v2.activate_extension` | POST | `/api/webchat/v2/extensions/{package_id}/activate` | None | `ProductWorkflow` | | `webui.v2.remove_extension` | POST | `/api/webchat/v2/extensions/{package_id}/remove` | None | `ProductWorkflow` | | `webui.v2.get_extension_setup` | GET | `/api/webchat/v2/extensions/{package_id}/setup` | None | `ProjectionOnly` | diff --git a/crates/ironclaw_webui_v2/tests/webui_v2_handlers_contract.rs b/crates/ironclaw_webui_v2/tests/webui_v2_handlers_contract.rs index f54799d7077..5322504ef98 100644 --- a/crates/ironclaw_webui_v2/tests/webui_v2_handlers_contract.rs +++ b/crates/ironclaw_webui_v2/tests/webui_v2_handlers_contract.rs @@ -27,7 +27,8 @@ use ironclaw_product_adapters::{ ProgressKind, ProgressUpdateView, ProjectionCursor, }; use ironclaw_product_workflow::{ - LifecyclePackageRef, LifecyclePhase, LlmActiveSelection, LlmConfigSnapshot, LlmModelsResult, + IronhubInstallDeliveryRequest, IronhubInstallDeliveryResult, LifecyclePackageRef, + LifecyclePhase, LlmActiveSelection, LlmConfigSnapshot, LlmModelsResult, LlmProbeRequest, LlmProbeResult, LlmProviderView, RebornAutomationInfo, RebornAutomationSource, RebornAutomationState, RebornCancelRunResponse, RebornChannelConnectAction, RebornChannelConnectStrategy, RebornConnectableChannelInfo, @@ -83,6 +84,7 @@ struct StubServices { list_extensions_calls: Mutex, list_extension_registry_calls: Mutex, install_extension_calls: Mutex>, + ironhub_deliver_install_calls: Mutex>, activate_extension_calls: Mutex>, remove_extension_calls: Mutex>, get_llm_config_calls: Mutex, @@ -400,6 +402,22 @@ impl RebornServicesApi for StubServices { Ok(extension_action_response("installed")) } + async fn ironhub_deliver_install( + &self, + request: IronhubInstallDeliveryRequest, + ) -> Result { + let slug = request.slug.clone(); + self.ironhub_deliver_install_calls + .lock() + .expect("lock") + .push(request); + Ok(IronhubInstallDeliveryResult { + installed: true, + slug, + message: "installed".to_string(), + }) + } + async fn activate_extension( &self, _caller: WebUiAuthenticatedCaller, @@ -604,6 +622,34 @@ async fn create_thread_dispatches_through_facade() { ); } +#[tokio::test] +async fn ironhub_deliver_install_dispatches_through_facade() { + let services = Arc::new(StubServices::default()); + let router = router_with(services.clone()); + + let response = router + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/api/webchat/v2/ironhub/install") + .header("content-type", "application/json") + .body(Body::from( + r#"{"slug":"my-skill","version":"1.0.0","uid":"u","aid":"a","ts":1700000000,"nonce":"n","artifact_digest":"sha256:deadbeef","sig":"sig-1"}"#, + )) + .expect("request"), + ) + .await + .expect("oneshot"); + + assert_eq!(response.status(), StatusCode::OK); + let body = read_json(response).await; + assert_eq!(body["slug"], "my-skill"); + let calls = services.ironhub_deliver_install_calls.lock().expect("lock"); + assert_eq!(calls.len(), 1, "facade called exactly once"); + assert_eq!(calls[0].slug, "my-skill"); + assert_eq!(calls[0].artifact_digest, "sha256:deadbeef"); +} + #[tokio::test] async fn send_message_path_overrides_body_thread_id() { let services = Arc::new(StubServices::default()); From d011d2cfc665914b96820885757f76fbd32f43bd Mon Sep 17 00:00:00 2001 From: neo-sky Date: Sun, 28 Jun 2026 23:19:40 -0400 Subject: [PATCH 7/8] feat(reborn-ironhub): thread caller through install, cover link paths Scope deliver_install egress to the authenticated caller, reject replayed install nonces single-use, and map install kinds and errors through the facade. Add link-service negative-path, register-handler, provenance-reject, and webui_v2 handler tests, plus review cleanups: a single-source register-enabled gate, shared-key length validation, verifier and catalog host-pinning annotations, and reverting unrelated CLI doc-comment churn. --- crates/ironclaw_product_workflow/src/lib.rs | 8 +- .../src/reborn_services.rs | 10 +- .../src/reborn_services/ironhub_link.rs | 22 +- .../src/commands/ironhub.rs | 71 +++-- .../ironclaw_reborn_cli/src/commands/serve.rs | 4 +- crates/ironclaw_reborn_cli/src/runtime/mod.rs | 3 +- .../src/ironhub/agent_link.rs | 20 +- .../src/ironhub/link_service.rs | 258 ++++++++++++++++-- .../src/ironhub/mod.rs | 2 +- .../src/ironhub/service.rs | 23 +- .../src/ironhub/tests.rs | 104 ++++++- .../src/ironhub_link_serve.rs | 15 +- crates/ironclaw_reborn_composition/src/lib.rs | 2 +- .../src/runtime.rs | 57 ++-- .../ironclaw_reborn_composition/src/webui.rs | 15 +- .../tests/webui_v2_serve.rs | 90 +++++- crates/ironclaw_webui_v2/src/handlers.rs | 15 +- crates/ironclaw_webui_v2/src/lib.rs | 25 +- crates/ironclaw_webui_v2/src/router.rs | 8 +- .../tests/webui_v2_descriptors_contract.rs | 15 +- .../tests/webui_v2_handlers_contract.rs | 80 +++++- 21 files changed, 675 insertions(+), 172 deletions(-) diff --git a/crates/ironclaw_product_workflow/src/lib.rs b/crates/ironclaw_product_workflow/src/lib.rs index 97efa5563bf..1ab306f54f6 100644 --- a/crates/ironclaw_product_workflow/src/lib.rs +++ b/crates/ironclaw_product_workflow/src/lib.rs @@ -136,10 +136,10 @@ pub use reborn_services::{ AUTOMATION_LIST_DEFAULT_PAGE_SIZE, AUTOMATION_LIST_MAX_PAGE_SIZE, AutomationProductFacade, CodexLoginStart, ConnectableChannelsProductFacade, ExtensionCredentialSetupService, ExtensionCredentialStatusRequest, ExtensionCredentialSubmitRequest, - IronhubInstallDeliveryRequest, IronhubInstallDeliveryResult, IronhubLinkError, - IronhubLinkService, IronhubRegisterRequest, LlmActiveSelection, LlmConfigService, - LlmConfigServiceError, LlmConfigSnapshot, LlmModelsResult, LlmProbeRequest, LlmProbeResult, - LlmProviderView, NearAiAuthProvider, NearAiLoginRequest, NearAiLoginStart, + IronhubInstallDeliveryRequest, IronhubInstallDeliveryResult, IronhubInstallKind, + IronhubLinkError, IronhubLinkService, IronhubRegisterRequest, LlmActiveSelection, + LlmConfigService, LlmConfigServiceError, LlmConfigSnapshot, LlmModelsResult, LlmProbeRequest, + LlmProbeResult, LlmProviderView, NearAiAuthProvider, NearAiLoginRequest, NearAiLoginStart, NearAiWalletLoginRequest, NearAiWalletLoginResult, ProductAgentBoundCaller, RebornAutomationInfo, RebornAutomationRunStatus, RebornAutomationSource, RebornAutomationState, RebornCancelRunResponse, RebornChannelConnectAction, RebornChannelConnectStrategy, diff --git a/crates/ironclaw_product_workflow/src/reborn_services.rs b/crates/ironclaw_product_workflow/src/reborn_services.rs index bf305a3e8b8..075711ff529 100644 --- a/crates/ironclaw_product_workflow/src/reborn_services.rs +++ b/crates/ironclaw_product_workflow/src/reborn_services.rs @@ -60,8 +60,8 @@ mod types; pub use error::{RebornServicesError, RebornServicesErrorCode, RebornServicesErrorKind}; pub use ironhub_link::{ - IronhubInstallDeliveryRequest, IronhubInstallDeliveryResult, IronhubLinkError, - IronhubLinkService, IronhubRegisterRequest, + IronhubInstallDeliveryRequest, IronhubInstallDeliveryResult, IronhubInstallKind, + IronhubLinkError, IronhubLinkService, IronhubRegisterRequest, }; pub use llm_config::{ CodexLoginStart, LlmActiveSelection, LlmConfigService, LlmConfigServiceError, @@ -481,9 +481,10 @@ pub trait RebornServicesApi: Send + Sync { async fn ironhub_deliver_install( &self, + caller: WebUiAuthenticatedCaller, request: IronhubInstallDeliveryRequest, ) -> Result { - let _ = request; + let _ = (caller, request); Err(ironhub_link::ironhub_link_unavailable()) } } @@ -1315,6 +1316,7 @@ impl RebornServicesApi for RebornServices { async fn ironhub_deliver_install( &self, + caller: WebUiAuthenticatedCaller, request: IronhubInstallDeliveryRequest, ) -> Result { let service = self @@ -1322,7 +1324,7 @@ impl RebornServicesApi for RebornServices { .as_ref() .ok_or_else(ironhub_link::ironhub_link_unavailable)?; service - .deliver_install(request) + .deliver_install(caller.user_id, request) .await .map_err(ironhub_link::map_ironhub_link_error) } diff --git a/crates/ironclaw_product_workflow/src/reborn_services/ironhub_link.rs b/crates/ironclaw_product_workflow/src/reborn_services/ironhub_link.rs index be9be9f6e7c..7f8cfc71c05 100644 --- a/crates/ironclaw_product_workflow/src/reborn_services/ironhub_link.rs +++ b/crates/ironclaw_product_workflow/src/reborn_services/ironhub_link.rs @@ -1,8 +1,16 @@ use async_trait::async_trait; +use ironclaw_host_api::UserId; use serde::{Deserialize, Serialize}; use super::error::{RebornServicesError, RebornServicesErrorCode}; +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum IronhubInstallKind { + Tool, + Skill, +} + #[derive(Debug, Clone, PartialEq, Eq, Deserialize)] pub struct IronhubRegisterRequest { pub uid: String, @@ -23,7 +31,7 @@ pub struct IronhubInstallDeliveryRequest { pub artifact_digest: String, pub sig: String, #[serde(default)] - pub kind: Option, + pub kind: Option, #[serde(default)] pub private_manifest_url: Option, } @@ -41,8 +49,12 @@ pub enum IronhubLinkError { InvalidSignature, #[error("agent-link timestamp outside the accepted window")] StaleTimestamp, + #[error("agent-link request replayed")] + Replay, #[error("ironhub install failed: {reason}")] Install { reason: String }, + #[error("invalid ironhub install request: {reason}")] + InvalidInput { reason: String }, #[error("ironhub link service is unavailable")] Unavailable, } @@ -53,6 +65,7 @@ pub trait IronhubLinkService: Send + Sync { async fn deliver_install( &self, + user_id: UserId, request: IronhubInstallDeliveryRequest, ) -> Result; } @@ -63,10 +76,15 @@ pub(super) fn ironhub_link_unavailable() -> RebornServicesError { pub(super) fn map_ironhub_link_error(error: IronhubLinkError) -> RebornServicesError { match error { - IronhubLinkError::InvalidSignature | IronhubLinkError::StaleTimestamp => { + IronhubLinkError::InvalidSignature + | IronhubLinkError::StaleTimestamp + | IronhubLinkError::Replay => { RebornServicesError::from_status(RebornServicesErrorCode::Forbidden, 403, false) } IronhubLinkError::Install { .. } => RebornServicesError::internal_invariant(), + IronhubLinkError::InvalidInput { .. } => { + RebornServicesError::from_status(RebornServicesErrorCode::InvalidRequest, 400, false) + } IronhubLinkError::Unavailable => RebornServicesError::service_unavailable(false), } } diff --git a/crates/ironclaw_reborn_cli/src/commands/ironhub.rs b/crates/ironclaw_reborn_cli/src/commands/ironhub.rs index 0ff8220e008..f784b113503 100644 --- a/crates/ironclaw_reborn_cli/src/commands/ironhub.rs +++ b/crates/ironclaw_reborn_cli/src/commands/ironhub.rs @@ -10,11 +10,8 @@ use crate::runtime::{RuntimeInputCaller, RuntimeInputOptions}; #[derive(Debug, Args)] pub(crate) struct IronHubCommand { - #[arg( - long = "confirm-host-access", - global = true, - help = "Confirm trusted-laptop host filesystem access for local-dev-yolo." - )] + /// Confirm trusted-laptop host filesystem access for local-dev-yolo. + #[arg(long = "confirm-host-access", global = true)] confirm_host_access: bool, #[command(subcommand)] @@ -23,84 +20,82 @@ pub(crate) struct IronHubCommand { #[derive(Debug, Subcommand)] enum IronHubSubcommand { - #[command(about = "Search the signed IronHub catalog.")] + /// Search the signed IronHub catalog. Search(IronHubSearchCommand), - #[command(about = "List available IronHub tools or skills.")] + /// List available IronHub tools or skills. List(IronHubListCommand), - #[command(about = "Show one IronHub catalog entry.")] + /// Show one IronHub catalog entry. Info(IronHubInfoCommand), - #[command(about = "Install an IronHub tool or skill into Reborn local-dev state.")] + /// Install an IronHub tool or skill into Reborn local-dev state. Install(IronHubInstallCommand), } #[derive(Debug, Args)] struct IronHubSearchCommand { - #[arg(help = "Optional query by name or description. Omit to list all entries.")] + /// Optional query by name or description. Omit to list all entries. query: Option, - #[arg(long, help = "Output the lifecycle response as JSON.")] + /// Output the lifecycle response as JSON. + #[arg(long)] json: bool, } #[derive(Debug, Args)] struct IronHubListCommand { - #[arg(long, value_enum, help = "Limit results to tools or skills.")] + /// Limit results to tools or skills. + #[arg(long, value_enum)] kind: Option, - #[arg(long, help = "Output the lifecycle response as JSON.")] + /// Output the lifecycle response as JSON. + #[arg(long)] json: bool, } #[derive(Debug, Args)] struct IronHubInfoCommand { - #[arg(help = "Tool or skill name.")] + /// Tool or skill name. name: String, - #[arg( - long, - value_enum, - help = "Disambiguate when a name exists as both a tool and a skill." - )] + /// Disambiguate when a name exists as both a tool and a skill. + #[arg(long, value_enum)] kind: Option, - #[arg(long, help = "Output the lifecycle response as JSON.")] + /// Output the lifecycle response as JSON. + #[arg(long)] json: bool, } #[derive(Debug, Args)] struct IronHubInstallCommand { - #[arg(help = "Tool or skill name.")] + /// Tool or skill name. name: String, - #[arg( - long, - value_enum, - help = "Disambiguate when a name exists as both a tool and a skill." - )] + /// Disambiguate when a name exists as both a tool and a skill. + #[arg(long, value_enum)] kind: Option, - #[arg(long, help = "Replace an already installed package.")] + /// Replace an already installed package. + #[arg(long)] force: bool, - #[arg(long, help = "Acknowledge installing unverified community content.")] + /// Acknowledge installing unverified community content. + #[arg(long)] acknowledge_unverified: bool, - #[arg(long, help = "Require the catalog entry to still have this version.")] + /// Require the catalog entry to still have this version. + #[arg(long)] expected_version: Option, - #[arg( - long, - help = "Require the catalog entry to still have this artifact digest." - )] + /// Require the catalog entry to still have this artifact digest. + #[arg(long)] expected_artifact_digest: Option, - #[arg( - long, - help = "Install from a private org-scoped signed manifest URL instead of the public catalog." - )] + /// Install from a private org-scoped signed manifest URL instead of the public catalog. + #[arg(long)] private_manifest_url: Option, - #[arg(long, help = "Output the lifecycle response as JSON.")] + /// Output the lifecycle response as JSON. + #[arg(long)] json: bool, } diff --git a/crates/ironclaw_reborn_cli/src/commands/serve.rs b/crates/ironclaw_reborn_cli/src/commands/serve.rs index e65127657ce..a2a7fc0b846 100644 --- a/crates/ironclaw_reborn_cli/src/commands/serve.rs +++ b/crates/ironclaw_reborn_cli/src/commands/serve.rs @@ -14,12 +14,12 @@ use ironclaw_reborn_composition::{ RebornRuntimeInput, RebornWebuiBundle, WebuiAuthenticator, WebuiServeConfig, build_reborn_runtime, open_local_trigger_access_store, webui_v2_app_with_lifecycle, }; +#[cfg(feature = "webui-v2-beta")] +use ironclaw_reborn_composition::{IronhubRegisterRouteState, ironhub_register_route_mount}; #[cfg(feature = "slack-v2-host-beta")] use ironclaw_reborn_composition::{ build_slack_host_beta_mounts, build_webui_services_with_slack_host_beta_mounts, }; -#[cfg(feature = "webui-v2-beta")] -use ironclaw_reborn_composition::{IronhubRegisterRouteState, ironhub_register_route_mount}; use ironclaw_reborn_config::IdentitySection; use ironclaw_reborn_webui_ingress::{ EnvBearerAuthenticator, RebornWebuiServeOptions, serve_webui_v2, diff --git a/crates/ironclaw_reborn_cli/src/runtime/mod.rs b/crates/ironclaw_reborn_cli/src/runtime/mod.rs index 204ccb21156..6322ded588c 100644 --- a/crates/ironclaw_reborn_cli/src/runtime/mod.rs +++ b/crates/ironclaw_reborn_cli/src/runtime/mod.rs @@ -370,7 +370,8 @@ pub(crate) fn build_runtime_input_with_options( let shared_key = shared_key.trim(); if !shared_key.is_empty() { runtime_input = runtime_input.with_ironhub_agent_shared_key( - ironclaw_reborn_composition::IronhubSharedKey::new(shared_key), + ironclaw_reborn_composition::IronhubSharedKey::new(shared_key) + .context("IRONHUB_AGENT_SHARED_KEY is invalid")?, ); } } diff --git a/crates/ironclaw_reborn_composition/src/ironhub/agent_link.rs b/crates/ironclaw_reborn_composition/src/ironhub/agent_link.rs index 963a132a8e7..76e84b407b4 100644 --- a/crates/ironclaw_reborn_composition/src/ironhub/agent_link.rs +++ b/crates/ironclaw_reborn_composition/src/ironhub/agent_link.rs @@ -4,12 +4,26 @@ use sha2::Sha256; type HmacSha256 = Hmac; +const MIN_SHARED_KEY_LEN: usize = 16; + +#[derive(Debug, thiserror::Error)] +pub enum IronhubSharedKeyError { + #[error("ironhub shared key must be at least {min} bytes")] + TooShort { min: usize }, +} + #[derive(Clone)] pub struct IronhubSharedKey(String); impl IronhubSharedKey { - pub fn new(value: impl Into) -> Self { - Self(value.into()) + pub fn new(value: impl Into) -> Result { + let value = value.into(); + if value.len() < MIN_SHARED_KEY_LEN { + return Err(IronhubSharedKeyError::TooShort { + min: MIN_SHARED_KEY_LEN, + }); + } + Ok(Self(value)) } pub(super) fn as_str(&self) -> &str { @@ -45,9 +59,11 @@ pub(super) fn install_payload(request: &IronhubInstallDeliveryRequest) -> String pub(super) fn verify_signature(shared_key: &str, payload: &str, sig_hex: &str) -> bool { let Ok(expected) = hex::decode(sig_hex) else { + // silent-ok: a non-hex signature is an invalid signature; reject it. return false; }; let Ok(mut mac) = HmacSha256::new_from_slice(shared_key.as_bytes()) else { + // silent-ok: HMAC-SHA256 accepts any key length, so this arm never fires. return false; }; mac.update(payload.as_bytes()); diff --git a/crates/ironclaw_reborn_composition/src/ironhub/link_service.rs b/crates/ironclaw_reborn_composition/src/ironhub/link_service.rs index 15947cba177..9d4953368cd 100644 --- a/crates/ironclaw_reborn_composition/src/ironhub/link_service.rs +++ b/crates/ironclaw_reborn_composition/src/ironhub/link_service.rs @@ -1,11 +1,13 @@ -use std::sync::Arc; +use std::collections::HashMap; +use std::sync::{Arc, LazyLock, Mutex}; +use std::time::{Duration, Instant}; use async_trait::async_trait; use ironclaw_host_api::{CapabilityId, InvocationId, ResourceScope, UserId}; use ironclaw_host_runtime::HostRuntimeHttpEgressPort; use ironclaw_product_workflow::{ - IronhubInstallDeliveryRequest, IronhubInstallDeliveryResult, IronhubLinkError, - IronhubLinkService, IronhubRegisterRequest, LifecyclePhase, + IronhubInstallDeliveryRequest, IronhubInstallDeliveryResult, IronhubInstallKind, + IronhubLinkError, IronhubLinkService, IronhubRegisterRequest, LifecyclePhase, }; use crate::RebornBuildError; @@ -13,19 +15,20 @@ use crate::extension_lifecycle::RebornLocalExtensionManagementPort; use crate::lifecycle::RebornLocalSkillManagementPort; use super::agent_link::{IronhubSharedKey, install_payload, register_payload, verify_signature}; -use super::model::{IronHubCommand, IronHubEntryKind, IronHubInstallOptions}; +use super::model::{IronHubCommand, IronHubCommandError, IronHubEntryKind, IronHubInstallOptions}; use super::service::IronHubService; const MAX_TIMESTAMP_DRIFT_SECS: i64 = 300; -const LINK_USER_ID: &str = "reborn-ironhub-link"; const INSTALL_CAPABILITY_ID: &str = "builtin.ironhub_install"; +static SEEN_INSTALL_NONCES: LazyLock>> = + LazyLock::new(|| Mutex::new(HashMap::new())); + pub(crate) struct RebornIronhubLinkService { skill_management: Arc, extension_management: Arc, host_runtime_http_egress: HostRuntimeHttpEgressPort, shared_key: IronhubSharedKey, - link_user_id: UserId, install_capability: CapabilityId, } @@ -41,14 +44,12 @@ impl RebornIronhubLinkService { extension_management, host_runtime_http_egress, shared_key, - link_user_id: UserId::new(LINK_USER_ID).map_err(invalid_config)?, install_capability: CapabilityId::new(INSTALL_CAPABILITY_ID).map_err(invalid_config)?, }) } - fn install_service(&self) -> Result { - let scope = ResourceScope::local_default(self.link_user_id.clone(), InvocationId::new()) - .map_err(internal)?; + fn install_service(&self, user_id: UserId) -> Result { + let scope = ResourceScope::local_default(user_id, InvocationId::new()).map_err(internal)?; Ok(IronHubService::new_with_host_egress( Arc::clone(&self.skill_management), Arc::clone(&self.extension_management), @@ -71,17 +72,41 @@ fn internal(error: impl std::fmt::Display) -> IronhubLinkError { } } +fn map_install_error(error: IronHubCommandError) -> IronhubLinkError { + match error { + IronHubCommandError::InvalidInput { reason } | IronHubCommandError::Catalog { reason } => { + IronhubLinkError::InvalidInput { reason } + } + other => IronhubLinkError::Install { + reason: other.to_string(), + }, + } +} + fn timestamp_fresh(ts: u64) -> bool { let drift = chrono::Utc::now().timestamp() - ts as i64; drift.abs() <= MAX_TIMESTAMP_DRIFT_SECS } -fn install_kind(kind: Option<&str>) -> Option { - match kind { - Some("tool") => Some(IronHubEntryKind::Tool), - Some("skill") => Some(IronHubEntryKind::Skill), - _ => None, +fn map_kind(kind: Option) -> Option { + kind.map(|kind| match kind { + IronhubInstallKind::Tool => IronHubEntryKind::Tool, + IronhubInstallKind::Skill => IronHubEntryKind::Skill, + }) +} + +fn reject_replayed_nonce(nonce: &str) -> Result<(), IronhubLinkError> { + let ttl = Duration::from_secs(MAX_TIMESTAMP_DRIFT_SECS as u64); + let now = Instant::now(); + let mut seen = SEEN_INSTALL_NONCES + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + seen.retain(|_, recorded| now.duration_since(*recorded) < ttl); + if seen.contains_key(nonce) { + return Err(IronhubLinkError::Replay); } + seen.insert(nonce.to_string(), now); + Ok(()) } #[async_trait] @@ -90,7 +115,13 @@ impl IronhubLinkService for RebornIronhubLinkService { if !timestamp_fresh(request.ts) { return Err(IronhubLinkError::StaleTimestamp); } - if verify_signature(self.shared_key.as_str(), ®ister_payload(&request), &request.sig) { + // replay-ok: register has no local side effect, so an idempotent retry is + // harmless; single-use is enforced on deliver_install only. + if verify_signature( + self.shared_key.as_str(), + ®ister_payload(&request), + &request.sig, + ) { Ok(()) } else { Err(IronhubLinkError::InvalidSignature) @@ -99,17 +130,23 @@ impl IronhubLinkService for RebornIronhubLinkService { async fn deliver_install( &self, + user_id: UserId, request: IronhubInstallDeliveryRequest, ) -> Result { if !timestamp_fresh(request.ts) { return Err(IronhubLinkError::StaleTimestamp); } - if !verify_signature(self.shared_key.as_str(), &install_payload(&request), &request.sig) { + if !verify_signature( + self.shared_key.as_str(), + &install_payload(&request), + &request.sig, + ) { return Err(IronhubLinkError::InvalidSignature); } + reject_replayed_nonce(&request.nonce)?; let options = IronHubInstallOptions { - kind: install_kind(request.kind.as_deref()), + kind: map_kind(request.kind), force: false, acknowledge_unverified: false, expected_version: Some(request.version), @@ -117,13 +154,13 @@ impl IronhubLinkService for RebornIronhubLinkService { private_manifest_url: request.private_manifest_url, }; let response = self - .install_service()? + .install_service(user_id)? .execute(IronHubCommand::Install { name: request.slug.clone(), options, }) .await - .map_err(internal)?; + .map_err(map_install_error)?; Ok(IronhubInstallDeliveryResult { installed: matches!(response.phase, LifecyclePhase::Installed), @@ -132,3 +169,184 @@ impl IronhubLinkService for RebornIronhubLinkService { }) } } + +#[cfg(test)] +mod tests { + use hmac::{Hmac, Mac}; + use sha2::Sha256; + + use crate::RebornBuildInput; + use crate::factory::build_reborn_services; + + use super::*; + + const SHARED_KEY: &str = "ihub_sk_LinkServiceTestKey0000000000000000000000000"; + + fn now_ts() -> u64 { + chrono::Utc::now().timestamp() as u64 + } + + fn sign(payload: &str) -> String { + let mut mac = Hmac::::new_from_slice(SHARED_KEY.as_bytes()).expect("hmac key"); + mac.update(payload.as_bytes()); + hex::encode(mac.finalize().into_bytes()) + } + + fn register_request(ts: u64, sig: String) -> IronhubRegisterRequest { + IronhubRegisterRequest { + uid: "user-1".to_string(), + aid: "aid-1".to_string(), + ts, + nonce: "nonce-register".to_string(), + sig, + } + } + + fn install_request(ts: u64, nonce: &str, sig: String) -> IronhubInstallDeliveryRequest { + IronhubInstallDeliveryRequest { + slug: "my-skill".to_string(), + version: "1.0.0".to_string(), + uid: "user-1".to_string(), + aid: "aid-1".to_string(), + ts, + nonce: nonce.to_string(), + artifact_digest: "sha256:deadbeef".to_string(), + sig, + kind: Some(IronhubInstallKind::Skill), + private_manifest_url: None, + } + } + + async fn build_link_service(root: &std::path::Path) -> RebornIronhubLinkService { + let services = build_reborn_services(RebornBuildInput::local_dev( + "ironhub-link-test-owner", + root.join("local-dev"), + )) + .await + .expect("local-dev services build"); + let local_runtime = services.local_runtime.expect("local runtime substrate"); + RebornIronhubLinkService::new( + Arc::clone(&local_runtime.skill_management), + local_runtime + .extension_management + .as_ref() + .expect("extension management") + .clone(), + local_runtime + .host_runtime_http_egress + .as_ref() + .expect("host runtime http egress") + .clone(), + IronhubSharedKey::new(SHARED_KEY).expect("shared key"), + ) + .expect("link service builds") + } + + #[test] + fn timestamp_fresh_accepts_now_and_rejects_stale() { + assert!(timestamp_fresh(now_ts())); + assert!(!timestamp_fresh(1)); + } + + #[test] + fn map_kind_maps_each_variant() { + assert!(matches!( + map_kind(Some(IronhubInstallKind::Tool)), + Some(IronHubEntryKind::Tool) + )); + assert!(matches!( + map_kind(Some(IronhubInstallKind::Skill)), + Some(IronHubEntryKind::Skill) + )); + assert!(map_kind(None).is_none()); + } + + #[test] + fn map_install_error_classifies_invalid_and_catalog_as_invalid_input() { + assert!(matches!( + map_install_error(IronHubCommandError::InvalidInput { + reason: "bad".to_string() + }), + IronhubLinkError::InvalidInput { .. } + )); + assert!(matches!( + map_install_error(IronHubCommandError::Catalog { + reason: "bad".to_string() + }), + IronhubLinkError::InvalidInput { .. } + )); + assert!(matches!( + map_install_error(IronHubCommandError::LocalRuntimeUnavailable), + IronhubLinkError::Install { .. } + )); + } + + #[test] + fn reject_replayed_nonce_rejects_second_use() { + let nonce = "nonce-replay-unit-unique"; + assert!(reject_replayed_nonce(nonce).is_ok()); + assert!(matches!( + reject_replayed_nonce(nonce), + Err(IronhubLinkError::Replay) + )); + } + + #[tokio::test] + async fn register_rejects_stale_timestamp() { + let dir = tempfile::tempdir().expect("tempdir"); + let service = build_link_service(dir.path()).await; + let request = register_request(1, "00".to_string()); + assert!(matches!( + service.register(request).await, + Err(IronhubLinkError::StaleTimestamp) + )); + } + + #[tokio::test] + async fn register_rejects_invalid_signature() { + let dir = tempfile::tempdir().expect("tempdir"); + let service = build_link_service(dir.path()).await; + let request = register_request(now_ts(), "00".to_string()); + assert!(matches!( + service.register(request).await, + Err(IronhubLinkError::InvalidSignature) + )); + } + + #[tokio::test] + async fn register_accepts_valid_signature() { + let dir = tempfile::tempdir().expect("tempdir"); + let service = build_link_service(dir.path()).await; + let ts = now_ts(); + let mut request = register_request(ts, String::new()); + request.sig = sign(®ister_payload(&request)); + service + .register(request) + .await + .expect("a correctly signed register handshake is accepted"); + } + + #[tokio::test] + async fn deliver_install_rejects_stale_timestamp() { + let dir = tempfile::tempdir().expect("tempdir"); + let service = build_link_service(dir.path()).await; + let request = install_request(1, "nonce-stale", "00".to_string()); + let user_id = UserId::new("user-1").expect("user id"); + assert!(matches!( + service.deliver_install(user_id, request).await, + Err(IronhubLinkError::StaleTimestamp) + )); + } + + #[tokio::test] + async fn deliver_install_rejects_invalid_signature() { + let dir = tempfile::tempdir().expect("tempdir"); + let service = build_link_service(dir.path()).await; + let request = install_request(now_ts(), "nonce-bad-sig", "00".to_string()); + let user_id = UserId::new("user-1").expect("user id"); + assert!(matches!( + service.deliver_install(user_id, request).await, + Err(IronhubLinkError::InvalidSignature) + )); + } +} diff --git a/crates/ironclaw_reborn_composition/src/ironhub/mod.rs b/crates/ironclaw_reborn_composition/src/ironhub/mod.rs index 31612b83746..ccdcfa5501d 100644 --- a/crates/ironclaw_reborn_composition/src/ironhub/mod.rs +++ b/crates/ironclaw_reborn_composition/src/ironhub/mod.rs @@ -14,7 +14,7 @@ mod service; mod tests; #[cfg(feature = "webui-v2-beta")] -pub use agent_link::IronhubSharedKey; +pub use agent_link::{IronhubSharedKey, IronhubSharedKeyError}; pub(crate) use capabilities::{extend_builtin_first_party_package, insert_handlers}; #[cfg(feature = "webui-v2-beta")] pub(crate) use link_service::RebornIronhubLinkService; diff --git a/crates/ironclaw_reborn_composition/src/ironhub/service.rs b/crates/ironclaw_reborn_composition/src/ironhub/service.rs index 2f3e4f3609c..044222ed22a 100644 --- a/crates/ironclaw_reborn_composition/src/ironhub/service.rs +++ b/crates/ironclaw_reborn_composition/src/ironhub/service.rs @@ -569,9 +569,15 @@ fn resolve_manifest_url() -> String { } fn manifest_host(url: &str) -> Option { - url::Url::parse(url) + let host = url::Url::parse(url) .ok() - .and_then(|parsed| parsed.host_str().map(str::to_string)) + .and_then(|parsed| parsed.host_str().map(str::to_string)); + if host.is_none() { + tracing::debug!( + "ironhub manifest url has no parseable host; catalog host pinning disabled" + ); + } + host } fn manifest_cache_get(url: &str, now: Instant) -> Option> { @@ -623,10 +629,11 @@ fn enforce_manifest_monotonic( reason: format!("manifest generated_at is not RFC3339: {error}"), })? .with_timezone(&Utc); + let key = manifest_replay_key(url, manifest); let mut guard = MANIFEST_LAST_SEEN .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()); - if let Some(previous) = guard.get(url) + if let Some(previous) = guard.get(&key) && generated_at < *previous { return Err(IronHubCommandError::Catalog { @@ -637,10 +644,18 @@ fn enforce_manifest_monotonic( ), }); } - guard.insert(url.to_string(), generated_at); + guard.insert(key, generated_at); Ok(()) } +fn manifest_replay_key(url: &str, manifest: &IronHubManifest) -> String { + let host = url::Url::parse(url) + .ok() + .and_then(|parsed| parsed.host_str().map(str::to_string)) + .unwrap_or_default(); + format!("{host}|{}", manifest.repo) +} + 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 39830fcaa6a..e795cd4d3c6 100644 --- a/crates/ironclaw_reborn_composition/src/ironhub/tests.rs +++ b/crates/ironclaw_reborn_composition/src/ironhub/tests.rs @@ -29,6 +29,8 @@ use super::model::{ use super::render::render_reborn_ironhub_response; use super::service::IronHubService; +const TEST_CATALOG_GENERATED_AT: &str = "2026-01-01T00:00:00Z"; + #[test] fn signed_manifest_verifies_known_test_vector() { let envelope = br#"{"v":1,"key_id":"test-vector","manifest_b64":"eyJ2ZXJzaW9uIjoiMSIsImdlbmVyYXRlZF9hdCI6IjIwMjYtMDEtMDFUMDA6MDA6MDBaIiwicmVsZWFzZV90YWciOiJ0ZXN0IiwicmVwbyI6Im5lYXJhaS9pcm9uaHViIiwidG9vbHMiOltdLCJza2lsbHMiOltdfQ","sig":"KjsUDgi1enj3iTPNQI6gU1Bwxf01hIUItlFvX9PxgWNybPPrJNIV7vFG-G8hJOalFMwFs5zQHrxbtFDZAlgtBg"}"#; @@ -134,6 +136,92 @@ fn unverified_install_requires_acknowledgement() { assert_eq!(allowed.1, IronHubProvenance::New); } +#[test] +fn private_provenance_requires_private_manifest_source() { + 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: "org-skill".to_string(), + trunk: String::new(), + version: "0.1.0".to_string(), + description: String::new(), + provenance: IronHubProvenance::Private, + skill_md: IronHubArtifact { + url: "https://hub.ironclaw.com/org-skill/SKILL.md".to_string(), + size_bytes: 1, + sha256: "c".repeat(64), + }, + }], + }; + + let rejected = classify_gate_and_digest( + &manifest, + "org-skill", + Some(IronHubEntryKind::Skill), + &IronHubInstallOptions::default(), + ) + .expect_err("private provenance without a private manifest source is rejected"); + assert!(rejected.to_string().contains("claims private provenance")); + + let allowed = classify_gate_and_digest( + &manifest, + "org-skill", + Some(IronHubEntryKind::Skill), + &IronHubInstallOptions { + private_manifest_url: Some("https://hub.ironclaw.com/org/manifest".to_string()), + ..IronHubInstallOptions::default() + }, + ) + .expect("a private manifest source allows private provenance"); + assert_eq!(allowed.1, IronHubProvenance::Private); +} + +#[tokio::test] +async fn install_rejects_replayed_older_manifest_for_same_catalog() { + let dir = tempfile::tempdir().expect("tempdir"); + let root = dir.path().join("local-dev"); + let catalog_url = "https://replay-catalog.example.com/catalog/manifest.json"; + let older_url = "https://replay-catalog.example.com/private/older-manifest.json"; + let egress = Arc::new(RecordingIronHubEgress::new([ + ( + catalog_url, + signed_manifest(empty_manifest_json("2026-02-02T00:00:00Z")), + ), + ( + older_url, + signed_manifest(empty_manifest_json("2026-02-01T00:00:00Z")), + ), + ])); + let service = ironhub_service(root, egress, catalog_url).await; + + service + .execute(super::model::IronHubCommand::Search { + query: String::new(), + }) + .await + .expect("the newer catalog manifest is accepted"); + + let rejected = service + .execute(super::model::IronHubCommand::Install { + name: "replay-skill".to_string(), + options: IronHubInstallOptions { + kind: Some(IronHubEntryKind::Skill), + private_manifest_url: Some(older_url.to_string()), + ..IronHubInstallOptions::default() + }, + }) + .await + .expect_err("an older manifest for the same catalog host and repo is a replay"); + assert!( + rejected.to_string().contains("replay rejected"), + "{rejected}" + ); +} + #[test] fn renderer_includes_tools_and_skills_in_mixed_search() { let skill = skill_summary(&IronHubSkillEntry { @@ -250,7 +338,7 @@ async fn install_rejects_artifact_sha256_mismatch_before_reborn_write() { 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", + TEST_CATALOG_GENERATED_AT, skill_url, &sha256_hex(b"expected skill"), IronHubProvenance::Official, @@ -293,7 +381,7 @@ async fn install_skill_and_tool_materialize_into_reborn_management() { let manifest = signed_manifest(mixed_manifest_json(MixedManifestFixture { skill_name: "installed-skill", tool_name: "installed-tool", - generated_at: "2026-01-02T00:00:00Z", + generated_at: TEST_CATALOG_GENERATED_AT, skill_url, skill_sha: &sha256_hex(skill_bytes), wasm_url, @@ -350,7 +438,7 @@ async fn install_resolves_skill_from_private_manifest_url() { let skill_bytes = b"# private skill\n"; let private_manifest = signed_manifest(skill_manifest_json( "private-skill", - "2026-01-04T00:00:00Z", + TEST_CATALOG_GENERATED_AT, skill_url, &sha256_hex(skill_bytes), IronHubProvenance::Private, @@ -358,7 +446,7 @@ async fn install_resolves_skill_from_private_manifest_url() { let egress = Arc::new(RecordingIronHubEgress::new([ ( public_manifest_url, - signed_manifest(empty_manifest_json("2026-01-04T00:00:00Z")), + signed_manifest(empty_manifest_json(TEST_CATALOG_GENERATED_AT)), ), (private_manifest_url, private_manifest), (skill_url, skill_bytes.to_vec()), @@ -392,7 +480,7 @@ async fn install_allows_private_artifacts_on_configured_catalog_host() { let skill_bytes = b"# scoped skill\n"; let private_manifest = signed_manifest(skill_manifest_json( "scoped-skill", - "2026-01-05T00:00:00Z", + TEST_CATALOG_GENERATED_AT, skill_url, &sha256_hex(skill_bytes), IronHubProvenance::Official, @@ -400,7 +488,7 @@ async fn install_allows_private_artifacts_on_configured_catalog_host() { let egress = Arc::new(RecordingIronHubEgress::new([ ( catalog_url, - signed_manifest(empty_manifest_json("2026-01-05T00:00:00Z")), + signed_manifest(empty_manifest_json(TEST_CATALOG_GENERATED_AT)), ), (private_manifest_url, private_manifest), (skill_url, skill_bytes.to_vec()), @@ -429,7 +517,7 @@ async fn fetch_manifest_uses_runtime_egress_host_policy() { 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")), + signed_manifest(empty_manifest_json(TEST_CATALOG_GENERATED_AT)), )])); let service = ironhub_service( dir.path().join("local-dev"), @@ -474,7 +562,7 @@ async fn concurrent_manifest_cache_miss_fetches_once() { let egress = Arc::new( RecordingIronHubEgress::new([( manifest_url, - signed_manifest(empty_manifest_json("2026-01-04T00:00:00Z")), + signed_manifest(empty_manifest_json(TEST_CATALOG_GENERATED_AT)), )]) .with_delay(Duration::from_millis(50)), ); diff --git a/crates/ironclaw_reborn_composition/src/ironhub_link_serve.rs b/crates/ironclaw_reborn_composition/src/ironhub_link_serve.rs index 8982fc94eb2..3ab6ca93127 100644 --- a/crates/ironclaw_reborn_composition/src/ironhub_link_serve.rs +++ b/crates/ironclaw_reborn_composition/src/ironhub_link_serve.rs @@ -20,12 +20,9 @@ use crate::webui_serve::PublicRouteMount; pub(crate) const IRONHUB_REGISTER_PATH: &str = "/api/ironhub/register"; const IRONHUB_REGISTER_ROUTE_ID: &str = "ironhub.register"; -// safety: 8 KiB is a non-zero literal. -const IRONHUB_REGISTER_BODY_LIMIT_BYTES: NonZeroU64 = NonZeroU64::new(8 * 1024).unwrap(); -// safety: 600 requests is a non-zero literal. -const IRONHUB_REGISTER_MAX_REQUESTS: NonZeroU32 = NonZeroU32::new(600).unwrap(); -// safety: 60 seconds is a non-zero literal. -const IRONHUB_REGISTER_RATE_WINDOW_SECONDS: NonZeroU32 = NonZeroU32::new(60).unwrap(); +const IRONHUB_REGISTER_BODY_LIMIT_BYTES: NonZeroU64 = NonZeroU64::new(8 * 1024).unwrap(); // safety: 8 KiB is a non-zero literal. +const IRONHUB_REGISTER_MAX_REQUESTS: NonZeroU32 = NonZeroU32::new(600).unwrap(); // safety: 600 requests is a non-zero literal. +const IRONHUB_REGISTER_RATE_WINDOW_SECONDS: NonZeroU32 = NonZeroU32::new(60).unwrap(); // safety: 60 seconds is a non-zero literal. #[derive(Clone)] pub struct IronhubRegisterRouteState { @@ -60,8 +57,7 @@ pub(crate) fn ironhub_register_route_descriptors() -> Vec IngressPolicy { audit: AuditTraceClass::PublicCallback, effect_path: AllowedEffectPath::ProductWorkflow, }) - // safety: policy combines validated constants and a host-resolved webhook-signature scope. - .expect("IronHub register ingress policy must validate") + .expect("IronHub register ingress policy must validate") // safety: policy combines validated constants and a host-resolved webhook-signature scope. } async fn ironhub_register_handler( diff --git a/crates/ironclaw_reborn_composition/src/lib.rs b/crates/ironclaw_reborn_composition/src/lib.rs index 8a4b646cf62..59a4d89ae80 100644 --- a/crates/ironclaw_reborn_composition/src/lib.rs +++ b/crates/ironclaw_reborn_composition/src/lib.rs @@ -170,7 +170,7 @@ pub use ironhub::{ execute_reborn_ironhub_command, render_reborn_ironhub_response, }; #[cfg(feature = "webui-v2-beta")] -pub use ironhub::IronhubSharedKey; +pub use ironhub::{IronhubSharedKey, IronhubSharedKeyError}; #[cfg(feature = "webui-v2-beta")] pub use ironhub_link_serve::{IronhubRegisterRouteState, ironhub_register_route_mount}; #[cfg(feature = "root-llm-provider")] diff --git a/crates/ironclaw_reborn_composition/src/runtime.rs b/crates/ironclaw_reborn_composition/src/runtime.rs index c23be2796a1..4b822913728 100644 --- a/crates/ironclaw_reborn_composition/src/runtime.rs +++ b/crates/ironclaw_reborn_composition/src/runtime.rs @@ -201,6 +201,14 @@ impl From for RebornRuntimeError { } } +#[cfg(feature = "webui-v2-beta")] +type IronhubLinkInputs<'a> = ( + &'a Arc, + &'a Arc, + &'a ironclaw_host_runtime::HostRuntimeHttpEgressPort, + &'a crate::ironhub::IronhubSharedKey, +); + /// Started, running Reborn agent runtime. /// /// `RebornRuntime` is the single user-facing handle returned by @@ -577,30 +585,43 @@ impl RebornRuntime { self.boot.as_ref() } - /// Shared HMAC key for the IronHub deep-link webhooks, when one was wired - /// at boot. The WebUI facade uses it to compose the agent-link service. #[cfg(feature = "webui-v2-beta")] - pub(crate) fn webui_ironhub_agent_shared_key( + fn ironhub_link_inputs(&self) -> Option> { + let local_runtime = self.services.local_runtime.as_ref()?; + Some(( + &local_runtime.skill_management, + local_runtime.extension_management.as_ref()?, + local_runtime.host_runtime_http_egress.as_ref()?, + self.ironhub_agent_shared_key.as_ref()?, + )) + } + + /// The composed IronHub deep-link service when every input is wired, ready + /// to attach to the WebUI facade. `Ok(None)` when the webhooks are disabled. + #[cfg(feature = "webui-v2-beta")] + pub(crate) fn webui_ironhub_link_service( &self, - ) -> Option { - self.ironhub_agent_shared_key.clone() + ) -> Result>, RebornBuildError> { + let Some((skill_management, extension_management, host_runtime_http_egress, shared_key)) = + self.ironhub_link_inputs() + else { + return Ok(None); + }; + Ok(Some(Arc::new( + crate::ironhub::RebornIronhubLinkService::new( + Arc::clone(skill_management), + Arc::clone(extension_management), + host_runtime_http_egress.clone(), + shared_key.clone(), + )?, + ))) } - /// Whether the IronHub register/install webhooks have everything the link - /// service needs (shared key plus local-runtime extension + host egress), - /// matching the facade-attach gate so serve never mounts a webhook the - /// facade left unavailable. + /// Whether the IronHub register/install webhooks are enabled. Reads the same + /// input gate as the facade attach so serve and facade cannot drift. #[cfg(feature = "webui-v2-beta")] pub fn ironhub_register_enabled(&self) -> bool { - self.ironhub_agent_shared_key.is_some() - && self - .services - .local_runtime - .as_ref() - .is_some_and(|local_runtime| { - local_runtime.extension_management.is_some() - && local_runtime.host_runtime_http_egress.is_some() - }) + self.ironhub_link_inputs().is_some() } /// The runtime's NEAR AI session manager, when an LLM seam is wired. The diff --git a/crates/ironclaw_reborn_composition/src/webui.rs b/crates/ironclaw_reborn_composition/src/webui.rs index e0147c9b231..306df201f83 100644 --- a/crates/ironclaw_reborn_composition/src/webui.rs +++ b/crates/ironclaw_reborn_composition/src/webui.rs @@ -112,19 +112,8 @@ pub(crate) fn build_webui_services_with_connectable_channels( api = api.with_lifecycle_product_facade(Arc::new(lifecycle_facade)); #[cfg(feature = "webui-v2-beta")] - if let (Some(extension_management), Some(host_runtime_http_egress), Some(shared_key)) = ( - &local_runtime.extension_management, - &local_runtime.host_runtime_http_egress, - runtime.webui_ironhub_agent_shared_key(), - ) { - api = api.with_ironhub_link_service(Arc::new( - crate::ironhub::RebornIronhubLinkService::new( - local_runtime.skill_management.clone(), - extension_management.clone(), - host_runtime_http_egress.clone(), - shared_key, - )?, - )); + if let Some(link_service) = runtime.webui_ironhub_link_service()? { + api = api.with_ironhub_link_service(link_service); } } if let Some(product_auth) = &services.product_auth { diff --git a/crates/ironclaw_reborn_composition/tests/webui_v2_serve.rs b/crates/ironclaw_reborn_composition/tests/webui_v2_serve.rs index aa69708650b..5474f8bb0ee 100644 --- a/crates/ironclaw_reborn_composition/tests/webui_v2_serve.rs +++ b/crates/ironclaw_reborn_composition/tests/webui_v2_serve.rs @@ -20,19 +20,20 @@ use axum::http::{HeaderValue, Method, Request, StatusCode, header}; use http_body_util::BodyExt; use ironclaw_host_api::{AgentId, NetworkMethod, ProjectId, TenantId, ThreadId, UserId}; use ironclaw_product_workflow::{ - LifecyclePackageRef, LifecyclePhase, RebornCancelRunResponse, RebornCreateThreadResponse, - RebornExtensionActionResponse, RebornExtensionListResponse, RebornExtensionRegistryResponse, - RebornGetRunStateRequest, RebornGetRunStateResponse, RebornListAutomationsResponse, - RebornListThreadsResponse, RebornResolveGateResponse, RebornServicesApi, RebornServicesError, - RebornServicesErrorCode, RebornServicesErrorKind, RebornSetupExtensionResponse, - RebornStreamEventsRequest, RebornStreamEventsResponse, RebornSubmitTurnResponse, - RebornTimelineRequest, RebornTimelineResponse, WebUiAuthenticatedCaller, WebUiCancelRunRequest, - WebUiCreateThreadRequest, WebUiListAutomationsRequest, WebUiListThreadsRequest, - WebUiResolveGateRequest, WebUiSendMessageRequest, WebUiSetupExtensionRequest, + IronhubRegisterRequest, LifecyclePackageRef, LifecyclePhase, RebornCancelRunResponse, + RebornCreateThreadResponse, RebornExtensionActionResponse, RebornExtensionListResponse, + RebornExtensionRegistryResponse, RebornGetRunStateRequest, RebornGetRunStateResponse, + RebornListAutomationsResponse, RebornListThreadsResponse, RebornResolveGateResponse, + RebornServicesApi, RebornServicesError, RebornServicesErrorCode, RebornServicesErrorKind, + RebornSetupExtensionResponse, RebornStreamEventsRequest, RebornStreamEventsResponse, + RebornSubmitTurnResponse, RebornTimelineRequest, RebornTimelineResponse, + WebUiAuthenticatedCaller, WebUiCancelRunRequest, WebUiCreateThreadRequest, + WebUiListAutomationsRequest, WebUiListThreadsRequest, WebUiResolveGateRequest, + WebUiSendMessageRequest, WebUiSetupExtensionRequest, }; use ironclaw_reborn_composition::{ - PublicRouteMount, RebornReadiness, RebornWebuiBundle, WebuiAuthenticator, WebuiServeConfig, - webui_v2_app, + IronhubRegisterRouteState, PublicRouteMount, RebornReadiness, RebornWebuiBundle, + WebuiAuthenticator, WebuiServeConfig, ironhub_register_route_mount, webui_v2_app, }; use ironclaw_threads::{SessionThreadRecord, ThreadScope}; use ironclaw_turns::{EventCursor, RunProfileId, RunProfileVersion, TurnRunId, TurnStatus}; @@ -247,10 +248,22 @@ struct StubServices { // calling the facade, so this captures whatever the path // extractor delivered. resolve_gate_refs: Mutex>>, + ironhub_register_calls: Mutex>, } #[async_trait] impl RebornServicesApi for StubServices { + async fn ironhub_register( + &self, + request: IronhubRegisterRequest, + ) -> Result<(), RebornServicesError> { + self.ironhub_register_calls + .lock() + .expect("lock") + .push(request); + Ok(()) + } + async fn create_thread( &self, caller: WebUiAuthenticatedCaller, @@ -1973,3 +1986,58 @@ async fn public_route_mount_is_merged_without_bearer_auth_and_keeps_descriptor_p .expect("oneshot"); assert_eq!(protected.status(), StatusCode::UNAUTHORIZED); } + +#[tokio::test] +async fn ironhub_register_route_rejects_malformed_body_with_400() { + let services = Arc::new(StubServices::default()); + let mount = ironhub_register_route_mount(IronhubRegisterRouteState::new(services.clone())); + + let response = mount + .router + .clone() + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/api/ironhub/register") + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from("{not json")) + .expect("request"), + ) + .await + .expect("oneshot"); + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert_eq!( + services.ironhub_register_calls.lock().expect("lock").len(), + 0, + "facade must not be called for a malformed body" + ); +} + +#[tokio::test] +async fn ironhub_register_route_dispatches_valid_body_to_facade() { + let services = Arc::new(StubServices::default()); + let mount = ironhub_register_route_mount(IronhubRegisterRouteState::new(services.clone())); + + let response = mount + .router + .clone() + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/api/ironhub/register") + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from( + r#"{"uid":"u","aid":"a","ts":1700000000,"nonce":"n","sig":"sig-1"}"#, + )) + .expect("request"), + ) + .await + .expect("oneshot"); + + assert_eq!(response.status(), StatusCode::OK); + let calls = services.ironhub_register_calls.lock().expect("lock"); + assert_eq!(calls.len(), 1, "facade called exactly once"); + assert_eq!(calls[0].uid, "u"); + assert_eq!(calls[0].nonce, "n"); +} diff --git a/crates/ironclaw_webui_v2/src/handlers.rs b/crates/ironclaw_webui_v2/src/handlers.rs index ab35389bfc0..6bc5563b0ee 100644 --- a/crates/ironclaw_webui_v2/src/handlers.rs +++ b/crates/ironclaw_webui_v2/src/handlers.rs @@ -23,10 +23,10 @@ use futures::SinkExt; use futures::stream::Stream; use ironclaw_product_workflow::{ CodexLoginStart, IronhubInstallDeliveryRequest, IronhubInstallDeliveryResult, - LifecyclePackageKind, LifecyclePackageRef, LlmConfigSnapshot, LlmModelsResult, - LlmProbeRequest, LlmProbeResult, NearAiLoginRequest, NearAiLoginStart, - NearAiWalletLoginRequest, NearAiWalletLoginResult, ProductWorkflowError, ProjectionCursor, - RebornCancelRunResponse, RebornConnectableChannelListResponse, RebornCreateThreadResponse, + LifecyclePackageKind, LifecyclePackageRef, LlmConfigSnapshot, LlmModelsResult, LlmProbeRequest, + LlmProbeResult, NearAiLoginRequest, NearAiLoginStart, NearAiWalletLoginRequest, + NearAiWalletLoginResult, ProductWorkflowError, ProjectionCursor, RebornCancelRunResponse, + RebornConnectableChannelListResponse, RebornCreateThreadResponse, RebornExtensionActionResponse, RebornExtensionListResponse, RebornExtensionRegistryResponse, RebornListAutomationsResponse, RebornListThreadsResponse, RebornResolveGateResponse, RebornServicesApi, RebornServicesError, RebornServicesErrorCode, RebornServicesErrorKind, @@ -463,10 +463,13 @@ pub async fn install_extension( /// `POST /api/webchat/v2/ironhub/install` pub async fn ironhub_deliver_install( State(state): State, - Extension(_caller): Extension, + Extension(caller): Extension, Json(body): Json, ) -> Result, WebUiV2HttpError> { - let response = state.services().ironhub_deliver_install(body).await?; + let response = state + .services() + .ironhub_deliver_install(caller, body) + .await?; Ok(Json(response)) } diff --git a/crates/ironclaw_webui_v2/src/lib.rs b/crates/ironclaw_webui_v2/src/lib.rs index 5a74da05ef4..7992b2ab124 100644 --- a/crates/ironclaw_webui_v2/src/lib.rs +++ b/crates/ironclaw_webui_v2/src/lib.rs @@ -61,14 +61,14 @@ pub use descriptors::{ WEBUI_V2_ROUTE_DELETE_LLM_PROVIDER, WEBUI_V2_ROUTE_GET_EXTENSION_SETUP, WEBUI_V2_ROUTE_GET_LLM_CONFIG, WEBUI_V2_ROUTE_GET_TIMELINE, WEBUI_V2_ROUTE_INSTALL_EXTENSION, WEBUI_V2_ROUTE_IRONHUB_DELIVER_INSTALL, WEBUI_V2_ROUTE_LIST_AUTOMATIONS, - WEBUI_V2_ROUTE_LIST_CONNECTABLE_CHANNELS, - WEBUI_V2_ROUTE_LIST_EXTENSION_REGISTRY, WEBUI_V2_ROUTE_LIST_EXTENSIONS, - WEBUI_V2_ROUTE_LIST_LLM_MODELS, WEBUI_V2_ROUTE_LIST_THREADS, WEBUI_V2_ROUTE_REMOVE_EXTENSION, - WEBUI_V2_ROUTE_RESOLVE_GATE, WEBUI_V2_ROUTE_SEND_MESSAGE, WEBUI_V2_ROUTE_SET_ACTIVE_LLM, - WEBUI_V2_ROUTE_SETUP_EXTENSION, WEBUI_V2_ROUTE_START_CODEX_LOGIN, - WEBUI_V2_ROUTE_START_NEARAI_LOGIN, WEBUI_V2_ROUTE_STREAM_EVENTS, - WEBUI_V2_ROUTE_STREAM_EVENTS_WS, WEBUI_V2_ROUTE_TEST_LLM_CONNECTION, - WEBUI_V2_ROUTE_UPSERT_LLM_PROVIDER, is_webui_v2_llm_config_route_id, webui_v2_routes, + WEBUI_V2_ROUTE_LIST_CONNECTABLE_CHANNELS, WEBUI_V2_ROUTE_LIST_EXTENSION_REGISTRY, + WEBUI_V2_ROUTE_LIST_EXTENSIONS, WEBUI_V2_ROUTE_LIST_LLM_MODELS, WEBUI_V2_ROUTE_LIST_THREADS, + WEBUI_V2_ROUTE_REMOVE_EXTENSION, WEBUI_V2_ROUTE_RESOLVE_GATE, WEBUI_V2_ROUTE_SEND_MESSAGE, + WEBUI_V2_ROUTE_SET_ACTIVE_LLM, WEBUI_V2_ROUTE_SETUP_EXTENSION, + WEBUI_V2_ROUTE_START_CODEX_LOGIN, WEBUI_V2_ROUTE_START_NEARAI_LOGIN, + WEBUI_V2_ROUTE_STREAM_EVENTS, WEBUI_V2_ROUTE_STREAM_EVENTS_WS, + WEBUI_V2_ROUTE_TEST_LLM_CONNECTION, WEBUI_V2_ROUTE_UPSERT_LLM_PROVIDER, + is_webui_v2_llm_config_route_id, webui_v2_routes, }; #[cfg(feature = "webui-v2-beta")] pub use error::{WebUiV2HttpError, WebUiV2HttpErrorBody}; @@ -76,11 +76,10 @@ pub use error::{WebUiV2HttpError, WebUiV2HttpErrorBody}; pub use handlers::{ activate_extension, cancel_run, complete_nearai_wallet_login, create_thread, delete_llm_provider, get_extension_setup, get_llm_config, get_timeline, install_extension, - ironhub_deliver_install, list_automations, list_connectable_channels, - list_extension_registry, list_extensions, - list_llm_models, list_threads, remove_extension, resolve_gate, send_message, set_active_llm, - setup_extension, start_codex_login, start_nearai_login, stream_events, stream_events_ws, - test_llm_connection, upsert_llm_provider, + ironhub_deliver_install, list_automations, list_connectable_channels, list_extension_registry, + list_extensions, list_llm_models, list_threads, remove_extension, resolve_gate, send_message, + set_active_llm, setup_extension, start_codex_login, start_nearai_login, stream_events, + stream_events_ws, test_llm_connection, upsert_llm_provider, }; #[cfg(feature = "webui-v2-beta")] pub use router::{ diff --git a/crates/ironclaw_webui_v2/src/router.rs b/crates/ironclaw_webui_v2/src/router.rs index 85e1bdf127c..987fa677d55 100644 --- a/crates/ironclaw_webui_v2/src/router.rs +++ b/crates/ironclaw_webui_v2/src/router.rs @@ -18,10 +18,10 @@ use crate::descriptors::{ WEBUI_V2_PATTERN_DELETE_LLM_PROVIDER, WEBUI_V2_PATTERN_GET_LLM_CONFIG, WEBUI_V2_PATTERN_GET_TIMELINE, WEBUI_V2_PATTERN_INSTALL_EXTENSION, WEBUI_V2_PATTERN_IRONHUB_DELIVER_INSTALL, WEBUI_V2_PATTERN_LIST_AUTOMATIONS, - WEBUI_V2_PATTERN_LIST_CONNECTABLE_CHANNELS, - WEBUI_V2_PATTERN_LIST_EXTENSION_REGISTRY, WEBUI_V2_PATTERN_LIST_EXTENSIONS, - WEBUI_V2_PATTERN_LIST_LLM_MODELS, WEBUI_V2_PATTERN_REMOVE_EXTENSION, - WEBUI_V2_PATTERN_RESOLVE_GATE, WEBUI_V2_PATTERN_SEND_MESSAGE, WEBUI_V2_PATTERN_SET_ACTIVE_LLM, + WEBUI_V2_PATTERN_LIST_CONNECTABLE_CHANNELS, WEBUI_V2_PATTERN_LIST_EXTENSION_REGISTRY, + WEBUI_V2_PATTERN_LIST_EXTENSIONS, WEBUI_V2_PATTERN_LIST_LLM_MODELS, + WEBUI_V2_PATTERN_REMOVE_EXTENSION, WEBUI_V2_PATTERN_RESOLVE_GATE, + WEBUI_V2_PATTERN_SEND_MESSAGE, WEBUI_V2_PATTERN_SET_ACTIVE_LLM, WEBUI_V2_PATTERN_SETUP_EXTENSION, WEBUI_V2_PATTERN_START_CODEX_LOGIN, WEBUI_V2_PATTERN_START_NEARAI_LOGIN, WEBUI_V2_PATTERN_STREAM_EVENTS, WEBUI_V2_PATTERN_STREAM_EVENTS_WS, WEBUI_V2_PATTERN_TEST_LLM_CONNECTION, diff --git a/crates/ironclaw_webui_v2/tests/webui_v2_descriptors_contract.rs b/crates/ironclaw_webui_v2/tests/webui_v2_descriptors_contract.rs index 3010b5fe64d..ecb452e06b6 100644 --- a/crates/ironclaw_webui_v2/tests/webui_v2_descriptors_contract.rs +++ b/crates/ironclaw_webui_v2/tests/webui_v2_descriptors_contract.rs @@ -23,14 +23,13 @@ use ironclaw_webui_v2::{ WEBUI_V2_ROUTE_DELETE_LLM_PROVIDER, WEBUI_V2_ROUTE_GET_EXTENSION_SETUP, WEBUI_V2_ROUTE_GET_LLM_CONFIG, WEBUI_V2_ROUTE_GET_TIMELINE, WEBUI_V2_ROUTE_INSTALL_EXTENSION, WEBUI_V2_ROUTE_IRONHUB_DELIVER_INSTALL, WEBUI_V2_ROUTE_LIST_AUTOMATIONS, - WEBUI_V2_ROUTE_LIST_CONNECTABLE_CHANNELS, - WEBUI_V2_ROUTE_LIST_EXTENSION_REGISTRY, WEBUI_V2_ROUTE_LIST_EXTENSIONS, - WEBUI_V2_ROUTE_LIST_LLM_MODELS, WEBUI_V2_ROUTE_LIST_THREADS, WEBUI_V2_ROUTE_REMOVE_EXTENSION, - WEBUI_V2_ROUTE_RESOLVE_GATE, WEBUI_V2_ROUTE_SEND_MESSAGE, WEBUI_V2_ROUTE_SET_ACTIVE_LLM, - WEBUI_V2_ROUTE_SETUP_EXTENSION, WEBUI_V2_ROUTE_START_CODEX_LOGIN, - WEBUI_V2_ROUTE_START_NEARAI_LOGIN, WEBUI_V2_ROUTE_STREAM_EVENTS, - WEBUI_V2_ROUTE_STREAM_EVENTS_WS, WEBUI_V2_ROUTE_TEST_LLM_CONNECTION, - WEBUI_V2_ROUTE_UPSERT_LLM_PROVIDER, webui_v2_routes, + WEBUI_V2_ROUTE_LIST_CONNECTABLE_CHANNELS, WEBUI_V2_ROUTE_LIST_EXTENSION_REGISTRY, + WEBUI_V2_ROUTE_LIST_EXTENSIONS, WEBUI_V2_ROUTE_LIST_LLM_MODELS, WEBUI_V2_ROUTE_LIST_THREADS, + WEBUI_V2_ROUTE_REMOVE_EXTENSION, WEBUI_V2_ROUTE_RESOLVE_GATE, WEBUI_V2_ROUTE_SEND_MESSAGE, + WEBUI_V2_ROUTE_SET_ACTIVE_LLM, WEBUI_V2_ROUTE_SETUP_EXTENSION, + WEBUI_V2_ROUTE_START_CODEX_LOGIN, WEBUI_V2_ROUTE_START_NEARAI_LOGIN, + WEBUI_V2_ROUTE_STREAM_EVENTS, WEBUI_V2_ROUTE_STREAM_EVENTS_WS, + WEBUI_V2_ROUTE_TEST_LLM_CONNECTION, WEBUI_V2_ROUTE_UPSERT_LLM_PROVIDER, webui_v2_routes, }; /// Expected policy surface for one route. Everything host composition diff --git a/crates/ironclaw_webui_v2/tests/webui_v2_handlers_contract.rs b/crates/ironclaw_webui_v2/tests/webui_v2_handlers_contract.rs index 5322504ef98..7c550b1060b 100644 --- a/crates/ironclaw_webui_v2/tests/webui_v2_handlers_contract.rs +++ b/crates/ironclaw_webui_v2/tests/webui_v2_handlers_contract.rs @@ -28,8 +28,8 @@ use ironclaw_product_adapters::{ }; use ironclaw_product_workflow::{ IronhubInstallDeliveryRequest, IronhubInstallDeliveryResult, LifecyclePackageRef, - LifecyclePhase, LlmActiveSelection, LlmConfigSnapshot, LlmModelsResult, - LlmProbeRequest, LlmProbeResult, LlmProviderView, RebornAutomationInfo, RebornAutomationSource, + LifecyclePhase, LlmActiveSelection, LlmConfigSnapshot, LlmModelsResult, LlmProbeRequest, + LlmProbeResult, LlmProviderView, RebornAutomationInfo, RebornAutomationSource, RebornAutomationState, RebornCancelRunResponse, RebornChannelConnectAction, RebornChannelConnectStrategy, RebornConnectableChannelInfo, RebornConnectableChannelListResponse, RebornCreateThreadResponse, @@ -85,6 +85,7 @@ struct StubServices { list_extension_registry_calls: Mutex, install_extension_calls: Mutex>, ironhub_deliver_install_calls: Mutex>, + next_ironhub_deliver_install_error: Mutex>, activate_extension_calls: Mutex>, remove_extension_calls: Mutex>, get_llm_config_calls: Mutex, @@ -118,6 +119,13 @@ impl StubServices { .expect("lock") = Some(error); } + fn fail_ironhub_deliver_install(&self, error: RebornServicesError) { + *self + .next_ironhub_deliver_install_error + .lock() + .expect("lock") = Some(error); + } + /// Queue one response for the next `stream_events` call. Tests use this /// to drive the SSE handler through programmable projection envelopes /// or error branches. Falls back to an empty `Ok` drain when the queue @@ -404,8 +412,17 @@ impl RebornServicesApi for StubServices { async fn ironhub_deliver_install( &self, + _caller: WebUiAuthenticatedCaller, request: IronhubInstallDeliveryRequest, ) -> Result { + if let Some(error) = self + .next_ironhub_deliver_install_error + .lock() + .expect("lock") + .take() + { + return Err(error); + } let slug = request.slug.clone(); self.ironhub_deliver_install_calls .lock() @@ -650,6 +667,65 @@ async fn ironhub_deliver_install_dispatches_through_facade() { assert_eq!(calls[0].artifact_digest, "sha256:deadbeef"); } +#[tokio::test] +async fn ironhub_deliver_install_rejects_malformed_body_without_dispatch() { + let services = Arc::new(StubServices::default()); + let router = router_with(services.clone()); + + let response = router + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/api/webchat/v2/ironhub/install") + .header("content-type", "application/json") + .body(Body::from("{not json")) + .expect("request"), + ) + .await + .expect("oneshot"); + + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert_eq!( + services + .ironhub_deliver_install_calls + .lock() + .expect("lock") + .len(), + 0, + "facade must not be called for a malformed body" + ); +} + +#[tokio::test] +async fn ironhub_deliver_install_error_maps_to_http_status() { + let services = Arc::new(StubServices::default()); + services.fail_ironhub_deliver_install(RebornServicesError { + code: RebornServicesErrorCode::Forbidden, + kind: RebornServicesErrorKind::ParticipantDenied, + status_code: 403, + retryable: false, + field: None, + validation_code: None, + }); + let router = router_with(services); + + let response = router + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/api/webchat/v2/ironhub/install") + .header("content-type", "application/json") + .body(Body::from( + r#"{"slug":"my-skill","version":"1.0.0","uid":"u","aid":"a","ts":1700000000,"nonce":"n","artifact_digest":"sha256:deadbeef","sig":"sig-1"}"#, + )) + .expect("request"), + ) + .await + .expect("oneshot"); + + assert_eq!(response.status(), StatusCode::FORBIDDEN); +} + #[tokio::test] async fn send_message_path_overrides_body_thread_id() { let services = Arc::new(StubServices::default()); From 26d603ab9ae0f1a2c1e939f6a6d0f8ab4c925dc8 Mon Sep 17 00:00:00 2001 From: neo-sky Date: Fri, 3 Jul 2026 02:02:06 -0400 Subject: [PATCH 8/8] Harden the ironhub deep-link install signature and trust boundary Cover the private manifest URL in the install HMAC and length-prefix every signed field so the payload stays injective. Keep that URL off the model-callable install path and the CLI argv, drop the unsigned kind field, and reject out-of-range timestamps without overflow. Widen the nonce window past the freshness check and tighten the register and install tests. --- .../src/first_party_tools/schemas.rs | 5 ++ crates/ironclaw_product_workflow/src/lib.rs | 8 +- .../src/reborn_services.rs | 4 +- .../src/reborn_services/ironhub_link.rs | 9 -- .../src/commands/ironhub.rs | 25 +++++- .../src/ironhub/agent_link.rs | 85 +++++++++++++------ .../src/ironhub/capabilities.rs | 6 +- .../src/ironhub/link_service.rs | 55 +++++------- .../src/ironhub/service.rs | 4 +- .../src/runtime_input.rs | 12 +-- .../tests/webui_v2_serve.rs | 3 + .../tests/webui_v2_handlers_contract.rs | 33 +++++-- 12 files changed, 154 insertions(+), 95 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 375198d45eb..eac515fdbb9 100644 --- a/crates/ironclaw_host_runtime/src/first_party_tools/schemas.rs +++ b/crates/ironclaw_host_runtime/src/first_party_tools/schemas.rs @@ -464,5 +464,10 @@ mod tests { None, "model-visible IronHub install must not self-acknowledge unverified community content" ); + assert_eq!( + install["properties"].get("private_manifest_url"), + None, + "model-visible IronHub install must not choose a private manifest source" + ); } } diff --git a/crates/ironclaw_product_workflow/src/lib.rs b/crates/ironclaw_product_workflow/src/lib.rs index 1ab306f54f6..97efa5563bf 100644 --- a/crates/ironclaw_product_workflow/src/lib.rs +++ b/crates/ironclaw_product_workflow/src/lib.rs @@ -136,10 +136,10 @@ pub use reborn_services::{ AUTOMATION_LIST_DEFAULT_PAGE_SIZE, AUTOMATION_LIST_MAX_PAGE_SIZE, AutomationProductFacade, CodexLoginStart, ConnectableChannelsProductFacade, ExtensionCredentialSetupService, ExtensionCredentialStatusRequest, ExtensionCredentialSubmitRequest, - IronhubInstallDeliveryRequest, IronhubInstallDeliveryResult, IronhubInstallKind, - IronhubLinkError, IronhubLinkService, IronhubRegisterRequest, LlmActiveSelection, - LlmConfigService, LlmConfigServiceError, LlmConfigSnapshot, LlmModelsResult, LlmProbeRequest, - LlmProbeResult, LlmProviderView, NearAiAuthProvider, NearAiLoginRequest, NearAiLoginStart, + IronhubInstallDeliveryRequest, IronhubInstallDeliveryResult, IronhubLinkError, + IronhubLinkService, IronhubRegisterRequest, LlmActiveSelection, LlmConfigService, + LlmConfigServiceError, LlmConfigSnapshot, LlmModelsResult, LlmProbeRequest, LlmProbeResult, + LlmProviderView, NearAiAuthProvider, NearAiLoginRequest, NearAiLoginStart, NearAiWalletLoginRequest, NearAiWalletLoginResult, ProductAgentBoundCaller, RebornAutomationInfo, RebornAutomationRunStatus, RebornAutomationSource, RebornAutomationState, RebornCancelRunResponse, RebornChannelConnectAction, RebornChannelConnectStrategy, diff --git a/crates/ironclaw_product_workflow/src/reborn_services.rs b/crates/ironclaw_product_workflow/src/reborn_services.rs index 075711ff529..17ddfcf342d 100644 --- a/crates/ironclaw_product_workflow/src/reborn_services.rs +++ b/crates/ironclaw_product_workflow/src/reborn_services.rs @@ -60,8 +60,8 @@ mod types; pub use error::{RebornServicesError, RebornServicesErrorCode, RebornServicesErrorKind}; pub use ironhub_link::{ - IronhubInstallDeliveryRequest, IronhubInstallDeliveryResult, IronhubInstallKind, - IronhubLinkError, IronhubLinkService, IronhubRegisterRequest, + IronhubInstallDeliveryRequest, IronhubInstallDeliveryResult, IronhubLinkError, + IronhubLinkService, IronhubRegisterRequest, }; pub use llm_config::{ CodexLoginStart, LlmActiveSelection, LlmConfigService, LlmConfigServiceError, diff --git a/crates/ironclaw_product_workflow/src/reborn_services/ironhub_link.rs b/crates/ironclaw_product_workflow/src/reborn_services/ironhub_link.rs index 7f8cfc71c05..bf24584a91e 100644 --- a/crates/ironclaw_product_workflow/src/reborn_services/ironhub_link.rs +++ b/crates/ironclaw_product_workflow/src/reborn_services/ironhub_link.rs @@ -4,13 +4,6 @@ use serde::{Deserialize, Serialize}; use super::error::{RebornServicesError, RebornServicesErrorCode}; -#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum IronhubInstallKind { - Tool, - Skill, -} - #[derive(Debug, Clone, PartialEq, Eq, Deserialize)] pub struct IronhubRegisterRequest { pub uid: String, @@ -31,8 +24,6 @@ pub struct IronhubInstallDeliveryRequest { pub artifact_digest: String, pub sig: String, #[serde(default)] - pub kind: Option, - #[serde(default)] pub private_manifest_url: Option, } diff --git a/crates/ironclaw_reborn_cli/src/commands/ironhub.rs b/crates/ironclaw_reborn_cli/src/commands/ironhub.rs index f784b113503..b6259ac603e 100644 --- a/crates/ironclaw_reborn_cli/src/commands/ironhub.rs +++ b/crates/ironclaw_reborn_cli/src/commands/ironhub.rs @@ -90,9 +90,10 @@ struct IronHubInstallCommand { #[arg(long)] expected_artifact_digest: Option, - /// Install from a private org-scoped signed manifest URL instead of the public catalog. - #[arg(long)] - private_manifest_url: Option, + /// Install from a private org-scoped signed manifest URL read from this + /// file (keeps the tokenized URL off argv and out of shell history). + #[arg(long, value_name = "PATH")] + private_manifest_url_file: Option, /// Output the lifecycle response as JSON. #[arg(long)] @@ -140,7 +141,9 @@ impl IronHubCommand { acknowledge_unverified: command.acknowledge_unverified, expected_version: command.expected_version, expected_artifact_digest: command.expected_artifact_digest, - private_manifest_url: command.private_manifest_url, + private_manifest_url: read_private_manifest_url( + command.private_manifest_url_file.as_deref(), + )?, }, }, command.json, @@ -166,6 +169,20 @@ impl From for IronHubEntryKind { } } +fn read_private_manifest_url(path: Option<&std::path::Path>) -> anyhow::Result> { + let Some(path) = path else { + return Ok(None); + }; + let url = std::fs::read_to_string(path) + .with_context(|| format!("reading private manifest URL file {}", path.display()))? + .trim() + .to_string(); + if url.is_empty() { + anyhow::bail!("private manifest URL file {} is empty", path.display()); + } + Ok(Some(url)) +} + fn execute_ironhub_command( context: RebornCliContext, command: RebornIronHubCommand, diff --git a/crates/ironclaw_reborn_composition/src/ironhub/agent_link.rs b/crates/ironclaw_reborn_composition/src/ironhub/agent_link.rs index 76e84b407b4..7676e20ff88 100644 --- a/crates/ironclaw_reborn_composition/src/ironhub/agent_link.rs +++ b/crates/ironclaw_reborn_composition/src/ironhub/agent_link.rs @@ -25,10 +25,6 @@ impl IronhubSharedKey { } Ok(Self(value)) } - - pub(super) fn as_str(&self) -> &str { - &self.0 - } } impl std::fmt::Debug for IronhubSharedKey { @@ -44,25 +40,40 @@ pub(super) fn register_payload(request: &IronhubRegisterRequest) -> String { ) } +// Length-prefix every field so the encoding stays injective regardless of +// field content; the artifact digest and manifest URL both contain colons. pub(super) fn install_payload(request: &IronhubInstallDeliveryRequest) -> String { - format!( - "install:{}:{}:{}:{}:{}:{}:{}", - request.slug, - request.version, - request.uid, - request.aid, - request.ts, - request.nonce, - request.artifact_digest - ) + let ts = request.ts.to_string(); + let fields = [ + request.slug.as_str(), + request.version.as_str(), + request.uid.as_str(), + request.aid.as_str(), + ts.as_str(), + request.nonce.as_str(), + request.artifact_digest.as_str(), + request.private_manifest_url.as_deref().unwrap_or(""), + ]; + let mut payload = String::from("install"); + for field in fields { + payload.push(':'); + payload.push_str(&field.len().to_string()); + payload.push(':'); + payload.push_str(field); + } + payload } -pub(super) fn verify_signature(shared_key: &str, payload: &str, sig_hex: &str) -> bool { +pub(super) fn verify_signature( + shared_key: &IronhubSharedKey, + payload: &str, + sig_hex: &str, +) -> bool { let Ok(expected) = hex::decode(sig_hex) else { // silent-ok: a non-hex signature is an invalid signature; reject it. return false; }; - let Ok(mut mac) = HmacSha256::new_from_slice(shared_key.as_bytes()) else { + let Ok(mut mac) = HmacSha256::new_from_slice(shared_key.0.as_bytes()) else { // silent-ok: HMAC-SHA256 accepts any key length, so this arm never fires. return false; }; @@ -76,7 +87,11 @@ mod tests { const SHARED_KEY: &str = "ihub_sk_E2ETestSharedKey0000000000000000000000000"; const REGISTER_SIG: &str = "7e69b8cd66138589a2ae1320d6ba894870462efeb295acf80336ddd00e0953b5"; - const INSTALL_SIG: &str = "f00a213648f926e263d25b02b5fcd2731d371355a5728c3c07a3c7eb817be2ea"; + const INSTALL_SIG: &str = "d1b7519d96c098b84554ac8c5be9838ccd979249ea892378105ab0febe9b0472"; + + fn shared_key() -> IronhubSharedKey { + IronhubSharedKey::new(SHARED_KEY).expect("shared key") + } fn register_request() -> IronhubRegisterRequest { IronhubRegisterRequest { @@ -98,7 +113,6 @@ mod tests { nonce: "nonce-abc".to_string(), artifact_digest: "sha256:deadbeef".to_string(), sig: String::new(), - kind: None, private_manifest_url: None, } } @@ -115,14 +129,25 @@ mod tests { fn install_payload_matches_hub_format() { assert_eq!( install_payload(&install_request()), - "install:my-skill:1.0.0:user-1:aid-1:1700000000:nonce-abc:sha256:deadbeef" + "install:8:my-skill:5:1.0.0:6:user-1:5:aid-1:10:1700000000:9:nonce-abc:15:sha256:deadbeef:0:" + ); + } + + #[test] + fn install_payload_covers_private_manifest_url() { + let mut request = install_request(); + request.private_manifest_url = + Some("https://hub.example/api/private-artifacts/manifest/tok".to_string()); + assert_eq!( + install_payload(&request), + "install:8:my-skill:5:1.0.0:6:user-1:5:aid-1:10:1700000000:9:nonce-abc:15:sha256:deadbeef:54:https://hub.example/api/private-artifacts/manifest/tok" ); } #[test] fn verifies_hub_register_signature() { assert!(verify_signature( - SHARED_KEY, + &shared_key(), ®ister_payload(®ister_request()), REGISTER_SIG )); @@ -131,17 +156,28 @@ mod tests { #[test] fn verifies_hub_install_signature() { assert!(verify_signature( - SHARED_KEY, + &shared_key(), &install_payload(&install_request()), INSTALL_SIG )); } + #[test] + fn install_signature_breaks_when_private_manifest_url_is_tampered() { + let mut request = install_request(); + request.private_manifest_url = Some("https://evil.example/manifest".to_string()); + assert!(!verify_signature( + &shared_key(), + &install_payload(&request), + INSTALL_SIG + )); + } + #[test] fn rejects_tampered_signature() { let tampered = format!("00{}", ®ISTER_SIG[2..]); assert!(!verify_signature( - SHARED_KEY, + &shared_key(), ®ister_payload(®ister_request()), &tampered )); @@ -149,8 +185,9 @@ mod tests { #[test] fn rejects_wrong_shared_key() { + let wrong = IronhubSharedKey::new("ihub_sk_wrong0000000000000000000").expect("shared key"); assert!(!verify_signature( - "ihub_sk_wrong", + &wrong, ®ister_payload(®ister_request()), REGISTER_SIG )); @@ -159,7 +196,7 @@ mod tests { #[test] fn rejects_non_hex_signature() { assert!(!verify_signature( - SHARED_KEY, + &shared_key(), ®ister_payload(®ister_request()), "zzzz" )); diff --git a/crates/ironclaw_reborn_composition/src/ironhub/capabilities.rs b/crates/ironclaw_reborn_composition/src/ironhub/capabilities.rs index e03578f8b43..1a3303811b5 100644 --- a/crates/ironclaw_reborn_composition/src/ironhub/capabilities.rs +++ b/crates/ironclaw_reborn_composition/src/ironhub/capabilities.rs @@ -136,8 +136,6 @@ struct InstallInput { expected_version: Option, #[serde(default)] expected_artifact_digest: Option, - #[serde(default)] - private_manifest_url: Option, } #[async_trait] @@ -182,7 +180,9 @@ impl FirstPartyCapabilityHandler for IronHubCapabilityHandler { acknowledge_unverified: false, expected_version: input.expected_version, expected_artifact_digest: input.expected_artifact_digest, - private_manifest_url: input.private_manifest_url, + // Private-manifest installs stay on the signed deep-link and + // CLI paths; model-invoked installs use the public catalog. + private_manifest_url: None, }, } } diff --git a/crates/ironclaw_reborn_composition/src/ironhub/link_service.rs b/crates/ironclaw_reborn_composition/src/ironhub/link_service.rs index 9d4953368cd..3863bab1890 100644 --- a/crates/ironclaw_reborn_composition/src/ironhub/link_service.rs +++ b/crates/ironclaw_reborn_composition/src/ironhub/link_service.rs @@ -6,8 +6,8 @@ use async_trait::async_trait; use ironclaw_host_api::{CapabilityId, InvocationId, ResourceScope, UserId}; use ironclaw_host_runtime::HostRuntimeHttpEgressPort; use ironclaw_product_workflow::{ - IronhubInstallDeliveryRequest, IronhubInstallDeliveryResult, IronhubInstallKind, - IronhubLinkError, IronhubLinkService, IronhubRegisterRequest, LifecyclePhase, + IronhubInstallDeliveryRequest, IronhubInstallDeliveryResult, IronhubLinkError, + IronhubLinkService, IronhubRegisterRequest, LifecyclePhase, }; use crate::RebornBuildError; @@ -15,10 +15,10 @@ use crate::extension_lifecycle::RebornLocalExtensionManagementPort; use crate::lifecycle::RebornLocalSkillManagementPort; use super::agent_link::{IronhubSharedKey, install_payload, register_payload, verify_signature}; -use super::model::{IronHubCommand, IronHubCommandError, IronHubEntryKind, IronHubInstallOptions}; +use super::model::{IronHubCommand, IronHubCommandError, IronHubInstallOptions}; use super::service::IronHubService; -const MAX_TIMESTAMP_DRIFT_SECS: i64 = 300; +const MAX_TIMESTAMP_DRIFT_SECS: u64 = 300; const INSTALL_CAPABILITY_ID: &str = "builtin.ironhub_install"; static SEEN_INSTALL_NONCES: LazyLock>> = @@ -84,19 +84,17 @@ fn map_install_error(error: IronHubCommandError) -> IronhubLinkError { } fn timestamp_fresh(ts: u64) -> bool { - let drift = chrono::Utc::now().timestamp() - ts as i64; - drift.abs() <= MAX_TIMESTAMP_DRIFT_SECS -} - -fn map_kind(kind: Option) -> Option { - kind.map(|kind| match kind { - IronhubInstallKind::Tool => IronHubEntryKind::Tool, - IronhubInstallKind::Skill => IronHubEntryKind::Skill, - }) + let Ok(ts) = i64::try_from(ts) else { + // silent-ok: a timestamp beyond i64::MAX is never a live unix time; reject it. + return false; + }; + chrono::Utc::now().timestamp().abs_diff(ts) <= MAX_TIMESTAMP_DRIFT_SECS } fn reject_replayed_nonce(nonce: &str) -> Result<(), IronhubLinkError> { - let ttl = Duration::from_secs(MAX_TIMESTAMP_DRIFT_SECS as u64); + // Keep nonces for twice the drift window so a hub clock ahead of ours + // cannot leave a request fresh after its nonce is evicted. + let ttl = Duration::from_secs(MAX_TIMESTAMP_DRIFT_SECS * 2); let now = Instant::now(); let mut seen = SEEN_INSTALL_NONCES .lock() @@ -117,11 +115,7 @@ impl IronhubLinkService for RebornIronhubLinkService { } // replay-ok: register has no local side effect, so an idempotent retry is // harmless; single-use is enforced on deliver_install only. - if verify_signature( - self.shared_key.as_str(), - ®ister_payload(&request), - &request.sig, - ) { + if verify_signature(&self.shared_key, ®ister_payload(&request), &request.sig) { Ok(()) } else { Err(IronhubLinkError::InvalidSignature) @@ -136,17 +130,13 @@ impl IronhubLinkService for RebornIronhubLinkService { if !timestamp_fresh(request.ts) { return Err(IronhubLinkError::StaleTimestamp); } - if !verify_signature( - self.shared_key.as_str(), - &install_payload(&request), - &request.sig, - ) { + if !verify_signature(&self.shared_key, &install_payload(&request), &request.sig) { return Err(IronhubLinkError::InvalidSignature); } reject_replayed_nonce(&request.nonce)?; let options = IronHubInstallOptions { - kind: map_kind(request.kind), + kind: None, force: false, acknowledge_unverified: false, expected_version: Some(request.version), @@ -212,7 +202,6 @@ mod tests { nonce: nonce.to_string(), artifact_digest: "sha256:deadbeef".to_string(), sig, - kind: Some(IronhubInstallKind::Skill), private_manifest_url: None, } } @@ -249,16 +238,10 @@ mod tests { } #[test] - fn map_kind_maps_each_variant() { - assert!(matches!( - map_kind(Some(IronhubInstallKind::Tool)), - Some(IronHubEntryKind::Tool) - )); - assert!(matches!( - map_kind(Some(IronhubInstallKind::Skill)), - Some(IronHubEntryKind::Skill) - )); - assert!(map_kind(None).is_none()); + fn timestamp_fresh_rejects_out_of_range_timestamps_without_panicking() { + assert!(!timestamp_fresh(u64::MAX)); + assert!(!timestamp_fresh(i64::MAX as u64 + 1)); + assert!(!timestamp_fresh(i64::MAX as u64)); } #[test] diff --git a/crates/ironclaw_reborn_composition/src/ironhub/service.rs b/crates/ironclaw_reborn_composition/src/ironhub/service.rs index 044222ed22a..330af84e740 100644 --- a/crates/ironclaw_reborn_composition/src/ironhub/service.rs +++ b/crates/ironclaw_reborn_composition/src/ironhub/service.rs @@ -653,7 +653,9 @@ fn manifest_replay_key(url: &str, manifest: &IronHubManifest) -> String { .ok() .and_then(|parsed| parsed.host_str().map(str::to_string)) .unwrap_or_default(); - format!("{host}|{}", manifest.repo) + // Length-prefix the host so a manifest-supplied repo containing the + // separator cannot collide with another (host, repo) pair. + format!("{}|{host}|{}", host.len(), manifest.repo) } fn install_lock(key: &str) -> Arc> { diff --git a/crates/ironclaw_reborn_composition/src/runtime_input.rs b/crates/ironclaw_reborn_composition/src/runtime_input.rs index a111e9d4a7a..cf3168ad173 100644 --- a/crates/ironclaw_reborn_composition/src/runtime_input.rs +++ b/crates/ironclaw_reborn_composition/src/runtime_input.rs @@ -253,9 +253,10 @@ pub struct RebornRuntimeInput { #[cfg(feature = "root-llm-provider")] pub boot: Option, /// Shared HMAC key the IronHub deep-link register/install webhooks verify - /// inbound hub signatures against. When present (and `webui-v2-beta` is on), - /// the WebUI facade composes the IronHub agent-link service and the serve - /// path mounts the public `/api/ironhub/register` webhook. + /// inbound hub signatures against. The key is one of the link-service + /// inputs: the webhooks mount only when the local runtime lifecycle ports + /// and host HTTP egress are also composed (see + /// `RebornRuntime::ironhub_register_enabled`). #[cfg(feature = "webui-v2-beta")] pub ironhub_agent_shared_key: Option, pub runner: TurnRunnerSettings, @@ -369,8 +370,9 @@ impl RebornRuntimeInput { self } - /// Supply the IronHub agent-link shared HMAC key. Enables the deep-link - /// register/install webhooks for this runtime. + /// Supply the IronHub agent-link shared HMAC key. The deep-link + /// register/install webhooks mount once the runtime also composes the + /// local lifecycle ports and host HTTP egress. #[cfg(feature = "webui-v2-beta")] pub fn with_ironhub_agent_shared_key( mut self, diff --git a/crates/ironclaw_reborn_composition/tests/webui_v2_serve.rs b/crates/ironclaw_reborn_composition/tests/webui_v2_serve.rs index 5474f8bb0ee..35c7e6b1f47 100644 --- a/crates/ironclaw_reborn_composition/tests/webui_v2_serve.rs +++ b/crates/ironclaw_reborn_composition/tests/webui_v2_serve.rs @@ -2039,5 +2039,8 @@ async fn ironhub_register_route_dispatches_valid_body_to_facade() { let calls = services.ironhub_register_calls.lock().expect("lock"); assert_eq!(calls.len(), 1, "facade called exactly once"); assert_eq!(calls[0].uid, "u"); + assert_eq!(calls[0].aid, "a"); + assert_eq!(calls[0].ts, 1_700_000_000); assert_eq!(calls[0].nonce, "n"); + assert_eq!(calls[0].sig, "sig-1"); } diff --git a/crates/ironclaw_webui_v2/tests/webui_v2_handlers_contract.rs b/crates/ironclaw_webui_v2/tests/webui_v2_handlers_contract.rs index 7c550b1060b..b5d82f820b0 100644 --- a/crates/ironclaw_webui_v2/tests/webui_v2_handlers_contract.rs +++ b/crates/ironclaw_webui_v2/tests/webui_v2_handlers_contract.rs @@ -415,6 +415,11 @@ impl RebornServicesApi for StubServices { _caller: WebUiAuthenticatedCaller, request: IronhubInstallDeliveryRequest, ) -> Result { + let slug = request.slug.clone(); + self.ironhub_deliver_install_calls + .lock() + .expect("lock") + .push(request); if let Some(error) = self .next_ironhub_deliver_install_error .lock() @@ -423,11 +428,6 @@ impl RebornServicesApi for StubServices { { return Err(error); } - let slug = request.slug.clone(); - self.ironhub_deliver_install_calls - .lock() - .expect("lock") - .push(request); Ok(IronhubInstallDeliveryResult { installed: true, slug, @@ -651,7 +651,7 @@ async fn ironhub_deliver_install_dispatches_through_facade() { .uri("/api/webchat/v2/ironhub/install") .header("content-type", "application/json") .body(Body::from( - r#"{"slug":"my-skill","version":"1.0.0","uid":"u","aid":"a","ts":1700000000,"nonce":"n","artifact_digest":"sha256:deadbeef","sig":"sig-1"}"#, + r#"{"slug":"my-skill","version":"1.0.0","uid":"u","aid":"a","ts":1700000000,"nonce":"n","artifact_digest":"sha256:deadbeef","sig":"sig-1","private_manifest_url":"https://hub.example/manifest/tok"}"#, )) .expect("request"), ) @@ -664,7 +664,17 @@ async fn ironhub_deliver_install_dispatches_through_facade() { let calls = services.ironhub_deliver_install_calls.lock().expect("lock"); assert_eq!(calls.len(), 1, "facade called exactly once"); assert_eq!(calls[0].slug, "my-skill"); + assert_eq!(calls[0].version, "1.0.0"); + assert_eq!(calls[0].uid, "u"); + assert_eq!(calls[0].aid, "a"); + assert_eq!(calls[0].ts, 1_700_000_000); + assert_eq!(calls[0].nonce, "n"); assert_eq!(calls[0].artifact_digest, "sha256:deadbeef"); + assert_eq!(calls[0].sig, "sig-1"); + assert_eq!( + calls[0].private_manifest_url.as_deref(), + Some("https://hub.example/manifest/tok") + ); } #[tokio::test] @@ -707,7 +717,7 @@ async fn ironhub_deliver_install_error_maps_to_http_status() { field: None, validation_code: None, }); - let router = router_with(services); + let router = router_with(services.clone()); let response = router .oneshot( @@ -724,6 +734,15 @@ async fn ironhub_deliver_install_error_maps_to_http_status() { .expect("oneshot"); assert_eq!(response.status(), StatusCode::FORBIDDEN); + assert_eq!( + services + .ironhub_deliver_install_calls + .lock() + .expect("lock") + .len(), + 1, + "handler dispatched to the facade before the error mapped" + ); } #[tokio::test]