fix: make onboarding installs prefer release artifacts with source fallback (#323)

* 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 <[email protected]>

* 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 <[email protected]>

* 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 <[email protected]>

---------

Co-authored-by: Claude Opus 4.6 <[email protected]>
Co-authored-by: Bowen Wang <[email protected]>
This commit is contained in:
firat.sertgoz
2026-02-23 18:49:18 +00:00
committed by GitHub
co-authored by Claude Opus 4.6 Bowen Wang
parent cbf5c93578
commit 3e552e0e8e
5 changed files with 606 additions and 30 deletions
+79 -2
View File
@@ -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
+34 -1
View File
@@ -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<RegistryError>,
},
#[error("Artifact install and source fallback both failed for '{name}'.")]
InstallFallbackFailed {
name: String,
artifact_error: Box<RegistryError>,
source_error: Box<RegistryError>,
},
#[error(
"Ambiguous name '{name}': exists as both {kind_a} and {kind_b}. Use '{prefix_a}/{name}' or '{prefix_b}/{name}'."
)]
+476 -17
View File
@@ -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::<IpAddr>().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<InstallOutcome, RegistryError> {
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<InstallOutcome, RegistryError> {
// 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<InstallOutcome, RegistryError> {
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<bytes::Bytes, RegistryError> {
.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<bytes::Bytes, RegistryError> {
.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<String>,
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<String>,
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;
+5 -4
View File
@@ -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`)
+12 -6
View File
@@ -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<crate::registry::catalog::RegistryCatalog>
/// 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) => {