From 3e552e0e8eb18fbd27d184c7122698536c052e99 Mon Sep 17 00:00:00 2001 From: "firat.sertgoz" Date: Mon, 23 Feb 2026 22:49:18 +0400 Subject: [PATCH] fix: make onboarding installs prefer release artifacts with source fallback (#323) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: make onboarding installs prefer release artifacts with source fallback * fix: harden extension fallback errors and surface setup warnings * fix: validate registry artifacts and harden fallback errors * fix: address review feedback on installer fallback - Add upfront validate_manifest_install_inputs() in install_with_source_fallback so bad manifests fail fast without relying on inner methods to catch them - Document ALLOWED_ARTIFACT_HOSTS as GitHub-only by design - Document intentional url omission from DownloadFailed Display - Add channel manifest validation tests (wrong prefix rejected, correct prefix accepted) Co-Authored-By: Claude Opus 4.6 * fix: require SHA256 checksum for artifact downloads Reject artifact installs when the manifest has sha256: null instead of warning and proceeding. This prevents installing unverified pre-built binaries during onboarding. The check runs before downloading to avoid wasting bandwidth. Since InvalidManifest blocks source fallback, manifests with URLs but no checksums will hard-fail rather than silently falling back to source build — forcing the manifest to be fixed. The release CI already computes SHA256 for each bundle; the manifests just need to be populated with the actual values. Co-Authored-By: Claude Opus 4.6 * fix: enforce SHA256 checksums and auto-patch manifests in CI - Fix cargo fmt on SHA256 check code - Reorder release CI: build WASM extensions before binary so manifests can be patched with computed SHA256 before build.rs embeds them - Add "Patch manifests with WASM checksums" step in build-local-artifacts that reads checksums.txt and updates registry JSON files before building - Add update-registry-checksums job that commits patched manifests back to main after release, keeping the repo in sync with released artifacts This closes the integrity gap where all manifests had sha256: null and artifact downloads were unverified. The binary now embeds correct SHA256 values and the installer hard-rejects null checksums. Co-Authored-By: Claude Opus 4.6 --------- Co-authored-by: Claude Opus 4.6 Co-authored-by: Bowen Wang --- .github/workflows/release.yml | 81 +++++- src/registry/catalog.rs | 35 ++- src/registry/installer.rs | 493 ++++++++++++++++++++++++++++++++-- src/setup/README.md | 9 +- src/setup/wizard.rs | 18 +- 5 files changed, 606 insertions(+), 30 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 0a414969..6b81154f 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -89,10 +89,12 @@ jobs: # Build and packages all the platform-specific things build-local-artifacts: name: build-local-artifacts (${{ join(matrix.targets, ', ') }}) - # Let the initial task tell us to not run (currently very blunt) + # Wait for WASM extensions so we can patch manifests with SHA256 checksums + # before build.rs bakes them into the embedded catalog. needs: - plan - if: ${{ fromJson(needs.plan.outputs.val).ci.github.artifacts_matrix.include != null && (needs.plan.outputs.publishing == 'true' || fromJson(needs.plan.outputs.val).ci.github.pr_run_mode == 'upload') }} + - build-wasm-extensions + if: ${{ fromJson(needs.plan.outputs.val).ci.github.artifacts_matrix.include != null && (needs.plan.outputs.publishing == 'true' || fromJson(needs.plan.outputs.val).ci.github.pr_run_mode == 'upload') && (needs.build-wasm-extensions.result == 'skipped' || needs.build-wasm-extensions.result == 'success') }} strategy: fail-fast: false # Target platforms/runners are computed by dist in create-release. @@ -139,6 +141,28 @@ jobs: pattern: artifacts-* path: target/distrib/ merge-multiple: true + - name: Patch manifests with WASM checksums + if: ${{ needs.plan.outputs.publishing == 'true' }} + shell: bash + run: | + CHECKSUMS="target/distrib/checksums.txt" + if [ ! -f "$CHECKSUMS" ]; then + echo "No checksums.txt found, skipping manifest patching" + exit 0 + fi + + while IFS= read -r line; do + sha256=$(echo "$line" | awk '{print $1}') + filename=$(echo "$line" | awk '{print $2}') + name=$(echo "$filename" | sed 's/-wasm32-wasip2\.tar\.gz$//') + + for manifest in registry/tools/${name}.json registry/channels/${name}.json; do + if [ -f "$manifest" ]; then + jq --arg sha "$sha256" '.artifacts["wasm32-wasip2"].sha256 = $sha' "$manifest" > "${manifest}.tmp" && mv "${manifest}.tmp" "$manifest" + echo "Patched $manifest with sha256=$sha256" + fi + done + done < "$CHECKSUMS" - name: Install dependencies run: | ${{ matrix.packages_install }} @@ -380,6 +404,59 @@ jobs: gh release create "${{ needs.plan.outputs.tag }}" --target "$RELEASE_COMMIT" $PRERELEASE_FLAG --title "$ANNOUNCEMENT_TITLE" --notes-file "$RUNNER_TEMP/notes.txt" artifacts/* + # Commit patched manifest SHA256 checksums back to main so the repo + # stays in sync with the released artifacts. + update-registry-checksums: + needs: + - plan + - host + - build-wasm-extensions + if: ${{ always() && needs.host.result == 'success' && needs.build-wasm-extensions.result == 'success' }} + runs-on: "ubuntu-22.04" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + steps: + - uses: actions/checkout@v4 + with: + ref: main + - name: Fetch WASM checksums + uses: actions/download-artifact@v4 + with: + name: artifacts-wasm-extensions + path: target/wasm-bundles/ + - name: Patch manifests with SHA256 + shell: bash + run: | + CHECKSUMS="target/wasm-bundles/checksums.txt" + if [ ! -f "$CHECKSUMS" ]; then + echo "No checksums.txt found" + exit 0 + fi + + while IFS= read -r line; do + sha256=$(echo "$line" | awk '{print $1}') + filename=$(echo "$line" | awk '{print $2}') + name=$(echo "$filename" | sed 's/-wasm32-wasip2\.tar\.gz$//') + + for manifest in registry/tools/${name}.json registry/channels/${name}.json; do + if [ -f "$manifest" ]; then + jq --arg sha "$sha256" '.artifacts["wasm32-wasip2"].sha256 = $sha' "$manifest" > "${manifest}.tmp" && mv "${manifest}.tmp" "$manifest" + echo "Patched $manifest with sha256=$sha256" + fi + done + done < "$CHECKSUMS" + - name: Commit updated manifests + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add registry/ + if git diff --cached --quiet; then + echo "No manifest changes to commit" + else + git commit -m "chore: update WASM artifact SHA256 checksums [skip ci]" + git push + fi + announce: needs: - plan diff --git a/src/registry/catalog.rs b/src/registry/catalog.rs index 7c264aa0..36f75d59 100644 --- a/src/registry/catalog.rs +++ b/src/registry/catalog.rs @@ -27,9 +27,42 @@ pub enum RegistryError { path: std::path::PathBuf, }, - #[error("Download failed for {url}: {reason}")] + // `url` is stored for programmatic access (logs, retries) but intentionally + // omitted from the Display message to avoid leaking internal artifact URLs + // to end users. + #[error("Artifact download failed: {reason}")] DownloadFailed { url: String, reason: String }, + #[error("Invalid extension manifest for '{name}' field '{field}': {reason}")] + InvalidManifest { + name: String, + field: &'static str, + reason: String, + }, + + #[error("Checksum verification failed: expected {expected_sha256}, got {actual_sha256}")] + ChecksumMismatch { + url: String, + expected_sha256: String, + actual_sha256: String, + }, + + #[error( + "Source fallback unavailable for '{name}' after artifact install failed. Retry artifact download or run from a repository checkout." + )] + SourceFallbackUnavailable { + name: String, + source_dir: PathBuf, + artifact_error: Box, + }, + + #[error("Artifact install and source fallback both failed for '{name}'.")] + InstallFallbackFailed { + name: String, + artifact_error: Box, + source_error: Box, + }, + #[error( "Ambiguous name '{name}': exists as both {kind_a} and {kind_b}. Use '{prefix_a}/{name}' or '{prefix_b}/{name}'." )] diff --git a/src/registry/installer.rs b/src/registry/installer.rs index 8dfe3908..8ee0d563 100644 --- a/src/registry/installer.rs +++ b/src/registry/installer.rs @@ -1,12 +1,148 @@ //! Install extensions from the registry: build-from-source or download pre-built artifacts. -use std::path::{Path, PathBuf}; +use std::net::IpAddr; +use std::path::{Component, Path, PathBuf}; use tokio::fs; use crate::registry::catalog::RegistryError; use crate::registry::manifest::{BundleDefinition, ExtensionManifest, ManifestKind}; +// GitHub-only by design. New trusted hosts (e.g. a NEAR AI CDN) must be +// explicitly added here; unknown hosts fall back to source build with a +// warning rather than surfacing a clear "host not allowed" error. +const ALLOWED_ARTIFACT_HOSTS: &[&str] = &[ + "github.com", + "objects.githubusercontent.com", + "github-releases.githubusercontent.com", + "raw.githubusercontent.com", +]; + +fn should_attempt_source_fallback(err: &RegistryError) -> bool { + !matches!( + err, + RegistryError::AlreadyInstalled { .. } + | RegistryError::ChecksumMismatch { .. } + | RegistryError::InvalidManifest { .. } + ) +} + +fn is_allowed_artifact_host(host: &str) -> bool { + ALLOWED_ARTIFACT_HOSTS + .iter() + .any(|allowed| host.eq_ignore_ascii_case(allowed)) + || host.ends_with(".githubusercontent.com") +} + +fn validate_artifact_url( + manifest_name: &str, + field: &'static str, + url: &str, +) -> Result<(), RegistryError> { + let parsed = reqwest::Url::parse(url).map_err(|e| RegistryError::InvalidManifest { + name: manifest_name.to_string(), + field, + reason: format!("invalid URL: {}", e), + })?; + + if parsed.scheme() != "https" { + return Err(RegistryError::InvalidManifest { + name: manifest_name.to_string(), + field, + reason: "URL must use https".to_string(), + }); + } + + let host = parsed + .host_str() + .ok_or_else(|| RegistryError::InvalidManifest { + name: manifest_name.to_string(), + field, + reason: "URL host is missing".to_string(), + })?; + + if host.parse::().is_ok() || !is_allowed_artifact_host(host) { + return Err(RegistryError::InvalidManifest { + name: manifest_name.to_string(), + field, + reason: format!("host '{}' is not allowed", host), + }); + } + + Ok(()) +} + +fn validate_manifest_install_inputs(manifest: &ExtensionManifest) -> Result<(), RegistryError> { + let is_valid_name = !manifest.name.is_empty() + && manifest + .name + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_'); + + if !is_valid_name { + return Err(RegistryError::InvalidManifest { + name: manifest.name.clone(), + field: "name", + reason: "name must contain only lowercase letters, digits, '-' or '_'".to_string(), + }); + } + + let expected_prefix = match manifest.kind { + ManifestKind::Tool => "tools-src/", + ManifestKind::Channel => "channels-src/", + }; + + if !manifest.source.dir.starts_with(expected_prefix) { + return Err(RegistryError::InvalidManifest { + name: manifest.name.clone(), + field: "source.dir", + reason: format!("must start with '{}'", expected_prefix), + }); + } + + let source_path = Path::new(&manifest.source.dir); + let has_unsafe_component = source_path.components().any(|component| { + matches!( + component, + Component::ParentDir | Component::RootDir | Component::Prefix(_) | Component::CurDir + ) + }); + + if source_path.is_absolute() || has_unsafe_component { + return Err(RegistryError::InvalidManifest { + name: manifest.name.clone(), + field: "source.dir", + reason: "must be a safe relative path without traversal segments".to_string(), + }); + } + + let has_path_separator = manifest.source.capabilities.contains('/') + || manifest.source.capabilities.contains('\\') + || manifest.source.capabilities.contains(".."); + + if has_path_separator { + return Err(RegistryError::InvalidManifest { + name: manifest.name.clone(), + field: "source.capabilities", + reason: "must be a file name without path separators".to_string(), + }); + } + + Ok(()) +} + +fn download_failure_reason(error: &reqwest::Error) -> String { + if error.is_timeout() { + "request timed out".to_string() + } else if error.is_connect() { + "connection failed".to_string() + } else if error.is_request() { + "request failed".to_string() + } else { + "network error".to_string() + } +} + /// Result of installing a single extension from the registry. #[derive(Debug)] pub struct InstallOutcome { @@ -57,6 +193,8 @@ impl RegistryInstaller { manifest: &ExtensionManifest, force: bool, ) -> Result { + validate_manifest_install_inputs(manifest)?; + let source_dir = self.repo_root.join(&manifest.source.dir); if !source_dir.exists() { return Err(RegistryError::ManifestRead { @@ -137,6 +275,67 @@ impl RegistryInstaller { }) } + pub async fn install_with_source_fallback( + &self, + manifest: &ExtensionManifest, + force: bool, + ) -> Result { + // Validate upfront so we fail fast on bad manifests regardless of + // which install path runs, without relying on inner methods to + // catch it first. + validate_manifest_install_inputs(manifest)?; + + let has_artifact = manifest + .artifacts + .get("wasm32-wasip2") + .and_then(|a| a.url.as_ref()) + .is_some(); + + if !has_artifact { + return self.install_from_source(manifest, force).await; + } + + let source_dir = self.repo_root.join(&manifest.source.dir); + + match self.install_from_artifact(manifest, force).await { + Ok(outcome) => Ok(outcome), + Err(artifact_err) => { + if !should_attempt_source_fallback(&artifact_err) { + return Err(artifact_err); + } + + if !source_dir.is_dir() { + return Err(RegistryError::SourceFallbackUnavailable { + name: manifest.name.clone(), + source_dir, + artifact_error: Box::new(artifact_err), + }); + } + + tracing::warn!( + extension = %manifest.name, + error = %artifact_err, + "Artifact install failed; falling back to build-from-source" + ); + + match self.install_from_source(manifest, force).await { + Ok(mut outcome) => { + outcome.warnings.push(format!( + "Artifact install failed ({}); installed via source fallback.", + artifact_err + )); + Ok(outcome) + } + Err(source_err) => Err(RegistryError::InstallFallbackFailed { + name: manifest.name.clone(), + artifact_error: Box::new(artifact_err), + source_error: Box::new(source_err), + }), + } + } + } + } + /// Download and install a pre-built artifact. /// /// Supports two formats: @@ -147,6 +346,8 @@ impl RegistryInstaller { manifest: &ExtensionManifest, force: bool, ) -> Result { + validate_manifest_install_inputs(manifest)?; + let artifact = manifest.artifacts.get("wasm32-wasip2").ok_or_else(|| { RegistryError::ExtensionNotFound(format!( "No wasm32-wasip2 artifact for '{}'", @@ -161,6 +362,21 @@ impl RegistryInstaller { )) })?; + validate_artifact_url(&manifest.name, "artifacts.wasm32-wasip2.url", url)?; + + // Require SHA256 — refuse to install unverified binaries. Check before + // downloading to avoid wasting bandwidth on manifests that are missing + // checksums. + let expected_sha = + artifact + .sha256 + .as_ref() + .ok_or_else(|| RegistryError::InvalidManifest { + name: manifest.name.clone(), + field: "artifacts.wasm32-wasip2.sha256", + reason: "sha256 is required for artifact downloads".to_string(), + })?; + let target_dir = match manifest.kind { ManifestKind::Tool => &self.tools_dir, ManifestKind::Channel => &self.channels_dir, @@ -185,16 +401,7 @@ impl RegistryInstaller { manifest.kind, manifest.display_name ); let bytes = download_artifact(url).await?; - - // Verify SHA256 if provided, warn otherwise - if let Some(expected_sha) = &artifact.sha256 { - verify_sha256(&bytes, expected_sha, url)?; - } else { - println!( - "WARNING: No SHA256 checksum for '{}'; download is not cryptographically verified.", - manifest.name - ); - } + verify_sha256(&bytes, expected_sha, url)?; let target_caps = target_dir.join(format!("{}.capabilities.json", manifest.name)); @@ -214,6 +421,11 @@ impl RegistryInstaller { // 1. Separate capabilities_url in the artifact // 2. Source tree (legacy, requires repo) if let Some(ref caps_url) = artifact.capabilities_url { + validate_artifact_url( + &manifest.name, + "artifacts.wasm32-wasip2.capabilities_url", + caps_url, + )?; const MAX_CAPS_SIZE: usize = 1024 * 1024; // 1 MB match download_artifact(caps_url).await { Ok(caps_bytes) if caps_bytes.len() <= MAX_CAPS_SIZE => { @@ -360,14 +572,18 @@ async fn download_artifact(url: &str) -> Result { .await .map_err(|e| RegistryError::DownloadFailed { url: url.to_string(), - reason: format!("request failed: {}", e), + reason: download_failure_reason(&e), })?; let response = response .error_for_status() .map_err(|e| RegistryError::DownloadFailed { url: url.to_string(), - reason: e.to_string(), + reason: format!( + "http status {}", + e.status() + .map_or("unknown".to_string(), |status| status.as_u16().to_string()) + ), })?; response @@ -375,7 +591,7 @@ async fn download_artifact(url: &str) -> Result { .await .map_err(|e| RegistryError::DownloadFailed { url: url.to_string(), - reason: format!("failed to read body: {}", e), + reason: format!("failed to read response body: {}", e), }) } @@ -387,9 +603,10 @@ fn verify_sha256(bytes: &[u8], expected: &str, url: &str) -> Result<(), Registry let actual = format!("{:x}", hasher.finalize()); if actual != expected { - return Err(RegistryError::DownloadFailed { + return Err(RegistryError::ChecksumMismatch { url: url.to_string(), - reason: format!("SHA256 mismatch: expected {}, got {}", expected, actual), + expected_sha256: expected.to_string(), + actual_sha256: actual, }); } Ok(()) @@ -510,6 +727,55 @@ fn extract_tar_gz( #[cfg(test)] mod tests { use super::*; + use std::collections::HashMap; + + use crate::registry::manifest::{ArtifactSpec, SourceSpec}; + + fn test_manifest( + name: &str, + source_dir: &str, + artifact_url: Option, + sha256: Option<&str>, + ) -> ExtensionManifest { + test_manifest_with_kind(name, source_dir, artifact_url, sha256, ManifestKind::Tool) + } + + fn test_manifest_with_kind( + name: &str, + source_dir: &str, + artifact_url: Option, + sha256: Option<&str>, + kind: ManifestKind, + ) -> ExtensionManifest { + let mut artifacts = HashMap::new(); + if artifact_url.is_some() || sha256.is_some() { + artifacts.insert( + "wasm32-wasip2".to_string(), + ArtifactSpec { + url: artifact_url, + sha256: sha256.map(ToString::to_string), + capabilities_url: None, + }, + ); + } + + ExtensionManifest { + name: name.to_string(), + display_name: name.to_string(), + kind, + version: "0.1.0".to_string(), + description: "test manifest".to_string(), + keywords: Vec::new(), + source: SourceSpec { + dir: source_dir.to_string(), + capabilities: format!("{}.capabilities.json", name), + crate_name: name.to_string(), + }, + artifacts, + auth_summary: None, + tags: Vec::new(), + } + } #[test] fn test_installer_creation() { @@ -541,7 +807,140 @@ mod tests { #[test] fn test_verify_sha256_invalid() { - assert!(verify_sha256(b"data", "0000", "test://url").is_err()); + let err = verify_sha256(b"data", "0000", "test://url").expect_err("checksum mismatch"); + assert!(matches!(err, RegistryError::ChecksumMismatch { .. })); + } + + #[tokio::test] + async fn test_install_from_source_rejects_path_traversal_name() { + let temp = tempfile::tempdir().expect("tempdir"); + let installer = RegistryInstaller::new( + temp.path().to_path_buf(), + temp.path().join("tools"), + temp.path().join("channels"), + ); + + let manifest = test_manifest("../evil", "tools-src/evil", None, None); + + let result = installer.install_from_source(&manifest, false).await; + match result { + Err(RegistryError::InvalidManifest { field, .. }) => { + assert_eq!(field, "name"); + } + other => panic!("unexpected result: {:?}", other), + } + } + + #[tokio::test] + async fn test_install_from_artifact_rejects_non_https_url() { + let temp = tempfile::tempdir().expect("tempdir"); + let installer = RegistryInstaller::new( + temp.path().to_path_buf(), + temp.path().join("tools"), + temp.path().join("channels"), + ); + + let manifest = test_manifest( + "demo", + "tools-src/demo", + Some( + "http://github.com/nearai/ironclaw/releases/latest/download/demo.wasm".to_string(), + ), + None, + ); + + let result = installer.install_from_artifact(&manifest, false).await; + match result { + Err(RegistryError::InvalidManifest { field, .. }) => { + assert_eq!(field, "artifacts.wasm32-wasip2.url"); + } + other => panic!("unexpected result: {:?}", other), + } + } + + #[tokio::test] + async fn test_install_from_artifact_rejects_disallowed_host() { + let temp = tempfile::tempdir().expect("tempdir"); + let installer = RegistryInstaller::new( + temp.path().to_path_buf(), + temp.path().join("tools"), + temp.path().join("channels"), + ); + + let manifest = test_manifest( + "demo", + "tools-src/demo", + Some("https://169.254.169.254/latest/meta-data".to_string()), + None, + ); + + let result = installer.install_from_artifact(&manifest, false).await; + match result { + Err(RegistryError::InvalidManifest { field, .. }) => { + assert_eq!(field, "artifacts.wasm32-wasip2.url"); + } + other => panic!("unexpected result: {:?}", other), + } + } + + #[tokio::test] + async fn test_install_from_artifact_rejects_null_sha256() { + let temp = tempfile::tempdir().expect("tempdir"); + let installer = RegistryInstaller::new( + temp.path().to_path_buf(), + temp.path().join("tools"), + temp.path().join("channels"), + ); + + // Valid URL but no sha256 — should be rejected before any download attempt + let manifest = test_manifest( + "demo", + "tools-src/demo", + Some( + "https://github.com/nearai/ironclaw/releases/latest/download/demo-wasm32-wasip2.tar.gz".to_string(), + ), + None, // sha256 = null + ); + + let result = installer.install_from_artifact(&manifest, false).await; + match result { + Err(RegistryError::InvalidManifest { field, reason, .. }) => { + assert_eq!(field, "artifacts.wasm32-wasip2.sha256"); + assert!(reason.contains("required"), "reason: {}", reason); + } + other => panic!("unexpected result: {:?}", other), + } + } + + #[test] + fn test_should_attempt_source_fallback_policy() { + let download = RegistryError::DownloadFailed { + url: "https://github.com/nearai/ironclaw/releases/latest/download/demo.wasm" + .to_string(), + reason: "http status 404".to_string(), + }; + assert!(should_attempt_source_fallback(&download)); + + let already = RegistryError::AlreadyInstalled { + name: "demo".to_string(), + path: PathBuf::from("/tmp/demo.wasm"), + }; + assert!(!should_attempt_source_fallback(&already)); + + let checksum = RegistryError::ChecksumMismatch { + url: "https://github.com/nearai/ironclaw/releases/latest/download/demo.wasm" + .to_string(), + expected_sha256: "deadbeef".to_string(), + actual_sha256: "feedface".to_string(), + }; + assert!(!should_attempt_source_fallback(&checksum)); + + let invalid = RegistryError::InvalidManifest { + name: "demo".to_string(), + field: "artifacts.wasm32-wasip2.url", + reason: "host not allowed".to_string(), + }; + assert!(!should_attempt_source_fallback(&invalid)); } #[test] @@ -587,6 +986,66 @@ mod tests { assert!(result.has_capabilities); } + #[tokio::test] + async fn test_install_from_source_rejects_wrong_prefix_for_channel() { + let temp = tempfile::tempdir().expect("tempdir"); + let installer = RegistryInstaller::new( + temp.path().to_path_buf(), + temp.path().join("tools"), + temp.path().join("channels"), + ); + + // Channel manifest with tools-src/ prefix should be rejected + let manifest = test_manifest_with_kind( + "telegram", + "tools-src/telegram", + None, + None, + ManifestKind::Channel, + ); + + let result = installer.install_from_source(&manifest, false).await; + match result { + Err(RegistryError::InvalidManifest { field, reason, .. }) => { + assert_eq!(field, "source.dir"); + assert!(reason.contains("channels-src/"), "reason: {}", reason); + } + other => panic!("unexpected result: {:?}", other), + } + } + + #[tokio::test] + async fn test_install_from_source_accepts_correct_channel_prefix() { + let temp = tempfile::tempdir().expect("tempdir"); + let installer = RegistryInstaller::new( + temp.path().to_path_buf(), + temp.path().join("tools"), + temp.path().join("channels"), + ); + + // Channel manifest with channels-src/ prefix should pass validation + // (will fail later because source dir doesn't exist, which is fine) + let manifest = test_manifest_with_kind( + "telegram", + "channels-src/telegram", + None, + None, + ManifestKind::Channel, + ); + + let result = installer.install_from_source(&manifest, false).await; + match result { + Err(RegistryError::ManifestRead { reason, .. }) => { + assert!( + reason.contains("source directory does not exist"), + "reason: {}", + reason + ); + } + other => panic!("unexpected result: {:?}", other), + } + } + #[test] fn test_extract_tar_gz_missing_wasm() { use flate2::Compression; diff --git a/src/setup/README.md b/src/setup/README.md index dfdd950d..19b210a2 100644 --- a/src/setup/README.md +++ b/src/setup/README.md @@ -258,7 +258,7 @@ key first, then falls back to the standard env var. 6c. Build channel options: discovered + bundled + registry catalog 6d. Multi-select: CLI/TUI, HTTP, all available channels 6e. Install missing bundled channels (copy WASM binaries) -6f. Install missing registry channels (build from source) +6f. Install missing registry channels (download artifacts, fallback to source build) 6g. Initialize SecretsContext (for token storage) 6h. Setup HTTP webhook (if selected) 6i. Setup each WASM channel (secrets, owner binding) @@ -267,7 +267,7 @@ key first, then falls back to the standard env var. **Channel sources** (priority order for installation): 1. Already installed in `~/.ironclaw/channels/` 2. Bundled channels (pre-compiled in `channels-src/`) -3. Registry channels (`registry/channels/*.json`, built from source) +3. Registry channels (`registry/channels/*.json`, download-first with source fallback) **Tunnel setup** (`setup_tunnel`): - Options: ngrok, Cloudflare Tunnel, localtunnel, custom URL @@ -305,8 +305,9 @@ key first, then falls back to the standard env var. 4. Discover already-installed tools in `~/.ironclaw/tools/` 5. Multi-select: show all registry tools with display name, auth method, and description. Pre-check tools tagged `"default"` and already installed. -6. For each selected tool not yet installed, build from source via - `RegistryInstaller::install_from_source()` +6. For each selected tool not yet installed, install via + `RegistryInstaller::install_with_source_fallback()` (download-first, + fallback to source build) 7. Print consolidated auth hints (deduplicated by provider, e.g. one hint for all Google tools sharing `google_oauth_token`) diff --git a/src/setup/wizard.rs b/src/setup/wizard.rs index 0e3e6202..ed548299 100644 --- a/src/setup/wizard.rs +++ b/src/setup/wizard.rs @@ -1493,7 +1493,6 @@ impl SetupWizard { any_installed = true; } - // Then try registry channels (build from source for any still missing) let installed_from_registry = install_selected_registry_channels( &channels_dir, &selected_wasm_channels, @@ -1685,9 +1684,12 @@ impl SetupWizard { continue; // Already installed, skip } - match installer.install_from_source(tool, false).await { + match installer.install_with_source_fallback(tool, false).await { Ok(outcome) => { print_success(&format!("Installed {}", outcome.name)); + for warning in &outcome.warnings { + print_info(&format!("{}: {}", outcome.name, warning)); + } installed_count += 1; // Track auth needs @@ -2657,8 +2659,6 @@ fn load_registry_catalog() -> Option /// Install selected channels from the registry that aren't already on disk /// and weren't handled by the bundled installer. -/// -/// This builds channels from source using `cargo component build`. async fn install_selected_registry_channels( channels_dir: &std::path::Path, selected_channels: &[String], @@ -2703,8 +2703,14 @@ async fn install_selected_registry_channels( channels_dir.to_path_buf(), ); - match installer.install_from_source(manifest, false).await { - Ok(_) => { + match installer + .install_with_source_fallback(manifest, false) + .await + { + Ok(outcome) => { + for warning in &outcome.warnings { + crate::setup::prompts::print_info(&format!("{}: {}", name, warning)); + } installed.push(name.clone()); } Err(e) => {