diff --git a/crates/ironclaw_gateway/src/assets.rs b/crates/ironclaw_gateway/src/assets.rs
index 10708fc6c5e..d0d54ef4baf 100644
--- a/crates/ironclaw_gateway/src/assets.rs
+++ b/crates/ironclaw_gateway/src/assets.rs
@@ -63,6 +63,8 @@ pub const APP_JS: &str = concat!(
"\n",
include_str!("../static/js/surfaces/settings.js"),
"\n",
+ include_str!("../static/js/surfaces/install.js"),
+ "\n",
include_str!("../static/js/core/ui-helpers.js"),
"\n",
include_str!("../static/js/surfaces/config.js"),
@@ -121,6 +123,8 @@ pub const STYLE_CSS: &str = concat!(
include_str!("../static/styles/surfaces/tool-permissions.css"),
"\n",
include_str!("../static/styles/surfaces/projects.css"),
+ "\n",
+ include_str!("../static/styles/surfaces/install.css"),
);
/// Theme initialization script (runs synchronously in `
` to prevent FOUC).
diff --git a/crates/ironclaw_gateway/static/i18n/en.js b/crates/ironclaw_gateway/static/i18n/en.js
index ab673b8cd6a..bda746b4711 100644
--- a/crates/ironclaw_gateway/static/i18n/en.js
+++ b/crates/ironclaw_gateway/static/i18n/en.js
@@ -841,6 +841,43 @@ I18n.register('en', {
'thread.heartbeatAlerts': 'Heartbeat Alerts',
'thread.routine': 'Routine',
+ // IronHub deep-link install
+ 'ironhub.install.verifyingTitle': 'Verifying install request',
+ 'ironhub.install.verifying': 'Checking the signature on this install link...',
+ 'ironhub.install.confirmTitle': 'Install from IronHub?',
+ 'ironhub.install.fromHub': 'This install was requested from IronHub. Review it before installing.',
+ 'ironhub.install.name': 'Name',
+ 'ironhub.install.kind': 'Type',
+ 'ironhub.install.version': 'Version',
+ 'ironhub.install.release': 'Release',
+ 'ironhub.install.confirm': 'Install',
+ 'ironhub.install.cancel': 'Cancel',
+ 'ironhub.install.close': 'Close',
+ 'ironhub.install.installing': 'Installing...',
+ 'ironhub.install.doneTitle': 'Installed',
+ 'ironhub.install.success': 'Installed {name}',
+ 'ironhub.install.failed': 'Install failed: {message}',
+ 'ironhub.install.unverifiedTitle': 'Install link could not be verified',
+ 'ironhub.install.unverified': 'This install link could not be verified. It may be expired, tampered with, or sent from a source your agent does not trust.',
+ 'ironhub.install.missingParams': 'This install link is missing required parameters.',
+ 'ironhub.install.trustLabel': 'Trust',
+ 'ironhub.install.communityWarning': 'This is community-submitted content that has not been verified by the NEAR team. Review it carefully before installing.',
+ 'ironhub.install.ackUnverified': 'I understand this is unverified community content and want to install anyway.',
+ 'ironhub.signingKey.title': 'IronHub Integration',
+ 'ironhub.signingKey.label': 'Shared key',
+ 'ironhub.signingKey.description': 'Generate a key in your IronHub profile, then paste it here so install requests from IronHub can be verified by this agent.',
+ 'ironhub.signingKey.placeholder': 'ihub_sk_...',
+ 'ironhub.signingKey.save': 'Save Key',
+ 'ironhub.signingKey.replace': 'Replace Key',
+ 'ironhub.signingKey.revoke': 'Revoke Key',
+ 'ironhub.signingKey.none': 'No signing key configured.',
+ 'ironhub.signingKey.active': 'Active key fingerprint: {fingerprint} (created {created})',
+ 'ironhub.signingKey.saved': 'Signing key saved.',
+ 'ironhub.signingKey.revoked': 'Signing key revoked.',
+ 'ironhub.signingKey.invalidPrefix': 'Key must start with ihub_sk_',
+ 'ironhub.signingKey.invalidLength': 'Key must be at least 32 characters',
+ 'ironhub.signingKey.error': 'Signing key operation failed: {message}',
+
// Extensions (dynamic)
'extensions.openingAuth': 'Opening authentication for {name}',
'extensions.installFailed': 'Install failed: {message}',
diff --git a/crates/ironclaw_gateway/static/index.html b/crates/ironclaw_gateway/static/index.html
index 223717ce7cf..48c58e5917e 100644
--- a/crates/ironclaw_gateway/static/index.html
+++ b/crates/ironclaw_gateway/static/index.html
@@ -457,6 +457,7 @@ Model Providers
+
@@ -570,6 +571,9 @@
Tool Permissions
+
+
+
diff --git a/crates/ironclaw_gateway/static/js/core/routing.js b/crates/ironclaw_gateway/static/js/core/routing.js
index 7c3a5ca861e..90145769a90 100644
--- a/crates/ironclaw_gateway/static/js/core/routing.js
+++ b/crates/ironclaw_gateway/static/js/core/routing.js
@@ -122,6 +122,24 @@ function restoreFromHash() {
case 'settings':
switchSettingsSubtab(state.detail);
break;
+ case 'install': {
+ var raw = state.detail || '';
+ var qIdx = raw.indexOf('?');
+ var pathSlug = qIdx >= 0 ? raw.substring(0, qIdx) : raw;
+ var qs = qIdx >= 0 ? raw.substring(qIdx + 1) : '';
+ var params = new URLSearchParams(qs);
+ startIronhubInstall({
+ slug: params.get('slug') || decodeURIComponent(pathSlug),
+ version: params.get('version'),
+ uid: params.get('uid'),
+ aid: params.get('aid'),
+ ts: params.get('ts'),
+ nonce: params.get('nonce'),
+ sig: params.get('sig'),
+ artifact_digest: params.get('artifact_digest'),
+ });
+ break;
+ }
}
}
diff --git a/crates/ironclaw_gateway/static/js/surfaces/install.js b/crates/ironclaw_gateway/static/js/surfaces/install.js
new file mode 100644
index 00000000000..b697d332cb1
--- /dev/null
+++ b/crates/ironclaw_gateway/static/js/surfaces/install.js
@@ -0,0 +1,203 @@
+function ironhubInstallPanel() {
+ return document.getElementById('tab-install');
+}
+
+function renderIronhubInstallState(html) {
+ var panel = ironhubInstallPanel();
+ if (!panel) return;
+ panel.innerHTML = '' + html + '
';
+ wireInstallActions();
+}
+
+function wireInstallActions() {
+ document.querySelectorAll('.ironhub-install-cancel').forEach(function(btn) {
+ btn.addEventListener('click', ironhubInstallCancel);
+ });
+ var ackCb = document.getElementById('ironhub-install-ack-cb');
+ if (ackCb) ackCb.addEventListener('change', ironhubAckChanged);
+}
+
+function ironhubInstallCancel() {
+ switchTab('chat');
+}
+
+function renderIronhubInstallError(message) {
+ renderIronhubInstallState(
+ '' + escapeHtml(I18n.t('ironhub.install.unverifiedTitle')) + ' ' +
+ '' + escapeHtml(message) + '
' +
+ '' +
+ escapeHtml(I18n.t('ironhub.install.close')) + ' '
+ );
+}
+
+function renderIronhubConfirm(signed, info) {
+ var tool = signed && signed.slug ? signed.slug : '';
+ var kind = info && info.kind ? info.kind : 'tool';
+ var version = info && info.version ? info.version : '';
+ var description = info && info.description ? info.description : '';
+ var release = info && info.release_tag ? info.release_tag : '';
+ var provenance = info && info.provenance ? info.provenance : '';
+ var trustLabel = info && info.trust_label ? info.trust_label : '';
+ var isCommunityUnverified = provenance === 'new';
+ var rows = '';
+ rows += '' +
+ escapeHtml(I18n.t('ironhub.install.name')) + ' ' +
+ escapeHtml(tool) + '
';
+ rows += '' +
+ escapeHtml(I18n.t('ironhub.install.kind')) + ' ' +
+ escapeHtml(kind) + '
';
+ if (version) {
+ rows += '' +
+ escapeHtml(I18n.t('ironhub.install.version')) + ' ' +
+ escapeHtml(version) + '
';
+ }
+ if (release) {
+ rows += '' +
+ escapeHtml(I18n.t('ironhub.install.release')) + ' ' +
+ escapeHtml(release) + '
';
+ }
+ if (trustLabel) {
+ rows += '' +
+ escapeHtml(I18n.t('ironhub.install.trustLabel')) + ' ' +
+ escapeHtml(trustLabel) + '
';
+ }
+ if (description) {
+ rows += '' + escapeHtml(description) + '
';
+ }
+
+ var warning = '';
+ var ackCheckbox = '';
+ var confirmDisabled = '';
+ if (isCommunityUnverified) {
+ warning = '' +
+ escapeHtml(I18n.t('ironhub.install.communityWarning')) + '
';
+ ackCheckbox =
+ ' ' +
+ escapeHtml(I18n.t('ironhub.install.ackUnverified')) + ' ';
+ confirmDisabled = ' disabled';
+ }
+
+ renderIronhubInstallState(
+ '' + escapeHtml(I18n.t('ironhub.install.confirmTitle')) + ' ' +
+ '' +
+ escapeHtml(I18n.t('ironhub.install.fromHub')) + '
' +
+ rows +
+ warning +
+ ackCheckbox +
+ '' +
+ '' +
+ escapeHtml(I18n.t('ironhub.install.confirm')) + ' ' +
+ '' +
+ escapeHtml(I18n.t('ironhub.install.cancel')) + ' ' +
+ '
'
+ );
+ var btn = document.getElementById('ironhub-install-confirm-btn');
+ if (btn) {
+ btn.addEventListener('click', function() {
+ ironhubInstallConfirm(signed, isCommunityUnverified);
+ });
+ }
+}
+
+function ironhubAckChanged() {
+ var cb = document.getElementById('ironhub-install-ack-cb');
+ var btn = document.getElementById('ironhub-install-confirm-btn');
+ if (cb && btn) {
+ btn.disabled = !cb.checked;
+ }
+}
+
+function ironhubInstallConfirm(signed, requireAck) {
+ var btn = document.getElementById('ironhub-install-confirm-btn');
+ if (btn) {
+ btn.disabled = true;
+ btn.textContent = I18n.t('ironhub.install.installing');
+ }
+ var body = {
+ slug: signed.slug,
+ version: signed.version,
+ uid: signed.uid,
+ aid: signed.aid,
+ ts: parseInt(signed.ts, 10),
+ nonce: signed.nonce,
+ sig: signed.sig,
+ artifact_digest: signed.artifact_digest,
+ };
+ if (requireAck) {
+ body.acknowledge_unverified = true;
+ }
+ apiFetch('/api/ironhub/install', {
+ method: 'POST',
+ body: body,
+ }).then(function(res) {
+ var name = res && res.name ? res.name : signed.slug;
+ showToast(I18n.t('ironhub.install.success', { name: name }), 'success');
+ renderIronhubInstallState(
+ '' + escapeHtml(I18n.t('ironhub.install.doneTitle')) + ' ' +
+ '' + escapeHtml(I18n.t('ironhub.install.success', { name: name })) + '
' +
+ '' +
+ escapeHtml(I18n.t('ironhub.install.close')) + ' '
+ );
+ }).catch(function(err) {
+ var msg = err && err.message ? err.message : 'unknown error';
+ showToast(I18n.t('ironhub.install.failed', { message: msg }), 'error');
+ if (btn) {
+ btn.disabled = false;
+ btn.textContent = I18n.t('ironhub.install.confirm');
+ }
+ });
+}
+
+function startIronhubInstall(params) {
+ renderIronhubInstallState(
+ '' + escapeHtml(I18n.t('ironhub.install.verifyingTitle')) + ' ' +
+ '' + escapeHtml(I18n.t('ironhub.install.verifying')) + '
'
+ );
+
+ var slug = params && params.slug;
+ var version = params && params.version;
+ var uid = params && params.uid;
+ var aid = params && params.aid;
+ var ts = params && params.ts;
+ var nonce = params && params.nonce;
+ var sig = params && params.sig;
+ var artifactDigest = params && params.artifact_digest;
+
+ if (!slug || !version || !uid || !aid || !ts || !nonce || !sig || !artifactDigest) {
+ renderIronhubInstallError(I18n.t('ironhub.install.missingParams'));
+ return;
+ }
+
+ var signed = {
+ slug: slug,
+ version: version,
+ uid: uid,
+ aid: aid,
+ ts: parseInt(ts, 10),
+ nonce: nonce,
+ sig: sig,
+ artifact_digest: artifactDigest,
+ };
+
+ apiFetch('/api/ironhub/verify-intent', {
+ method: 'POST',
+ body: signed,
+ }).then(function(res) {
+ if (!res || !res.valid) {
+ var reason = res && res.reason ? res.reason : I18n.t('ironhub.install.unverified');
+ renderIronhubInstallError(reason);
+ return;
+ }
+ return apiFetch('/api/ironhub/info?name=' + encodeURIComponent(slug))
+ .then(function(info) {
+ renderIronhubConfirm(signed, info);
+ })
+ .catch(function() {
+ renderIronhubConfirm(signed, null);
+ });
+ }).catch(function(err) {
+ var msg = err && err.message ? err.message : I18n.t('ironhub.install.unverified');
+ renderIronhubInstallError(msg);
+ });
+}
diff --git a/crates/ironclaw_gateway/static/js/surfaces/settings.js b/crates/ironclaw_gateway/static/js/surfaces/settings.js
index 72aaa619dc1..6d740088abd 100644
--- a/crates/ironclaw_gateway/static/js/surfaces/settings.js
+++ b/crates/ironclaw_gateway/static/js/surfaces/settings.js
@@ -223,6 +223,107 @@ function loadInferenceSettings() {
function loadAgentSettings() {
loadStructuredSettings('settings-agent-content', AGENT_SETTINGS);
+ loadIronhubSigningKeyCard();
+}
+
+function renderIronhubSigningKeyCard(rowHtml) {
+ var card = document.getElementById('settings-ironhub-card');
+ if (!card) return;
+ card.innerHTML =
+ '' +
+ '
' +
+ escapeHtml(I18n.t('ironhub.signingKey.title')) + '
' +
+ rowHtml +
+ '
';
+ var form = document.getElementById('ironhub-key-form');
+ if (form) form.addEventListener('submit', ironhubSaveSigningKey);
+ var revokeBtn = document.getElementById('ironhub-revoke-btn');
+ if (revokeBtn) revokeBtn.addEventListener('click', ironhubRevokeSigningKey);
+}
+
+function ironhubSigningKeyRowHtml(statusHtml, hasKey) {
+ var submitLabel = hasKey
+ ? I18n.t('ironhub.signingKey.replace')
+ : I18n.t('ironhub.signingKey.save');
+ var revoke = hasKey
+ ? '' +
+ escapeHtml(I18n.t('ironhub.signingKey.revoke')) + ' '
+ : '';
+ return '' +
+ '' +
+ '
' +
+ '
' +
+ escapeHtml(I18n.t('ironhub.signingKey.label')) + ' ' +
+ '
' +
+ escapeHtml(I18n.t('ironhub.signingKey.description')) + '
' +
+ statusHtml +
+ '
' +
+ '
' +
+ '
';
+}
+
+function loadIronhubSigningKeyCard() {
+ renderIronhubSigningKeyCard(
+ '' +
+ '
' + escapeHtml(I18n.t('extensions.loading')) + '
' +
+ '
'
+ );
+ apiFetch('/api/ironhub/signing-key').then(function(meta) {
+ var status =
+ '' +
+ escapeHtml(I18n.t('ironhub.signingKey.active', {
+ fingerprint: meta.fingerprint,
+ created: meta.created_at,
+ })) + '
';
+ renderIronhubSigningKeyCard(ironhubSigningKeyRowHtml(status, true));
+ }).catch(function() {
+ var status =
+ '' +
+ escapeHtml(I18n.t('ironhub.signingKey.none')) + '
';
+ renderIronhubSigningKeyCard(ironhubSigningKeyRowHtml(status, false));
+ });
+}
+
+function ironhubSaveSigningKey(evt) {
+ if (evt && evt.preventDefault) evt.preventDefault();
+ var input = document.getElementById('ironhub-key-input');
+ if (!input) return;
+ var key = (input.value || '').trim();
+ if (key.indexOf('ihub_sk_') !== 0) {
+ showToast(I18n.t('ironhub.signingKey.invalidPrefix'), 'error');
+ return;
+ }
+ if (key.length < 32) {
+ showToast(I18n.t('ironhub.signingKey.invalidLength'), 'error');
+ return;
+ }
+ apiFetch('/api/ironhub/signing-key', {
+ method: 'POST',
+ body: { shared_key: key },
+ }).then(function() {
+ showToast(I18n.t('ironhub.signingKey.saved'), 'success');
+ loadIronhubSigningKeyCard();
+ }).catch(function(err) {
+ showToast(I18n.t('ironhub.signingKey.error', {
+ message: err && err.message ? err.message : 'unknown error',
+ }), 'error');
+ });
+}
+
+function ironhubRevokeSigningKey() {
+ apiFetch('/api/ironhub/signing-key', { method: 'DELETE' }).then(function() {
+ showToast(I18n.t('ironhub.signingKey.revoked'), 'success');
+ loadIronhubSigningKeyCard();
+ }).catch(function(err) {
+ showToast(I18n.t('ironhub.signingKey.error', {
+ message: err && err.message ? err.message : 'unknown error',
+ }), 'error');
+ });
}
function loadStructuredSettings(containerId, settingsDefs) {
diff --git a/crates/ironclaw_gateway/static/styles/surfaces/install.css b/crates/ironclaw_gateway/static/styles/surfaces/install.css
new file mode 100644
index 00000000000..c9d0f2e7c31
--- /dev/null
+++ b/crates/ironclaw_gateway/static/styles/surfaces/install.css
@@ -0,0 +1,49 @@
+#tab-install {
+ justify-content: center;
+ align-items: center;
+ padding: 48px 16px;
+}
+
+.ironhub-install-card {
+ max-width: 480px;
+ width: 100%;
+}
+
+.ironhub-install-card h2 {
+ margin: 0 0 12px;
+}
+
+.ironhub-install-source {
+ color: var(--text-muted);
+ font-size: 13px;
+ margin: 0 0 16px;
+}
+
+.ironhub-install-row {
+ display: flex;
+ justify-content: space-between;
+ gap: 16px;
+ padding: 6px 0;
+ border-bottom: 1px solid var(--border);
+}
+
+.ironhub-install-row span:first-child {
+ color: var(--text-muted);
+}
+
+.ironhub-install-desc {
+ margin: 16px 0 0;
+ color: var(--text);
+ font-size: 14px;
+}
+
+.ironhub-install-error {
+ color: var(--danger, #d33);
+ margin: 0 0 16px;
+}
+
+.ironhub-install-actions {
+ display: flex;
+ gap: 12px;
+ margin-top: 24px;
+}
diff --git a/src/app.rs b/src/app.rs
index 4d9ad8dd281..3fa256829c0 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -1323,6 +1323,13 @@ impl AppBuilder {
(None, None)
};
+ if let Some(manager) = extension_manager.as_ref() {
+ tools.register_ironhub_tools(crate::tools::builtin::IronhubDeps {
+ extension_manager: Arc::clone(manager),
+ skill_registry: skill_registry.clone(),
+ });
+ }
+
let context_manager = Arc::new(ContextManager::new(self.config.agent.max_parallel_jobs));
let cost_guard = Arc::new(crate::agent::cost_guard::CostGuard::new(
crate::agent::cost_guard::CostGuardConfig {
diff --git a/src/channels/web/CLAUDE.md b/src/channels/web/CLAUDE.md
index e405c2bce75..0880aacb3d9 100644
--- a/src/channels/web/CLAUDE.md
+++ b/src/channels/web/CLAUDE.md
@@ -26,7 +26,8 @@ Browser-facing HTTP API and SSE/WebSocket real-time streaming. Axum-based, singl
| `features/oauth/` | First feature slice landed per ironclaw#2599 stage 4a: OAuth callback (`/oauth/callback`), channel-relay event webhook (`/relay/events`), and the Slack-specific relay OAuth completion flow (`/oauth/slack/callback`). Owns its private helpers (`oauth_error_page`, `redact_oauth_state_for_logs`). |
| `features/pairing/` | `GET /api/pairing/{channel}` + `POST /api/pairing/{channel}/approve` — WASM channel pairing approvals. Validates the URL path through `ExtensionName::new` at the handler boundary so invalid channel names reject with 400 instead of silently routing to a lookup-miss. Migrated from `server.rs` in ironclaw#2599 stage 4b. |
| `features/status/` | `GET /api/gateway/status` — runtime snapshot for the admin dashboard (uptime, SSE/WS counts, cost / usage aggregates, active config). Owns the `GatewayStatusResponse` DTO. Migrated from `server.rs` in ironclaw#2599 stage 4b. |
-| `handlers/` | Transitional feature handlers that haven't migrated to `features//` yet: `auth`, `engine`, `frontend`, `llm`, `memory`, `secrets`, `skills`, `system_prompt`, `tokens`, `tool_policy`, `users`, `webhooks`. Targeted for migration per ironclaw#2599 if churn / slice-boundary pressure justifies it. |
+| `handlers/` | Transitional feature handlers that haven't migrated to `features//` yet: `auth`, `engine`, `frontend`, `ironhub`, `llm`, `memory`, `secrets`, `skills`, `system_prompt`, `tokens`, `tool_policy`, `users`, `webhooks`. Targeted for migration per ironclaw#2599 if churn / slice-boundary pressure justifies it. |
+| `handlers/ironhub.rs` | IronHub catalog dispatch (`install`, `search`, `list`, `info`) and the deep-link install protocol surface (`signing-key` CRUD, `register`, `verify-intent`). Owns `hmac_hex`, `install_payload`, `register_payload`, the bounded process-global nonce cache (`NONCE_CACHE_MAX_ENTRIES = 16,384`, TTL = `VERIFY_INTENT_WINDOW_SECS`), and `validate_shared_key` (enforces `ihub_sk_` prefix, at-least-32 character length, and a minimum distinct-character floor so trivially low-entropy keys are rejected). Catalog dispatch routes through `ToolDispatcher`; signing-key + register + verify-intent are direct handlers operating on `SecretsStore` under the `ironhub_signing_key` name. `install` is also signature-gated: it runs the shared `verify_signed_install` check (slug, freshness, HMAC) and burns the one-shot nonce before routing to `ToolDispatcher`. |
| `openai_compat.rs` | OpenAI-compatible proxy (`/v1/chat/completions`, `/v1/models`) |
| `util.rs` | Shared helpers (`web_incoming_message`, `build_turns_from_db_messages`, `images_to_attachments`, `truncate_preview`) |
| `test_helpers.rs` | Always-compiled test utilities. `TestGatewayBuilder` (public) — the `tests/` crate's entry point for spinning up a `GatewayState` + optional Axum server on a random port. Plus seven `pub(crate)` `#[cfg(test)]`-gated cross-slice fixtures — `test_gateway_state(ext_mgr)`, `test_gateway_state_with_dependencies(ext_mgr, store, db_auth, pairing_store)`, `test_gateway_state_with_store_and_session_manager(store, session_manager)`, `insert_test_user`, `test_secrets_store`, `test_ext_mgr`, `test_ext_mgr_with_db` — landed in ironclaw#2599 stages 6a+6 so the chat / extensions / oauth / pairing / users slice test modules can share construction helpers without a central mega-tests block. |
@@ -68,6 +69,7 @@ subset that can later be replaced by a typed `Deps` alias.
|--------|------|-------------|
| GET | `/api/health` | Health check |
| GET | `/oauth/callback` | OAuth callback for extension auth |
+| POST | `/api/ironhub/register` | IronHub deep-link handshake (HMAC-authenticated, no session) |
### Chat
| Method | Path | Description |
@@ -201,6 +203,30 @@ Legacy cleanup note:
- That path exists solely for prompts that do not carry a gate `request_id`.
- Do not expand it. When v1 auth mode is removed, delete these endpoints and the corresponding no-`request_id` branch in `static/js/core/onboarding.js`.
+### IronHub Catalog & Install Protocol
+| Method | Path | Description |
+|--------|------|-------------|
+| POST | `/api/ironhub/install` | Install a tool/skill from the IronHub catalog (admin-only; body is the full signed deep-link payload `{slug, version, uid, aid, ts, nonce, sig, artifact_digest, acknowledge_unverified?}`; re-verifies the install signature (which binds the artifact digest) and burns the one-shot nonce, then dispatches `ironhub_install`, which refuses unless the current catalog entry's version and artifact digest both match the signed values; 403 on bad/forged signature, 409 on replayed nonce, 503 when no signing key) |
+| GET | `/api/ironhub/search` | Search the catalog (dispatches `ironhub_search` tool) |
+| GET | `/api/ironhub/list` | List catalog entries (dispatches `ironhub_list` tool) |
+| GET | `/api/ironhub/info` | Catalog entry detail (dispatches `ironhub_info` tool) |
+| POST | `/api/ironhub/signing-key` | Set the shared install key (body: `{shared_key}`; validates `ihub_sk_` prefix, minimum 32 characters, and a distinct-character floor) |
+| GET | `/api/ironhub/signing-key` | Get fingerprint + created_at for the stored key (never returns the key) |
+| DELETE | `/api/ironhub/signing-key` | Remove the stored key |
+| POST | `/api/ironhub/register` | IronHub-side handshake confirming the agent owns the shared key. **Public route, no session** (lives on the `public` router): IronHub calls it server-to-server, authenticated solely by the HMAC `sig` verified against the owner's stored key (`state.owner_id`). Body `{uid, aid, ts, nonce, sig}`; sig over `register:{uid}:{aid}:{ts}:{nonce}`; returns 200 on valid, 401 on bad sig, 408 on stale timestamp, 409 on replayed nonce |
+| POST | `/api/ironhub/verify-intent` | Browser deep-link install preview (body: `{slug, version, uid, aid, ts, nonce, sig, artifact_digest}`; sig over `install:{slug}:{version}:{uid}:{aid}:{ts}:{nonce}:{artifact_digest}`; returns `{valid, reason}`). Repeatable: it checks slug, freshness, and signature but does NOT consume the nonce; the nonce is burned only by `/api/ironhub/install`. |
+
+**Install protocol contract** (canonical shape that matches IronHub PR #43 and replaces the earlier `{tool}:{ts}` minimal verify shape):
+
+- **Shared key:** user-generated on the IronHub side (per `AgentInstallation` row, encrypted at rest with AES-256-GCM in IronHub's DB), pasted into the agent via `POST /api/ironhub/signing-key`. Format: `ihub_sk_` prefix, minimum 32 characters total, generated by IronHub as `ihub_sk_` + base64url(32 random bytes). The agent rejects trivially low-entropy keys via a distinct-character floor; the security guarantee comes from IronHub generating the key, the floor is a sanity guard.
+- **HMAC:** SHA-256 with the shared key's UTF-8 bytes as the MAC key (no hex-decode). Output is lowercase hex, 64 chars.
+- **Install payload:** `install:{slug}:{version}:{uid}:{aid}:{ts}:{nonce}:{artifact_digest}` (literal `install` lead, colon-separated, `ts` decimal seconds).
+- **Artifact digest:** lowercase hex `sha256` that binds the entry's content, computed identically on both sides. Tool: `sha256(wasm.sha256 + ":" + capabilities.sha256)`. Skill: `sha256(skill_md.sha256)` (manifest hashes are lowercase hex). The agent re-verifies the HMAC over the digest, then recomputes the digest from the manifest entry it is about to install and requires equality, so a same-`version` content swap inside the freshness window is rejected. IronHub and IronClaw share fixed test vectors to lock cross-language agreement.
+- **Register payload:** `register:{uid}:{aid}:{ts}:{nonce}`.
+- **Deep-link URL:** `https:///#/install/?slug=&version=&uid=&aid=&ts=&nonce=&sig=&artifact_digest=` parsed by `static/js/core/routing.js` and consumed by `static/js/surfaces/install.js`.
+- **Two-step binding:** `/verify-intent` is a repeatable preview (slug + freshness + signature, no side effect). `/install` re-verifies the same signed payload against the stored key and only then burns the nonce and dispatches `ironhub_install`, which binds the install to the signed `version` (the current catalog entry must still match that version or the install is refused, so a catalog roll inside the window cannot swap the artifact). The signature is the security gate on both endpoints; the nonce makes the install single-use. An admin session cannot install without a valid signature (403), and a signed payload cannot be replayed within the window (409).
+- **Freshness window:** 300s timestamp drift, enforced on both endpoints. Nonces are scoped per `user_id` and stored in the in-memory `NONCE_CACHE`. Replay-after-window is blocked by drift; in-window replay is blocked by the nonce (consumed at `/install`). The cache evicts entries past TTL (`VERIFY_INTENT_WINDOW_SECS`) from the front on every insert, and evicts the oldest entry when at `NONCE_CACHE_MAX_ENTRIES` capacity (FIFO via `VecDeque`).
+
### Routines
| Method | Path | Description |
|--------|------|-------------|
diff --git a/src/channels/web/features/settings/mod.rs b/src/channels/web/features/settings/mod.rs
index 413ce02c7d7..9c725f17868 100644
--- a/src/channels/web/features/settings/mod.rs
+++ b/src/channels/web/features/settings/mod.rs
@@ -1312,6 +1312,8 @@ mod tests {
oauth_rate_limiter: crate::channels::web::platform::state::PerUserRateLimiter::new(
20, 60,
),
+ ironhub_catalog_rate_limiter:
+ crate::channels::web::platform::state::PerUserRateLimiter::new(30, 60),
webhook_rate_limiter: crate::channels::web::platform::state::RateLimiter::new(10, 60),
registry_entries: Vec::new(),
cost_guard: None,
diff --git a/src/channels/web/handlers/ironhub.rs b/src/channels/web/handlers/ironhub.rs
new file mode 100644
index 00000000000..6cb965e9e9e
--- /dev/null
+++ b/src/channels/web/handlers/ironhub.rs
@@ -0,0 +1,2140 @@
+use std::collections::{HashMap, HashSet, VecDeque};
+use std::sync::{Arc, LazyLock, Mutex};
+use std::time::{Duration, Instant};
+
+use axum::{
+ Json,
+ extract::{Query, State},
+ http::StatusCode,
+};
+
+use crate::channels::web::auth::{AdminUser, AuthenticatedUser};
+use crate::channels::web::platform::state::GatewayState;
+use crate::channels::web::types::{
+ IronhubInfoQuery, IronhubInstallRequest, IronhubListQuery, IronhubRegisterRequest,
+ IronhubSearchQuery, IronhubSigningKeyMetadata, IronhubSigningKeySetRequest,
+ IronhubVerifyIntentRequest, IronhubVerifyIntentResponse,
+};
+use crate::secrets::{CreateSecretParams, SecretError, SecretsStore};
+use crate::tools::ToolError;
+use crate::tools::dispatch::DispatchSource;
+
+const IRONHUB_SIGNING_KEY_NAME: &str = "ironhub_signing_key";
+const VERIFY_INTENT_WINDOW_SECS: u64 = 300;
+const SHARED_KEY_PREFIX: &str = "ihub_sk_";
+const SHARED_KEY_MIN_LEN: usize = 32;
+const SHARED_KEY_MIN_DISTINCT: usize = 12;
+const NONCE_CACHE_MAX_ENTRIES: usize = 16_384;
+
+struct NonceCache {
+ seen: HashMap,
+ order: VecDeque,
+}
+
+impl NonceCache {
+ fn new() -> Self {
+ Self {
+ seen: HashMap::new(),
+ order: VecDeque::new(),
+ }
+ }
+
+ fn front_is_expired(&self, now: Instant, ttl: Duration) -> bool {
+ match self.order.front() {
+ Some(front) => match self.seen.get(front) {
+ Some(seen_at) => now.duration_since(*seen_at) > ttl,
+ None => true,
+ },
+ None => false,
+ }
+ }
+
+ fn record_or_seen(&mut self, key: String, now: Instant, ttl: Duration) -> bool {
+ while self.front_is_expired(now, ttl) {
+ if let Some(evicted) = self.order.pop_front() {
+ self.seen.remove(&evicted);
+ }
+ }
+ if self.seen.contains_key(&key) {
+ return true;
+ }
+ if self.seen.len() >= NONCE_CACHE_MAX_ENTRIES
+ && let Some(evicted) = self.order.pop_front()
+ {
+ self.seen.remove(&evicted);
+ }
+ self.seen.insert(key.clone(), now);
+ self.order.push_back(key);
+ false
+ }
+}
+
+static NONCE_CACHE: LazyLock> = LazyLock::new(|| Mutex::new(NonceCache::new()));
+
+fn nonce_seen_or_record(uid: &str, nonce: &str) -> bool {
+ let key = format!("{uid}:{nonce}");
+ let ttl = Duration::from_secs(VERIFY_INTENT_WINDOW_SECS);
+ let now = Instant::now();
+ let mut guard = match NONCE_CACHE.lock() {
+ Ok(g) => g,
+ Err(poisoned) => poisoned.into_inner(),
+ };
+ guard.record_or_seen(key, now, ttl)
+}
+
+fn validate_shared_key(value: &str) -> Result<(), String> {
+ if !value.starts_with(SHARED_KEY_PREFIX) {
+ return Err(format!(
+ "shared key must start with {SHARED_KEY_PREFIX} prefix"
+ ));
+ }
+ if value.len() < SHARED_KEY_MIN_LEN {
+ return Err(format!(
+ "shared key must be at least {SHARED_KEY_MIN_LEN} characters"
+ ));
+ }
+ let body = &value[SHARED_KEY_PREFIX.len()..];
+ let distinct = body.chars().collect::>().len();
+ if distinct < SHARED_KEY_MIN_DISTINCT {
+ return Err(format!(
+ "shared key is too low-entropy; the part after {SHARED_KEY_PREFIX} must contain at least {SHARED_KEY_MIN_DISTINCT} distinct characters"
+ ));
+ }
+ Ok(())
+}
+
+fn tool_error_to_http(err: ToolError) -> (StatusCode, String) {
+ match err {
+ ToolError::InvalidParameters(msg) => (StatusCode::BAD_REQUEST, msg),
+ ToolError::NotAuthorized(msg) => (StatusCode::UNAUTHORIZED, msg),
+ ToolError::RateLimited(retry) => (
+ StatusCode::TOO_MANY_REQUESTS,
+ retry
+ .map(|d: Duration| format!("rate limited; retry after {}s", d.as_secs()))
+ .unwrap_or_else(|| "rate limited".to_string()),
+ ),
+ ToolError::Timeout(d) => (
+ StatusCode::GATEWAY_TIMEOUT,
+ format!("execution timed out after {}s", d.as_secs()),
+ ),
+ ToolError::ExternalService(msg) => (StatusCode::BAD_GATEWAY, msg),
+ ToolError::ExecutionFailed(msg) => (StatusCode::INTERNAL_SERVER_ERROR, msg),
+ ToolError::Sandbox(msg) => (StatusCode::INTERNAL_SERVER_ERROR, msg),
+ }
+}
+
+fn dispatcher_or_503(
+ state: &Arc,
+) -> Result, (StatusCode, String)> {
+ state.tool_dispatcher.as_ref().map(Arc::clone).ok_or((
+ StatusCode::SERVICE_UNAVAILABLE,
+ "tool dispatcher not available".to_string(),
+ ))
+}
+
+fn catalog_rate_limit(
+ state: &Arc,
+ user_id: &str,
+) -> Result<(), (StatusCode, String)> {
+ if state.ironhub_catalog_rate_limiter.check(user_id) {
+ Ok(())
+ } else {
+ Err((
+ StatusCode::TOO_MANY_REQUESTS,
+ "IronHub catalog rate limit exceeded; try again later".to_string(),
+ ))
+ }
+}
+
+/// Map an internal error to a generic INTERNAL_SERVER_ERROR response while
+/// logging the underlying detail server-side. Per the channel-boundary rule
+/// in `.claude/rules/error-handling.md`, raw error strings from `SecretsStore`,
+/// `hmac`, or any other internal source must not cross to the user.
+fn internal_err(context: &'static str, err: impl std::fmt::Display) -> (StatusCode, String) {
+ tracing::error!(error = %err, "{context}");
+ (StatusCode::INTERNAL_SERVER_ERROR, context.to_string())
+}
+
+pub async fn ironhub_install_handler(
+ State(state): State>,
+ AdminUser(user): AdminUser,
+ Json(req): Json,
+) -> Result, (StatusCode, String)> {
+ catalog_rate_limit(&state, &user.user_id)?;
+
+ let store = secrets_store_or_503(&state)?;
+ let signed = SignedInstall {
+ slug: &req.slug,
+ version: &req.version,
+ uid: &req.uid,
+ aid: &req.aid,
+ ts: req.ts,
+ nonce: &req.nonce,
+ sig: &req.sig,
+ artifact_digest: &req.artifact_digest,
+ };
+ match verify_signed_install(store.as_ref(), &user.user_id, &signed).await {
+ Ok(()) => {}
+ Err(SignedInstallError::Rejected(reason)) => return Err((StatusCode::FORBIDDEN, reason)),
+ Err(SignedInstallError::NoSigningKey) => {
+ return Err((
+ StatusCode::SERVICE_UNAVAILABLE,
+ "no signing key configured on this agent".to_string(),
+ ));
+ }
+ Err(SignedInstallError::Internal(e)) => {
+ return Err(internal_err("install request failed", e));
+ }
+ }
+
+ // verify-intent is a repeatable preview, so the one-shot nonce is only burned
+ // here, at the install that actually mutates state.
+ if nonce_seen_or_record(&user.user_id, &req.nonce) {
+ return Err((StatusCode::CONFLICT, "nonce already used".to_string()));
+ }
+
+ let dispatcher = dispatcher_or_503(&state)?;
+ let mut params = serde_json::Map::new();
+ params.insert("name".into(), serde_json::Value::String(req.slug));
+ params.insert("version".into(), serde_json::Value::String(req.version));
+ params.insert(
+ "artifact_digest".into(),
+ serde_json::Value::String(req.artifact_digest),
+ );
+ // Local owner consent, deliberately outside the HMAC: the signature already binds the
+ // artifact digest, so this can only downgrade the owner's own unverified-content warning,
+ // never change which artifact installs.
+ params.insert(
+ "acknowledge_unverified".into(),
+ serde_json::Value::Bool(req.acknowledge_unverified),
+ );
+
+ let output = dispatcher
+ .dispatch(
+ "ironhub_install",
+ serde_json::Value::Object(params),
+ &user.user_id,
+ DispatchSource::Channel("gateway".into()),
+ )
+ .await
+ .map_err(tool_error_to_http)?;
+ Ok(Json(output.result))
+}
+
+pub async fn ironhub_search_handler(
+ State(state): State>,
+ AuthenticatedUser(user): AuthenticatedUser,
+ Query(q): Query,
+) -> Result, (StatusCode, String)> {
+ catalog_rate_limit(&state, &user.user_id)?;
+ let dispatcher = dispatcher_or_503(&state)?;
+ let mut params = serde_json::Map::new();
+ params.insert("query".into(), serde_json::Value::String(q.query));
+ if let Some(tag) = q.release_tag {
+ params.insert("release_tag".into(), serde_json::Value::String(tag));
+ }
+ let output = dispatcher
+ .dispatch(
+ "ironhub_search",
+ serde_json::Value::Object(params),
+ &user.user_id,
+ DispatchSource::Channel("gateway".into()),
+ )
+ .await
+ .map_err(tool_error_to_http)?;
+ Ok(Json(output.result))
+}
+
+pub async fn ironhub_list_handler(
+ State(state): State>,
+ AuthenticatedUser(user): AuthenticatedUser,
+ Query(q): Query,
+) -> Result, (StatusCode, String)> {
+ catalog_rate_limit(&state, &user.user_id)?;
+ let dispatcher = dispatcher_or_503(&state)?;
+ let mut params = serde_json::Map::new();
+ if let Some(tag) = q.release_tag {
+ params.insert("release_tag".into(), serde_json::Value::String(tag));
+ }
+ let output = dispatcher
+ .dispatch(
+ "ironhub_list",
+ serde_json::Value::Object(params),
+ &user.user_id,
+ DispatchSource::Channel("gateway".into()),
+ )
+ .await
+ .map_err(tool_error_to_http)?;
+ Ok(Json(output.result))
+}
+
+pub async fn ironhub_info_handler(
+ State(state): State>,
+ AuthenticatedUser(user): AuthenticatedUser,
+ Query(q): Query,
+) -> Result, (StatusCode, String)> {
+ catalog_rate_limit(&state, &user.user_id)?;
+ let dispatcher = dispatcher_or_503(&state)?;
+ let mut params = serde_json::Map::new();
+ params.insert("name".into(), serde_json::Value::String(q.name));
+ if let Some(tag) = q.release_tag {
+ params.insert("release_tag".into(), serde_json::Value::String(tag));
+ }
+ let output = dispatcher
+ .dispatch(
+ "ironhub_info",
+ serde_json::Value::Object(params),
+ &user.user_id,
+ DispatchSource::Channel("gateway".into()),
+ )
+ .await
+ .map_err(tool_error_to_http)?;
+ Ok(Json(output.result))
+}
+
+fn secrets_store_or_503(
+ state: &Arc,
+) -> Result, (StatusCode, String)> {
+ state.secrets_store.as_ref().map(Arc::clone).ok_or((
+ StatusCode::SERVICE_UNAVAILABLE,
+ "secrets store not available".to_string(),
+ ))
+}
+
+fn fingerprint(key_hex: &str) -> String {
+ use sha2::{Digest, Sha256};
+ let mut hasher = Sha256::new();
+ hasher.update(key_hex.as_bytes());
+ let digest = hasher.finalize();
+ hex::encode(&digest[..6])
+}
+
+fn now_unix() -> u64 {
+ std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .map(|d| d.as_secs())
+ .unwrap_or(0)
+}
+
+fn hmac_hex(shared_key: &str, msg: &str) -> Result {
+ use hmac::{Hmac, Mac};
+ use sha2::Sha256;
+ type HmacSha256 = Hmac;
+ let mut mac = HmacSha256::new_from_slice(shared_key.as_bytes())
+ .map_err(|e| format!("hmac initialization failed: {e}"))?;
+ mac.update(msg.as_bytes());
+ Ok(hex::encode(mac.finalize().into_bytes()))
+}
+
+/// Reject any payload field that contains the canonical ':' delimiter used by
+/// `install_payload` and `register_payload`. Without escaping, two distinct
+/// field tuples could canonical-serialize to the same HMAC input, so the
+/// verifier rejects colons in any field. `slug` is independently validated by
+/// `validate_hub_name`, which already excludes ':'.
+fn assert_no_delimiter(fields: &[(&'static str, &str)]) -> Result<(), String> {
+ for (name, value) in fields {
+ if value.contains(':') {
+ return Err(format!(
+ "field '{name}' contains ':' delimiter; ambiguous canonical payload"
+ ));
+ }
+ }
+ Ok(())
+}
+
+fn install_payload(
+ slug: &str,
+ version: &str,
+ uid: &str,
+ aid: &str,
+ ts: u64,
+ nonce: &str,
+ artifact_digest: &str,
+) -> String {
+ format!("install:{slug}:{version}:{uid}:{aid}:{ts}:{nonce}:{artifact_digest}")
+}
+
+fn register_payload(uid: &str, aid: &str, ts: u64, nonce: &str) -> String {
+ format!("register:{uid}:{aid}:{ts}:{nonce}")
+}
+
+struct SignedInstall<'a> {
+ slug: &'a str,
+ version: &'a str,
+ uid: &'a str,
+ aid: &'a str,
+ ts: u64,
+ nonce: &'a str,
+ sig: &'a str,
+ artifact_digest: &'a str,
+}
+
+enum SignedInstallError {
+ Rejected(String),
+ NoSigningKey,
+ Internal(String),
+}
+
+async fn verify_signed_install(
+ store: &(dyn SecretsStore + Send + Sync),
+ user_id: &str,
+ signed: &SignedInstall<'_>,
+) -> Result<(), SignedInstallError> {
+ if let Err(e) = crate::cli::hub_install::validate_hub_name(signed.slug) {
+ return Err(SignedInstallError::Rejected(format!("invalid slug: {e}")));
+ }
+
+ assert_no_delimiter(&[
+ ("version", signed.version),
+ ("uid", signed.uid),
+ ("aid", signed.aid),
+ ("nonce", signed.nonce),
+ ("artifact_digest", signed.artifact_digest),
+ ])
+ .map_err(SignedInstallError::Rejected)?;
+
+ let drift = now_unix().abs_diff(signed.ts);
+ if drift > VERIFY_INTENT_WINDOW_SECS {
+ return Err(SignedInstallError::Rejected(format!(
+ "timestamp drift {drift}s exceeds window {VERIFY_INTENT_WINDOW_SECS}s"
+ )));
+ }
+
+ let decrypted = match store.get_decrypted(user_id, IRONHUB_SIGNING_KEY_NAME).await {
+ Ok(s) => s,
+ Err(SecretError::NotFound(_)) => return Err(SignedInstallError::NoSigningKey),
+ Err(e) => return Err(SignedInstallError::Internal(e.to_string())),
+ };
+
+ let payload = install_payload(
+ signed.slug,
+ signed.version,
+ signed.uid,
+ signed.aid,
+ signed.ts,
+ signed.nonce,
+ signed.artifact_digest,
+ );
+ let expected = hmac_hex(decrypted.expose(), &payload).map_err(SignedInstallError::Internal)?;
+
+ use subtle::ConstantTimeEq;
+ let sig_valid: bool = expected.as_bytes().ct_eq(signed.sig.as_bytes()).into();
+ if sig_valid {
+ Ok(())
+ } else {
+ Err(SignedInstallError::Rejected(
+ "signature mismatch".to_string(),
+ ))
+ }
+}
+
+pub async fn ironhub_signing_key_set_handler(
+ State(state): State>,
+ AdminUser(user): AdminUser,
+ Json(req): Json,
+) -> Result, (StatusCode, String)> {
+ let shared_key = req.shared_key.trim();
+ if let Err(e) = validate_shared_key(shared_key) {
+ return Err((StatusCode::BAD_REQUEST, e));
+ }
+
+ let store = secrets_store_or_503(&state)?;
+ let secret = store
+ .create(
+ &user.user_id,
+ CreateSecretParams::new(IRONHUB_SIGNING_KEY_NAME, shared_key),
+ )
+ .await
+ .map_err(|e| internal_err("signing-key set failed", e))?;
+
+ let stored = store
+ .get_decrypted(&user.user_id, IRONHUB_SIGNING_KEY_NAME)
+ .await
+ .map_err(|e| internal_err("signing-key set failed", e))?;
+
+ Ok(Json(IronhubSigningKeyMetadata {
+ fingerprint: fingerprint(stored.expose()),
+ created_at: secret.created_at.to_rfc3339(),
+ }))
+}
+
+pub async fn ironhub_signing_key_get_handler(
+ State(state): State>,
+ AdminUser(user): AdminUser,
+) -> Result, (StatusCode, String)> {
+ let store = secrets_store_or_503(&state)?;
+ let meta = match store.get(&user.user_id, IRONHUB_SIGNING_KEY_NAME).await {
+ Ok(s) => s,
+ Err(SecretError::NotFound(_)) => {
+ return Err((StatusCode::NOT_FOUND, "no signing key set".to_string()));
+ }
+ Err(e) => return Err(internal_err("signing-key read failed", e)),
+ };
+ let decrypted = store
+ .get_decrypted(&user.user_id, IRONHUB_SIGNING_KEY_NAME)
+ .await
+ .map_err(|e| internal_err("signing-key read failed", e))?;
+ Ok(Json(IronhubSigningKeyMetadata {
+ fingerprint: fingerprint(decrypted.expose()),
+ created_at: meta.created_at.to_rfc3339(),
+ }))
+}
+
+pub async fn ironhub_signing_key_delete_handler(
+ State(state): State>,
+ AdminUser(user): AdminUser,
+) -> Result {
+ let store = secrets_store_or_503(&state)?;
+ let removed = store
+ .delete(&user.user_id, IRONHUB_SIGNING_KEY_NAME)
+ .await
+ .map_err(|e| internal_err("signing-key delete failed", e))?;
+ if removed {
+ Ok(StatusCode::NO_CONTENT)
+ } else {
+ Err((StatusCode::NOT_FOUND, "no signing key set".to_string()))
+ }
+}
+
+pub async fn ironhub_verify_intent_handler(
+ State(state): State>,
+ AuthenticatedUser(user): AuthenticatedUser,
+ Json(req): Json,
+) -> Result, (StatusCode, String)> {
+ catalog_rate_limit(&state, &user.user_id)?;
+ let store = secrets_store_or_503(&state)?;
+ let signed = SignedInstall {
+ slug: &req.slug,
+ version: &req.version,
+ uid: &req.uid,
+ aid: &req.aid,
+ ts: req.ts,
+ nonce: &req.nonce,
+ sig: &req.sig,
+ artifact_digest: &req.artifact_digest,
+ };
+ match verify_signed_install(store.as_ref(), &user.user_id, &signed).await {
+ Ok(()) => Ok(Json(IronhubVerifyIntentResponse {
+ valid: true,
+ reason: None,
+ })),
+ Err(SignedInstallError::Rejected(reason)) => Ok(Json(IronhubVerifyIntentResponse {
+ valid: false,
+ reason: Some(reason),
+ })),
+ Err(SignedInstallError::NoSigningKey) => Ok(Json(IronhubVerifyIntentResponse {
+ valid: false,
+ reason: Some("no signing key configured on this agent".to_string()),
+ })),
+ Err(SignedInstallError::Internal(e)) => {
+ Err(internal_err("install-intent verification failed", e))
+ }
+ }
+}
+
+pub async fn ironhub_register_handler(
+ State(state): State>,
+ Json(req): Json,
+) -> Result {
+ catalog_rate_limit(&state, &state.owner_id)?;
+ let now = now_unix();
+ let drift = now.abs_diff(req.ts);
+ if drift > VERIFY_INTENT_WINDOW_SECS {
+ return Err((
+ StatusCode::REQUEST_TIMEOUT,
+ format!("timestamp drift {drift}s exceeds window {VERIFY_INTENT_WINDOW_SECS}s"),
+ ));
+ }
+
+ if let Err(reason) =
+ assert_no_delimiter(&[("uid", &req.uid), ("aid", &req.aid), ("nonce", &req.nonce)])
+ {
+ return Err((StatusCode::BAD_REQUEST, reason));
+ }
+
+ let store = secrets_store_or_503(&state)?;
+ let decrypted = match store
+ .get_decrypted(&state.owner_id, IRONHUB_SIGNING_KEY_NAME)
+ .await
+ {
+ Ok(s) => s,
+ Err(SecretError::NotFound(_)) => {
+ return Err((
+ StatusCode::SERVICE_UNAVAILABLE,
+ "no signing key configured on this agent".to_string(),
+ ));
+ }
+ Err(e) => return Err(internal_err("register request failed", e)),
+ };
+
+ let payload = register_payload(&req.uid, &req.aid, req.ts, &req.nonce);
+ let expected = hmac_hex(decrypted.expose(), &payload)
+ .map_err(|e| internal_err("register request failed", e))?;
+
+ use subtle::ConstantTimeEq;
+ let supplied = req.sig.as_bytes();
+ let sig_valid: bool = expected.as_bytes().ct_eq(supplied).into();
+ if !sig_valid {
+ return Err((StatusCode::UNAUTHORIZED, "signature mismatch".to_string()));
+ }
+
+ if nonce_seen_or_record(&state.owner_id, &req.nonce) {
+ return Err((StatusCode::CONFLICT, "nonce already used".to_string()));
+ }
+
+ Ok(StatusCode::OK)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::channels::web::platform::auth::UserIdentity;
+ use crate::config::SafetyConfig;
+ use crate::db::Database;
+ use crate::db::UserRecord;
+ use crate::db::libsql::LibSqlBackend;
+ use crate::tools::dispatch::ToolDispatcher;
+ use crate::tools::{ApprovalRequirement, Tool, ToolOutput, ToolRegistry};
+ use async_trait::async_trait;
+ use axum::Router;
+ use axum::body::Body;
+ use axum::routing::{get, post};
+ use ironclaw_safety::SafetyLayer;
+ use std::sync::atomic::{AtomicUsize, Ordering};
+ use tower::ServiceExt;
+
+ struct StubIronhubTool {
+ name: &'static str,
+ schema: serde_json::Value,
+ approval: ApprovalRequirement,
+ calls: Arc,
+ response: serde_json::Value,
+ }
+
+ #[async_trait]
+ impl Tool for StubIronhubTool {
+ fn name(&self) -> &str {
+ self.name
+ }
+ fn description(&self) -> &str {
+ "stub"
+ }
+ fn parameters_schema(&self) -> serde_json::Value {
+ self.schema.clone()
+ }
+ fn requires_approval(&self, _: &serde_json::Value) -> ApprovalRequirement {
+ self.approval
+ }
+ async fn execute(
+ &self,
+ _params: serde_json::Value,
+ _ctx: &crate::context::JobContext,
+ ) -> Result {
+ self.calls.fetch_add(1, Ordering::SeqCst);
+ Ok(ToolOutput::success(
+ self.response.clone(),
+ std::time::Duration::from_millis(1),
+ ))
+ }
+ }
+
+ fn install_schema() -> serde_json::Value {
+ serde_json::json!({
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "name": { "type": "string", "pattern": "^[a-z0-9][a-z0-9_-]*$", "minLength": 1, "maxLength": 64 },
+ "kind": { "type": "string", "enum": ["tool", "skill"] },
+ "release_tag": { "type": "string", "pattern": "^[A-Za-z0-9._-]+$", "minLength": 1, "maxLength": 128 },
+ "version": { "type": "string", "minLength": 1, "maxLength": 128 },
+ "artifact_digest": { "type": "string", "minLength": 1, "maxLength": 128 },
+ "force": { "type": "boolean", "default": false },
+ "acknowledge_unverified": { "type": "boolean", "default": false }
+ },
+ "required": ["name"]
+ })
+ }
+
+ fn search_schema() -> serde_json::Value {
+ serde_json::json!({
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "query": { "type": "string", "minLength": 1, "maxLength": 128 },
+ "release_tag": { "type": "string", "pattern": "^[A-Za-z0-9._-]+$", "minLength": 1, "maxLength": 128 }
+ },
+ "required": ["query"]
+ })
+ }
+
+ fn list_schema() -> serde_json::Value {
+ serde_json::json!({
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "release_tag": { "type": "string", "pattern": "^[A-Za-z0-9._-]+$", "minLength": 1, "maxLength": 128 }
+ }
+ })
+ }
+
+ fn info_schema() -> serde_json::Value {
+ serde_json::json!({
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "name": { "type": "string", "pattern": "^[a-z0-9][a-z0-9_-]*$", "minLength": 1, "maxLength": 64 },
+ "release_tag": { "type": "string", "pattern": "^[A-Za-z0-9._-]+$", "minLength": 1, "maxLength": 128 }
+ },
+ "required": ["name"]
+ })
+ }
+
+ async fn build_state_with_stubs() -> (Arc, Arc) {
+ let dir = tempfile::tempdir().expect("tempdir");
+ let backend = Arc::new(
+ LibSqlBackend::new_local(&dir.path().join("test.db"))
+ .await
+ .expect("libsql backend"),
+ );
+ backend.run_migrations().await.expect("migrations");
+ let db: Arc = Arc::clone(&backend) as Arc;
+ let now = chrono::Utc::now();
+ for id in ["test-admin", "test-user"] {
+ db.create_user(&UserRecord {
+ id: id.into(),
+ email: None,
+ display_name: id.into(),
+ status: "active".into(),
+ role: if id == "test-admin" {
+ "admin".into()
+ } else {
+ "regular".into()
+ },
+ created_at: now,
+ updated_at: now,
+ last_login_at: None,
+ created_by: None,
+ metadata: serde_json::json!({}),
+ })
+ .await
+ .expect("create user");
+ }
+ let registry = Arc::new(ToolRegistry::new());
+ let calls = Arc::new(AtomicUsize::new(0));
+ registry
+ .register(Arc::new(StubIronhubTool {
+ name: "ironhub_install",
+ schema: install_schema(),
+ approval: ApprovalRequirement::Never,
+ calls: Arc::clone(&calls),
+ response: serde_json::json!({"status": "installed", "name": "clickup"}),
+ }))
+ .await;
+ registry
+ .register(Arc::new(StubIronhubTool {
+ name: "ironhub_search",
+ schema: search_schema(),
+ approval: ApprovalRequirement::Never,
+ calls: Arc::clone(&calls),
+ response: serde_json::json!({"results": []}),
+ }))
+ .await;
+ registry
+ .register(Arc::new(StubIronhubTool {
+ name: "ironhub_list",
+ schema: list_schema(),
+ approval: ApprovalRequirement::Never,
+ calls: Arc::clone(&calls),
+ response: serde_json::json!({"tools": [], "skills": []}),
+ }))
+ .await;
+ registry
+ .register(Arc::new(StubIronhubTool {
+ name: "ironhub_info",
+ schema: info_schema(),
+ approval: ApprovalRequirement::Never,
+ calls: Arc::clone(&calls),
+ response: serde_json::json!({"kind": "tool", "name": "clickup"}),
+ }))
+ .await;
+
+ let safety = Arc::new(SafetyLayer::new(&SafetyConfig {
+ max_output_length: 65_536,
+ injection_check_enabled: false,
+ }));
+ let dispatcher = Arc::new(ToolDispatcher::new(registry, safety, db));
+ std::mem::forget(dir);
+
+ let secrets = crate::channels::web::test_helpers::test_secrets_store();
+ secrets
+ .create(
+ "test-admin",
+ CreateSecretParams::new(IRONHUB_SIGNING_KEY_NAME, TEST_SHARED_KEY),
+ )
+ .await
+ .expect("seed signing key");
+
+ let state = crate::channels::web::test_helpers::TestGatewayBuilder::new()
+ .with_tool_dispatcher(dispatcher)
+ .with_secrets_store(secrets)
+ .build();
+ (state, calls)
+ }
+
+ fn req_with_identity(
+ method: &str,
+ uri: &str,
+ body: Body,
+ role: &str,
+ ) -> axum::http::Request {
+ let mut req = axum::http::Request::builder()
+ .method(method)
+ .uri(uri)
+ .header("content-type", "application/json")
+ .body(body)
+ .expect("request");
+ req.extensions_mut().insert(UserIdentity {
+ user_id: if role == "admin" {
+ "test-admin".into()
+ } else {
+ "test-user".into()
+ },
+ role: role.into(),
+ workspace_read_scopes: Vec::new(),
+ });
+ req
+ }
+
+ fn req_no_identity(method: &str, uri: &str, body: Body) -> axum::http::Request {
+ axum::http::Request::builder()
+ .method(method)
+ .uri(uri)
+ .header("content-type", "application/json")
+ .body(body)
+ .expect("request")
+ }
+
+ fn install_signed_body(slug: &str, nonce: &str, ts: u64) -> serde_json::Value {
+ let payload = install_payload(slug, "1.0.0", "u1", "a1", ts, nonce, TEST_ARTIFACT_DIGEST);
+ let sig = hmac_hex(TEST_SHARED_KEY, &payload).expect("sign");
+ serde_json::json!({
+ "slug": slug,
+ "version": "1.0.0",
+ "uid": "u1",
+ "aid": "a1",
+ "ts": ts,
+ "nonce": nonce,
+ "sig": sig,
+ "artifact_digest": TEST_ARTIFACT_DIGEST,
+ })
+ }
+
+ #[test]
+ fn nonce_cache_records_then_detects_replay() {
+ let mut cache = NonceCache::new();
+ let now = Instant::now();
+ let ttl = Duration::from_secs(VERIFY_INTENT_WINDOW_SECS);
+ assert!(!cache.record_or_seen("u:n1".into(), now, ttl));
+ assert!(cache.record_or_seen("u:n1".into(), now, ttl));
+ assert!(!cache.record_or_seen("u:n2".into(), now, ttl));
+ }
+
+ #[test]
+ fn nonce_cache_evicts_expired_before_recording() {
+ let mut cache = NonceCache::new();
+ let ttl = Duration::from_secs(VERIFY_INTENT_WINDOW_SECS);
+ let base = Instant::now();
+ let later = base + ttl + Duration::from_secs(10);
+ assert!(!cache.record_or_seen("u:old".into(), base, ttl));
+ assert!(!cache.record_or_seen("u:fresh".into(), later, ttl));
+ assert!(
+ !cache.record_or_seen("u:old".into(), later, ttl),
+ "an expired nonce must be evicted, so re-recording it is new, not a replay"
+ );
+ }
+
+ #[test]
+ fn nonce_cache_stays_bounded_and_evicts_oldest() {
+ let mut cache = NonceCache::new();
+ let now = Instant::now();
+ let ttl = Duration::from_secs(VERIFY_INTENT_WINDOW_SECS);
+ for i in 0..NONCE_CACHE_MAX_ENTRIES {
+ assert!(!cache.record_or_seen(format!("u:{i}"), now, ttl));
+ }
+ assert_eq!(cache.seen.len(), NONCE_CACHE_MAX_ENTRIES);
+ assert!(!cache.record_or_seen("u:overflow".into(), now, ttl));
+ assert_eq!(cache.seen.len(), NONCE_CACHE_MAX_ENTRIES);
+ assert!(!cache.seen.contains_key("u:0"));
+ }
+
+ #[tokio::test]
+ async fn ironhub_install_rejects_unauthenticated() {
+ let (state, _calls) = build_state_with_stubs().await;
+ let app = Router::new()
+ .route("/api/ironhub/install", post(ironhub_install_handler))
+ .with_state(state);
+ let req = req_no_identity(
+ "POST",
+ "/api/ironhub/install",
+ Body::from(serde_json::json!({"name": "clickup"}).to_string()),
+ );
+ let resp = ServiceExt::>::oneshot(app, req)
+ .await
+ .expect("response");
+ assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
+ }
+
+ #[tokio::test]
+ async fn ironhub_install_rejects_non_admin() {
+ let (state, _calls) = build_state_with_stubs().await;
+ let app = Router::new()
+ .route("/api/ironhub/install", post(ironhub_install_handler))
+ .with_state(state);
+ let req = req_with_identity(
+ "POST",
+ "/api/ironhub/install",
+ Body::from(serde_json::json!({"name": "clickup"}).to_string()),
+ "regular",
+ );
+ let resp = ServiceExt::>::oneshot(app, req)
+ .await
+ .expect("response");
+ assert_eq!(resp.status(), StatusCode::FORBIDDEN);
+ }
+
+ #[tokio::test]
+ async fn ironhub_install_admin_dispatches_tool() {
+ let (state, calls) = build_state_with_stubs().await;
+ let app = Router::new()
+ .route("/api/ironhub/install", post(ironhub_install_handler))
+ .with_state(state);
+ let nonce = uuid::Uuid::new_v4().to_string();
+ let body = install_signed_body("clickup", &nonce, now_unix());
+ let req = req_with_identity(
+ "POST",
+ "/api/ironhub/install",
+ Body::from(body.to_string()),
+ "admin",
+ );
+ let resp = ServiceExt::>::oneshot(app, req)
+ .await
+ .expect("response");
+ assert_eq!(resp.status(), StatusCode::OK);
+ assert_eq!(calls.load(Ordering::SeqCst), 1);
+ }
+
+ #[tokio::test]
+ async fn ironhub_install_handler_forwards_acknowledge_unverified() {
+ use std::sync::Mutex as StdMutex;
+ let dir = tempfile::tempdir().expect("tempdir");
+ let backend = Arc::new(
+ LibSqlBackend::new_local(&dir.path().join("test.db"))
+ .await
+ .expect("libsql backend"),
+ );
+ backend.run_migrations().await.expect("migrations");
+ let db: Arc = Arc::clone(&backend) as Arc;
+ let now = chrono::Utc::now();
+ db.create_user(&UserRecord {
+ id: "test-admin".into(),
+ email: None,
+ display_name: "test-admin".into(),
+ status: "active".into(),
+ role: "admin".into(),
+ created_at: now,
+ updated_at: now,
+ last_login_at: None,
+ created_by: None,
+ metadata: serde_json::json!({}),
+ })
+ .await
+ .expect("create user");
+
+ struct ParamCaptureStub {
+ captured: Arc>>,
+ }
+ #[async_trait]
+ impl Tool for ParamCaptureStub {
+ fn name(&self) -> &str {
+ "ironhub_install"
+ }
+ fn description(&self) -> &str {
+ "stub"
+ }
+ fn parameters_schema(&self) -> serde_json::Value {
+ install_schema()
+ }
+ async fn execute(
+ &self,
+ params: serde_json::Value,
+ _ctx: &crate::context::JobContext,
+ ) -> Result {
+ *self.captured.lock().unwrap() = Some(params);
+ Ok(ToolOutput::success(
+ serde_json::json!({"status": "installed"}),
+ std::time::Duration::from_millis(1),
+ ))
+ }
+ }
+
+ let captured = Arc::new(StdMutex::new(None));
+ let registry = Arc::new(ToolRegistry::new());
+ registry
+ .register(Arc::new(ParamCaptureStub {
+ captured: Arc::clone(&captured),
+ }))
+ .await;
+ let safety = Arc::new(SafetyLayer::new(&SafetyConfig {
+ max_output_length: 65_536,
+ injection_check_enabled: false,
+ }));
+ let dispatcher = Arc::new(ToolDispatcher::new(registry, safety, db));
+ std::mem::forget(dir);
+
+ let secrets = crate::channels::web::test_helpers::test_secrets_store();
+ secrets
+ .create(
+ "test-admin",
+ CreateSecretParams::new(IRONHUB_SIGNING_KEY_NAME, TEST_SHARED_KEY),
+ )
+ .await
+ .expect("seed signing key");
+
+ let state = crate::channels::web::test_helpers::TestGatewayBuilder::new()
+ .with_tool_dispatcher(dispatcher)
+ .with_secrets_store(secrets)
+ .build();
+ let app = Router::new()
+ .route("/api/ironhub/install", post(ironhub_install_handler))
+ .with_state(state);
+ let nonce = uuid::Uuid::new_v4().to_string();
+ let mut body = install_signed_body("clickup", &nonce, now_unix());
+ body["acknowledge_unverified"] = serde_json::json!(true);
+ let req = req_with_identity(
+ "POST",
+ "/api/ironhub/install",
+ Body::from(body.to_string()),
+ "admin",
+ );
+ let resp = ServiceExt::>::oneshot(app, req)
+ .await
+ .expect("response");
+ assert_eq!(resp.status(), StatusCode::OK);
+ let params = captured.lock().unwrap().clone().expect("params captured");
+ assert_eq!(
+ params
+ .get("acknowledge_unverified")
+ .and_then(|v| v.as_bool()),
+ Some(true),
+ "gateway handler must forward acknowledge_unverified to the ironhub_install tool"
+ );
+ assert_eq!(
+ params.get("name").and_then(|v| v.as_str()),
+ Some("clickup"),
+ "gateway handler must forward the signed slug as the install name"
+ );
+ assert_eq!(
+ params.get("version").and_then(|v| v.as_str()),
+ Some("1.0.0"),
+ "gateway handler must forward the signed version so the tool binds the install to it"
+ );
+ assert_eq!(
+ params.get("artifact_digest").and_then(|v| v.as_str()),
+ Some(TEST_ARTIFACT_DIGEST),
+ "gateway handler must forward the signed artifact_digest so the tool binds the install to the artifact content"
+ );
+ }
+
+ #[tokio::test]
+ async fn ironhub_catalog_handlers_rate_limit_per_user() {
+ let dir = tempfile::tempdir().expect("tempdir");
+ let backend = Arc::new(
+ LibSqlBackend::new_local(&dir.path().join("test.db"))
+ .await
+ .expect("libsql backend"),
+ );
+ backend.run_migrations().await.expect("migrations");
+ let db: Arc = Arc::clone(&backend) as Arc;
+ let now = chrono::Utc::now();
+ db.create_user(&UserRecord {
+ id: "test-user".into(),
+ email: None,
+ display_name: "test-user".into(),
+ status: "active".into(),
+ role: "regular".into(),
+ created_at: now,
+ updated_at: now,
+ last_login_at: None,
+ created_by: None,
+ metadata: serde_json::json!({}),
+ })
+ .await
+ .expect("create user");
+ let registry = Arc::new(ToolRegistry::new());
+ registry
+ .register(Arc::new(StubIronhubTool {
+ name: "ironhub_search",
+ schema: search_schema(),
+ approval: ApprovalRequirement::Never,
+ calls: Arc::new(AtomicUsize::new(0)),
+ response: serde_json::json!({"results": []}),
+ }))
+ .await;
+ let safety = Arc::new(SafetyLayer::new(&SafetyConfig {
+ max_output_length: 65_536,
+ injection_check_enabled: false,
+ }));
+ let dispatcher = Arc::new(ToolDispatcher::new(registry, safety, db));
+ std::mem::forget(dir);
+
+ let state = crate::channels::web::test_helpers::TestGatewayBuilder::new()
+ .with_tool_dispatcher(dispatcher)
+ .with_ironhub_catalog_rate_limit(1, 60)
+ .build();
+ let app = Router::new()
+ .route("/api/ironhub/search", get(ironhub_search_handler))
+ .with_state(state);
+
+ let first = req_with_identity(
+ "GET",
+ "/api/ironhub/search?query=rpc",
+ Body::empty(),
+ "regular",
+ );
+ let resp1 = ServiceExt::>::oneshot(app.clone(), first)
+ .await
+ .expect("first");
+ assert_eq!(resp1.status(), StatusCode::OK);
+
+ let second = req_with_identity(
+ "GET",
+ "/api/ironhub/search?query=rpc",
+ Body::empty(),
+ "regular",
+ );
+ let resp2 = ServiceExt::>::oneshot(app, second)
+ .await
+ .expect("second");
+ assert_eq!(
+ resp2.status(),
+ StatusCode::TOO_MANY_REQUESTS,
+ "catalog dispatch must rate-limit per user"
+ );
+ }
+
+ #[tokio::test]
+ async fn ironhub_install_accepts_underscore_in_name() {
+ let (state, calls) = build_state_with_stubs().await;
+ let app = Router::new()
+ .route("/api/ironhub/install", post(ironhub_install_handler))
+ .with_state(state);
+ let nonce = uuid::Uuid::new_v4().to_string();
+ let body = install_signed_body("microsoft_365", &nonce, now_unix());
+ let req = req_with_identity(
+ "POST",
+ "/api/ironhub/install",
+ Body::from(body.to_string()),
+ "admin",
+ );
+ let resp = ServiceExt::>::oneshot(app, req)
+ .await
+ .expect("response");
+ assert_eq!(
+ resp.status(),
+ StatusCode::OK,
+ "underscore names like microsoft_365 must pass the schema regex"
+ );
+ assert_eq!(calls.load(Ordering::SeqCst), 1);
+ }
+
+ #[tokio::test]
+ async fn ironhub_install_rejects_path_traversal_in_name() {
+ let (state, calls) = build_state_with_stubs().await;
+ let app = Router::new()
+ .route("/api/ironhub/install", post(ironhub_install_handler))
+ .with_state(state);
+ let nonce = uuid::Uuid::new_v4().to_string();
+ let body = install_signed_body("../etc/passwd", &nonce, now_unix());
+ let req = req_with_identity(
+ "POST",
+ "/api/ironhub/install",
+ Body::from(body.to_string()),
+ "admin",
+ );
+ let resp = ServiceExt::>::oneshot(app, req)
+ .await
+ .expect("response");
+ assert_eq!(resp.status(), StatusCode::FORBIDDEN);
+ assert_eq!(
+ calls.load(Ordering::SeqCst),
+ 0,
+ "tool execute must NOT run when the signed slug is rejected"
+ );
+ }
+
+ #[tokio::test]
+ async fn ironhub_search_rejects_unauthenticated() {
+ let (state, _calls) = build_state_with_stubs().await;
+ let app = Router::new()
+ .route("/api/ironhub/search", get(ironhub_search_handler))
+ .with_state(state);
+ let req = req_no_identity("GET", "/api/ironhub/search?query=rpc", Body::empty());
+ let resp = ServiceExt::>::oneshot(app, req)
+ .await
+ .expect("response");
+ assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
+ }
+
+ #[tokio::test]
+ async fn ironhub_search_returns_dispatch_result_for_authenticated_user() {
+ let (state, calls) = build_state_with_stubs().await;
+ let app = Router::new()
+ .route("/api/ironhub/search", get(ironhub_search_handler))
+ .with_state(state);
+ let req = req_with_identity(
+ "GET",
+ "/api/ironhub/search?query=rpc",
+ Body::empty(),
+ "regular",
+ );
+ let resp = ServiceExt::>::oneshot(app, req)
+ .await
+ .expect("response");
+ assert_eq!(resp.status(), StatusCode::OK);
+ assert_eq!(calls.load(Ordering::SeqCst), 1);
+ }
+
+ #[tokio::test]
+ async fn ironhub_list_passes_release_tag_query_to_dispatch() {
+ let (state, calls) = build_state_with_stubs().await;
+ let app = Router::new()
+ .route("/api/ironhub/list", get(ironhub_list_handler))
+ .with_state(state);
+ let req = req_with_identity(
+ "GET",
+ "/api/ironhub/list?release_tag=release-test",
+ Body::empty(),
+ "regular",
+ );
+ let resp = ServiceExt::>::oneshot(app, req)
+ .await
+ .expect("response");
+ assert_eq!(resp.status(), StatusCode::OK);
+ assert_eq!(calls.load(Ordering::SeqCst), 1);
+ }
+
+ #[tokio::test]
+ async fn ironhub_info_rejects_path_traversal_in_query_name() {
+ let (state, calls) = build_state_with_stubs().await;
+ let app = Router::new()
+ .route("/api/ironhub/info", get(ironhub_info_handler))
+ .with_state(state);
+ let req = req_with_identity(
+ "GET",
+ "/api/ironhub/info?name=..%2Fetc%2Fpasswd",
+ Body::empty(),
+ "regular",
+ );
+ let resp = ServiceExt::>::oneshot(app, req)
+ .await
+ .expect("response");
+ assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
+ assert_eq!(
+ calls.load(Ordering::SeqCst),
+ 0,
+ "tool execute must NOT run when schema rejects"
+ );
+ }
+
+ #[tokio::test]
+ async fn ironhub_install_rejects_unknown_field() {
+ let (state, calls) = build_state_with_stubs().await;
+ let app = Router::new()
+ .route("/api/ironhub/install", post(ironhub_install_handler))
+ .with_state(state);
+ let nonce = uuid::Uuid::new_v4().to_string();
+ let mut body = install_signed_body("clickup", &nonce, now_unix());
+ body["evil"] = serde_json::json!("exfil");
+ let req = req_with_identity(
+ "POST",
+ "/api/ironhub/install",
+ Body::from(body.to_string()),
+ "admin",
+ );
+ let resp = ServiceExt::>::oneshot(app, req)
+ .await
+ .expect("response");
+ assert!(
+ resp.status() == StatusCode::BAD_REQUEST
+ || resp.status() == StatusCode::UNPROCESSABLE_ENTITY,
+ "expected 400 or 422 for unknown field, got {:?}",
+ resp.status()
+ );
+ assert_eq!(
+ calls.load(Ordering::SeqCst),
+ 0,
+ "tool execute must NOT run when extra field rejected"
+ );
+ }
+
+ #[tokio::test]
+ async fn ironhub_install_rejects_bad_signature() {
+ let (state, calls) = build_state_with_stubs().await;
+ let app = Router::new()
+ .route("/api/ironhub/install", post(ironhub_install_handler))
+ .with_state(state);
+ let nonce = uuid::Uuid::new_v4().to_string();
+ let body = serde_json::json!({
+ "slug": "clickup",
+ "version": "1.0.0",
+ "uid": "u1",
+ "aid": "a1",
+ "ts": now_unix(),
+ "nonce": nonce,
+ "sig": "deadbeef".repeat(8),
+ "artifact_digest": "d0",
+ });
+ let req = req_with_identity(
+ "POST",
+ "/api/ironhub/install",
+ Body::from(body.to_string()),
+ "admin",
+ );
+ let resp = ServiceExt::>::oneshot(app, req)
+ .await
+ .expect("response");
+ assert_eq!(resp.status(), StatusCode::FORBIDDEN);
+ assert_eq!(
+ calls.load(Ordering::SeqCst),
+ 0,
+ "install must reject an unsigned/forged request before dispatching"
+ );
+ }
+
+ #[tokio::test]
+ async fn ironhub_install_rejects_replayed_nonce() {
+ let (state, calls) = build_state_with_stubs().await;
+ let app = Router::new()
+ .route("/api/ironhub/install", post(ironhub_install_handler))
+ .with_state(state);
+ let nonce = uuid::Uuid::new_v4().to_string();
+ let body = install_signed_body("clickup", &nonce, now_unix());
+ let first = req_with_identity(
+ "POST",
+ "/api/ironhub/install",
+ Body::from(body.to_string()),
+ "admin",
+ );
+ let r1 = ServiceExt::>::oneshot(app.clone(), first)
+ .await
+ .expect("first");
+ assert_eq!(r1.status(), StatusCode::OK);
+
+ let replay = req_with_identity(
+ "POST",
+ "/api/ironhub/install",
+ Body::from(body.to_string()),
+ "admin",
+ );
+ let r2 = ServiceExt::>::oneshot(app, replay)
+ .await
+ .expect("replay");
+ assert_eq!(r2.status(), StatusCode::CONFLICT);
+ assert_eq!(
+ calls.load(Ordering::SeqCst),
+ 1,
+ "a replayed signed install must not dispatch the tool twice"
+ );
+ }
+
+ #[test]
+ fn assert_no_delimiter_rejects_colon_in_any_field() {
+ assert!(assert_no_delimiter(&[("version", "1.0.0")]).is_ok());
+ assert!(assert_no_delimiter(&[("uid", "user-1")]).is_ok());
+ assert!(assert_no_delimiter(&[("nonce", "n1"), ("aid", "a1")]).is_ok());
+ let err = assert_no_delimiter(&[("version", "1.0:malicious")])
+ .expect_err("colon in version must be rejected");
+ assert!(
+ err.contains("version") && err.contains("':' delimiter"),
+ "expected named-field delimiter message, got: {err}"
+ );
+ }
+
+ #[test]
+ fn internal_err_returns_generic_message_without_inner_detail() {
+ let (status, body) = internal_err("widget failed", "secret /path/internal/file");
+ assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR);
+ assert_eq!(body, "widget failed");
+ assert!(
+ !body.contains("secret") && !body.contains("/path"),
+ "internal detail must not cross the channel boundary: {body}"
+ );
+ }
+
+ #[tokio::test]
+ async fn verify_signed_install_rejects_version_with_colon() {
+ let store = crate::channels::web::test_helpers::test_secrets_store();
+ let signed = SignedInstall {
+ slug: "clickup",
+ version: "1.0:malicious",
+ uid: "u1",
+ aid: "a1",
+ ts: now_unix(),
+ nonce: "n1",
+ sig: "deadbeef",
+ artifact_digest: "deadbeef",
+ };
+ let err = verify_signed_install(store.as_ref(), "test-user", &signed)
+ .await
+ .expect_err("colon in version must be rejected");
+ match err {
+ SignedInstallError::Rejected(msg) => {
+ assert!(
+ msg.contains("version") && msg.contains("delimiter"),
+ "expected delimiter rejection through verify_signed_install, got: {msg}"
+ );
+ }
+ SignedInstallError::NoSigningKey => {
+ panic!("delimiter check must reject before the store is touched")
+ }
+ SignedInstallError::Internal(msg) => {
+ panic!("expected Rejected, got Internal({msg})")
+ }
+ }
+ }
+
+ #[test]
+ fn install_hmac_is_deterministic_and_payload_sensitive() {
+ let key_a = "ihub_sk_aaaaaaaaaaaaaaaaaaaaaaaa";
+ let key_b = "ihub_sk_bbbbbbbbbbbbbbbbbbbbbbbb";
+ let p1 = install_payload("clickup", "1.0.0", "u1", "a1", 1_700_000_000, "n1", "d0");
+ let p_other_slug =
+ install_payload("evm-rpc", "1.0.0", "u1", "a1", 1_700_000_000, "n1", "d0");
+ let p_other_nonce =
+ install_payload("clickup", "1.0.0", "u1", "a1", 1_700_000_000, "n2", "d0");
+ let s1 = hmac_hex(key_a, &p1).expect("hmac");
+ let s2 = hmac_hex(key_a, &p1).expect("hmac");
+ let s3 = hmac_hex(key_b, &p1).expect("hmac");
+ let s4 = hmac_hex(key_a, &p_other_slug).expect("hmac");
+ let s5 = hmac_hex(key_a, &p_other_nonce).expect("hmac");
+ assert_eq!(s1, s2, "same inputs must produce same signature");
+ assert_ne!(s1, s3, "different key must produce different signature");
+ assert_ne!(s1, s4, "different slug must produce different signature");
+ assert_ne!(s1, s5, "different nonce must produce different signature");
+ assert_eq!(s1.len(), 64, "hex sha256 hmac is 64 chars");
+ }
+
+ #[test]
+ fn install_payload_format_is_stable() {
+ let p = install_payload(
+ "clickup",
+ "1.0.0",
+ "u1",
+ "a1",
+ 1_700_000_000,
+ "n1",
+ "deadbeef",
+ );
+ assert_eq!(p, "install:clickup:1.0.0:u1:a1:1700000000:n1:deadbeef");
+ }
+
+ #[test]
+ fn register_payload_format_is_stable() {
+ let p = register_payload("u1", "a1", 1_700_000_000, "n1");
+ assert_eq!(p, "register:u1:a1:1700000000:n1");
+ }
+
+ #[test]
+ fn validate_shared_key_enforces_prefix_length_and_entropy() {
+ assert!(validate_shared_key(TEST_SHARED_KEY).is_ok());
+ assert!(validate_shared_key("ihub_sk_short").is_err());
+ assert!(validate_shared_key("not_prefixed_keykey_keykey_keykey").is_err());
+ assert!(
+ validate_shared_key("ihub_sk_aaaaaaaaaaaaaaaaaaaaaaaa").is_err(),
+ "a 32-char key whose body is one repeated character must be rejected as low-entropy"
+ );
+ }
+
+ #[test]
+ fn fingerprint_is_stable_and_short() {
+ let key = "ihub_sk_aaaaaaaaaaaaaaaaaaaaaaaa";
+ let f1 = fingerprint(key);
+ let f2 = fingerprint(key);
+ assert_eq!(f1, f2);
+ assert_eq!(f1.len(), 12, "6 bytes -> 12 hex chars");
+ assert_ne!(f1, fingerprint("ihub_sk_bbbbbbbbbbbbbbbbbbbbbbbb"));
+ }
+
+ const TEST_SHARED_KEY: &str = "ihub_sk_x7K2p9mQ4vR8tL3nB6wZ1yD5cF0jH";
+ const TEST_ARTIFACT_DIGEST: &str =
+ "4e205e4f8061512d5bca40ebe50acbb93d44afa3e083981a7b434f9ee3bab6a3";
+
+ #[tokio::test]
+ async fn verify_intent_is_rate_limited_per_user() {
+ let secrets = crate::channels::web::test_helpers::test_secrets_store();
+ let state = crate::channels::web::test_helpers::TestGatewayBuilder::new()
+ .user_id("test-user")
+ .with_secrets_store(secrets)
+ .with_ironhub_catalog_rate_limit(1, 60)
+ .build();
+ let app = Router::new()
+ .route(
+ "/api/ironhub/verify-intent",
+ post(ironhub_verify_intent_handler),
+ )
+ .with_state(state);
+ let body = serde_json::json!({
+ "slug": "clickup",
+ "version": "1.0.0",
+ "uid": "u1",
+ "aid": "a1",
+ "ts": now_unix(),
+ "nonce": "rl-verify-1",
+ "sig": "deadbeef",
+ "artifact_digest": "d0"
+ })
+ .to_string();
+ let first = req_with_identity(
+ "POST",
+ "/api/ironhub/verify-intent",
+ Body::from(body.clone()),
+ "regular",
+ );
+ let r1 = ServiceExt::>::oneshot(app.clone(), first)
+ .await
+ .expect("first");
+ assert_eq!(r1.status(), StatusCode::OK);
+ let second = req_with_identity(
+ "POST",
+ "/api/ironhub/verify-intent",
+ Body::from(body),
+ "regular",
+ );
+ let r2 = ServiceExt::>::oneshot(app, second)
+ .await
+ .expect("second");
+ assert_eq!(r2.status(), StatusCode::TOO_MANY_REQUESTS);
+ }
+
+ #[tokio::test]
+ async fn register_is_rate_limited_per_user() {
+ let secrets = crate::channels::web::test_helpers::test_secrets_store();
+ let state = crate::channels::web::test_helpers::TestGatewayBuilder::new()
+ .user_id("test-user")
+ .with_secrets_store(secrets)
+ .with_ironhub_catalog_rate_limit(1, 60)
+ .build();
+ let app = Router::new()
+ .route("/api/ironhub/register", post(ironhub_register_handler))
+ .with_state(state);
+ let body = serde_json::json!({
+ "uid": "u1",
+ "aid": "a1",
+ "ts": now_unix(),
+ "nonce": "rl-register-1",
+ "sig": "deadbeef"
+ })
+ .to_string();
+ let first = req_with_identity(
+ "POST",
+ "/api/ironhub/register",
+ Body::from(body.clone()),
+ "regular",
+ );
+ let r1 = ServiceExt::>::oneshot(app.clone(), first)
+ .await
+ .expect("first");
+ assert_eq!(r1.status(), StatusCode::SERVICE_UNAVAILABLE);
+ let second =
+ req_with_identity("POST", "/api/ironhub/register", Body::from(body), "regular");
+ let r2 = ServiceExt::>::oneshot(app, second)
+ .await
+ .expect("second");
+ assert_eq!(r2.status(), StatusCode::TOO_MANY_REQUESTS);
+ }
+
+ async fn verify_app() -> (Router, String, String, u64) {
+ use crate::secrets::CreateSecretParams;
+
+ let secrets = crate::channels::web::test_helpers::test_secrets_store();
+ secrets
+ .create(
+ "test-user",
+ CreateSecretParams::new("ironhub_signing_key", TEST_SHARED_KEY),
+ )
+ .await
+ .expect("seed signing key");
+
+ let state = crate::channels::web::test_helpers::TestGatewayBuilder::new()
+ .user_id("test-user")
+ .with_secrets_store(secrets)
+ .build();
+ let app = Router::new()
+ .route(
+ "/api/ironhub/verify-intent",
+ post(ironhub_verify_intent_handler),
+ )
+ .route("/api/ironhub/register", post(ironhub_register_handler))
+ .with_state(state);
+ let ts = now_unix();
+ let nonce = uuid::Uuid::new_v4().to_string();
+ let payload = install_payload(
+ "clickup",
+ "1.0.0",
+ "u1",
+ "a1",
+ ts,
+ &nonce,
+ TEST_ARTIFACT_DIGEST,
+ );
+ let sig = hmac_hex(TEST_SHARED_KEY, &payload).expect("sign");
+ (app, sig, nonce, ts)
+ }
+
+ fn verify_body(slug: &str, ts: u64, nonce: &str, sig: &str) -> serde_json::Value {
+ serde_json::json!({
+ "slug": slug,
+ "version": "1.0.0",
+ "uid": "u1",
+ "aid": "a1",
+ "ts": ts,
+ "nonce": nonce,
+ "sig": sig,
+ "artifact_digest": TEST_ARTIFACT_DIGEST,
+ })
+ }
+
+ fn verify_req(body: serde_json::Value) -> axum::http::Request {
+ let mut req = axum::http::Request::builder()
+ .method("POST")
+ .uri("/api/ironhub/verify-intent")
+ .header("content-type", "application/json")
+ .body(Body::from(body.to_string()))
+ .expect("request");
+ req.extensions_mut().insert(UserIdentity {
+ user_id: "test-user".into(),
+ role: "regular".into(),
+ workspace_read_scopes: Vec::new(),
+ });
+ req
+ }
+
+ fn register_req(body: serde_json::Value) -> axum::http::Request {
+ let mut req = axum::http::Request::builder()
+ .method("POST")
+ .uri("/api/ironhub/register")
+ .header("content-type", "application/json")
+ .body(Body::from(body.to_string()))
+ .expect("request");
+ req.extensions_mut().insert(UserIdentity {
+ user_id: "test-user".into(),
+ role: "regular".into(),
+ workspace_read_scopes: Vec::new(),
+ });
+ req
+ }
+
+ async fn body_json(resp: axum::response::Response) -> serde_json::Value {
+ let bytes = axum::body::to_bytes(resp.into_body(), 65536)
+ .await
+ .expect("body");
+ serde_json::from_slice(&bytes).expect("json")
+ }
+
+ #[test]
+ fn tool_error_to_http_maps_all_variants() {
+ use std::time::Duration;
+ assert_eq!(
+ tool_error_to_http(ToolError::InvalidParameters("x".into())).0,
+ StatusCode::BAD_REQUEST
+ );
+ assert_eq!(
+ tool_error_to_http(ToolError::NotAuthorized("x".into())).0,
+ StatusCode::UNAUTHORIZED
+ );
+ assert_eq!(
+ tool_error_to_http(ToolError::RateLimited(None)).0,
+ StatusCode::TOO_MANY_REQUESTS
+ );
+ assert_eq!(
+ tool_error_to_http(ToolError::RateLimited(Some(Duration::from_secs(5)))).0,
+ StatusCode::TOO_MANY_REQUESTS
+ );
+ assert_eq!(
+ tool_error_to_http(ToolError::Timeout(Duration::from_secs(3))).0,
+ StatusCode::GATEWAY_TIMEOUT
+ );
+ assert_eq!(
+ tool_error_to_http(ToolError::ExternalService("x".into())).0,
+ StatusCode::BAD_GATEWAY
+ );
+ assert_eq!(
+ tool_error_to_http(ToolError::ExecutionFailed("x".into())).0,
+ StatusCode::INTERNAL_SERVER_ERROR
+ );
+ assert_eq!(
+ tool_error_to_http(ToolError::Sandbox("x".into())).0,
+ StatusCode::INTERNAL_SERVER_ERROR
+ );
+ }
+
+ #[tokio::test]
+ async fn verify_intent_returns_invalid_when_no_signing_key() {
+ let secrets = crate::channels::web::test_helpers::test_secrets_store();
+ let state = crate::channels::web::test_helpers::TestGatewayBuilder::new()
+ .user_id("test-user")
+ .with_secrets_store(secrets)
+ .build();
+ let app = Router::new()
+ .route(
+ "/api/ironhub/verify-intent",
+ post(ironhub_verify_intent_handler),
+ )
+ .with_state(state);
+ let req = verify_req(verify_body(
+ "clickup",
+ now_unix(),
+ "nokey-nonce",
+ "deadbeef",
+ ));
+ let resp = ServiceExt::>::oneshot(app, req)
+ .await
+ .expect("response");
+ assert_eq!(resp.status(), StatusCode::OK);
+ let json = body_json(resp).await;
+ assert_eq!(json["valid"], false);
+ assert!(
+ json["reason"]
+ .as_str()
+ .unwrap_or("")
+ .contains("no signing key"),
+ "{json:?}"
+ );
+ }
+
+ #[tokio::test]
+ async fn register_rejects_replayed_nonce() {
+ let (app, _sig, _nonce, ts) = verify_app().await;
+ let nonce = "register-replay-nonce";
+ let payload = register_payload("u1", "a1", ts, nonce);
+ let sig = hmac_hex(TEST_SHARED_KEY, &payload).expect("sign");
+ let body = serde_json::json!({
+ "uid": "u1",
+ "aid": "a1",
+ "ts": ts,
+ "nonce": nonce,
+ "sig": sig,
+ });
+ let first = ServiceExt::>::oneshot(
+ app.clone(),
+ register_req(body.clone()),
+ )
+ .await
+ .expect("first");
+ assert_eq!(first.status(), StatusCode::OK);
+ let second = ServiceExt::>::oneshot(app, register_req(body))
+ .await
+ .expect("second");
+ assert_eq!(second.status(), StatusCode::CONFLICT);
+ }
+
+ #[tokio::test]
+ async fn verify_intent_accepts_valid_signature() {
+ let (app, sig, nonce, ts) = verify_app().await;
+ let req = verify_req(verify_body("clickup", ts, &nonce, &sig));
+ let resp = ServiceExt::>::oneshot(app, req)
+ .await
+ .expect("response");
+ assert_eq!(resp.status(), StatusCode::OK);
+ let json = body_json(resp).await;
+ assert_eq!(json["valid"], true, "valid sig must verify: {json:?}");
+ }
+
+ #[tokio::test]
+ async fn verify_intent_rejects_tampered_signature() {
+ let (app, _sig, nonce, ts) = verify_app().await;
+ let req = verify_req(verify_body("clickup", ts, &nonce, &"deadbeef".repeat(8)));
+ let resp = ServiceExt::>::oneshot(app, req)
+ .await
+ .expect("response");
+ let json = body_json(resp).await;
+ assert_eq!(json["valid"], false);
+ assert!(json["reason"].as_str().unwrap().contains("mismatch"));
+ }
+
+ #[tokio::test]
+ async fn verify_intent_rejects_expired_timestamp() {
+ let (app, _sig, _nonce, _ts) = verify_app().await;
+ let stale_ts = now_unix() - 4000;
+ let req = verify_req(verify_body(
+ "clickup",
+ stale_ts,
+ "n_stale",
+ &"00".repeat(32),
+ ));
+ let resp = ServiceExt::>::oneshot(app, req)
+ .await
+ .expect("response");
+ let json = body_json(resp).await;
+ assert_eq!(json["valid"], false);
+ assert!(json["reason"].as_str().unwrap().contains("drift"));
+ }
+
+ #[tokio::test]
+ async fn verify_intent_rejects_invalid_slug() {
+ let (app, _sig, nonce, ts) = verify_app().await;
+ let req = verify_req(verify_body("../etc/passwd", ts, &nonce, &"00".repeat(32)));
+ let resp = ServiceExt::>::oneshot(app, req)
+ .await
+ .expect("response");
+ let json = body_json(resp).await;
+ assert_eq!(json["valid"], false);
+ assert!(json["reason"].as_str().unwrap().contains("invalid slug"));
+ }
+
+ #[tokio::test]
+ async fn verify_intent_is_repeatable_preview() {
+ let (app, sig, nonce, ts) = verify_app().await;
+ let first = verify_req(verify_body("clickup", ts, &nonce, &sig));
+ let resp1 = ServiceExt::>::oneshot(app.clone(), first)
+ .await
+ .expect("first response");
+ assert_eq!(resp1.status(), StatusCode::OK);
+ assert_eq!(body_json(resp1).await["valid"], true);
+
+ let again = verify_req(verify_body("clickup", ts, &nonce, &sig));
+ let resp2 = ServiceExt::>::oneshot(app, again)
+ .await
+ .expect("second response");
+ assert_eq!(resp2.status(), StatusCode::OK);
+ assert_eq!(
+ body_json(resp2).await["valid"],
+ true,
+ "verify-intent is a preview and must not consume the one-shot nonce"
+ );
+ }
+
+ #[tokio::test]
+ async fn verify_intent_requires_authentication() {
+ let (app, sig, nonce, ts) = verify_app().await;
+ let req = axum::http::Request::builder()
+ .method("POST")
+ .uri("/api/ironhub/verify-intent")
+ .header("content-type", "application/json")
+ .body(Body::from(
+ verify_body("clickup", ts, &nonce, &sig).to_string(),
+ ))
+ .expect("request");
+ let resp = ServiceExt::>::oneshot(app, req)
+ .await
+ .expect("response");
+ assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
+ }
+
+ async fn signing_key_app() -> Router {
+ let secrets = crate::channels::web::test_helpers::test_secrets_store();
+ let state = crate::channels::web::test_helpers::TestGatewayBuilder::new()
+ .user_id("test-user")
+ .with_secrets_store(secrets)
+ .build();
+ Router::new()
+ .route(
+ "/api/ironhub/signing-key",
+ post(ironhub_signing_key_set_handler)
+ .get(ironhub_signing_key_get_handler)
+ .delete(ironhub_signing_key_delete_handler),
+ )
+ .with_state(state)
+ }
+
+ fn signing_key_req(method: &str, body: Body) -> axum::http::Request {
+ signing_key_req_as(method, body, "admin")
+ }
+
+ fn signing_key_req_as(method: &str, body: Body, role: &str) -> axum::http::Request {
+ let mut req = axum::http::Request::builder()
+ .method(method)
+ .uri("/api/ironhub/signing-key")
+ .header("content-type", "application/json")
+ .body(body)
+ .expect("request");
+ req.extensions_mut().insert(UserIdentity {
+ user_id: "test-user".into(),
+ role: role.into(),
+ workspace_read_scopes: Vec::new(),
+ });
+ req
+ }
+
+ #[tokio::test]
+ async fn signing_key_set_accepts_valid_prefix_and_returns_metadata() {
+ let app = signing_key_app().await;
+ let body = serde_json::json!({"shared_key": TEST_SHARED_KEY}).to_string();
+ let resp = ServiceExt::>::oneshot(
+ app,
+ signing_key_req("POST", Body::from(body)),
+ )
+ .await
+ .expect("response");
+ assert_eq!(resp.status(), StatusCode::OK);
+ let meta = body_json(resp).await;
+ assert_eq!(meta["fingerprint"].as_str().unwrap().len(), 12);
+ assert!(meta["created_at"].as_str().is_some());
+ assert!(
+ meta.get("shared_key").is_none(),
+ "set response must NOT echo the key"
+ );
+ }
+
+ #[tokio::test]
+ async fn signing_key_set_rejects_missing_prefix() {
+ let app = signing_key_app().await;
+ let body =
+ serde_json::json!({"shared_key": "no_prefix_key_with_enough_length_xx"}).to_string();
+ let resp = ServiceExt::>::oneshot(
+ app,
+ signing_key_req("POST", Body::from(body)),
+ )
+ .await
+ .expect("response");
+ assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
+ }
+
+ #[tokio::test]
+ async fn signing_key_set_rejects_too_short() {
+ let app = signing_key_app().await;
+ let body = serde_json::json!({"shared_key": "ihub_sk_short"}).to_string();
+ let resp = ServiceExt::>::oneshot(
+ app,
+ signing_key_req("POST", Body::from(body)),
+ )
+ .await
+ .expect("response");
+ assert_eq!(resp.status(), StatusCode::BAD_REQUEST);
+ }
+
+ #[tokio::test]
+ async fn signing_key_get_returns_404_when_unset() {
+ let app = signing_key_app().await;
+ let resp = ServiceExt::>::oneshot(
+ app,
+ signing_key_req("GET", Body::empty()),
+ )
+ .await
+ .expect("response");
+ assert_eq!(resp.status(), StatusCode::NOT_FOUND);
+ }
+
+ #[tokio::test]
+ async fn signing_key_get_returns_metadata_without_exposing_key() {
+ let app = signing_key_app().await;
+ let set_body = serde_json::json!({"shared_key": TEST_SHARED_KEY}).to_string();
+ let set_resp = ServiceExt::>::oneshot(
+ app.clone(),
+ signing_key_req("POST", Body::from(set_body)),
+ )
+ .await
+ .expect("set response");
+ assert_eq!(set_resp.status(), StatusCode::OK);
+
+ let get_resp = ServiceExt::>::oneshot(
+ app,
+ signing_key_req("GET", Body::empty()),
+ )
+ .await
+ .expect("get response");
+ assert_eq!(get_resp.status(), StatusCode::OK);
+ let meta = body_json(get_resp).await;
+ assert!(meta["fingerprint"].as_str().is_some());
+ let serialized = serde_json::to_string(&meta).unwrap();
+ assert!(
+ !serialized.contains(TEST_SHARED_KEY),
+ "GET response must never echo the raw key: {serialized}"
+ );
+ }
+
+ #[tokio::test]
+ async fn signing_key_delete_removes_existing_and_404s_on_missing() {
+ let app = signing_key_app().await;
+ let set_body = serde_json::json!({"shared_key": TEST_SHARED_KEY}).to_string();
+ ServiceExt::>::oneshot(
+ app.clone(),
+ signing_key_req("POST", Body::from(set_body)),
+ )
+ .await
+ .expect("set");
+
+ let del1 = ServiceExt::>::oneshot(
+ app.clone(),
+ signing_key_req("DELETE", Body::empty()),
+ )
+ .await
+ .expect("delete");
+ assert_eq!(del1.status(), StatusCode::NO_CONTENT);
+
+ let del2 = ServiceExt::>::oneshot(
+ app,
+ signing_key_req("DELETE", Body::empty()),
+ )
+ .await
+ .expect("second delete");
+ assert_eq!(del2.status(), StatusCode::NOT_FOUND);
+ }
+
+ #[tokio::test]
+ async fn signing_key_set_rejects_non_admin() {
+ let app = signing_key_app().await;
+ let body = serde_json::json!({"shared_key": TEST_SHARED_KEY}).to_string();
+ let resp = ServiceExt::>::oneshot(
+ app,
+ signing_key_req_as("POST", Body::from(body), "regular"),
+ )
+ .await
+ .expect("response");
+ assert_eq!(resp.status(), StatusCode::FORBIDDEN);
+ }
+
+ #[tokio::test]
+ async fn signing_key_set_replaces_existing_and_updates_fingerprint() {
+ let app = signing_key_app().await;
+ let first = serde_json::json!({"shared_key": TEST_SHARED_KEY}).to_string();
+ let first_resp = ServiceExt::>::oneshot(
+ app.clone(),
+ signing_key_req("POST", Body::from(first)),
+ )
+ .await
+ .expect("first set");
+ assert_eq!(first_resp.status(), StatusCode::OK);
+ let first_fp = body_json(first_resp).await["fingerprint"]
+ .as_str()
+ .expect("fingerprint")
+ .to_string();
+
+ let rotated = format!("{TEST_SHARED_KEY}_rotated");
+ let second = serde_json::json!({"shared_key": rotated}).to_string();
+ let second_resp = ServiceExt::>::oneshot(
+ app,
+ signing_key_req("POST", Body::from(second)),
+ )
+ .await
+ .expect("second set");
+ assert_eq!(second_resp.status(), StatusCode::OK);
+ let second_fp = body_json(second_resp).await["fingerprint"]
+ .as_str()
+ .expect("fingerprint")
+ .to_string();
+
+ assert_ne!(
+ first_fp, second_fp,
+ "replacing the signing key must change the returned fingerprint"
+ );
+ }
+
+ #[tokio::test]
+ async fn register_accepts_valid_signature() {
+ let (app, _intent_sig, _intent_nonce, ts) = verify_app().await;
+ let nonce = uuid::Uuid::new_v4().to_string();
+ let payload = register_payload("u1", "a1", ts, &nonce);
+ let sig = hmac_hex(TEST_SHARED_KEY, &payload).expect("sign");
+ let body = serde_json::json!({
+ "uid": "u1",
+ "aid": "a1",
+ "ts": ts,
+ "nonce": nonce,
+ "sig": sig,
+ });
+ let resp = ServiceExt::>::oneshot(app, register_req(body))
+ .await
+ .expect("response");
+ assert_eq!(resp.status(), StatusCode::OK);
+ }
+
+ #[tokio::test]
+ async fn register_rejects_bad_signature() {
+ let (app, _intent_sig, _intent_nonce, ts) = verify_app().await;
+ let nonce = uuid::Uuid::new_v4().to_string();
+ let body = serde_json::json!({
+ "uid": "u1",
+ "aid": "a1",
+ "ts": ts,
+ "nonce": nonce,
+ "sig": "deadbeef".repeat(8),
+ });
+ let resp = ServiceExt::>::oneshot(app, register_req(body))
+ .await
+ .expect("response");
+ assert_eq!(resp.status(), StatusCode::UNAUTHORIZED);
+ }
+
+ #[tokio::test]
+ async fn register_rejects_expired_timestamp() {
+ let (app, _intent_sig, _intent_nonce, _ts) = verify_app().await;
+ let stale_ts = now_unix() - 4000;
+ let nonce = uuid::Uuid::new_v4().to_string();
+ let payload = register_payload("u1", "a1", stale_ts, &nonce);
+ let sig = hmac_hex(TEST_SHARED_KEY, &payload).expect("sign");
+ let body = serde_json::json!({
+ "uid": "u1",
+ "aid": "a1",
+ "ts": stale_ts,
+ "nonce": nonce,
+ "sig": sig,
+ });
+ let resp = ServiceExt::>::oneshot(app, register_req(body))
+ .await
+ .expect("response");
+ assert_eq!(resp.status(), StatusCode::REQUEST_TIMEOUT);
+ }
+
+ #[tokio::test]
+ async fn register_rejects_when_no_signing_key_configured() {
+ let secrets = crate::channels::web::test_helpers::test_secrets_store();
+ let state = crate::channels::web::test_helpers::TestGatewayBuilder::new()
+ .user_id("test-user")
+ .with_secrets_store(secrets)
+ .build();
+ let app = Router::new()
+ .route("/api/ironhub/register", post(ironhub_register_handler))
+ .with_state(state);
+ let body = serde_json::json!({
+ "uid": "u1",
+ "aid": "a1",
+ "ts": now_unix(),
+ "nonce": uuid::Uuid::new_v4().to_string(),
+ "sig": "deadbeef".repeat(8),
+ });
+ let resp = ServiceExt::>::oneshot(app, register_req(body))
+ .await
+ .expect("response");
+ assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
+ }
+
+ #[tokio::test]
+ async fn register_succeeds_without_session_via_hmac() {
+ let (app, _intent_sig, _intent_nonce, ts) = verify_app().await;
+ let nonce = uuid::Uuid::new_v4().to_string();
+ let payload = register_payload("u1", "a1", ts, &nonce);
+ let sig = hmac_hex(TEST_SHARED_KEY, &payload).expect("sign");
+ let body = serde_json::json!({
+ "uid": "u1",
+ "aid": "a1",
+ "ts": ts,
+ "nonce": nonce,
+ "sig": sig,
+ });
+ let req = axum::http::Request::builder()
+ .method("POST")
+ .uri("/api/ironhub/register")
+ .header("content-type", "application/json")
+ .body(Body::from(body.to_string()))
+ .expect("request");
+ let resp = ServiceExt::>::oneshot(app, req)
+ .await
+ .expect("response");
+ assert_eq!(
+ resp.status(),
+ StatusCode::OK,
+ "register must authenticate by HMAC alone with no session; IronHub calls it server-to-server"
+ );
+ }
+}
diff --git a/src/channels/web/handlers/memory.rs b/src/channels/web/handlers/memory.rs
index 2a220c9c210..63ff45279c0 100644
--- a/src/channels/web/handlers/memory.rs
+++ b/src/channels/web/handlers/memory.rs
@@ -294,6 +294,7 @@ mod tests {
auth_manager: None,
chat_rate_limiter: PerUserRateLimiter::new(30, 60),
oauth_rate_limiter: PerUserRateLimiter::new(20, 60),
+ ironhub_catalog_rate_limiter: PerUserRateLimiter::new(30, 60),
webhook_rate_limiter: RateLimiter::new(10, 60),
registry_entries: Vec::new(),
cost_guard: None,
diff --git a/src/channels/web/handlers/mod.rs b/src/channels/web/handlers/mod.rs
index 0684b104db1..ddf39465918 100644
--- a/src/channels/web/handlers/mod.rs
+++ b/src/channels/web/handlers/mod.rs
@@ -4,6 +4,7 @@
pub mod auth;
pub mod engine;
+pub mod ironhub;
pub mod llm;
pub mod memory;
pub mod secrets;
diff --git a/src/channels/web/mod.rs b/src/channels/web/mod.rs
index 0247c54ef0f..af89d894185 100644
--- a/src/channels/web/mod.rs
+++ b/src/channels/web/mod.rs
@@ -176,6 +176,7 @@ impl GatewayChannel {
auth_manager: None,
chat_rate_limiter: platform::state::PerUserRateLimiter::new(30, 60),
oauth_rate_limiter: platform::state::PerUserRateLimiter::new(20, 60),
+ ironhub_catalog_rate_limiter: platform::state::PerUserRateLimiter::new(30, 60),
webhook_rate_limiter: platform::state::RateLimiter::new(10, 60),
registry_entries: Vec::new(),
cost_guard: None,
@@ -242,6 +243,7 @@ impl GatewayChannel {
auth_manager: self.state.auth_manager.clone(),
chat_rate_limiter: platform::state::PerUserRateLimiter::new(30, 60),
oauth_rate_limiter: platform::state::PerUserRateLimiter::new(20, 60),
+ ironhub_catalog_rate_limiter: platform::state::PerUserRateLimiter::new(30, 60),
webhook_rate_limiter: platform::state::RateLimiter::new(10, 60),
registry_entries: self.state.registry_entries.clone(),
cost_guard: self.state.cost_guard.clone(),
diff --git a/src/channels/web/platform/router.rs b/src/channels/web/platform/router.rs
index 684f9a84076..5278bdade31 100644
--- a/src/channels/web/platform/router.rs
+++ b/src/channels/web/platform/router.rs
@@ -46,6 +46,11 @@ use crate::channels::web::handlers::frontend::{
frontend_layout_handler, frontend_layout_update_handler, frontend_widget_file_handler,
frontend_widgets_handler,
};
+use crate::channels::web::handlers::ironhub::{
+ ironhub_info_handler, ironhub_install_handler, ironhub_list_handler, ironhub_register_handler,
+ ironhub_search_handler, ironhub_signing_key_delete_handler, ironhub_signing_key_get_handler,
+ ironhub_signing_key_set_handler, ironhub_verify_intent_handler,
+};
use crate::channels::web::handlers::llm::{
llm_list_models_handler, llm_providers_handler, llm_test_connection_handler,
};
@@ -131,6 +136,7 @@ pub async fn start_server(
get(slack_relay_oauth_callback_handler),
)
.route("/relay/events", post(relay_events_handler))
+ .route("/api/ironhub/register", post(ironhub_register_handler))
.route(
"/api/webhooks/{path}",
post(crate::channels::web::handlers::webhooks::webhook_trigger_handler),
@@ -307,6 +313,21 @@ pub async fn start_server(
"/api/skills/{name}",
axum::routing::delete(skills_remove_handler),
)
+ // IronHub catalog
+ .route("/api/ironhub/install", post(ironhub_install_handler))
+ .route("/api/ironhub/search", get(ironhub_search_handler))
+ .route("/api/ironhub/list", get(ironhub_list_handler))
+ .route("/api/ironhub/info", get(ironhub_info_handler))
+ .route(
+ "/api/ironhub/signing-key",
+ post(ironhub_signing_key_set_handler)
+ .get(ironhub_signing_key_get_handler)
+ .delete(ironhub_signing_key_delete_handler),
+ )
+ .route(
+ "/api/ironhub/verify-intent",
+ post(ironhub_verify_intent_handler),
+ )
// Settings
.route("/api/settings", get(settings_list_handler))
.route("/api/settings/export", get(settings_export_handler))
diff --git a/src/channels/web/platform/state.rs b/src/channels/web/platform/state.rs
index a5581a9f2de..e85b3cc1338 100644
--- a/src/channels/web/platform/state.rs
+++ b/src/channels/web/platform/state.rs
@@ -414,6 +414,10 @@ pub struct GatewayState {
pub chat_rate_limiter: PerUserRateLimiter,
/// Per-IP rate limiter for OAuth/auth endpoints (20 requests per 60 seconds per IP).
pub oauth_rate_limiter: PerUserRateLimiter,
+ /// Per-user rate limiter for IronHub catalog dispatch (30 requests per 60 seconds per user).
+ /// Each search/list/info/install triggers a remote manifest fetch + audit write; this prevents
+ /// an authenticated user from fanning out unbounded outbound work via the gateway.
+ pub ironhub_catalog_rate_limiter: PerUserRateLimiter,
/// Rate limiter for webhook trigger endpoints (10 requests per 60 seconds).
pub webhook_rate_limiter: RateLimiter,
/// Registry catalog entries for the available extensions API.
diff --git a/src/channels/web/platform/ws.rs b/src/channels/web/platform/ws.rs
index 8d64ae665eb..7f43f32f878 100644
--- a/src/channels/web/platform/ws.rs
+++ b/src/channels/web/platform/ws.rs
@@ -589,6 +589,8 @@ mod tests {
oauth_rate_limiter: crate::channels::web::platform::state::PerUserRateLimiter::new(
20, 60,
),
+ ironhub_catalog_rate_limiter:
+ crate::channels::web::platform::state::PerUserRateLimiter::new(30, 60),
webhook_rate_limiter: crate::channels::web::platform::state::RateLimiter::new(10, 60),
registry_entries: Vec::new(),
cost_guard: None,
diff --git a/src/channels/web/test_helpers.rs b/src/channels/web/test_helpers.rs
index d3875f7f360..a5c4d2ee975 100644
--- a/src/channels/web/test_helpers.rs
+++ b/src/channels/web/test_helpers.rs
@@ -48,6 +48,9 @@ pub struct TestGatewayBuilder {
llm_provider: Option>,
user_id: String,
tool_registry: Option>,
+ tool_dispatcher: Option>,
+ secrets_store: Option>,
+ ironhub_catalog_rate_limit: Option<(u64, u64)>,
}
impl Default for TestGatewayBuilder {
@@ -57,6 +60,9 @@ impl Default for TestGatewayBuilder {
llm_provider: None,
user_id: "test-user".to_string(),
tool_registry: None,
+ tool_dispatcher: None,
+ secrets_store: None,
+ ironhub_catalog_rate_limit: None,
}
}
}
@@ -94,6 +100,27 @@ impl TestGatewayBuilder {
self
}
+ pub fn with_tool_dispatcher(
+ mut self,
+ dispatcher: Arc,
+ ) -> Self {
+ self.tool_dispatcher = Some(dispatcher);
+ self
+ }
+
+ pub fn with_secrets_store(
+ mut self,
+ store: Arc,
+ ) -> Self {
+ self.secrets_store = Some(store);
+ self
+ }
+
+ pub fn with_ironhub_catalog_rate_limit(mut self, max_requests: u64, window_secs: u64) -> Self {
+ self.ironhub_catalog_rate_limit = Some((max_requests, window_secs));
+ self
+ }
+
/// Build the `Arc` without starting a server.
pub fn build(self) -> Arc {
Arc::new(GatewayState {
@@ -124,6 +151,10 @@ impl TestGatewayBuilder {
scheduler: None,
chat_rate_limiter: PerUserRateLimiter::new(30, 60),
oauth_rate_limiter: PerUserRateLimiter::new(20, 60),
+ ironhub_catalog_rate_limiter: {
+ let (max, window) = self.ironhub_catalog_rate_limit.unwrap_or((30, 60));
+ PerUserRateLimiter::new(max, window)
+ },
webhook_rate_limiter: RateLimiter::new(10, 60),
registry_entries: Vec::new(),
cost_guard: None,
@@ -132,7 +163,7 @@ impl TestGatewayBuilder {
active_config: Arc::new(tokio::sync::RwLock::new(
crate::channels::web::platform::state::ActiveConfigSnapshot::default(),
)),
- secrets_store: None,
+ secrets_store: self.secrets_store,
db_auth: None,
pairing_store: None,
oauth_providers: None,
@@ -144,7 +175,7 @@ impl TestGatewayBuilder {
near_network: None,
oauth_sweep_shutdown: None,
frontend_html_cache: Arc::new(tokio::sync::RwLock::new(None)),
- tool_dispatcher: None,
+ tool_dispatcher: self.tool_dispatcher,
})
}
@@ -240,6 +271,7 @@ pub(crate) fn test_gateway_state_with_dependencies(
scheduler: None,
chat_rate_limiter: PerUserRateLimiter::new(30, 60),
oauth_rate_limiter: PerUserRateLimiter::new(20, 60),
+ ironhub_catalog_rate_limiter: PerUserRateLimiter::new(30, 60),
webhook_rate_limiter: RateLimiter::new(10, 60),
registry_entries: vec![],
cost_guard: None,
@@ -297,6 +329,7 @@ pub(crate) fn test_gateway_state_with_store_and_session_manager(
scheduler: None,
chat_rate_limiter: PerUserRateLimiter::new(30, 60),
oauth_rate_limiter: PerUserRateLimiter::new(20, 60),
+ ironhub_catalog_rate_limiter: PerUserRateLimiter::new(30, 60),
webhook_rate_limiter: RateLimiter::new(10, 60),
registry_entries: vec![],
cost_guard: None,
diff --git a/src/channels/web/tests/multi_tenant.rs b/src/channels/web/tests/multi_tenant.rs
index 1186c62f352..a850a147e9b 100644
--- a/src/channels/web/tests/multi_tenant.rs
+++ b/src/channels/web/tests/multi_tenant.rs
@@ -81,6 +81,7 @@ fn build_state(
scheduler: None,
chat_rate_limiter: PerUserRateLimiter::new(30, 60),
oauth_rate_limiter: PerUserRateLimiter::new(20, 60),
+ ironhub_catalog_rate_limiter: PerUserRateLimiter::new(30, 60),
webhook_rate_limiter: RateLimiter::new(10, 60),
registry_entries: Vec::new(),
cost_guard: None,
@@ -1399,6 +1400,7 @@ mod admin_tool_policy {
scheduler: None,
chat_rate_limiter: PerUserRateLimiter::new(30, 60),
oauth_rate_limiter: PerUserRateLimiter::new(20, 60),
+ ironhub_catalog_rate_limiter: PerUserRateLimiter::new(30, 60),
webhook_rate_limiter: RateLimiter::new(10, 60),
registry_entries: Vec::new(),
cost_guard: None,
diff --git a/src/channels/web/types.rs b/src/channels/web/types.rs
index 7b645f3ecd9..95d0aa2f658 100644
--- a/src/channels/web/types.rs
+++ b/src/channels/web/types.rs
@@ -1242,6 +1242,86 @@ pub struct EngineActionResponse {
pub ok: bool,
}
+#[derive(Debug, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub struct IronhubInstallRequest {
+ pub slug: String,
+ pub version: String,
+ pub uid: String,
+ pub aid: String,
+ pub ts: u64,
+ pub nonce: String,
+ pub sig: String,
+ pub artifact_digest: String,
+ #[serde(default)]
+ pub acknowledge_unverified: bool,
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub struct IronhubSearchQuery {
+ pub query: String,
+ #[serde(default)]
+ pub release_tag: Option,
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub struct IronhubListQuery {
+ #[serde(default)]
+ pub release_tag: Option,
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub struct IronhubInfoQuery {
+ pub name: String,
+ #[serde(default)]
+ pub release_tag: Option,
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub struct IronhubVerifyIntentRequest {
+ pub slug: String,
+ pub version: String,
+ pub uid: String,
+ pub aid: String,
+ pub ts: u64,
+ pub nonce: String,
+ pub sig: String,
+ pub artifact_digest: String,
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub struct IronhubRegisterRequest {
+ pub uid: String,
+ pub aid: String,
+ pub ts: u64,
+ pub nonce: String,
+ pub sig: String,
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(deny_unknown_fields)]
+pub struct IronhubSigningKeySetRequest {
+ pub shared_key: String,
+}
+
+#[derive(Debug, Serialize)]
+pub struct IronhubSigningKeyMetadata {
+ pub fingerprint: String,
+ pub created_at: String,
+}
+
+#[derive(Debug, Serialize)]
+pub struct IronhubVerifyIntentResponse {
+ pub valid: bool,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub reason: Option,
+}
+
#[cfg(test)]
mod tests {
use super::*;
diff --git a/src/cli/hub.rs b/src/cli/hub.rs
new file mode 100644
index 00000000000..72b509435fb
--- /dev/null
+++ b/src/cli/hub.rs
@@ -0,0 +1,679 @@
+use std::path::PathBuf;
+
+use clap::Subcommand;
+
+use crate::cli::hub_install::validate_hub_name;
+use crate::registry::{HubInstaller, HubManifest, HubSkillEntry, HubToolEntry, Provenance};
+
+#[derive(Subcommand, Debug, Clone)]
+pub enum HubCommand {
+ /// Install a tool or skill from IronHub by name
+ Install {
+ name: String,
+
+ #[arg(long, conflicts_with = "tool")]
+ skill: bool,
+
+ #[arg(long, conflicts_with = "skill")]
+ tool: bool,
+
+ #[arg(long)]
+ release_tag: Option,
+
+ #[arg(long)]
+ target: Option,
+
+ #[arg(short, long)]
+ force: bool,
+
+ /// Acknowledge installing UNVERIFIED community content (required when the entry is not NEAR-vetted).
+ #[arg(long)]
+ acknowledge_unverified: bool,
+ },
+
+ /// Search the IronHub catalog by name or description
+ Search {
+ query: String,
+
+ #[arg(long)]
+ release_tag: Option,
+ },
+
+ /// List everything available in IronHub
+ List {
+ #[arg(long, conflicts_with = "skills")]
+ tools: bool,
+
+ #[arg(long, conflicts_with = "tools")]
+ skills: bool,
+
+ #[arg(long)]
+ release_tag: Option,
+ },
+
+ /// Show detailed metadata for an entry
+ Info {
+ name: String,
+
+ #[arg(long)]
+ release_tag: Option,
+ },
+}
+
+pub async fn run_hub_command(cmd: HubCommand) -> anyhow::Result<()> {
+ match cmd {
+ HubCommand::Install {
+ name,
+ skill,
+ tool,
+ release_tag,
+ target,
+ force,
+ acknowledge_unverified,
+ } => {
+ install(
+ &name,
+ skill,
+ tool,
+ release_tag,
+ target,
+ force,
+ acknowledge_unverified,
+ )
+ .await
+ }
+ HubCommand::Search { query, release_tag } => search(&query, release_tag).await,
+ HubCommand::List {
+ tools,
+ skills,
+ release_tag,
+ } => list(tools, skills, release_tag).await,
+ HubCommand::Info { name, release_tag } => info(&name, release_tag).await,
+ }
+}
+
+fn build_installer(
+ release_tag: Option,
+ tools_dir_override: Option,
+ skills_dir_override: Option,
+) -> anyhow::Result {
+ if let Some(tag) = release_tag.as_deref() {
+ anyhow::bail!(
+ "--release-tag ('{tag}') is not supported: pinning to a GitHub release fetches an \
+ unsigned manifest, which the agent rejects fail-closed. Install from the default \
+ signed catalog by omitting --release-tag."
+ );
+ }
+ let mut installer = HubInstaller::with_defaults();
+ if let Some(dir) = tools_dir_override {
+ installer = HubInstaller::new(
+ installer.manifest_url().to_string(),
+ dir,
+ installer.skills_dir().to_path_buf(),
+ );
+ }
+ if let Some(dir) = skills_dir_override {
+ installer = HubInstaller::new(
+ installer.manifest_url().to_string(),
+ installer.tools_dir().to_path_buf(),
+ dir,
+ );
+ }
+ Ok(installer)
+}
+
+#[derive(Debug)]
+enum Kind {
+ Tool,
+ Skill,
+}
+
+fn classify(
+ manifest: &HubManifest,
+ name: &str,
+ force_tool: bool,
+ force_skill: bool,
+) -> anyhow::Result {
+ let in_tools = manifest.find_tool(name).is_some();
+ let in_skills = manifest.find_skill(name).is_some();
+
+ if force_tool {
+ if !in_tools {
+ anyhow::bail!("'{}' is not a tool in this IronHub release", name);
+ }
+ return Ok(Kind::Tool);
+ }
+ if force_skill {
+ if !in_skills {
+ anyhow::bail!("'{}' is not a skill in this IronHub release", name);
+ }
+ return Ok(Kind::Skill);
+ }
+
+ match (in_tools, in_skills) {
+ (true, false) => Ok(Kind::Tool),
+ (false, true) => Ok(Kind::Skill),
+ (true, true) => anyhow::bail!(
+ "'{}' exists as both a tool and a skill in this release; pass --tool or --skill to disambiguate",
+ name
+ ),
+ (false, false) => {
+ let suggestions = nearest_matches(manifest, name);
+ if suggestions.is_empty() {
+ anyhow::bail!("'{}' is not in this IronHub release", name);
+ }
+ anyhow::bail!(
+ "'{}' is not in this IronHub release. Did you mean: {}?",
+ name,
+ suggestions.join(", ")
+ )
+ }
+ }
+}
+
+fn nearest_matches(manifest: &HubManifest, query: &str) -> Vec {
+ let q = query.to_ascii_lowercase();
+ let mut out: Vec = manifest
+ .tools
+ .iter()
+ .map(|t| t.name.clone())
+ .chain(manifest.skills.iter().map(|s| s.name.clone()))
+ .filter(|n| {
+ let nl = n.to_ascii_lowercase();
+ nl.contains(&q) || q.contains(&nl)
+ })
+ .collect();
+ out.sort();
+ out.truncate(5);
+ out
+}
+
+async fn install(
+ name: &str,
+ force_skill: bool,
+ force_tool: bool,
+ release_tag: Option,
+ target: Option,
+ force: bool,
+ acknowledge_unverified: bool,
+) -> anyhow::Result<()> {
+ validate_hub_name(name)?;
+
+ let probe = build_installer(release_tag.clone(), None, None)?;
+ println!("Fetching IronHub manifest from {}", probe.manifest_url());
+ let manifest = probe
+ .fetch_manifest()
+ .await
+ .map_err(|e| anyhow::anyhow!(e.to_string()))?;
+
+ install_with_manifest(
+ manifest,
+ name,
+ force_skill,
+ force_tool,
+ release_tag,
+ target,
+ force,
+ acknowledge_unverified,
+ )
+ .await
+}
+
+#[allow(clippy::too_many_arguments)]
+async fn install_with_manifest(
+ manifest: HubManifest,
+ name: &str,
+ force_skill: bool,
+ force_tool: bool,
+ release_tag: Option,
+ target: Option,
+ force: bool,
+ acknowledge_unverified: bool,
+) -> anyhow::Result<()> {
+ let kind = classify(&manifest, name, force_tool, force_skill)?;
+
+ let provenance = match kind {
+ Kind::Tool => manifest.find_tool(name).map(|t| t.provenance),
+ Kind::Skill => manifest.find_skill(name).map(|s| s.provenance),
+ }
+ .unwrap_or(Provenance::New);
+
+ if provenance.is_community_unverified() && !acknowledge_unverified {
+ anyhow::bail!(
+ "'{name}' is UNVERIFIED community content (trust tier: {}). \
+ Not NEAR-vetted. Re-run with --acknowledge-unverified to install at your own risk.",
+ provenance.as_wire()
+ );
+ }
+
+ let (tools_override, skills_override) = match (&kind, &target) {
+ (Kind::Tool, Some(dir)) => (Some(dir.clone()), None),
+ (Kind::Skill, Some(dir)) => (None, Some(dir.clone())),
+ _ => (None, None),
+ };
+ let installer = build_installer(release_tag, tools_override, skills_override)?;
+
+ match kind {
+ Kind::Tool => {
+ println!(
+ "Installing tool '{}' ({}) from IronHub...",
+ name,
+ provenance.as_wire()
+ );
+ let outcome = installer
+ .install_tool_from_manifest(&manifest, name, force)
+ .await
+ .map_err(|e| anyhow::anyhow!(e.to_string()))?;
+ println!("\nInstalled successfully:");
+ println!(" Kind: tool");
+ println!(" Name: {}", outcome.name);
+ println!(" Version: {}", outcome.version);
+ println!(" Release: {}", outcome.release_tag);
+ println!(" Provenance: {}", provenance.as_wire());
+ println!(" WASM: {}", outcome.primary_path.display());
+ if let Some(caps) = outcome.metadata_path {
+ println!(" Caps: {}", caps.display());
+ }
+ }
+ Kind::Skill => {
+ println!(
+ "Installing skill '{}' ({}) from IronHub...",
+ name,
+ provenance.as_wire()
+ );
+ let outcome = installer
+ .install_skill_from_manifest(&manifest, name, force)
+ .await
+ .map_err(|e| anyhow::anyhow!(e.to_string()))?;
+ println!("\nInstalled successfully:");
+ println!(" Kind: skill");
+ println!(" Name: {}", outcome.name);
+ println!(" Version: {}", outcome.version);
+ println!(" Release: {}", outcome.release_tag);
+ println!(" Provenance: {}", provenance.as_wire());
+ println!(" SKILL.md: {}", outcome.primary_path.display());
+ }
+ }
+ Ok(())
+}
+
+async fn search(query: &str, release_tag: Option) -> anyhow::Result<()> {
+ let installer = build_installer(release_tag, None, None)?;
+ let manifest = installer
+ .fetch_manifest()
+ .await
+ .map_err(|e| anyhow::anyhow!(e.to_string()))?;
+
+ let q = query.to_ascii_lowercase();
+ let tool_hits: Vec<&HubToolEntry> = manifest
+ .tools
+ .iter()
+ .filter(|t| entry_matches(&t.name, &t.description, &q))
+ .collect();
+ let skill_hits: Vec<&HubSkillEntry> = manifest
+ .skills
+ .iter()
+ .filter(|s| entry_matches(&s.name, &s.description, &q))
+ .collect();
+
+ if tool_hits.is_empty() && skill_hits.is_empty() {
+ println!(
+ "No matches for '{}' in release {}",
+ query, manifest.release_tag
+ );
+ return Ok(());
+ }
+
+ println!("Release: {}", manifest.release_tag);
+ if !tool_hits.is_empty() {
+ println!("\nTools:");
+ for t in &tool_hits {
+ print_tool_row(t);
+ }
+ }
+ if !skill_hits.is_empty() {
+ println!("\nSkills:");
+ for s in &skill_hits {
+ print_skill_row(s);
+ }
+ }
+ Ok(())
+}
+
+async fn list(
+ tools_only: bool,
+ skills_only: bool,
+ release_tag: Option,
+) -> anyhow::Result<()> {
+ let installer = build_installer(release_tag, None, None)?;
+ let manifest = installer
+ .fetch_manifest()
+ .await
+ .map_err(|e| anyhow::anyhow!(e.to_string()))?;
+
+ println!("Release: {}", manifest.release_tag);
+ println!("Repo: {}", manifest.repo);
+
+ let show_tools = !skills_only;
+ let show_skills = !tools_only;
+
+ if show_tools {
+ if manifest.tools.is_empty() {
+ println!("\nTools: (none in this release)");
+ } else {
+ println!("\nTools ({}):", manifest.tools.len());
+ for t in &manifest.tools {
+ print_tool_row(t);
+ }
+ }
+ }
+
+ if show_skills {
+ if manifest.skills.is_empty() {
+ println!("\nSkills: (none in this release)");
+ } else {
+ println!("\nSkills ({}):", manifest.skills.len());
+ for s in &manifest.skills {
+ print_skill_row(s);
+ }
+ }
+ }
+ Ok(())
+}
+
+async fn info(name: &str, release_tag: Option) -> anyhow::Result<()> {
+ validate_hub_name(name)?;
+ let installer = build_installer(release_tag, None, None)?;
+ let manifest = installer
+ .fetch_manifest()
+ .await
+ .map_err(|e| anyhow::anyhow!(e.to_string()))?;
+
+ if let Some(t) = manifest.find_tool(name) {
+ println!("Kind: tool");
+ println!("Name: {}", t.name);
+ println!("Crate: {}", t.crate_name);
+ println!("Version: {}", t.version);
+ println!("Description: {}", display_or_dash(&t.description));
+ println!("Release: {}", manifest.release_tag);
+ println!("\nWASM artifact:");
+ println!(" URL: {}", t.wasm.url);
+ println!(" Size: {} bytes", t.wasm.size_bytes);
+ println!(" SHA256: {}", t.wasm.sha256);
+ println!("\nCapabilities artifact:");
+ println!(" URL: {}", t.capabilities.url);
+ println!(" Size: {} bytes", t.capabilities.size_bytes);
+ println!(" SHA256: {}", t.capabilities.sha256);
+ return Ok(());
+ }
+ if let Some(s) = manifest.find_skill(name) {
+ println!("Kind: skill");
+ println!("Name: {}", s.name);
+ if !s.trunk.is_empty() {
+ println!("Trunk: {}", s.trunk);
+ }
+ println!("Version: {}", display_or_dash(&s.version));
+ println!("Description: {}", display_or_dash(&s.description));
+ println!("Release: {}", manifest.release_tag);
+ println!("\nSKILL.md artifact:");
+ println!(" URL: {}", s.skill_md.url);
+ println!(" Size: {} bytes", s.skill_md.size_bytes);
+ println!(" SHA256: {}", s.skill_md.sha256);
+ return Ok(());
+ }
+
+ let suggestions = nearest_matches(&manifest, name);
+ if suggestions.is_empty() {
+ anyhow::bail!("'{}' is not in this IronHub release", name);
+ }
+ anyhow::bail!(
+ "'{}' is not in this IronHub release. Did you mean: {}?",
+ name,
+ suggestions.join(", ")
+ );
+}
+
+fn entry_matches(name: &str, description: &str, query_lower: &str) -> bool {
+ name.to_ascii_lowercase().contains(query_lower)
+ || description.to_ascii_lowercase().contains(query_lower)
+}
+
+fn print_tool_row(t: &HubToolEntry) {
+ println!(
+ " {:<24} {:<10} {:<12} {}",
+ t.name,
+ t.version,
+ t.provenance.trust_label(),
+ display_or_dash(&t.description)
+ );
+}
+
+fn print_skill_row(s: &HubSkillEntry) {
+ let version = if s.version.is_empty() {
+ "-"
+ } else {
+ s.version.as_str()
+ };
+ println!(
+ " {:<24} {:<10} {:<12} {}",
+ s.name,
+ version,
+ s.provenance.trust_label(),
+ display_or_dash(&s.description)
+ );
+}
+
+fn display_or_dash(s: &str) -> &str {
+ if s.is_empty() { "-" } else { s }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::registry::{HubArtifact, HubSkillEntry, HubToolEntry, Provenance};
+
+ fn art(name: &str, ext: &str) -> HubArtifact {
+ HubArtifact {
+ url: format!(
+ "https://github.com/nearai/ironhub/releases/download/test/{}.{}",
+ name, ext
+ ),
+ size_bytes: 1024,
+ sha256: "a".repeat(64),
+ }
+ }
+
+ #[test]
+ fn build_installer_rejects_release_tag() {
+ let msg = match build_installer(Some("release-2026-05-12-24".into()), None, None) {
+ Ok(_) => panic!("--release-tag must be rejected under signed manifests"),
+ Err(e) => e.to_string(),
+ };
+ assert!(msg.contains("--release-tag"), "got: {msg}");
+ assert!(msg.contains("signed catalog"), "got: {msg}");
+ }
+
+ fn manifest_with(tools: Vec<&str>, skills: Vec<&str>) -> HubManifest {
+ HubManifest {
+ version: "1".into(),
+ generated_at: "2026-05-14T00:00:00Z".into(),
+ release_tag: "release-test".into(),
+ repo: "nearai/ironhub".into(),
+ tools: tools
+ .into_iter()
+ .map(|n| HubToolEntry {
+ name: n.into(),
+ crate_name: format!("{}-tool", n),
+ version: "0.1.0".into(),
+ description: format!("{} tool", n),
+ provenance: Provenance::Official,
+ wasm: art(n, "wasm"),
+ capabilities: art(n, "capabilities.json"),
+ })
+ .collect(),
+ skills: skills
+ .into_iter()
+ .map(|n| HubSkillEntry {
+ name: n.into(),
+ trunk: String::new(),
+ version: "0.1.0".into(),
+ description: format!("{} skill", n),
+ provenance: Provenance::Official,
+ skill_md: art(n, "SKILL.md"),
+ })
+ .collect(),
+ }
+ }
+
+ #[test]
+ fn classify_picks_tool_when_only_in_tools() {
+ let m = manifest_with(vec!["clickup"], vec!["chief-of-staff"]);
+ assert!(matches!(
+ classify(&m, "clickup", false, false).unwrap(),
+ Kind::Tool
+ ));
+ }
+
+ #[test]
+ fn classify_picks_skill_when_only_in_skills() {
+ let m = manifest_with(vec!["clickup"], vec!["chief-of-staff"]);
+ assert!(matches!(
+ classify(&m, "chief-of-staff", false, false).unwrap(),
+ Kind::Skill
+ ));
+ }
+
+ #[test]
+ fn classify_requires_disambiguation_when_in_both() {
+ let m = manifest_with(vec!["overlap"], vec!["overlap"]);
+ let err = classify(&m, "overlap", false, false).expect_err("must error");
+ assert!(err.to_string().contains("disambiguate"));
+ }
+
+ #[test]
+ fn classify_honors_force_tool_flag() {
+ let m = manifest_with(vec!["overlap"], vec!["overlap"]);
+ assert!(matches!(
+ classify(&m, "overlap", true, false).unwrap(),
+ Kind::Tool
+ ));
+ }
+
+ #[test]
+ fn classify_honors_force_skill_flag() {
+ let m = manifest_with(vec!["overlap"], vec!["overlap"]);
+ assert!(matches!(
+ classify(&m, "overlap", false, true).unwrap(),
+ Kind::Skill
+ ));
+ }
+
+ #[test]
+ fn classify_force_tool_rejects_skill_only_name() {
+ let m = manifest_with(vec![], vec!["chief-of-staff"]);
+ let err = classify(&m, "chief-of-staff", true, false).expect_err("must error");
+ assert!(err.to_string().contains("not a tool"));
+ }
+
+ #[test]
+ fn classify_force_skill_rejects_tool_only_name() {
+ let m = manifest_with(vec!["clickup"], vec![]);
+ let err = classify(&m, "clickup", false, true).expect_err("must error");
+ assert!(err.to_string().contains("not a skill"));
+ }
+
+ #[test]
+ fn classify_unknown_name_suggests_nearest() {
+ let m = manifest_with(vec!["clickup", "evm-rpc"], vec![]);
+ let err = classify(&m, "click", false, false).expect_err("must error");
+ let msg = err.to_string();
+ assert!(msg.contains("Did you mean"));
+ assert!(msg.contains("clickup"));
+ }
+
+ #[test]
+ fn entry_matches_lowercases_name_against_already_lowercased_query() {
+ assert!(entry_matches("ClickUp", "Task tracking", "clickup"));
+ assert!(entry_matches("clickup", "Task tracking", "click"));
+ assert!(!entry_matches("clickup", "Task tracking", "CLICK"));
+ }
+
+ #[test]
+ fn entry_matches_searches_description() {
+ assert!(entry_matches(
+ "evm-rpc",
+ "Ethereum RPC bindings",
+ "ethereum"
+ ));
+ assert!(!entry_matches("evm-rpc", "Ethereum RPC bindings", "solana"));
+ }
+
+ #[test]
+ fn nearest_matches_filters_by_substring_both_directions() {
+ let m = manifest_with(vec!["clickup", "evm-rpc", "near-rpc"], vec![]);
+ let hits = nearest_matches(&m, "rpc");
+ assert!(hits.contains(&"evm-rpc".to_string()));
+ assert!(hits.contains(&"near-rpc".to_string()));
+ assert!(!hits.contains(&"clickup".to_string()));
+ }
+
+ fn manifest_with_one_new_tool(name: &str) -> HubManifest {
+ let mut m = manifest_with(vec![name], vec![]);
+ if let Some(tool) = m.tools.first_mut() {
+ tool.provenance = Provenance::New;
+ }
+ m
+ }
+
+ #[tokio::test]
+ async fn install_with_manifest_rejects_provenance_new_without_acknowledgement() {
+ let manifest = manifest_with_one_new_tool("indie-tool");
+ let err = install_with_manifest(
+ manifest,
+ "indie-tool",
+ false,
+ false,
+ None,
+ None,
+ false,
+ false,
+ )
+ .await
+ .expect_err("community-unverified entry without --acknowledge-unverified must bail");
+ let msg = err.to_string();
+ assert!(
+ msg.contains("UNVERIFIED") && msg.contains("--acknowledge-unverified"),
+ "CLI gate error must name the flag the operator needs to set, got: {msg}"
+ );
+ }
+
+ #[tokio::test]
+ async fn install_with_manifest_passes_gate_with_acknowledgement() {
+ let manifest = manifest_with_one_new_tool("indie-tool");
+ let result = install_with_manifest(
+ manifest,
+ "indie-tool",
+ false,
+ false,
+ None,
+ None,
+ false,
+ true,
+ )
+ .await;
+ match result {
+ Err(e) => {
+ let msg = e.to_string();
+ assert!(
+ !msg.contains("UNVERIFIED"),
+ "ack=true must clear the gate; any UNVERIFIED bail here means the gate \
+ fired despite acknowledgement, got: {msg}"
+ );
+ }
+ Ok(_) => panic!(
+ "test-fixture artifact URLs cannot resolve in a unit-test environment; \
+ an Ok result means the install pipeline silently bypassed network entirely"
+ ),
+ }
+ }
+}
diff --git a/src/cli/hub_install.rs b/src/cli/hub_install.rs
new file mode 100644
index 00000000000..8760264f7ce
--- /dev/null
+++ b/src/cli/hub_install.rs
@@ -0,0 +1,129 @@
+pub fn looks_like_hub_name(s: &str) -> bool {
+ if s.is_empty() {
+ return false;
+ }
+ let mut chars = s.chars();
+ let first = chars.next().unwrap_or('\0');
+ if !(first.is_ascii_lowercase() || first.is_ascii_digit()) {
+ return false;
+ }
+ chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_')
+}
+
+pub fn validate_hub_name(s: &str) -> anyhow::Result<()> {
+ if looks_like_hub_name(s) {
+ Ok(())
+ } else {
+ anyhow::bail!(
+ "'{}' is not a valid IronHub name (lowercase letters, digits, hyphens, underscores; must start with a letter or digit).",
+ s
+ )
+ }
+}
+
+pub fn hub_manifest_url_for_tag(tag: &str) -> anyhow::Result {
+ validate_release_tag(tag)?;
+ Ok(format!(
+ "https://github.com/nearai/ironhub/releases/download/{}/tools.json",
+ tag
+ ))
+}
+
+pub fn validate_release_tag(tag: &str) -> anyhow::Result<()> {
+ if tag.is_empty() {
+ anyhow::bail!("release tag must not be empty");
+ }
+ let valid = tag
+ .chars()
+ .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '.' || c == '_');
+ if !valid {
+ anyhow::bail!(
+ "release tag '{}' contains characters outside [A-Za-z0-9._-]",
+ tag
+ );
+ }
+ if tag.contains("..") {
+ anyhow::bail!("release tag '{}' must not contain '..'", tag);
+ }
+ Ok(())
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn looks_like_hub_name_accepts_simple_names() {
+ assert!(looks_like_hub_name("clickup"));
+ assert!(looks_like_hub_name("evm-rpc"));
+ assert!(looks_like_hub_name("near-rpc"));
+ assert!(looks_like_hub_name("microsoft-365"));
+ assert!(looks_like_hub_name("microsoft_365"));
+ assert!(looks_like_hub_name("a1-b2_c3"));
+ assert!(looks_like_hub_name("a"));
+ }
+
+ #[test]
+ fn looks_like_hub_name_rejects_paths_and_extensions() {
+ assert!(!looks_like_hub_name(""));
+ assert!(!looks_like_hub_name("./local"));
+ assert!(!looks_like_hub_name("/abs/path"));
+ assert!(!looks_like_hub_name("tools/clickup"));
+ assert!(!looks_like_hub_name("name.wasm"));
+ assert!(!looks_like_hub_name("Name"));
+ assert!(!looks_like_hub_name("-leading-hyphen"));
+ assert!(!looks_like_hub_name("_leading-underscore"));
+ assert!(!looks_like_hub_name("name with space"));
+ }
+
+ #[test]
+ fn hub_manifest_url_for_tag_renders_release_url() {
+ let url = hub_manifest_url_for_tag("release-2026-05-12-24").expect("valid tag");
+ assert_eq!(
+ url,
+ "https://github.com/nearai/ironhub/releases/download/release-2026-05-12-24/tools.json"
+ );
+ }
+
+ #[test]
+ fn validate_release_tag_accepts_real_tags() {
+ for tag in [
+ "release-2026-05-12-24",
+ "v1.0.0",
+ "release_2026_05_12",
+ "RC1",
+ "0",
+ ] {
+ assert!(validate_release_tag(tag).is_ok(), "expected {:?}", tag);
+ }
+ }
+
+ #[test]
+ fn validate_release_tag_rejects_unsafe_input() {
+ for tag in [
+ "",
+ "..",
+ "v1..0",
+ "../etc",
+ "release/../other",
+ "release@evil.com",
+ "release with space",
+ "release\nnewline",
+ "release\0null",
+ "release?query=1",
+ "release#frag",
+ ] {
+ assert!(
+ validate_release_tag(tag).is_err(),
+ "expected {:?} to fail",
+ tag
+ );
+ }
+ }
+
+ #[test]
+ fn hub_manifest_url_for_tag_propagates_validation_failure() {
+ let err = hub_manifest_url_for_tag("../etc").expect_err("traversal must fail");
+ assert!(err.to_string().contains("characters outside"));
+ }
+}
diff --git a/src/cli/mod.rs b/src/cli/mod.rs
index 7dd097dfb12..4d768196c4f 100644
--- a/src/cli/mod.rs
+++ b/src/cli/mod.rs
@@ -22,6 +22,8 @@ mod config;
mod doctor;
pub mod fmt;
mod hooks;
+mod hub;
+pub(crate) mod hub_install;
#[cfg(feature = "import")]
pub mod import;
mod logs;
@@ -43,6 +45,7 @@ pub use completion::Completion;
pub use config::{ConfigCommand, run_config_command};
pub use doctor::run_doctor_command;
pub use hooks::{HooksCommand, run_hooks_command};
+pub use hub::{HubCommand, run_hub_command};
#[cfg(feature = "import")]
pub use import::{ImportCommand, run_import_command};
pub use logs::{LogsCommand, run_logs_command};
@@ -183,6 +186,14 @@ pub enum Command {
)]
Tool(ToolCommand),
+ /// Browse and install tools and skills from IronHub
+ #[command(
+ subcommand,
+ about = "Browse and install tools and skills from IronHub",
+ long_about = "Catalog at hub.ironclaw.com.\nExample: ironclaw hub install clickup"
+ )]
+ Hub(HubCommand),
+
/// Browse and install extensions from the registry
#[command(
subcommand,
diff --git a/src/cli/skills.rs b/src/cli/skills.rs
index b0cdbe6232d..593626d535a 100644
--- a/src/cli/skills.rs
+++ b/src/cli/skills.rs
@@ -1,15 +1,23 @@
//! Skills management CLI commands.
//!
-//! Commands for listing, searching, and inspecting SKILL.md-based skills.
-//! List and info operate on the filesystem only; search queries the ClawHub registry.
+//! Commands for listing, searching, inspecting, and installing SKILL.md-based skills.
+//! List and info operate on the filesystem only; search queries the ClawHub registry;
+//! install pulls from the IronHub release manifest or copies from a local path.
-use std::path::Path;
+use std::path::{Path, PathBuf};
use clap::Subcommand;
+use tokio::fs;
+use crate::bootstrap::ironclaw_base_dir;
+use crate::cli::hub_install::looks_like_hub_name;
use crate::config::SkillsConfig;
use ironclaw_skills::catalog::SkillCatalog;
-use ironclaw_skills::{SkillRegistry, SkillSource};
+use ironclaw_skills::{SkillRegistry, SkillSource, parse_skill_md};
+
+fn default_skills_dir() -> PathBuf {
+ ironclaw_base_dir().join("skills")
+}
#[derive(Subcommand, Debug, Clone)]
pub enum SkillsCommand {
@@ -43,6 +51,17 @@ pub enum SkillsCommand {
#[arg(long)]
json: bool,
},
+
+ /// Install a skill from a local SKILL.md or directory. For IronHub installs use `ironclaw hub install `.
+ Install {
+ path: PathBuf,
+
+ #[arg(short, long)]
+ target: Option,
+
+ #[arg(short, long)]
+ force: bool,
+ },
}
/// Run the skills CLI subcommand.
@@ -63,7 +82,106 @@ pub async fn run_skills_command(
SkillsCommand::List { verbose, json } => cmd_list(&config, verbose, json).await,
SkillsCommand::Search { query, json } => cmd_search(&query, json).await,
SkillsCommand::Info { name, json } => cmd_info(&config, &name, json).await,
+ SkillsCommand::Install {
+ path,
+ target,
+ force,
+ } => install_skill_local(&path, target, force).await,
+ }
+}
+
+async fn install_skill_local(
+ source_path: &Path,
+ target: Option,
+ force: bool,
+) -> anyhow::Result<()> {
+ let skills_dir = target.unwrap_or_else(default_skills_dir);
+
+ let metadata = fs::metadata(source_path)
+ .await
+ .map_err(|e| anyhow::anyhow!("Cannot read {}: {}", source_path.display(), e))?;
+
+ let (skill_md_src, skill_name) = if metadata.is_dir() {
+ let candidate = source_path.join("SKILL.md");
+ if !candidate.exists() {
+ anyhow::bail!("No SKILL.md found in directory {}.", source_path.display());
+ }
+ let name = source_path
+ .file_name()
+ .and_then(|n| n.to_str())
+ .ok_or_else(|| {
+ anyhow::anyhow!("Cannot derive skill name from {}", source_path.display())
+ })?
+ .to_string();
+ (candidate, name)
+ } else if source_path.file_name().and_then(|n| n.to_str()) == Some("SKILL.md") {
+ let parent = source_path
+ .parent()
+ .ok_or_else(|| anyhow::anyhow!("SKILL.md has no parent directory"))?;
+ let name = parent
+ .file_name()
+ .and_then(|n| n.to_str())
+ .ok_or_else(|| anyhow::anyhow!("Cannot derive skill name from {}", parent.display()))?
+ .to_string();
+ (source_path.to_path_buf(), name)
+ } else {
+ anyhow::bail!(
+ "Expected a SKILL.md file or a directory containing one, got: {}",
+ source_path.display()
+ );
+ };
+
+ if !looks_like_hub_name(&skill_name) {
+ anyhow::bail!(
+ "Skill directory name '{}' is not a valid identifier (lowercase letters, digits, hyphens; must start with a letter or digit).",
+ skill_name
+ );
+ }
+
+ let content = fs::read_to_string(&skill_md_src)
+ .await
+ .map_err(|e| anyhow::anyhow!("Cannot read {}: {}", skill_md_src.display(), e))?;
+ let parsed = parse_skill_md(&content)
+ .map_err(|e| anyhow::anyhow!("Invalid SKILL.md at {}: {}", skill_md_src.display(), e))?;
+ if parsed.manifest.name != skill_name {
+ anyhow::bail!(
+ "SKILL.md manifest name '{}' does not match directory '{}'.",
+ parsed.manifest.name,
+ skill_name
+ );
+ }
+
+ let target_dir = skills_dir.join(&skill_name);
+ let target_md = target_dir.join("SKILL.md");
+
+ if target_md.exists() && !force {
+ anyhow::bail!(
+ "Skill '{}' already exists at {}. Use --force to overwrite.",
+ skill_name,
+ target_md.display()
+ );
+ }
+
+ fs::create_dir_all(&target_dir).await?;
+ fs::write(&target_md, content.as_bytes()).await?;
+
+ let on_disk = fs::metadata(&target_md).await?;
+ if on_disk.len() as usize != content.len() {
+ anyhow::bail!(
+ "On-disk size mismatch after write at {} (expected {} bytes, got {}).",
+ target_md.display(),
+ content.len(),
+ on_disk.len()
+ );
}
+
+ println!("\nInstalled successfully:");
+ println!(" Name: {}", skill_name);
+ println!(" Version: {}", parsed.manifest.version);
+ println!(" Path: {}", target_md.display());
+ println!(" Size: {} bytes", content.len());
+
+ Ok(())
}
/// Discover skills from all configured directories.
@@ -373,4 +491,190 @@ mod tests {
"bundled"
);
}
+
+ fn skill_md_fixture(name: &str, version: &str) -> String {
+ format!(
+ "---\nname: {}\nversion: {}\ndescription: test fixture\n---\n\nBody content for {}.\n",
+ name, version, name
+ )
+ }
+
+ #[tokio::test]
+ async fn install_skill_local_copies_skill_md_from_directory() {
+ let src = tempfile::tempdir().expect("src tempdir");
+ let skill_dir = src.path().join("test-skill");
+ fs::create_dir(&skill_dir).await.expect("mkdir");
+ let body = skill_md_fixture("test-skill", "0.1.0");
+ fs::write(skill_dir.join("SKILL.md"), &body)
+ .await
+ .expect("write");
+
+ let dest = tempfile::tempdir().expect("dest tempdir");
+ install_skill_local(&skill_dir, Some(dest.path().to_path_buf()), false)
+ .await
+ .expect("install ok");
+
+ let installed = dest.path().join("test-skill/SKILL.md");
+ assert!(installed.exists());
+ let on_disk = fs::read_to_string(&installed).await.expect("read");
+ assert_eq!(on_disk, body);
+ }
+
+ #[tokio::test]
+ async fn install_skill_local_accepts_direct_skill_md_path() {
+ let src = tempfile::tempdir().expect("src tempdir");
+ let skill_dir = src.path().join("test-skill");
+ fs::create_dir(&skill_dir).await.expect("mkdir");
+ let md_path = skill_dir.join("SKILL.md");
+ let body = skill_md_fixture("test-skill", "0.1.0");
+ fs::write(&md_path, &body).await.expect("write");
+
+ let dest = tempfile::tempdir().expect("dest tempdir");
+ install_skill_local(&md_path, Some(dest.path().to_path_buf()), false)
+ .await
+ .expect("install ok");
+
+ assert!(dest.path().join("test-skill/SKILL.md").exists());
+ }
+
+ #[tokio::test]
+ async fn install_skill_local_refuses_overwrite_without_force() {
+ let src = tempfile::tempdir().expect("src tempdir");
+ let skill_dir = src.path().join("test-skill");
+ fs::create_dir(&skill_dir).await.expect("mkdir");
+ fs::write(
+ skill_dir.join("SKILL.md"),
+ skill_md_fixture("test-skill", "0.1.0"),
+ )
+ .await
+ .expect("write");
+
+ let dest = tempfile::tempdir().expect("dest tempdir");
+ install_skill_local(&skill_dir, Some(dest.path().to_path_buf()), false)
+ .await
+ .expect("first install");
+
+ let err = install_skill_local(&skill_dir, Some(dest.path().to_path_buf()), false)
+ .await
+ .expect_err("second install must fail without force");
+ assert!(err.to_string().contains("already exists"));
+ }
+
+ #[tokio::test]
+ async fn install_skill_local_overwrites_with_force() {
+ let src = tempfile::tempdir().expect("src tempdir");
+ let skill_dir = src.path().join("test-skill");
+ fs::create_dir(&skill_dir).await.expect("mkdir");
+ fs::write(
+ skill_dir.join("SKILL.md"),
+ skill_md_fixture("test-skill", "0.1.0"),
+ )
+ .await
+ .expect("write");
+
+ let dest = tempfile::tempdir().expect("dest tempdir");
+ install_skill_local(&skill_dir, Some(dest.path().to_path_buf()), false)
+ .await
+ .expect("first install");
+
+ let updated = skill_md_fixture("test-skill", "0.2.0");
+ fs::write(skill_dir.join("SKILL.md"), &updated)
+ .await
+ .expect("update src");
+ install_skill_local(&skill_dir, Some(dest.path().to_path_buf()), true)
+ .await
+ .expect("forced reinstall");
+
+ let on_disk = fs::read_to_string(dest.path().join("test-skill/SKILL.md"))
+ .await
+ .expect("read");
+ assert_eq!(on_disk, updated);
+ }
+
+ #[tokio::test]
+ async fn install_skill_local_rejects_directory_without_skill_md() {
+ let src = tempfile::tempdir().expect("src tempdir");
+ let bare = src.path().join("empty-skill");
+ fs::create_dir(&bare).await.expect("mkdir");
+
+ let dest = tempfile::tempdir().expect("dest tempdir");
+ let err = install_skill_local(&bare, Some(dest.path().to_path_buf()), false)
+ .await
+ .expect_err("missing SKILL.md must fail");
+ assert!(err.to_string().contains("No SKILL.md"));
+ }
+
+ #[tokio::test]
+ async fn install_skill_local_rejects_arbitrary_file() {
+ let src = tempfile::tempdir().expect("src tempdir");
+ let stray = src.path().join("README.md");
+ fs::write(&stray, b"# not a skill").await.expect("write");
+
+ let dest = tempfile::tempdir().expect("dest tempdir");
+ let err = install_skill_local(&stray, Some(dest.path().to_path_buf()), false)
+ .await
+ .expect_err("arbitrary file must fail");
+ assert!(err.to_string().contains("SKILL.md"));
+ }
+
+ #[tokio::test]
+ async fn install_skill_local_rejects_bad_dir_name() {
+ let src = tempfile::tempdir().expect("src tempdir");
+ let bad_dir = src.path().join("Bad Name!");
+ fs::create_dir(&bad_dir).await.expect("mkdir");
+ fs::write(
+ bad_dir.join("SKILL.md"),
+ skill_md_fixture("bad-name", "0.1.0"),
+ )
+ .await
+ .expect("write");
+
+ let dest = tempfile::tempdir().expect("dest tempdir");
+ let err = install_skill_local(&bad_dir, Some(dest.path().to_path_buf()), false)
+ .await
+ .expect_err("bad dir name must fail");
+ assert!(err.to_string().contains("not a valid identifier"));
+ }
+
+ #[tokio::test]
+ async fn install_skill_local_rejects_malformed_skill_md() {
+ let src = tempfile::tempdir().expect("src tempdir");
+ let skill_dir = src.path().join("test-skill");
+ fs::create_dir(&skill_dir).await.expect("mkdir");
+ fs::write(skill_dir.join("SKILL.md"), b"no frontmatter here at all")
+ .await
+ .expect("write");
+
+ let dest = tempfile::tempdir().expect("dest tempdir");
+ let err = install_skill_local(&skill_dir, Some(dest.path().to_path_buf()), false)
+ .await
+ .expect_err("malformed SKILL.md must fail");
+ assert!(err.to_string().contains("Invalid SKILL.md"));
+ }
+
+ #[tokio::test]
+ async fn install_skill_local_rejects_manifest_name_mismatch() {
+ let src = tempfile::tempdir().expect("src tempdir");
+ let skill_dir = src.path().join("test-skill");
+ fs::create_dir(&skill_dir).await.expect("mkdir");
+ fs::write(
+ skill_dir.join("SKILL.md"),
+ skill_md_fixture("different-name", "0.1.0"),
+ )
+ .await
+ .expect("write");
+
+ let dest = tempfile::tempdir().expect("dest tempdir");
+ let err = install_skill_local(&skill_dir, Some(dest.path().to_path_buf()), false)
+ .await
+ .expect_err("name mismatch must fail");
+ assert!(err.to_string().contains("does not match"));
+ }
+
+ #[test]
+ fn default_skills_dir_under_ironclaw_base() {
+ let dir = default_skills_dir();
+ assert!(dir.to_string_lossy().contains(".ironclaw"));
+ assert!(dir.to_string_lossy().contains("skills"));
+ }
}
diff --git a/src/cli/snapshots/ironclaw__cli__tests__help_output_without_import.snap b/src/cli/snapshots/ironclaw__cli__tests__help_output_without_import.snap
index 7d1d8afc6a3..696238a97d7 100644
--- a/src/cli/snapshots/ironclaw__cli__tests__help_output_without_import.snap
+++ b/src/cli/snapshots/ironclaw__cli__tests__help_output_without_import.snap
@@ -1,5 +1,6 @@
---
source: src/cli/mod.rs
+assertion_line: 505
expression: help
---
Secure personal AI assistant that protects your data and expands its capabilities
@@ -11,6 +12,7 @@ Commands:
onboard Run interactive setup wizard (start here if new to IronClaw)
config Manage app configuration settings
tool Manage WASM tools
+ hub Browse and install tools and skills from IronHub
registry Browse/install extensions
channels Manage channels
routines Manage routines
diff --git a/src/cli/snapshots/ironclaw__cli__tests__long_help_output_without_import.snap b/src/cli/snapshots/ironclaw__cli__tests__long_help_output_without_import.snap
index 0aa6e16739e..863dfe4ba67 100644
--- a/src/cli/snapshots/ironclaw__cli__tests__long_help_output_without_import.snap
+++ b/src/cli/snapshots/ironclaw__cli__tests__long_help_output_without_import.snap
@@ -1,5 +1,6 @@
---
source: src/cli/mod.rs
+assertion_line: 521
expression: help
---
IronClaw is a secure AI assistant.
@@ -25,6 +26,7 @@ Commands:
onboard Run interactive setup wizard (start here if new to IronClaw)
config Manage app configuration settings
tool Manage WASM tools
+ hub Browse and install tools and skills from IronHub
registry Browse/install extensions
channels Manage channels
routines Manage routines
diff --git a/src/cli/tool.rs b/src/cli/tool.rs
index dfe94ae31b1..986637d763a 100644
--- a/src/cli/tool.rs
+++ b/src/cli/tool.rs
@@ -21,32 +21,25 @@ fn default_tools_dir() -> PathBuf {
#[derive(Subcommand, Debug, Clone)]
pub enum ToolCommand {
- /// Install a WASM tool from source directory or .wasm file
+ /// Install a WASM tool from a source dir or .wasm file. For IronHub installs use `ironclaw hub install `.
Install {
- /// Path to tool source directory (with Cargo.toml) or .wasm file
path: PathBuf,
- /// Tool name (defaults to directory/file name)
#[arg(short, long)]
name: Option,
- /// Path to capabilities JSON file (auto-detected if not specified)
#[arg(long)]
capabilities: Option,
- /// Target directory for installation (default: ~/.ironclaw/tools/)
#[arg(short, long)]
target: Option,
- /// Build in release mode (default: true)
#[arg(long, default_value = "true")]
release: bool,
- /// Skip compilation (use existing .wasm file)
#[arg(long)]
skip_build: bool,
- /// Force overwrite if tool already exists
#[arg(short, long)]
force: bool,
},
diff --git a/src/main.rs b/src/main.rs
index 5a4021f4e6e..cc9e881aea1 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -148,6 +148,10 @@ async fn async_main() -> anyhow::Result<()> {
init_cli_tracing();
return run_tool_command(tool_cmd.clone()).await;
}
+ Some(Command::Hub(hub_cmd)) => {
+ init_cli_tracing();
+ return ironclaw::cli::run_hub_command(hub_cmd.clone()).await;
+ }
Some(Command::Config(config_cmd)) => {
init_cli_tracing();
return ironclaw::cli::run_config_command(config_cmd.clone()).await;
diff --git a/src/registry/hub_installer.rs b/src/registry/hub_installer.rs
new file mode 100644
index 00000000000..ac392c1a79c
--- /dev/null
+++ b/src/registry/hub_installer.rs
@@ -0,0 +1,1343 @@
+use std::collections::HashMap;
+use std::path::{Path, PathBuf};
+use std::sync::{Arc, LazyLock};
+use std::time::{Duration, Instant};
+
+use tokio::fs;
+use tokio::sync::Mutex as AsyncMutex;
+
+use crate::bootstrap::ironclaw_base_dir;
+use crate::registry::catalog::RegistryError;
+use crate::registry::hub_manifest::{
+ DEFAULT_HUB_MANIFEST_URL, HubManifest, HubSkillEntry, HubToolEntry, MANIFEST_VERIFY_KEYS,
+ Provenance, verify_signed_manifest,
+};
+use crate::registry::installer::{download_artifact, validate_artifact_url, verify_sha256};
+
+const MAX_MANIFEST_BYTES: usize = 1024 * 1024;
+const MAX_SIGNED_MANIFEST_BYTES: usize = MAX_MANIFEST_BYTES * 2;
+const MAX_METADATA_BYTES: usize = 1024 * 1024;
+const MAX_WASM_BYTES: usize = 16 * 1024 * 1024;
+
+static INSTALL_LOCKS: LazyLock>>>> =
+ LazyLock::new(|| std::sync::Mutex::new(HashMap::new()));
+
+struct InstallLock {
+ key: String,
+ mutex: Arc>,
+}
+
+impl InstallLock {
+ async fn lock(&self) -> tokio::sync::MutexGuard<'_, ()> {
+ self.mutex.lock().await
+ }
+}
+
+impl Drop for InstallLock {
+ fn drop(&mut self) {
+ let mut guard = INSTALL_LOCKS
+ .lock()
+ .unwrap_or_else(|poisoned| poisoned.into_inner());
+ if let Some(existing) = guard.get(&self.key)
+ && Arc::ptr_eq(existing, &self.mutex)
+ && Arc::strong_count(&self.mutex) <= 2
+ {
+ guard.remove(&self.key);
+ }
+ }
+}
+
+fn acquire_install_lock(key: &str) -> InstallLock {
+ let mut guard = INSTALL_LOCKS
+ .lock()
+ .unwrap_or_else(|poisoned| poisoned.into_inner());
+ let mutex = if let Some(existing) = guard.get(key) {
+ Arc::clone(existing)
+ } else {
+ let fresh = Arc::new(AsyncMutex::new(()));
+ guard.insert(key.to_string(), Arc::clone(&fresh));
+ fresh
+ };
+ InstallLock {
+ key: key.to_string(),
+ mutex,
+ }
+}
+
+const MANIFEST_CACHE_TTL: Duration = Duration::from_secs(60);
+const MANIFEST_CACHE_MAX_ENTRIES: usize = 64;
+
+struct CachedManifest {
+ manifest: Arc,
+ fetched_at: Instant,
+}
+
+static MANIFEST_CACHE: LazyLock>> =
+ LazyLock::new(|| std::sync::Mutex::new(HashMap::new()));
+
+fn manifest_cache_get(url: &str, now: Instant) -> Option> {
+ let guard = MANIFEST_CACHE
+ .lock()
+ .unwrap_or_else(|poisoned| poisoned.into_inner());
+ let entry = guard.get(url)?;
+ (now.duration_since(entry.fetched_at) <= MANIFEST_CACHE_TTL)
+ .then(|| Arc::clone(&entry.manifest))
+}
+
+fn manifest_cache_put(url: &str, manifest: Arc, now: Instant) {
+ let mut guard = MANIFEST_CACHE
+ .lock()
+ .unwrap_or_else(|poisoned| poisoned.into_inner());
+ if guard.len() >= MANIFEST_CACHE_MAX_ENTRIES && !guard.contains_key(url) {
+ guard.retain(|_, e| now.duration_since(e.fetched_at) <= MANIFEST_CACHE_TTL);
+ if guard.len() >= MANIFEST_CACHE_MAX_ENTRIES
+ && let Some(victim) = guard.keys().next().cloned()
+ {
+ guard.remove(&victim);
+ }
+ }
+ guard.insert(
+ url.to_string(),
+ CachedManifest {
+ manifest,
+ fetched_at: now,
+ },
+ );
+}
+
+async fn write_atomic(target: &Path, bytes: &[u8]) -> Result<(), RegistryError> {
+ let file_name = target
+ .file_name()
+ .and_then(|n| n.to_str())
+ .unwrap_or("artifact");
+ let temp_name = format!("{}.tmp.{}", file_name, uuid::Uuid::new_v4());
+ let temp = target.with_file_name(temp_name);
+ if let Err(e) = fs::write(&temp, bytes).await {
+ cleanup_partial_artifact(&temp).await;
+ return Err(RegistryError::Io(e));
+ }
+ if let Err(e) = confirm_written_size(&temp, bytes.len()).await {
+ cleanup_partial_artifact(&temp).await;
+ return Err(e);
+ }
+ if let Err(e) = fs::rename(&temp, target).await {
+ cleanup_partial_artifact(&temp).await;
+ return Err(RegistryError::Io(e));
+ }
+ Ok(())
+}
+
+pub(crate) fn validate_hub_artifact_name(
+ name: &str,
+ field: &'static str,
+) -> Result<(), RegistryError> {
+ let is_valid = !name.is_empty()
+ && name
+ .chars()
+ .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_');
+
+ if is_valid {
+ Ok(())
+ } else {
+ Err(RegistryError::InvalidManifest {
+ name: name.to_string(),
+ field,
+ reason: "name must be non-empty and contain only lowercase letters, digits, '-', '_'"
+ .to_string(),
+ })
+ }
+}
+
+#[derive(Debug)]
+pub struct HubInstallOutcome {
+ pub name: String,
+ pub version: String,
+ pub release_tag: String,
+ pub provenance: Provenance,
+ pub primary_path: PathBuf,
+ pub metadata_path: Option,
+}
+
+pub struct HubInstaller {
+ manifest_url: String,
+ tools_dir: PathBuf,
+ skills_dir: PathBuf,
+}
+
+fn resolve_manifest_url(env_value: Option) -> String {
+ env_value
+ .filter(|s| !s.trim().is_empty())
+ .unwrap_or_else(|| DEFAULT_HUB_MANIFEST_URL.to_string())
+}
+
+impl HubInstaller {
+ pub fn new(manifest_url: String, tools_dir: PathBuf, skills_dir: PathBuf) -> Self {
+ Self {
+ manifest_url,
+ tools_dir,
+ skills_dir,
+ }
+ }
+
+ pub fn with_defaults() -> Self {
+ let base = ironclaw_base_dir();
+ let manifest_url = resolve_manifest_url(std::env::var("IRONHUB_MANIFEST_URL").ok());
+ Self::new(manifest_url, base.join("tools"), base.join("skills"))
+ }
+
+ pub fn with_manifest_url(mut self, url: String) -> Self {
+ self.manifest_url = url;
+ self
+ }
+
+ pub fn with_tools_dir(mut self, dir: PathBuf) -> Self {
+ self.tools_dir = dir;
+ self
+ }
+
+ pub fn with_skills_dir(mut self, dir: PathBuf) -> Self {
+ self.skills_dir = dir;
+ self
+ }
+
+ pub fn manifest_url(&self) -> &str {
+ &self.manifest_url
+ }
+
+ pub fn tools_dir(&self) -> &Path {
+ &self.tools_dir
+ }
+
+ pub fn skills_dir(&self) -> &Path {
+ &self.skills_dir
+ }
+
+ pub async fn fetch_manifest_cached(&self) -> Result {
+ let now = Instant::now();
+ if let Some(hit) = manifest_cache_get(&self.manifest_url, now) {
+ return Ok((*hit).clone());
+ }
+ let manifest = Arc::new(self.fetch_manifest().await?);
+ manifest_cache_put(&self.manifest_url, Arc::clone(&manifest), now);
+ Ok((*manifest).clone())
+ }
+
+ pub async fn fetch_manifest(&self) -> Result {
+ validate_artifact_url("hub-manifest", "manifest_url", &self.manifest_url)?;
+
+ let envelope =
+ download_artifact(&self.manifest_url, MAX_SIGNED_MANIFEST_BYTES as u64).await?;
+ if envelope.len() > MAX_SIGNED_MANIFEST_BYTES {
+ return Err(RegistryError::DownloadFailed {
+ url: self.manifest_url.clone(),
+ reason: format!(
+ "signed manifest exceeds {} byte cap (got {})",
+ MAX_SIGNED_MANIFEST_BYTES,
+ envelope.len()
+ ),
+ });
+ }
+
+ let bytes = verify_signed_manifest(&envelope, MANIFEST_VERIFY_KEYS).map_err(|reason| {
+ RegistryError::InvalidManifest {
+ name: "hub-manifest".to_string(),
+ field: "signature",
+ reason,
+ }
+ })?;
+ if bytes.len() > MAX_MANIFEST_BYTES {
+ return Err(RegistryError::DownloadFailed {
+ url: self.manifest_url.clone(),
+ reason: format!(
+ "manifest exceeds {} byte cap (got {})",
+ MAX_MANIFEST_BYTES,
+ bytes.len()
+ ),
+ });
+ }
+
+ serde_json::from_slice::(&bytes).map_err(|e| RegistryError::ManifestParse {
+ path: PathBuf::from(&self.manifest_url),
+ reason: e.to_string(),
+ })
+ }
+
+ pub async fn install_tool_from_manifest(
+ &self,
+ manifest: &HubManifest,
+ name: &str,
+ force: bool,
+ ) -> Result {
+ let entry = manifest
+ .find_tool(name)
+ .ok_or_else(|| RegistryError::ExtensionNotFound(format!("tool '{}'", name)))?;
+ self.install_tool_entry(entry, &manifest.release_tag, force)
+ .await
+ }
+
+ pub async fn install_skill_from_manifest(
+ &self,
+ manifest: &HubManifest,
+ name: &str,
+ force: bool,
+ ) -> Result {
+ let entry = manifest
+ .find_skill(name)
+ .ok_or_else(|| RegistryError::ExtensionNotFound(format!("skill '{}'", name)))?;
+ self.install_skill_entry(entry, &manifest.release_tag, force)
+ .await
+ }
+
+ fn tool_wasm_path(&self, name: &str) -> PathBuf {
+ self.tools_dir.join(format!("{name}.wasm"))
+ }
+
+ fn skill_md_path(&self, name: &str) -> PathBuf {
+ self.skills_dir.join(name).join("SKILL.md")
+ }
+
+ pub async fn install_tool_entry(
+ &self,
+ entry: &HubToolEntry,
+ release_tag: &str,
+ force: bool,
+ ) -> Result {
+ validate_tool_entry(entry)?;
+
+ if !force {
+ let target = self.tool_wasm_path(&entry.name);
+ if target.exists() {
+ return Err(RegistryError::AlreadyInstalled {
+ name: entry.name.clone(),
+ path: target,
+ });
+ }
+ }
+
+ let wasm_bytes = download_artifact(&entry.wasm.url, MAX_WASM_BYTES as u64).await?;
+ let caps_bytes =
+ download_artifact(&entry.capabilities.url, MAX_METADATA_BYTES as u64).await?;
+
+ self.install_tool_from_bytes(entry, release_tag, &wasm_bytes, &caps_bytes, force)
+ .await
+ }
+
+ pub async fn install_skill_entry(
+ &self,
+ entry: &HubSkillEntry,
+ release_tag: &str,
+ force: bool,
+ ) -> Result {
+ validate_skill_entry(entry)?;
+
+ if !force {
+ let target = self.skill_md_path(&entry.name);
+ if target.exists() {
+ return Err(RegistryError::AlreadyInstalled {
+ name: entry.name.clone(),
+ path: target,
+ });
+ }
+ }
+
+ let md_bytes = download_artifact(&entry.skill_md.url, MAX_METADATA_BYTES as u64).await?;
+
+ self.install_skill_from_bytes(entry, release_tag, &md_bytes, force)
+ .await
+ }
+
+ pub async fn install_tool_from_bytes(
+ &self,
+ entry: &HubToolEntry,
+ release_tag: &str,
+ wasm_bytes: &[u8],
+ caps_bytes: &[u8],
+ force: bool,
+ ) -> Result {
+ validate_hub_artifact_name(&entry.name, "tools[].name")?;
+
+ if wasm_bytes.len() > MAX_WASM_BYTES {
+ return Err(RegistryError::DownloadFailed {
+ url: entry.wasm.url.clone(),
+ reason: format!(
+ "wasm exceeds {} byte cap (got {})",
+ MAX_WASM_BYTES,
+ wasm_bytes.len()
+ ),
+ });
+ }
+ if caps_bytes.len() > MAX_METADATA_BYTES {
+ return Err(RegistryError::DownloadFailed {
+ url: entry.capabilities.url.clone(),
+ reason: format!(
+ "capabilities exceeds {} byte cap (got {})",
+ MAX_METADATA_BYTES,
+ caps_bytes.len()
+ ),
+ });
+ }
+
+ verify_sha256(wasm_bytes, &entry.wasm.sha256, &entry.wasm.url)?;
+ verify_sha256(
+ caps_bytes,
+ &entry.capabilities.sha256,
+ &entry.capabilities.url,
+ )?;
+
+ fs::create_dir_all(&self.tools_dir)
+ .await
+ .map_err(RegistryError::Io)?;
+
+ let target_wasm = self.tool_wasm_path(&entry.name);
+ let target_caps = self
+ .tools_dir
+ .join(format!("{}.capabilities.json", entry.name));
+
+ let lock = acquire_install_lock(&format!("tool:{}", entry.name));
+ let _guard = lock.lock().await;
+
+ if target_wasm.exists() && !force {
+ return Err(RegistryError::AlreadyInstalled {
+ name: entry.name.clone(),
+ path: target_wasm,
+ });
+ }
+
+ write_atomic(&target_wasm, wasm_bytes).await?;
+ if let Err(e) = write_atomic(&target_caps, caps_bytes).await {
+ cleanup_partial_artifact(&target_wasm).await;
+ return Err(e);
+ }
+
+ Ok(HubInstallOutcome {
+ name: entry.name.clone(),
+ version: entry.version.clone(),
+ release_tag: release_tag.to_string(),
+ provenance: entry.provenance,
+ primary_path: target_wasm,
+ metadata_path: Some(target_caps),
+ })
+ }
+
+ pub async fn install_skill_from_bytes(
+ &self,
+ entry: &HubSkillEntry,
+ release_tag: &str,
+ md_bytes: &[u8],
+ force: bool,
+ ) -> Result {
+ validate_hub_artifact_name(&entry.name, "skills[].name")?;
+
+ if md_bytes.len() > MAX_METADATA_BYTES {
+ return Err(RegistryError::DownloadFailed {
+ url: entry.skill_md.url.clone(),
+ reason: format!(
+ "SKILL.md exceeds {} byte cap (got {})",
+ MAX_METADATA_BYTES,
+ md_bytes.len()
+ ),
+ });
+ }
+
+ verify_sha256(md_bytes, &entry.skill_md.sha256, &entry.skill_md.url)?;
+
+ let skill_dir = self.skills_dir.join(&entry.name);
+ fs::create_dir_all(&skill_dir)
+ .await
+ .map_err(RegistryError::Io)?;
+
+ let target_md = self.skill_md_path(&entry.name);
+
+ let lock = acquire_install_lock(&format!("skill:{}", entry.name));
+ let _guard = lock.lock().await;
+
+ if target_md.exists() && !force {
+ return Err(RegistryError::AlreadyInstalled {
+ name: entry.name.clone(),
+ path: target_md,
+ });
+ }
+
+ write_atomic(&target_md, md_bytes).await?;
+
+ Ok(HubInstallOutcome {
+ name: entry.name.clone(),
+ version: entry.version.clone(),
+ release_tag: release_tag.to_string(),
+ provenance: entry.provenance,
+ primary_path: target_md,
+ metadata_path: None,
+ })
+ }
+}
+
+async fn confirm_written_size(path: &Path, expected: usize) -> Result<(), RegistryError> {
+ let metadata = fs::metadata(path).await.map_err(RegistryError::Io)?;
+ let actual = metadata.len() as usize;
+ if actual != expected {
+ return Err(RegistryError::DownloadFailed {
+ url: path.display().to_string(),
+ reason: format!(
+ "on-disk size mismatch after write: expected {} bytes, got {}",
+ expected, actual
+ ),
+ });
+ }
+ Ok(())
+}
+
+async fn cleanup_partial_artifact(path: &Path) {
+ match fs::remove_file(path).await {
+ Ok(()) => {}
+ Err(e) if e.kind() == std::io::ErrorKind::NotFound => {}
+ Err(e) => tracing::warn!("failed to remove partial artifact {}: {e}", path.display()),
+ }
+}
+
+fn validate_tool_entry(entry: &HubToolEntry) -> Result<(), RegistryError> {
+ validate_hub_artifact_name(&entry.name, "tools[].name")?;
+ validate_artifact_url(&entry.name, "tools[].wasm.url", &entry.wasm.url)?;
+ validate_artifact_url(
+ &entry.name,
+ "tools[].capabilities.url",
+ &entry.capabilities.url,
+ )?;
+ Ok(())
+}
+
+fn validate_skill_entry(entry: &HubSkillEntry) -> Result<(), RegistryError> {
+ validate_hub_artifact_name(&entry.name, "skills[].name")?;
+ validate_artifact_url(&entry.name, "skills[].skill_md.url", &entry.skill_md.url)
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::registry::hub_manifest::{
+ HubArtifact, HubManifest, HubSkillEntry, HubToolEntry, Provenance,
+ };
+ use sha2::{Digest, Sha256};
+ use tempfile::TempDir;
+
+ fn sha256_hex(bytes: &[u8]) -> String {
+ let mut hasher = Sha256::new();
+ hasher.update(bytes);
+ format!("{:x}", hasher.finalize())
+ }
+
+ fn build_tool_entry(name: &str, wasm_bytes: &[u8], caps_bytes: &[u8]) -> HubToolEntry {
+ HubToolEntry {
+ name: name.to_string(),
+ crate_name: format!("{}-tool", name),
+ version: "0.1.0".to_string(),
+ description: format!("{} integration", name),
+ provenance: Provenance::Official,
+ wasm: HubArtifact {
+ url: format!(
+ "https://github.com/nearai/ironhub/releases/download/test/{}.wasm",
+ name
+ ),
+ size_bytes: wasm_bytes.len() as u64,
+ sha256: sha256_hex(wasm_bytes),
+ },
+ capabilities: HubArtifact {
+ url: format!(
+ "https://github.com/nearai/ironhub/releases/download/test/{}.capabilities.json",
+ name
+ ),
+ size_bytes: caps_bytes.len() as u64,
+ sha256: sha256_hex(caps_bytes),
+ },
+ }
+ }
+
+ fn build_skill_entry(name: &str, md_bytes: &[u8]) -> HubSkillEntry {
+ HubSkillEntry {
+ name: name.to_string(),
+ trunk: "test-tool".to_string(),
+ version: "1.0.0".to_string(),
+ description: format!("{} skill", name),
+ provenance: Provenance::Official,
+ skill_md: HubArtifact {
+ url: format!(
+ "https://github.com/nearai/ironhub/releases/download/test/{}.SKILL.md",
+ name
+ ),
+ size_bytes: md_bytes.len() as u64,
+ sha256: sha256_hex(md_bytes),
+ },
+ }
+ }
+
+ fn build_manifest(tools: Vec, skills: Vec) -> HubManifest {
+ HubManifest {
+ version: "1".to_string(),
+ generated_at: "2026-05-13T00:00:00Z".to_string(),
+ release_tag: "test-release".to_string(),
+ repo: "nearai/ironhub".to_string(),
+ tools,
+ skills,
+ }
+ }
+
+ fn installer_in(tmp: &TempDir) -> HubInstaller {
+ HubInstaller::new(
+ DEFAULT_HUB_MANIFEST_URL.to_string(),
+ tmp.path().join("tools"),
+ tmp.path().join("skills"),
+ )
+ }
+
+ #[test]
+ fn defaults_use_ironclaw_base_dirs() {
+ let installer = HubInstaller::with_defaults();
+ let base = ironclaw_base_dir();
+ assert_eq!(installer.tools_dir(), base.join("tools"));
+ assert_eq!(installer.skills_dir(), base.join("skills"));
+ assert_eq!(installer.manifest_url(), DEFAULT_HUB_MANIFEST_URL);
+ }
+
+ #[test]
+ fn pinned_manifest_url_replaces_default() {
+ let pinned =
+ "https://github.com/nearai/ironhub/releases/download/test/tools.json".to_string();
+ let installer = HubInstaller::with_defaults().with_manifest_url(pinned.clone());
+ assert_eq!(installer.manifest_url(), pinned);
+ }
+
+ #[test]
+ fn resolve_manifest_url_prefers_env_then_falls_back() {
+ assert_eq!(
+ resolve_manifest_url(Some(
+ "https://ironhub-staging.up.railway.app/api/catalog/manifest.json".to_string()
+ )),
+ "https://ironhub-staging.up.railway.app/api/catalog/manifest.json"
+ );
+ assert_eq!(resolve_manifest_url(None), DEFAULT_HUB_MANIFEST_URL);
+ assert_eq!(
+ resolve_manifest_url(Some(String::new())),
+ DEFAULT_HUB_MANIFEST_URL
+ );
+ assert_eq!(
+ resolve_manifest_url(Some(" ".to_string())),
+ DEFAULT_HUB_MANIFEST_URL
+ );
+ }
+
+ #[test]
+ fn with_tools_dir_overrides_default() {
+ let custom = PathBuf::from("/custom/tools");
+ let installer = HubInstaller::with_defaults().with_tools_dir(custom.clone());
+ assert_eq!(installer.tools_dir(), custom);
+ let base = ironclaw_base_dir();
+ assert_eq!(installer.skills_dir(), base.join("skills"));
+ }
+
+ #[test]
+ fn with_skills_dir_overrides_default() {
+ let custom = PathBuf::from("/custom/skills");
+ let installer = HubInstaller::with_defaults().with_skills_dir(custom.clone());
+ assert_eq!(installer.skills_dir(), custom);
+ let base = ironclaw_base_dir();
+ assert_eq!(installer.tools_dir(), base.join("tools"));
+ }
+
+ #[tokio::test]
+ async fn install_tool_from_bytes_serializes_concurrent_same_name_installs() {
+ let tmp = TempDir::new().expect("tempdir");
+ let installer = Arc::new(installer_in(&tmp));
+ let wasm = b"fake-wasm-bytes";
+ let caps = br#"{"name":"clickup"}"#;
+ let entry_a = build_tool_entry("clickup", wasm, caps);
+ let entry_b = entry_a.clone();
+ let a = Arc::clone(&installer);
+ let b = Arc::clone(&installer);
+ let (r1, r2) = tokio::join!(
+ async move {
+ a.install_tool_from_bytes(&entry_a, "test-release", wasm, caps, false)
+ .await
+ },
+ async move {
+ b.install_tool_from_bytes(&entry_b, "test-release", wasm, caps, false)
+ .await
+ },
+ );
+
+ let outcomes = [r1, r2];
+ let oks = outcomes.iter().filter(|r| r.is_ok()).count();
+ let already_installed = outcomes
+ .iter()
+ .filter(|r| matches!(r, Err(RegistryError::AlreadyInstalled { .. })))
+ .count();
+ assert_eq!(oks, 1, "exactly one concurrent install must succeed");
+ assert_eq!(
+ already_installed, 1,
+ "the other must see AlreadyInstalled, not a race-induced failure"
+ );
+
+ let target_wasm = tmp.path().join("tools/clickup.wasm");
+ let target_caps = tmp.path().join("tools/clickup.capabilities.json");
+ assert!(
+ target_wasm.exists(),
+ "wasm must survive: losing install's cleanup must not delete the winner's artifact"
+ );
+ assert!(
+ target_caps.exists(),
+ "capabilities must survive the loser's cleanup"
+ );
+ let stray: Vec<_> = std::fs::read_dir(tmp.path().join("tools"))
+ .expect("read tools dir")
+ .filter_map(|e| e.ok())
+ .filter(|e| e.file_name().to_string_lossy().contains(".tmp."))
+ .collect();
+ assert!(
+ stray.is_empty(),
+ "no temp files must remain on disk after install"
+ );
+ }
+
+ #[test]
+ fn manifest_cache_hits_within_ttl_and_expires_after() {
+ let url = "https://hub.ironclaw.com/manifest-cache-test.json";
+ let manifest = Arc::new(build_manifest(vec![], vec![]));
+ let base = Instant::now();
+ manifest_cache_put(url, Arc::clone(&manifest), base);
+ assert!(
+ manifest_cache_get(url, base).is_some(),
+ "a fresh entry must be a cache hit"
+ );
+ let later = base + MANIFEST_CACHE_TTL + Duration::from_secs(1);
+ assert!(
+ manifest_cache_get(url, later).is_none(),
+ "an entry past its TTL must miss"
+ );
+ }
+
+ #[tokio::test]
+ async fn install_lock_entry_reclaimed_after_install() {
+ let tmp = TempDir::new().expect("tempdir");
+ let installer = installer_in(&tmp);
+ let wasm = b"fake-wasm-bytes";
+ let caps = br#"{"name":"reclaim-probe"}"#;
+ let entry = build_tool_entry("reclaim-probe", wasm, caps);
+ installer
+ .install_tool_from_bytes(&entry, "test-release", wasm, caps, false)
+ .await
+ .expect("install succeeds");
+ let retained = INSTALL_LOCKS
+ .lock()
+ .unwrap_or_else(|p| p.into_inner())
+ .contains_key("tool:reclaim-probe");
+ assert!(
+ !retained,
+ "install lock entry must be reclaimed after the install completes"
+ );
+ }
+
+ #[tokio::test]
+ async fn install_tool_entry_skips_download_when_already_installed() {
+ let tmp = TempDir::new().expect("tempdir");
+ let installer = installer_in(&tmp);
+ std::fs::create_dir_all(tmp.path().join("tools")).expect("tools dir");
+ std::fs::write(tmp.path().join("tools/clickup.wasm"), b"already here").expect("seed");
+
+ let entry = build_tool_entry("clickup", b"fake-wasm-bytes", br#"{"name":"clickup"}"#);
+ let result = installer
+ .install_tool_entry(&entry, "test-release", false)
+ .await;
+ assert!(
+ matches!(result, Err(RegistryError::AlreadyInstalled { .. })),
+ "force=false re-install must short-circuit before downloading, got {result:?}"
+ );
+ }
+
+ #[tokio::test]
+ async fn install_tool_from_bytes_writes_artifacts_and_returns_outcome() {
+ let tmp = TempDir::new().expect("tempdir");
+ let installer = installer_in(&tmp);
+ let wasm = b"fake-wasm-bytes";
+ let caps = br#"{"name":"clickup"}"#;
+ let entry = build_tool_entry("clickup", wasm, caps);
+
+ let outcome = installer
+ .install_tool_from_bytes(&entry, "test-release", wasm, caps, false)
+ .await
+ .expect("install succeeds");
+
+ assert_eq!(outcome.name, "clickup");
+ assert_eq!(outcome.version, "0.1.0");
+ assert_eq!(outcome.release_tag, "test-release");
+ assert_eq!(outcome.primary_path, tmp.path().join("tools/clickup.wasm"));
+ assert_eq!(
+ outcome.metadata_path,
+ Some(tmp.path().join("tools/clickup.capabilities.json"))
+ );
+
+ let wasm_on_disk = fs::read(&outcome.primary_path).await.expect("read wasm");
+ assert_eq!(wasm_on_disk, wasm);
+ let caps_on_disk = fs::read(outcome.metadata_path.as_ref().unwrap())
+ .await
+ .expect("read caps");
+ assert_eq!(caps_on_disk, caps);
+ }
+
+ #[tokio::test]
+ async fn install_tool_from_bytes_rejects_wasm_sha_mismatch() {
+ let tmp = TempDir::new().expect("tempdir");
+ let installer = installer_in(&tmp);
+ let wasm = b"original-wasm";
+ let caps = br#"{"name":"clickup"}"#;
+ let entry = build_tool_entry("clickup", wasm, caps);
+
+ let tampered_wasm = b"tampered-wasm";
+ let err = installer
+ .install_tool_from_bytes(&entry, "test-release", tampered_wasm, caps, false)
+ .await
+ .expect_err("sha mismatch must fail");
+ assert!(matches!(err, RegistryError::ChecksumMismatch { .. }));
+ assert!(!tmp.path().join("tools/clickup.wasm").exists());
+ }
+
+ #[tokio::test]
+ async fn install_tool_from_bytes_rejects_caps_sha_mismatch() {
+ let tmp = TempDir::new().expect("tempdir");
+ let installer = installer_in(&tmp);
+ let wasm = b"original-wasm";
+ let caps = br#"{"name":"clickup"}"#;
+ let entry = build_tool_entry("clickup", wasm, caps);
+
+ let tampered_caps = br#"{"name":"tampered"}"#;
+ let err = installer
+ .install_tool_from_bytes(&entry, "test-release", wasm, tampered_caps, false)
+ .await
+ .expect_err("sha mismatch must fail");
+ assert!(matches!(err, RegistryError::ChecksumMismatch { .. }));
+ assert!(!tmp.path().join("tools/clickup.wasm").exists());
+ assert!(!tmp.path().join("tools/clickup.capabilities.json").exists());
+ }
+
+ #[tokio::test]
+ async fn install_tool_from_bytes_rejects_oversized_caps() {
+ let tmp = TempDir::new().expect("tempdir");
+ let installer = installer_in(&tmp);
+ let wasm = b"fake-wasm";
+ let big_caps = vec![b'x'; MAX_METADATA_BYTES + 1];
+ let entry = build_tool_entry("clickup", wasm, &big_caps);
+
+ let err = installer
+ .install_tool_from_bytes(&entry, "test-release", wasm, &big_caps, false)
+ .await
+ .expect_err("oversized caps must fail");
+ match err {
+ RegistryError::DownloadFailed { reason, .. } => {
+ assert!(reason.contains("capabilities exceeds"));
+ }
+ other => panic!("expected DownloadFailed, got {:?}", other),
+ }
+ assert!(!tmp.path().join("tools/clickup.wasm").exists());
+ }
+
+ #[tokio::test]
+ async fn install_tool_from_bytes_rejects_oversized_wasm() {
+ let tmp = TempDir::new().expect("tempdir");
+ let installer = installer_in(&tmp);
+ let big_wasm = vec![b'x'; MAX_WASM_BYTES + 1];
+ let caps = br#"{"name":"clickup"}"#;
+ let entry = build_tool_entry("clickup", &big_wasm, caps);
+
+ let err = installer
+ .install_tool_from_bytes(&entry, "test-release", &big_wasm, caps, false)
+ .await
+ .expect_err("oversized wasm must fail");
+ match err {
+ RegistryError::DownloadFailed { reason, .. } => {
+ assert!(reason.contains("wasm exceeds"));
+ }
+ other => panic!("expected DownloadFailed, got {:?}", other),
+ }
+ assert!(!tmp.path().join("tools/clickup.wasm").exists());
+ }
+
+ #[tokio::test]
+ async fn install_tool_from_bytes_refuses_overwrite_without_force() {
+ let tmp = TempDir::new().expect("tempdir");
+ let installer = installer_in(&tmp);
+ let wasm = b"wasm";
+ let caps = br#"{"name":"clickup"}"#;
+ let entry = build_tool_entry("clickup", wasm, caps);
+
+ installer
+ .install_tool_from_bytes(&entry, "test-release", wasm, caps, false)
+ .await
+ .expect("first install");
+
+ let err = installer
+ .install_tool_from_bytes(&entry, "test-release", wasm, caps, false)
+ .await
+ .expect_err("second install must fail without force");
+ assert!(matches!(err, RegistryError::AlreadyInstalled { .. }));
+ }
+
+ #[tokio::test]
+ async fn install_tool_from_bytes_overwrites_with_force() {
+ let tmp = TempDir::new().expect("tempdir");
+ let installer = installer_in(&tmp);
+ let original = b"original-wasm";
+ let caps = br#"{"name":"clickup"}"#;
+ let entry_v1 = build_tool_entry("clickup", original, caps);
+
+ installer
+ .install_tool_from_bytes(&entry_v1, "test-release", original, caps, false)
+ .await
+ .expect("first install");
+
+ let updated = b"updated-wasm-bytes";
+ let entry_v2 = build_tool_entry("clickup", updated, caps);
+ installer
+ .install_tool_from_bytes(&entry_v2, "test-release", updated, caps, true)
+ .await
+ .expect("forced reinstall");
+
+ let on_disk = fs::read(tmp.path().join("tools/clickup.wasm"))
+ .await
+ .expect("read");
+ assert_eq!(on_disk, updated);
+ }
+
+ #[tokio::test]
+ async fn install_skill_from_bytes_writes_skill_md() {
+ let tmp = TempDir::new().expect("tempdir");
+ let installer = installer_in(&tmp);
+ let md = b"# Chief of Staff\n\nactivation:\n keywords:\n - briefing\n";
+ let entry = build_skill_entry("chief-of-staff", md);
+
+ let outcome = installer
+ .install_skill_from_bytes(&entry, "test-release", md, false)
+ .await
+ .expect("skill install");
+ assert_eq!(outcome.name, "chief-of-staff");
+ assert_eq!(
+ outcome.primary_path,
+ tmp.path().join("skills/chief-of-staff/SKILL.md")
+ );
+ assert!(outcome.metadata_path.is_none());
+
+ let on_disk = fs::read(outcome.primary_path).await.expect("read");
+ assert_eq!(on_disk, md);
+ }
+
+ #[tokio::test]
+ async fn install_skill_from_bytes_rejects_sha_mismatch() {
+ let tmp = TempDir::new().expect("tempdir");
+ let installer = installer_in(&tmp);
+ let md = b"# original";
+ let entry = build_skill_entry("test-skill", md);
+
+ let tampered = b"# tampered";
+ let err = installer
+ .install_skill_from_bytes(&entry, "test-release", tampered, false)
+ .await
+ .expect_err("sha mismatch must fail");
+ assert!(matches!(err, RegistryError::ChecksumMismatch { .. }));
+ assert!(!tmp.path().join("skills/test-skill/SKILL.md").exists());
+ }
+
+ #[tokio::test]
+ async fn install_skill_from_bytes_rejects_oversized() {
+ let tmp = TempDir::new().expect("tempdir");
+ let installer = installer_in(&tmp);
+ let big = vec![b'x'; MAX_METADATA_BYTES + 1];
+ let entry = build_skill_entry("test-skill", &big);
+
+ let err = installer
+ .install_skill_from_bytes(&entry, "test-release", &big, false)
+ .await
+ .expect_err("oversized must fail");
+ match err {
+ RegistryError::DownloadFailed { reason, .. } => {
+ assert!(reason.contains("SKILL.md exceeds"));
+ }
+ other => panic!("expected DownloadFailed, got {:?}", other),
+ }
+ }
+
+ #[tokio::test]
+ async fn install_skill_from_bytes_refuses_overwrite_without_force() {
+ let tmp = TempDir::new().expect("tempdir");
+ let installer = installer_in(&tmp);
+ let md = b"# skill";
+ let entry = build_skill_entry("test-skill", md);
+
+ installer
+ .install_skill_from_bytes(&entry, "test-release", md, false)
+ .await
+ .expect("first install");
+
+ let err = installer
+ .install_skill_from_bytes(&entry, "test-release", md, false)
+ .await
+ .expect_err("second install must fail");
+ assert!(matches!(err, RegistryError::AlreadyInstalled { .. }));
+ }
+
+ #[tokio::test]
+ async fn install_skill_from_bytes_overwrites_with_force() {
+ let tmp = TempDir::new().expect("tempdir");
+ let installer = installer_in(&tmp);
+ let original = b"# v1";
+ let entry_v1 = build_skill_entry("test-skill", original);
+ installer
+ .install_skill_from_bytes(&entry_v1, "test-release", original, false)
+ .await
+ .expect("first install");
+
+ let updated = b"# v2";
+ let entry_v2 = build_skill_entry("test-skill", updated);
+ installer
+ .install_skill_from_bytes(&entry_v2, "test-release", updated, true)
+ .await
+ .expect("forced reinstall");
+
+ let on_disk = fs::read(tmp.path().join("skills/test-skill/SKILL.md"))
+ .await
+ .expect("read");
+ assert_eq!(on_disk, updated);
+ }
+
+ #[tokio::test]
+ async fn install_tool_entry_rejects_non_https_url() {
+ let tmp = TempDir::new().expect("tempdir");
+ let installer = installer_in(&tmp);
+ let wasm = b"wasm";
+ let caps = br#"{}"#;
+ let mut entry = build_tool_entry("clickup", wasm, caps);
+ entry.wasm.url =
+ "http://github.com/nearai/ironhub/releases/download/test/clickup.wasm".to_string();
+
+ let err = installer
+ .install_tool_entry(&entry, "test-release", false)
+ .await
+ .expect_err("non-https must fail before fetch");
+ match err {
+ RegistryError::InvalidManifest { reason, .. } => {
+ assert!(reason.contains("https"));
+ }
+ other => panic!("expected InvalidManifest, got {:?}", other),
+ }
+ }
+
+ #[tokio::test]
+ async fn install_tool_entry_rejects_disallowed_host() {
+ let tmp = TempDir::new().expect("tempdir");
+ let installer = installer_in(&tmp);
+ let wasm = b"wasm";
+ let caps = br#"{}"#;
+ let mut entry = build_tool_entry("clickup", wasm, caps);
+ entry.wasm.url = "https://evil.example.com/clickup.wasm".to_string();
+
+ let err = installer
+ .install_tool_entry(&entry, "test-release", false)
+ .await
+ .expect_err("disallowed host must fail before fetch");
+ match err {
+ RegistryError::InvalidManifest { reason, .. } => {
+ assert!(reason.contains("not allowed"));
+ }
+ other => panic!("expected InvalidManifest, got {:?}", other),
+ }
+ }
+
+ #[tokio::test]
+ async fn install_tool_entry_rejects_disallowed_caps_host() {
+ let tmp = TempDir::new().expect("tempdir");
+ let installer = installer_in(&tmp);
+ let wasm = b"wasm";
+ let caps = br#"{}"#;
+ let mut entry = build_tool_entry("clickup", wasm, caps);
+ entry.capabilities.url = "https://evil.example.com/clickup.capabilities.json".to_string();
+
+ let err = installer
+ .install_tool_entry(&entry, "test-release", false)
+ .await
+ .expect_err("disallowed caps host must fail before fetch");
+ assert!(matches!(err, RegistryError::InvalidManifest { .. }));
+ }
+
+ #[tokio::test]
+ async fn install_skill_entry_rejects_disallowed_host() {
+ let tmp = TempDir::new().expect("tempdir");
+ let installer = installer_in(&tmp);
+ let md = b"# skill";
+ let mut entry = build_skill_entry("test-skill", md);
+ entry.skill_md.url = "https://evil.example.com/test-skill.SKILL.md".to_string();
+
+ let err = installer
+ .install_skill_entry(&entry, "test-release", false)
+ .await
+ .expect_err("disallowed skill host must fail before fetch");
+ assert!(matches!(err, RegistryError::InvalidManifest { .. }));
+ }
+
+ #[tokio::test]
+ async fn fetch_manifest_rejects_non_https_url() {
+ let installer = HubInstaller::with_defaults().with_manifest_url(
+ "http://github.com/nearai/ironhub/releases/latest/download/tools.json".to_string(),
+ );
+
+ let err = installer
+ .fetch_manifest()
+ .await
+ .expect_err("non-https manifest url must fail");
+ assert!(matches!(err, RegistryError::InvalidManifest { .. }));
+ }
+
+ #[tokio::test]
+ async fn fetch_manifest_rejects_disallowed_host() {
+ let installer = HubInstaller::with_defaults()
+ .with_manifest_url("https://evil.example.com/tools.json".to_string());
+
+ let err = installer
+ .fetch_manifest()
+ .await
+ .expect_err("disallowed manifest host must fail");
+ assert!(matches!(err, RegistryError::InvalidManifest { .. }));
+ }
+
+ #[tokio::test]
+ async fn install_tool_from_manifest_reports_missing_tool() {
+ let tmp = TempDir::new().expect("tempdir");
+ let installer = installer_in(&tmp);
+ let manifest = build_manifest(vec![build_tool_entry("clickup", b"wasm", br#"{}"#)], vec![]);
+
+ let err = installer
+ .install_tool_from_manifest(&manifest, "absent", false)
+ .await
+ .expect_err("missing tool must fail");
+ match err {
+ RegistryError::ExtensionNotFound(msg) => {
+ assert!(msg.contains("absent"));
+ assert!(msg.contains("tool"));
+ }
+ other => panic!("expected ExtensionNotFound, got {:?}", other),
+ }
+ }
+
+ #[tokio::test]
+ async fn install_skill_from_manifest_reports_missing_skill() {
+ let tmp = TempDir::new().expect("tempdir");
+ let installer = installer_in(&tmp);
+ let manifest = build_manifest(vec![], vec![build_skill_entry("present", b"# md")]);
+
+ let err = installer
+ .install_skill_from_manifest(&manifest, "absent", false)
+ .await
+ .expect_err("missing skill must fail");
+ match err {
+ RegistryError::ExtensionNotFound(msg) => {
+ assert!(msg.contains("absent"));
+ assert!(msg.contains("skill"));
+ }
+ other => panic!("expected ExtensionNotFound, got {:?}", other),
+ }
+ }
+
+ #[tokio::test]
+ async fn install_tool_from_manifest_delegates_to_entry_validation() {
+ let tmp = TempDir::new().expect("tempdir");
+ let installer = installer_in(&tmp);
+ let wasm = b"wasm";
+ let caps = br#"{"name":"clickup"}"#;
+ let mut entry = build_tool_entry("clickup", wasm, caps);
+ entry.wasm.url = "https://evil.example.com/clickup.wasm".to_string();
+ let manifest = build_manifest(vec![entry], vec![]);
+
+ let err = installer
+ .install_tool_from_manifest(&manifest, "clickup", false)
+ .await
+ .expect_err("delegation must surface entry validation error");
+ assert!(matches!(err, RegistryError::InvalidManifest { .. }));
+ }
+
+ #[tokio::test]
+ async fn install_skill_from_manifest_delegates_to_entry_validation() {
+ let tmp = TempDir::new().expect("tempdir");
+ let installer = installer_in(&tmp);
+ let md = b"# skill";
+ let mut entry = build_skill_entry("test-skill", md);
+ entry.skill_md.url = "https://evil.example.com/test-skill.SKILL.md".to_string();
+ let manifest = build_manifest(vec![], vec![entry]);
+
+ let err = installer
+ .install_skill_from_manifest(&manifest, "test-skill", false)
+ .await
+ .expect_err("delegation must surface entry validation error");
+ assert!(matches!(err, RegistryError::InvalidManifest { .. }));
+ }
+
+ #[test]
+ fn validate_hub_artifact_name_accepts_valid_names() {
+ assert!(validate_hub_artifact_name("clickup", "test").is_ok());
+ assert!(validate_hub_artifact_name("evm-rpc", "test").is_ok());
+ assert!(validate_hub_artifact_name("microsoft_365", "test").is_ok());
+ assert!(validate_hub_artifact_name("a1-b2_c3", "test").is_ok());
+ }
+
+ #[test]
+ fn validate_hub_artifact_name_rejects_traversal_and_unsafe_chars() {
+ for bad in [
+ "",
+ "..",
+ "../evil",
+ "/etc/passwd",
+ "evil/sub",
+ "evil\\sub",
+ "evil.wasm",
+ "Uppercase",
+ "name with space",
+ "name\nnewline",
+ "name\0null",
+ "name@host",
+ ] {
+ assert!(
+ validate_hub_artifact_name(bad, "test").is_err(),
+ "expected rejection for {:?}",
+ bad
+ );
+ }
+ }
+
+ #[tokio::test]
+ async fn install_tool_from_bytes_rejects_traversal_in_entry_name() {
+ let tmp = TempDir::new().expect("tempdir");
+ let installer = installer_in(&tmp);
+ let wasm = b"wasm";
+ let caps = br#"{}"#;
+ let mut entry = build_tool_entry("clickup", wasm, caps);
+ entry.name = "../evil".to_string();
+
+ let err = installer
+ .install_tool_from_bytes(&entry, "test-release", wasm, caps, false)
+ .await
+ .expect_err("traversal in entry name must fail");
+ assert!(matches!(err, RegistryError::InvalidManifest { .. }));
+ assert!(!tmp.path().join("tools/../evil.wasm").exists());
+ }
+
+ #[tokio::test]
+ async fn install_tool_from_bytes_rejects_absolute_path_in_entry_name() {
+ let tmp = TempDir::new().expect("tempdir");
+ let installer = installer_in(&tmp);
+ let wasm = b"wasm";
+ let caps = br#"{}"#;
+ let mut entry = build_tool_entry("clickup", wasm, caps);
+ entry.name = "/tmp/escape-me".to_string();
+
+ let err = installer
+ .install_tool_from_bytes(&entry, "test-release", wasm, caps, false)
+ .await
+ .expect_err("absolute name must fail");
+ assert!(matches!(err, RegistryError::InvalidManifest { .. }));
+ assert!(!std::path::Path::new("/tmp/escape-me.wasm").exists());
+ }
+
+ #[tokio::test]
+ async fn install_tool_entry_rejects_bad_name_before_fetch() {
+ let tmp = TempDir::new().expect("tempdir");
+ let installer = installer_in(&tmp);
+ let wasm = b"wasm";
+ let caps = br#"{}"#;
+ let mut entry = build_tool_entry("clickup", wasm, caps);
+ entry.name = "../evil".to_string();
+
+ let err = installer
+ .install_tool_entry(&entry, "test-release", false)
+ .await
+ .expect_err("bad name must fail before any HTTP fetch");
+ assert!(matches!(err, RegistryError::InvalidManifest { .. }));
+ }
+
+ #[tokio::test]
+ async fn install_skill_from_bytes_rejects_traversal_in_entry_name() {
+ let tmp = TempDir::new().expect("tempdir");
+ let installer = installer_in(&tmp);
+ let md = b"# skill";
+ let mut entry = build_skill_entry("test-skill", md);
+ entry.name = "../evil".to_string();
+
+ let err = installer
+ .install_skill_from_bytes(&entry, "test-release", md, false)
+ .await
+ .expect_err("traversal in skill name must fail");
+ assert!(matches!(err, RegistryError::InvalidManifest { .. }));
+ assert!(!tmp.path().join("skills/../evil/SKILL.md").exists());
+ }
+
+ #[tokio::test]
+ async fn install_skill_from_bytes_rejects_absolute_path_in_entry_name() {
+ let tmp = TempDir::new().expect("tempdir");
+ let installer = installer_in(&tmp);
+ let md = b"# skill";
+ let mut entry = build_skill_entry("test-skill", md);
+ entry.name = "/tmp/escape-skill".to_string();
+
+ let err = installer
+ .install_skill_from_bytes(&entry, "test-release", md, false)
+ .await
+ .expect_err("absolute skill name must fail");
+ assert!(matches!(err, RegistryError::InvalidManifest { .. }));
+ }
+
+ #[tokio::test]
+ async fn install_skill_entry_rejects_bad_name_before_fetch() {
+ let tmp = TempDir::new().expect("tempdir");
+ let installer = installer_in(&tmp);
+ let md = b"# skill";
+ let mut entry = build_skill_entry("test-skill", md);
+ entry.name = "../evil".to_string();
+
+ let err = installer
+ .install_skill_entry(&entry, "test-release", false)
+ .await
+ .expect_err("bad name must fail before any HTTP fetch");
+ assert!(matches!(err, RegistryError::InvalidManifest { .. }));
+ }
+
+ #[tokio::test]
+ async fn confirm_written_size_passes_on_match() {
+ let tmp = TempDir::new().expect("tempdir");
+ let path = tmp.path().join("artifact.bin");
+ let bytes = b"hello world";
+ fs::write(&path, bytes).await.expect("write");
+ confirm_written_size(&path, bytes.len())
+ .await
+ .expect("matching size must pass");
+ }
+
+ #[tokio::test]
+ async fn confirm_written_size_rejects_truncation() {
+ let tmp = TempDir::new().expect("tempdir");
+ let path = tmp.path().join("artifact.bin");
+ fs::write(&path, b"actual").await.expect("write");
+ let err = confirm_written_size(&path, 9999)
+ .await
+ .expect_err("size mismatch must fail");
+ match err {
+ RegistryError::DownloadFailed { reason, .. } => {
+ assert!(reason.contains("on-disk size mismatch"));
+ }
+ other => panic!("expected DownloadFailed, got {:?}", other),
+ }
+ }
+
+ #[tokio::test]
+ async fn cleanup_partial_artifact_removes_file_and_tolerates_missing() {
+ let tmp = TempDir::new().expect("tempdir");
+ let path = tmp.path().join("orphan.wasm");
+ fs::write(&path, b"partial").await.expect("write");
+ assert!(path.exists());
+ cleanup_partial_artifact(&path).await;
+ assert!(!path.exists(), "partial artifact must be removed");
+ cleanup_partial_artifact(&path).await;
+ assert!(!path.exists(), "second call on missing path is a no-op");
+ }
+}
diff --git a/src/registry/hub_manifest.rs b/src/registry/hub_manifest.rs
new file mode 100644
index 00000000000..c1f79505b1e
--- /dev/null
+++ b/src/registry/hub_manifest.rs
@@ -0,0 +1,370 @@
+use base64::Engine;
+use base64::engine::general_purpose::URL_SAFE_NO_PAD;
+use ed25519_dalek::{Signature, VerifyingKey};
+use serde::{Deserialize, Serialize};
+
+pub const DEFAULT_HUB_MANIFEST_URL: &str = "https://hub.ironclaw.com/api/catalog/manifest.json";
+
+// Ed25519 PUBLIC keys trusted to verify the catalog manifest signature, by key_id.
+// PUBLIC KEYS ONLY. The private signing key lives off the catalog host (IronHub
+// IRONHUB_MANIFEST_SIGNING_KEY) and must never appear in this repo or binary.
+// Embedding the public half is deliberate: changing which key the agent trusts
+// requires changing this source, not compromising the catalog host.
+//
+// Rotation procedure (when rolling the signing keypair):
+// 1. Generate the new keypair off-host. Put the new public key here as a
+// second entry alongside the existing one; ship a release. Both old and
+// new manifests verify during the rollout window.
+// 2. Deploy the new private key to IronHub so it signs with the new key_id.
+// `verify_signed_manifest` selects the entry by `key_id`, so signing
+// switches over atomically without breaking agents still on the old
+// release.
+// 3. After agents have updated past step 1, ship a follow-up release that
+// removes the old entry from this slice. Older agents continue to
+// verify the older signed manifests until they update.
+pub const MANIFEST_VERIFY_KEYS: &[(&str, &str)] = &[(
+ "5895a21abea89672",
+ "f64d2d3a3228b16ca59450364d26b278071a1a425544f242504033341d8459bd",
+)];
+
+#[derive(Debug, Deserialize)]
+struct SignedManifestEnvelope {
+ v: u8,
+ key_id: String,
+ manifest_b64: String,
+ sig: String,
+}
+
+fn verifying_key_from_hex(hex: &str) -> Result {
+ if hex.len() != 64 {
+ return Err("verify key must be 64 hex chars".to_string());
+ }
+ let mut raw = [0u8; 32];
+ for (i, byte) in raw.iter_mut().enumerate() {
+ *byte = u8::from_str_radix(&hex[i * 2..i * 2 + 2], 16)
+ .map_err(|_| "verify key is not valid hex".to_string())?;
+ }
+ VerifyingKey::from_bytes(&raw).map_err(|e| format!("invalid ed25519 verify key: {e}"))
+}
+
+/// Verifies a signed-manifest envelope and returns the exact inner manifest bytes
+/// the signature covers. Fail-closed: any decode, key-lookup, or signature failure
+/// returns Err and never yields partial bytes.
+pub fn verify_signed_manifest(
+ envelope_bytes: &[u8],
+ keys: &[(&str, &str)],
+) -> Result, String> {
+ let env: SignedManifestEnvelope = serde_json::from_slice(envelope_bytes)
+ .map_err(|e| format!("signed-manifest envelope parse failed: {e}"))?;
+ if env.v != 1 {
+ return Err(format!("unsupported signed-manifest version {}", env.v));
+ }
+ let key_hex = keys
+ .iter()
+ .find(|(id, _)| *id == env.key_id)
+ .map(|(_, hex)| *hex)
+ .ok_or_else(|| format!("unknown manifest signing key_id '{}'", env.key_id))?;
+ let verifying_key = verifying_key_from_hex(key_hex)?;
+ let manifest_bytes = URL_SAFE_NO_PAD
+ .decode(env.manifest_b64.as_bytes())
+ .map_err(|e| format!("manifest_b64 decode failed: {e}"))?;
+ let sig_bytes = URL_SAFE_NO_PAD
+ .decode(env.sig.as_bytes())
+ .map_err(|e| format!("signature decode failed: {e}"))?;
+ let signature =
+ Signature::from_slice(&sig_bytes).map_err(|e| format!("signature malformed: {e}"))?;
+ verifying_key
+ .verify_strict(&manifest_bytes, &signature)
+ .map_err(|_| "manifest signature verification failed".to_string())?;
+ Ok(manifest_bytes)
+}
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum Provenance {
+ #[serde(alias = "repo")]
+ Official,
+ Trusted,
+ Verified,
+ #[default]
+ #[serde(alias = "community")]
+ New,
+}
+
+impl Provenance {
+ pub fn as_wire(&self) -> &'static str {
+ match self {
+ Provenance::Official => "official",
+ Provenance::Trusted => "trusted",
+ Provenance::Verified => "verified",
+ Provenance::New => "new",
+ }
+ }
+
+ pub fn is_community_unverified(&self) -> bool {
+ matches!(self, Provenance::New)
+ }
+
+ pub fn trust_label(&self) -> &'static str {
+ match self {
+ Provenance::Official => "NEAR-vetted (official)",
+ Provenance::Trusted => "community, trusted publisher",
+ Provenance::Verified => "community, verified publisher",
+ Provenance::New => "UNVERIFIED community (new author)",
+ }
+ }
+}
+
+#[derive(Debug, Clone, Deserialize, Serialize)]
+pub struct HubManifest {
+ pub version: String,
+ pub generated_at: String,
+ pub release_tag: String,
+ pub repo: String,
+ #[serde(default)]
+ pub tools: Vec,
+ #[serde(default)]
+ pub skills: Vec,
+}
+
+impl HubManifest {
+ pub fn find_tool(&self, name: &str) -> Option<&HubToolEntry> {
+ self.tools.iter().find(|t| t.name == name)
+ }
+
+ pub fn find_skill(&self, name: &str) -> Option<&HubSkillEntry> {
+ self.skills.iter().find(|s| s.name == name)
+ }
+}
+
+#[derive(Debug, Clone, Deserialize, Serialize)]
+pub struct HubToolEntry {
+ pub name: String,
+ pub crate_name: String,
+ pub version: String,
+ #[serde(default)]
+ pub description: String,
+ #[serde(default)]
+ pub provenance: Provenance,
+ pub wasm: HubArtifact,
+ pub capabilities: HubArtifact,
+}
+
+#[derive(Debug, Clone, Deserialize, Serialize)]
+pub struct HubSkillEntry {
+ pub name: String,
+ #[serde(default)]
+ pub trunk: String,
+ #[serde(default)]
+ pub version: String,
+ #[serde(default)]
+ pub description: String,
+ #[serde(default)]
+ pub provenance: Provenance,
+ pub skill_md: HubArtifact,
+}
+
+#[derive(Debug, Clone, Deserialize, Serialize)]
+pub struct HubArtifact {
+ pub url: String,
+ pub size_bytes: u64,
+ pub sha256: String,
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ // Shared cross-language vector: a fixed manifest signed with a throwaway test
+ // Ed25519 key. The same (pubkey, manifest_b64, sig) tuple is asserted in the
+ // IronHub signer's tests. All public; the test private key was discarded.
+ const VEC_KEY_ID: &str = "test-vector";
+ const VEC_PUBKEY_HEX: &str = "ca46572f4dcd485599cdf95442934a3e3c86e2cae766a85fbffc8d6540959928";
+ const VEC_MANIFEST_B64: &str = "eyJ2ZXJzaW9uIjoiMSIsImdlbmVyYXRlZF9hdCI6IjIwMjYtMDEtMDFUMDA6MDA6MDBaIiwicmVsZWFzZV90YWciOiJ0ZXN0IiwicmVwbyI6Im5lYXJhaS9pcm9uaHViIiwidG9vbHMiOltdLCJza2lsbHMiOltdfQ";
+ const VEC_SIG: &str =
+ "KjsUDgi1enj3iTPNQI6gU1Bwxf01hIUItlFvX9PxgWNybPPrJNIV7vFG-G8hJOalFMwFs5zQHrxbtFDZAlgtBg";
+ const VEC_MANIFEST_BYTES: &str = r#"{"version":"1","generated_at":"2026-01-01T00:00:00Z","release_tag":"test","repo":"nearai/ironhub","tools":[],"skills":[]}"#;
+
+ fn vec_keys() -> Vec<(&'static str, &'static str)> {
+ vec![(VEC_KEY_ID, VEC_PUBKEY_HEX)]
+ }
+
+ fn vec_envelope(manifest_b64: &str, sig: &str) -> String {
+ format!(r#"{{"v":1,"key_id":"test-vector","manifest_b64":"{manifest_b64}","sig":"{sig}"}}"#)
+ }
+
+ #[test]
+ fn verify_signed_manifest_accepts_valid_vector() {
+ let env = vec_envelope(VEC_MANIFEST_B64, VEC_SIG);
+ let bytes =
+ verify_signed_manifest(env.as_bytes(), &vec_keys()).expect("valid vector must verify");
+ assert_eq!(bytes, VEC_MANIFEST_BYTES.as_bytes());
+ }
+
+ #[test]
+ fn verify_signed_manifest_rejects_tampered_manifest() {
+ let tampered = URL_SAFE_NO_PAD.encode(br#"{"version":"1","tools":[{"name":"evil"}]}"#);
+ let env = vec_envelope(&tampered, VEC_SIG);
+ assert!(verify_signed_manifest(env.as_bytes(), &vec_keys()).is_err());
+ }
+
+ #[test]
+ fn verify_signed_manifest_rejects_wrong_key() {
+ let wrong = vec![(VEC_KEY_ID, MANIFEST_VERIFY_KEYS[0].1)];
+ let env = vec_envelope(VEC_MANIFEST_B64, VEC_SIG);
+ assert!(verify_signed_manifest(env.as_bytes(), &wrong).is_err());
+ }
+
+ #[test]
+ fn verify_signed_manifest_rejects_unknown_key_id() {
+ let env = format!(
+ r#"{{"v":1,"key_id":"nope","manifest_b64":"{VEC_MANIFEST_B64}","sig":"{VEC_SIG}"}}"#
+ );
+ assert!(verify_signed_manifest(env.as_bytes(), &vec_keys()).is_err());
+ }
+
+ #[test]
+ fn embedded_manifest_verify_keys_are_valid_public_keys() {
+ for (key_id, hex) in MANIFEST_VERIFY_KEYS {
+ assert_eq!(hex.len(), 64, "key {key_id} must be 64 hex chars");
+ assert!(
+ verifying_key_from_hex(hex).is_ok(),
+ "embedded key {key_id} must decode to a valid ed25519 public key"
+ );
+ }
+ }
+
+ const SAMPLE_MANIFEST: &str = r#"{
+ "version": "1",
+ "generated_at": "2026-05-12T23:43:46Z",
+ "release_tag": "release-2026-05-12-24",
+ "repo": "nearai/ironhub",
+ "tools": [
+ {
+ "name": "clickup",
+ "crate_name": "clickup-tool",
+ "version": "0.1.0",
+ "description": "ClickUp integration",
+ "wasm": {
+ "url": "https://github.com/nearai/ironhub/releases/download/release-2026-05-12-24/clickup.wasm",
+ "size_bytes": 433139,
+ "sha256": "f96f9f24c379a9bcf714e3fb7692a712b1ffd8432884af0a2120f1ad1bb8c619"
+ },
+ "capabilities": {
+ "url": "https://github.com/nearai/ironhub/releases/download/release-2026-05-12-24/clickup.capabilities.json",
+ "size_bytes": 3287,
+ "sha256": "1815aa5019cf4b329ee3269a5a3bbd301f690c4d9505c3fd4e5062983cedc4ef"
+ }
+ }
+ ],
+ "skills": [
+ {
+ "name": "microsoft-365-workflow",
+ "trunk": "microsoft-365",
+ "version": "1.0.0",
+ "description": "Microsoft 365 business workflow patterns",
+ "skill_md": {
+ "url": "https://github.com/nearai/ironhub/releases/download/release-2026-05-12-24/microsoft-365-workflow.SKILL.md",
+ "size_bytes": 14000,
+ "sha256": "a1b2c3d4e5f6789012345678901234567890123456789012345678901234abcd"
+ }
+ }
+ ]
+ }"#;
+
+ #[test]
+ fn parses_sample_manifest() {
+ let manifest: HubManifest = serde_json::from_str(SAMPLE_MANIFEST).expect("valid manifest");
+ assert_eq!(manifest.version, "1");
+ assert_eq!(manifest.release_tag, "release-2026-05-12-24");
+ assert_eq!(manifest.repo, "nearai/ironhub");
+ assert_eq!(manifest.tools.len(), 1);
+ assert_eq!(manifest.skills.len(), 1);
+ }
+
+ #[test]
+ fn find_tool_returns_entry_by_name() {
+ let manifest: HubManifest = serde_json::from_str(SAMPLE_MANIFEST).expect("valid manifest");
+ let tool = manifest.find_tool("clickup").expect("clickup present");
+ assert_eq!(tool.crate_name, "clickup-tool");
+ assert_eq!(tool.wasm.size_bytes, 433139);
+ assert!(manifest.find_tool("nonexistent").is_none());
+ }
+
+ #[test]
+ fn find_skill_returns_entry_by_name() {
+ let manifest: HubManifest = serde_json::from_str(SAMPLE_MANIFEST).expect("valid manifest");
+ let skill = manifest
+ .find_skill("microsoft-365-workflow")
+ .expect("skill present");
+ assert_eq!(skill.trunk, "microsoft-365");
+ assert!(manifest.find_skill("nonexistent").is_none());
+ }
+
+ #[test]
+ fn default_manifest_url_is_https_hub_endpoint() {
+ assert_eq!(
+ DEFAULT_HUB_MANIFEST_URL,
+ "https://hub.ironclaw.com/api/catalog/manifest.json"
+ );
+ }
+
+ #[test]
+ fn provenance_defaults_to_new_when_field_absent() {
+ let manifest: HubManifest = serde_json::from_str(SAMPLE_MANIFEST).expect("valid manifest");
+ assert_eq!(manifest.tools[0].provenance, Provenance::New);
+ assert_eq!(manifest.skills[0].provenance, Provenance::New);
+ }
+
+ #[test]
+ fn provenance_parses_each_tier_and_aliases() {
+ let cases = [
+ (r#""official""#, Provenance::Official),
+ (r#""repo""#, Provenance::Official),
+ (r#""trusted""#, Provenance::Trusted),
+ (r#""verified""#, Provenance::Verified),
+ (r#""new""#, Provenance::New),
+ (r#""community""#, Provenance::New),
+ ];
+ for (raw, expected) in cases {
+ let got: Provenance = serde_json::from_str(raw).expect("valid provenance");
+ assert_eq!(got, expected, "input {raw}");
+ }
+ }
+
+ #[test]
+ fn provenance_rejects_unknown_string() {
+ assert!(serde_json::from_str::(r#""banned""#).is_err());
+ assert!(serde_json::from_str::(r#""whatever""#).is_err());
+ }
+
+ #[test]
+ fn provenance_wire_round_trips() {
+ for p in [
+ Provenance::Official,
+ Provenance::Trusted,
+ Provenance::Verified,
+ Provenance::New,
+ ] {
+ let json = serde_json::to_string(&p).expect("serialize");
+ assert_eq!(json, format!("\"{}\"", p.as_wire()));
+ let back: Provenance = serde_json::from_str(&json).expect("deserialize");
+ assert_eq!(back, p);
+ }
+ assert!(Provenance::New.is_community_unverified());
+ assert!(!Provenance::Official.is_community_unverified());
+ }
+
+ #[test]
+ fn manifest_with_no_tools_or_skills_parses() {
+ let raw = r#"{
+ "version": "1",
+ "generated_at": "2026-05-12T23:43:46Z",
+ "release_tag": "release-2026-05-12-24",
+ "repo": "nearai/ironhub"
+ }"#;
+ let manifest: HubManifest = serde_json::from_str(raw).expect("valid manifest");
+ assert!(manifest.tools.is_empty());
+ assert!(manifest.skills.is_empty());
+ }
+}
diff --git a/src/registry/installer.rs b/src/registry/installer.rs
index 68a664d8ae0..9cf88e03545 100644
--- a/src/registry/installer.rs
+++ b/src/registry/installer.rs
@@ -9,16 +9,64 @@ use crate::bootstrap::ironclaw_base_dir;
use crate::registry::catalog::RegistryError;
use crate::registry::manifest::{BundleDefinition, ExtensionManifest, ManifestKind, SourceSpec};
-// 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] = &[
+ "hub.ironclaw.com",
"github.com",
"objects.githubusercontent.com",
"github-releases.githubusercontent.com",
"raw.githubusercontent.com",
];
+fn extra_artifact_hosts() -> &'static [String] {
+ use std::sync::OnceLock;
+ static EXTRA: OnceLock> = OnceLock::new();
+ EXTRA.get_or_init(|| {
+ parse_extra_artifact_hosts(
+ std::env::var("IRONHUB_EXTRA_ARTIFACT_HOSTS")
+ .ok()
+ .as_deref(),
+ )
+ })
+}
+
+fn parse_extra_artifact_hosts(env_value: Option<&str>) -> Vec {
+ env_value
+ .unwrap_or("")
+ .split(',')
+ .map(|h| h.trim().to_ascii_lowercase())
+ .filter(|h| !h.is_empty())
+ .filter(|h| !host_is_disallowed_target(h))
+ .collect()
+}
+
+fn host_is_disallowed_target(host: &str) -> bool {
+ let h = host.strip_suffix('.').unwrap_or(host);
+ let ip_form = h
+ .strip_prefix('[')
+ .and_then(|s| s.strip_suffix(']'))
+ .unwrap_or(h);
+ if ip_form.parse::().is_ok() {
+ return true;
+ }
+ if h == "localhost" {
+ return true;
+ }
+ const INTERNAL_SUFFIXES: &[&str] = &[
+ ".localhost",
+ ".local",
+ ".internal",
+ ".intranet",
+ ".lan",
+ ".home",
+ ".corp",
+ ".private",
+ ];
+ if INTERNAL_SUFFIXES.iter().any(|s| h.ends_with(s)) {
+ return true;
+ }
+ !h.contains('.')
+}
+
fn should_attempt_source_fallback(err: &RegistryError) -> bool {
match err {
// `releases/latest` is a moving target: every new release rebuilds WASM
@@ -39,13 +87,18 @@ fn should_attempt_source_fallback(err: &RegistryError) -> bool {
}
fn is_allowed_artifact_host(host: &str) -> bool {
+ is_allowed_artifact_host_with_extras(host, extra_artifact_hosts())
+}
+
+fn is_allowed_artifact_host_with_extras(host: &str, extras: &[String]) -> bool {
ALLOWED_ARTIFACT_HOSTS
.iter()
.any(|allowed| host.eq_ignore_ascii_case(allowed))
|| host.ends_with(".githubusercontent.com")
+ || extras.iter().any(|h| host.eq_ignore_ascii_case(h))
}
-fn validate_artifact_url(
+pub(crate) fn validate_artifact_url(
manifest_name: &str,
field: &'static str,
url: &str,
@@ -72,7 +125,7 @@ fn validate_artifact_url(
reason: "URL host is missing".to_string(),
})?;
- if host.parse::().is_ok() || !is_allowed_artifact_host(host) {
+ if host_is_disallowed_target(host) || !is_allowed_artifact_host(host) {
return Err(RegistryError::InvalidManifest {
name: manifest_name.to_string(),
field,
@@ -468,7 +521,8 @@ impl RegistryInstaller {
"Downloading {} '{}'...",
manifest.kind, manifest.display_name
);
- let bytes = download_artifact(url).await?;
+ const MAX_REGISTRY_ARTIFACT_BYTES: u64 = 64 * 1024 * 1024;
+ let bytes = download_artifact(url, MAX_REGISTRY_ARTIFACT_BYTES).await?;
verify_sha256(&bytes, expected_sha, url)?;
let target_caps = target_dir.join(format!("{}.capabilities.json", manifest.name));
@@ -495,7 +549,7 @@ impl RegistryInstaller {
caps_url,
)?;
const MAX_CAPS_SIZE: usize = 1024 * 1024; // 1 MB
- match download_artifact(caps_url).await {
+ match download_artifact(caps_url, MAX_CAPS_SIZE as u64).await {
Ok(caps_bytes) if caps_bytes.len() <= MAX_CAPS_SIZE => {
fs::write(&target_caps, &caps_bytes)
.await
@@ -633,8 +687,22 @@ impl RegistryInstaller {
}
}
-/// Download an artifact from a URL.
-async fn download_artifact(url: &str) -> Result {
+fn enforce_size_cap(observed: u64, max_bytes: u64, url: &str) -> Result<(), RegistryError> {
+ if observed > max_bytes {
+ return Err(RegistryError::DownloadFailed {
+ url: url.to_string(),
+ reason: format!("response exceeds {max_bytes} byte size cap"),
+ });
+ }
+ Ok(())
+}
+
+/// Download an artifact from a URL, streaming with a hard size cap so a
+/// malicious or oversized response cannot exhaust memory.
+pub(crate) async fn download_artifact(
+ url: &str,
+ max_bytes: u64,
+) -> Result {
let response = reqwest::get(url)
.await
.map_err(|e| RegistryError::DownloadFailed {
@@ -642,7 +710,7 @@ async fn download_artifact(url: &str) -> Result {
reason: download_failure_reason(&e),
})?;
- let response = response
+ let mut response = response
.error_for_status()
.map_err(|e| RegistryError::DownloadFailed {
url: url.to_string(),
@@ -653,23 +721,36 @@ async fn download_artifact(url: &str) -> Result {
),
})?;
- response
- .bytes()
+ if let Some(len) = response.content_length() {
+ enforce_size_cap(len, max_bytes, url)?;
+ }
+
+ let mut buf: Vec = Vec::new();
+ let mut total: u64 = 0;
+ while let Some(chunk) = response
+ .chunk()
.await
.map_err(|e| RegistryError::DownloadFailed {
url: url.to_string(),
reason: format!("failed to read response body: {}", e),
- })
+ })?
+ {
+ total += chunk.len() as u64;
+ enforce_size_cap(total, max_bytes, url)?;
+ buf.extend_from_slice(&chunk);
+ }
+
+ Ok(bytes::Bytes::from(buf))
}
/// Verify SHA256 of downloaded bytes.
-fn verify_sha256(bytes: &[u8], expected: &str, url: &str) -> Result<(), RegistryError> {
+pub(crate) fn verify_sha256(bytes: &[u8], expected: &str, url: &str) -> Result<(), RegistryError> {
use sha2::{Digest, Sha256};
let mut hasher = Sha256::new();
hasher.update(bytes);
let actual = format!("{:x}", hasher.finalize());
- if actual != expected {
+ if !actual.eq_ignore_ascii_case(expected) {
return Err(RegistryError::ChecksumMismatch {
url: url.to_string(),
expected_sha256: expected.to_string(),
@@ -881,6 +962,16 @@ mod tests {
assert!(matches!(err, RegistryError::ChecksumMismatch { .. }));
}
+ #[test]
+ fn test_verify_sha256_accepts_uppercase_expected() {
+ use sha2::{Digest, Sha256};
+ let data = b"hello world";
+ let mut hasher = Sha256::new();
+ hasher.update(data);
+ let hash = format!("{:X}", hasher.finalize());
+ assert!(verify_sha256(data, &hash, "test://url").is_ok());
+ }
+
#[tokio::test]
async fn test_install_from_source_rejects_path_traversal_name() {
let temp = tempfile::tempdir().expect("tempdir");
@@ -1370,4 +1461,114 @@ mod tests {
other => panic!("expected ManifestRead for channel, got: {:?}", other),
}
}
+
+ #[test]
+ fn allowlist_includes_hub_and_github() {
+ assert!(is_allowed_artifact_host("hub.ironclaw.com"));
+ assert!(is_allowed_artifact_host("github.com"));
+ assert!(is_allowed_artifact_host("objects.githubusercontent.com"));
+ }
+
+ #[test]
+ fn allowlist_excludes_tigris_and_spoofs() {
+ assert!(!is_allowed_artifact_host("fly.storage.tigris.dev"));
+ assert!(!is_allowed_artifact_host("hub.ironclaw.com.evil.com"));
+ assert!(!is_allowed_artifact_host("evil.hub.ironclaw.com"));
+ assert!(!is_allowed_artifact_host("ironclaw.com"));
+ }
+
+ #[test]
+ fn parse_extra_artifact_hosts_handles_unset_empty_and_comma_list() {
+ assert!(parse_extra_artifact_hosts(None).is_empty());
+ assert!(parse_extra_artifact_hosts(Some("")).is_empty());
+ assert!(parse_extra_artifact_hosts(Some(" , , ")).is_empty());
+ assert_eq!(
+ parse_extra_artifact_hosts(Some("ironhub-staging.up.railway.app")),
+ vec!["ironhub-staging.up.railway.app".to_string()]
+ );
+ assert_eq!(
+ parse_extra_artifact_hosts(Some(" Foo.Example , bar.example , ")),
+ vec!["foo.example".to_string(), "bar.example".to_string()]
+ );
+ }
+
+ #[test]
+ fn parse_extra_artifact_hosts_filters_unsafe_targets() {
+ assert_eq!(
+ parse_extra_artifact_hosts(Some(
+ "ironhub-staging.up.railway.app, localhost, 127.0.0.1, metadata.internal, intranet, partner.example.com"
+ )),
+ vec![
+ "ironhub-staging.up.railway.app".to_string(),
+ "partner.example.com".to_string()
+ ]
+ );
+ }
+
+ #[test]
+ fn extras_allow_listed_host_without_widening_const_allowlist() {
+ let extras = vec!["ironhub-staging.up.railway.app".to_string()];
+ assert!(is_allowed_artifact_host_with_extras(
+ "ironhub-staging.up.railway.app",
+ &extras
+ ));
+ assert!(is_allowed_artifact_host_with_extras(
+ "IRONHUB-STAGING.up.railway.app",
+ &extras
+ ));
+ assert!(!is_allowed_artifact_host_with_extras(
+ "evil.example.com",
+ &extras
+ ));
+ assert!(is_allowed_artifact_host_with_extras(
+ "hub.ironclaw.com",
+ &extras
+ ));
+ assert!(!is_allowed_artifact_host_with_extras(
+ "ironhub-staging.up.railway.app",
+ &[]
+ ));
+ }
+
+ #[test]
+ fn validate_artifact_url_rejects_non_https_ip_and_unknown_host() {
+ assert!(validate_artifact_url("m", "f", "http://hub.ironclaw.com/x").is_err());
+ assert!(validate_artifact_url("m", "f", "https://1.2.3.4/x").is_err());
+ assert!(validate_artifact_url("m", "f", "https://[::1]/x").is_err());
+ assert!(validate_artifact_url("m", "f", "https://[fd00::1]/x").is_err());
+ assert!(validate_artifact_url("m", "f", "https://localhost/x").is_err());
+ assert!(validate_artifact_url("m", "f", "https://metadata.internal/x").is_err());
+ assert!(validate_artifact_url("m", "f", "https://intranet/x").is_err());
+ assert!(validate_artifact_url("m", "f", "https://evil.example.com/x").is_err());
+ assert!(validate_artifact_url("m", "f", "https://hub.ironclaw.com/catalog/x.wasm").is_ok());
+ }
+
+ #[test]
+ fn host_is_disallowed_target_rejects_ip_internal_and_bare() {
+ assert!(host_is_disallowed_target("127.0.0.1"));
+ assert!(host_is_disallowed_target("169.254.169.254"));
+ assert!(host_is_disallowed_target("[::1]"));
+ assert!(host_is_disallowed_target("localhost"));
+ assert!(host_is_disallowed_target("foo.localhost"));
+ assert!(host_is_disallowed_target("svc.internal"));
+ assert!(host_is_disallowed_target("db.local"));
+ assert!(host_is_disallowed_target("intranet"));
+ assert!(host_is_disallowed_target("router.lan."));
+ assert!(!host_is_disallowed_target("hub.ironclaw.com"));
+ assert!(!host_is_disallowed_target("ironhub-staging.up.railway.app"));
+ assert!(!host_is_disallowed_target("objects.githubusercontent.com"));
+ }
+
+ #[test]
+ fn enforce_size_cap_allows_within_and_rejects_over() {
+ assert!(enforce_size_cap(0, 1024, "u").is_ok());
+ assert!(enforce_size_cap(1024, 1024, "u").is_ok());
+ let err = enforce_size_cap(1025, 1024, "u").expect_err("over cap must fail");
+ match err {
+ RegistryError::DownloadFailed { reason, .. } => {
+ assert!(reason.contains("exceeds 1024 byte size cap"));
+ }
+ other => panic!("expected DownloadFailed, got {:?}", other),
+ }
+ }
}
diff --git a/src/registry/mod.rs b/src/registry/mod.rs
index 5649f821072..c8a449c4467 100644
--- a/src/registry/mod.rs
+++ b/src/registry/mod.rs
@@ -14,10 +14,16 @@
pub mod artifacts;
pub mod catalog;
pub mod embedded;
+pub mod hub_installer;
+pub mod hub_manifest;
pub mod installer;
pub mod manifest;
pub use catalog::{RegistryCatalog, RegistryError};
+pub use hub_installer::{HubInstallOutcome, HubInstaller};
+pub use hub_manifest::{
+ DEFAULT_HUB_MANIFEST_URL, HubArtifact, HubManifest, HubSkillEntry, HubToolEntry, Provenance,
+};
pub use installer::RegistryInstaller;
pub use manifest::{
ArtifactSpec, AuthSummary, BundleDefinition, BundlesFile, ExtensionManifest, ManifestKind,
diff --git a/src/tools/builtin/extension_tools.rs b/src/tools/builtin/extension_tools.rs
index aff9f327b8c..49429076ff7 100644
--- a/src/tools/builtin/extension_tools.rs
+++ b/src/tools/builtin/extension_tools.rs
@@ -33,7 +33,7 @@ fn activation_error_requires_auth(err: &str) -> bool {
|| err.contains("401")
}
-fn output_from_ensure_ready(outcome: EnsureReadyOutcome) -> serde_json::Value {
+pub(crate) fn output_from_ensure_ready(outcome: EnsureReadyOutcome) -> serde_json::Value {
match outcome {
EnsureReadyOutcome::Ready {
name,
diff --git a/src/tools/builtin/ironhub.rs b/src/tools/builtin/ironhub.rs
new file mode 100644
index 00000000000..93b3132bbd2
--- /dev/null
+++ b/src/tools/builtin/ironhub.rs
@@ -0,0 +1,2119 @@
+use std::sync::Arc;
+
+use async_trait::async_trait;
+use ironclaw_skills::SkillRegistry;
+
+use crate::cli::hub_install::{hub_manifest_url_for_tag, validate_hub_name};
+use crate::context::JobContext;
+use crate::extensions::{EnsureReadyIntent, ExtensionKind, ExtensionManager};
+use crate::registry::{
+ HubInstallOutcome, HubInstaller, HubManifest, HubSkillEntry, HubToolEntry, Provenance,
+ RegistryError,
+};
+use crate::tools::builtin::extension_tools::output_from_ensure_ready;
+use crate::tools::tool::{ApprovalRequirement, Tool, ToolError, ToolOutput, require_str};
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+enum HubEntryKind {
+ Tool,
+ Skill,
+}
+
+impl HubEntryKind {
+ fn as_str(self) -> &'static str {
+ match self {
+ HubEntryKind::Tool => "tool",
+ HubEntryKind::Skill => "skill",
+ }
+ }
+
+ fn from_param(s: &str) -> Result {
+ match s {
+ "tool" => Ok(Self::Tool),
+ "skill" => Ok(Self::Skill),
+ other => Err(ToolError::InvalidParameters(format!(
+ "kind must be 'tool' or 'skill', got '{other}'"
+ ))),
+ }
+ }
+}
+
+#[derive(Clone)]
+pub struct IronhubDeps {
+ pub extension_manager: Arc,
+ pub skill_registry: Option>>,
+}
+
+fn build_installer(
+ release_tag: Option<&str>,
+ skills_dir_override: Option,
+) -> Result {
+ let mut installer = HubInstaller::with_defaults();
+ if let Some(dir) = skills_dir_override {
+ installer = installer.with_skills_dir(dir);
+ }
+ if let Some(tag) = release_tag {
+ let url = hub_manifest_url_for_tag(tag)
+ .map_err(|e: anyhow::Error| ToolError::InvalidParameters(e.to_string()))?;
+ installer = installer.with_manifest_url(url);
+ }
+ Ok(installer)
+}
+
+fn catalog_unavailable() -> ToolError {
+ ToolError::ExternalService("IronHub catalog is temporarily unavailable".into())
+}
+
+fn catalog_unavailable_from(err: RegistryError) -> ToolError {
+ tracing::debug!("IronHub catalog fetch failed: {err}");
+ catalog_unavailable()
+}
+
+fn classify_and_gate(
+ manifest: &HubManifest,
+ name: &str,
+ hint: Option,
+ acknowledge_unverified: bool,
+) -> Result<(HubEntryKind, Provenance), ToolError> {
+ let kind = classify(manifest, name, hint)?;
+ let provenance = match kind {
+ HubEntryKind::Tool => manifest.find_tool(name).map(|t| t.provenance),
+ HubEntryKind::Skill => manifest.find_skill(name).map(|s| s.provenance),
+ }
+ .unwrap_or(Provenance::New);
+ if provenance.is_community_unverified() && !acknowledge_unverified {
+ return Err(ToolError::InvalidParameters(format!(
+ "'{name}' is UNVERIFIED community content (trust tier: {}). \
+ Not NEAR-vetted. Re-run with acknowledge_unverified=true to \
+ install at your own risk.",
+ provenance.as_wire()
+ )));
+ }
+ Ok((kind, provenance))
+}
+
+fn entry_artifact_digest(kind: HubEntryKind, manifest: &HubManifest, name: &str) -> Option {
+ use sha2::{Digest, Sha256};
+ let input = match kind {
+ HubEntryKind::Tool => {
+ let t = manifest.find_tool(name)?;
+ format!("{}:{}", t.wasm.sha256, t.capabilities.sha256)
+ }
+ HubEntryKind::Skill => manifest.find_skill(name)?.skill_md.sha256.clone(),
+ };
+ let mut hasher = Sha256::new();
+ hasher.update(input.as_bytes());
+ Some(hex::encode(hasher.finalize()))
+}
+
+fn classify(
+ manifest: &HubManifest,
+ name: &str,
+ hint: Option,
+) -> Result {
+ let in_tools = manifest.find_tool(name).is_some();
+ let in_skills = manifest.find_skill(name).is_some();
+
+ if let Some(HubEntryKind::Tool) = hint {
+ if !in_tools {
+ return Err(ToolError::InvalidParameters(format!(
+ "'{name}' is not a tool in this IronHub release"
+ )));
+ }
+ return Ok(HubEntryKind::Tool);
+ }
+ if let Some(HubEntryKind::Skill) = hint {
+ if !in_skills {
+ return Err(ToolError::InvalidParameters(format!(
+ "'{name}' is not a skill in this IronHub release"
+ )));
+ }
+ return Ok(HubEntryKind::Skill);
+ }
+
+ match (in_tools, in_skills) {
+ (true, false) => Ok(HubEntryKind::Tool),
+ (false, true) => Ok(HubEntryKind::Skill),
+ (true, true) => Err(ToolError::InvalidParameters(format!(
+ "'{name}' exists as both a tool and a skill in this release; pass kind='tool' or kind='skill' to disambiguate"
+ ))),
+ (false, false) => {
+ let suggestions = nearest_matches(manifest, name);
+ if suggestions.is_empty() {
+ Err(ToolError::InvalidParameters(format!(
+ "'{name}' is not in this IronHub release"
+ )))
+ } else {
+ Err(ToolError::InvalidParameters(format!(
+ "'{name}' is not in this IronHub release. Did you mean: {}?",
+ suggestions.join(", ")
+ )))
+ }
+ }
+ }
+}
+
+fn nearest_matches(manifest: &HubManifest, query: &str) -> Vec {
+ let q = query.to_ascii_lowercase();
+ let mut out: Vec = manifest
+ .tools
+ .iter()
+ .map(|t| t.name.clone())
+ .chain(manifest.skills.iter().map(|s| s.name.clone()))
+ .filter(|n| {
+ let nl = n.to_ascii_lowercase();
+ nl.contains(&q) || q.contains(&nl)
+ })
+ .collect();
+ out.sort();
+ out.truncate(5);
+ out
+}
+
+fn entry_matches(name: &str, description: &str, query_lower: &str) -> bool {
+ name.to_ascii_lowercase().contains(query_lower)
+ || description.to_ascii_lowercase().contains(query_lower)
+}
+
+fn install_outcome_to_json(kind: HubEntryKind, outcome: &HubInstallOutcome) -> serde_json::Value {
+ let mut obj = serde_json::Map::new();
+ obj.insert(
+ "status".into(),
+ serde_json::Value::String("installed".into()),
+ );
+ obj.insert(
+ "kind".into(),
+ serde_json::Value::String(kind.as_str().into()),
+ );
+ obj.insert(
+ "name".into(),
+ serde_json::Value::String(outcome.name.clone()),
+ );
+ obj.insert(
+ "version".into(),
+ serde_json::Value::String(outcome.version.clone()),
+ );
+ obj.insert(
+ "release_tag".into(),
+ serde_json::Value::String(outcome.release_tag.clone()),
+ );
+ obj.insert(
+ "primary_path".into(),
+ serde_json::Value::String(outcome.primary_path.display().to_string()),
+ );
+ if let Some(meta) = &outcome.metadata_path {
+ obj.insert(
+ "metadata_path".into(),
+ serde_json::Value::String(meta.display().to_string()),
+ );
+ }
+ obj.insert(
+ "provenance".into(),
+ serde_json::Value::String(outcome.provenance.as_wire().into()),
+ );
+ if outcome.provenance.is_community_unverified() {
+ obj.insert("unverified".into(), serde_json::Value::Bool(true));
+ obj.insert(
+ "warning".into(),
+ serde_json::Value::String(format!(
+ "{} - not NEAR-vetted",
+ outcome.provenance.trust_label()
+ )),
+ );
+ }
+ serde_json::Value::Object(obj)
+}
+
+fn annotate_reload_verification(json: &mut serde_json::Value, name: &str, loaded: &[String]) {
+ let verified = loaded.iter().any(|n| n.eq_ignore_ascii_case(name));
+ let Some(obj) = json.as_object_mut() else {
+ return;
+ };
+ obj.insert("reload_verified".into(), serde_json::Value::Bool(verified));
+ if !verified {
+ obj.insert(
+ "reload_warning".into(),
+ serde_json::Value::String(format!(
+ "skill '{name}' written to disk but not present in the registry after reload"
+ )),
+ );
+ }
+}
+
+fn install_error_to_tool_error(name: &str, err: RegistryError) -> ToolError {
+ match err {
+ RegistryError::AlreadyInstalled { .. } => ToolError::InvalidParameters(format!(
+ "'{name}' is already installed; pass force=true to reinstall"
+ )),
+ RegistryError::ChecksumMismatch { .. } => {
+ ToolError::ExternalService(format!("'{name}' failed artifact integrity verification"))
+ }
+ other => {
+ tracing::debug!("IronHub install failed for '{name}': {other}");
+ ToolError::ExecutionFailed(format!("install of '{name}' failed"))
+ }
+ }
+}
+
+fn tool_entry_json(entry: &HubToolEntry) -> serde_json::Value {
+ serde_json::json!({
+ "kind": "tool",
+ "name": entry.name,
+ "version": entry.version,
+ "description": entry.description,
+ "provenance": entry.provenance.as_wire(),
+ "trust_label": entry.provenance.trust_label(),
+ })
+}
+
+fn skill_entry_json(entry: &HubSkillEntry) -> serde_json::Value {
+ serde_json::json!({
+ "kind": "skill",
+ "name": entry.name,
+ "version": entry.version,
+ "description": entry.description,
+ "provenance": entry.provenance.as_wire(),
+ "trust_label": entry.provenance.trust_label(),
+ })
+}
+
+fn info_tool_json(entry: &HubToolEntry, release_tag: &str) -> serde_json::Value {
+ serde_json::json!({
+ "kind": "tool",
+ "name": entry.name,
+ "crate_name": entry.crate_name,
+ "version": entry.version,
+ "description": entry.description,
+ "provenance": entry.provenance.as_wire(),
+ "trust_label": entry.provenance.trust_label(),
+ "release_tag": release_tag,
+ "wasm": {
+ "url": entry.wasm.url,
+ "size_bytes": entry.wasm.size_bytes,
+ "sha256": entry.wasm.sha256,
+ },
+ "capabilities": {
+ "url": entry.capabilities.url,
+ "size_bytes": entry.capabilities.size_bytes,
+ "sha256": entry.capabilities.sha256,
+ }
+ })
+}
+
+fn info_skill_json(entry: &HubSkillEntry, release_tag: &str) -> serde_json::Value {
+ serde_json::json!({
+ "kind": "skill",
+ "name": entry.name,
+ "trunk": entry.trunk,
+ "version": entry.version,
+ "description": entry.description,
+ "provenance": entry.provenance.as_wire(),
+ "trust_label": entry.provenance.trust_label(),
+ "release_tag": release_tag,
+ "skill_md": {
+ "url": entry.skill_md.url,
+ "size_bytes": entry.skill_md.size_bytes,
+ "sha256": entry.skill_md.sha256,
+ }
+ })
+}
+
+fn skill_install_dir(
+ registry: &Option>>,
+) -> Option {
+ let registry = registry.as_ref()?;
+ let guard = registry.read().unwrap_or_else(|poison| {
+ tracing::error!(
+ "skill registry RwLock was poisoned (a previous writer panicked); recovering"
+ );
+ poison.into_inner()
+ });
+ Some(
+ guard
+ .installed_dir()
+ .map(|p| p.to_path_buf())
+ .unwrap_or_else(|| guard.install_target_dir().to_path_buf()),
+ )
+}
+
+pub struct IronhubInstallTool {
+ deps: IronhubDeps,
+}
+
+impl IronhubInstallTool {
+ pub fn new(deps: IronhubDeps) -> Self {
+ Self { deps }
+ }
+
+ async fn install_from_manifest(
+ &self,
+ start: std::time::Instant,
+ manifest: HubManifest,
+ parsed: InstallParams,
+ ctx: &JobContext,
+ ) -> Result {
+ let (kind, _provenance) = classify_and_gate(
+ &manifest,
+ &parsed.name,
+ parsed.kind_hint,
+ parsed.acknowledge_unverified,
+ )?;
+
+ if let Some(expected) = parsed.version.as_deref() {
+ let actual = match kind {
+ HubEntryKind::Tool => manifest.find_tool(&parsed.name).map(|t| t.version.as_str()),
+ HubEntryKind::Skill => manifest
+ .find_skill(&parsed.name)
+ .map(|s| s.version.as_str()),
+ };
+ if actual != Some(expected) {
+ let current = actual.unwrap_or("unknown");
+ return Err(ToolError::InvalidParameters(format!(
+ "signed install version '{expected}' does not match current IronHub catalog version '{current}' for '{}'; the catalog changed since this install was approved",
+ parsed.name
+ )));
+ }
+ }
+
+ if let Some(expected) = parsed.artifact_digest.as_deref() {
+ let actual = entry_artifact_digest(kind, &manifest, &parsed.name);
+ if actual.as_deref() != Some(expected) {
+ let current = actual.as_deref().unwrap_or("unknown");
+ return Err(ToolError::InvalidParameters(format!(
+ "signed artifact digest '{expected}' does not match the current IronHub catalog artifact for '{}' (computed '{current}'); the artifact changed since this install was approved",
+ parsed.name
+ )));
+ }
+ }
+
+ let skills_dir = match kind {
+ HubEntryKind::Skill => skill_install_dir(&self.deps.skill_registry),
+ HubEntryKind::Tool => None,
+ };
+ let installer = build_installer(parsed.release_tag.as_deref(), skills_dir)?;
+
+ match kind {
+ HubEntryKind::Tool => {
+ let outcome = installer
+ .install_tool_from_manifest(&manifest, &parsed.name, parsed.force)
+ .await
+ .map_err(|e| install_error_to_tool_error(&parsed.name, e))?;
+ let ready = self
+ .deps
+ .extension_manager
+ .ensure_extension_ready(
+ &parsed.name,
+ &ctx.user_id,
+ EnsureReadyIntent::PostInstall,
+ )
+ .await
+ .map_err(|e| ToolError::ExecutionFailed(e.to_string()))?;
+ let mut json = install_outcome_to_json(kind, &outcome);
+ if let Some(obj) = json.as_object_mut() {
+ obj.insert("activation".into(), output_from_ensure_ready(ready));
+ }
+ Ok(ToolOutput::success(json, start.elapsed()))
+ }
+ HubEntryKind::Skill => {
+ let outcome = installer
+ .install_skill_from_manifest(&manifest, &parsed.name, parsed.force)
+ .await
+ .map_err(|e| install_error_to_tool_error(&parsed.name, e))?;
+ let mut json = install_outcome_to_json(kind, &outcome);
+ // std::sync::RwLock matches the rest of the repo. spawn_blocking with
+ // a fresh current-thread runtime avoids deadlock under outer-runtime
+ // saturation; poison recovery keeps a prior writer panic from sticking.
+ if let Some(reg) = &self.deps.skill_registry {
+ let reg = Arc::clone(reg);
+ let loaded = tokio::task::spawn_blocking(
+ move || -> Result, ToolError> {
+ let mut guard = reg.write().unwrap_or_else(|poison| {
+ tracing::error!(
+ "skill registry RwLock was poisoned (a previous writer panicked); recovering"
+ );
+ poison.into_inner()
+ });
+ let rt = tokio::runtime::Builder::new_current_thread()
+ .enable_all()
+ .build()
+ .map_err(|e| {
+ ToolError::ExecutionFailed(format!(
+ "skill registry reload runtime: {e}"
+ ))
+ })?;
+ Ok(rt.block_on(guard.reload()))
+ },
+ )
+ .await
+ .map_err(|e| {
+ ToolError::ExecutionFailed(format!("skill registry reload join: {e}"))
+ })??;
+ annotate_reload_verification(&mut json, &outcome.name, &loaded);
+ }
+ Ok(ToolOutput::success(json, start.elapsed()))
+ }
+ }
+ }
+}
+
+struct InstallParams {
+ name: String,
+ kind_hint: Option,
+ release_tag: Option,
+ version: Option,
+ artifact_digest: Option,
+ force: bool,
+ acknowledge_unverified: bool,
+}
+
+impl InstallParams {
+ fn from_json(params: &serde_json::Value) -> Result {
+ let name = require_str(params, "name")?.to_string();
+ validate_hub_name(&name)
+ .map_err(|e: anyhow::Error| ToolError::InvalidParameters(e.to_string()))?;
+ let kind_hint = params
+ .get("kind")
+ .and_then(|v| v.as_str())
+ .map(HubEntryKind::from_param)
+ .transpose()?;
+ let release_tag = params
+ .get("release_tag")
+ .and_then(|v| v.as_str())
+ .map(str::to_string);
+ let version = params
+ .get("version")
+ .and_then(|v| v.as_str())
+ .map(str::to_string);
+ if let Some(v) = &version
+ && (v.is_empty() || v.len() > 128)
+ {
+ return Err(ToolError::InvalidParameters(
+ "version must be 1 to 128 characters".into(),
+ ));
+ }
+ let artifact_digest = params
+ .get("artifact_digest")
+ .and_then(|v| v.as_str())
+ .map(str::to_string);
+ if let Some(d) = &artifact_digest
+ && (d.is_empty() || d.len() > 128)
+ {
+ return Err(ToolError::InvalidParameters(
+ "artifact_digest must be 1 to 128 characters".into(),
+ ));
+ }
+ let force = params
+ .get("force")
+ .and_then(|v| v.as_bool())
+ .unwrap_or(false);
+ let acknowledge_unverified = params
+ .get("acknowledge_unverified")
+ .and_then(|v| v.as_bool())
+ .unwrap_or(false);
+ Ok(Self {
+ name,
+ kind_hint,
+ release_tag,
+ version,
+ artifact_digest,
+ force,
+ acknowledge_unverified,
+ })
+ }
+}
+
+#[async_trait]
+impl Tool for IronhubInstallTool {
+ fn name(&self) -> &str {
+ "ironhub_install"
+ }
+
+ fn description(&self) -> &str {
+ "Install a tool or skill from the IronHub catalog by name. \
+ Auto-detects whether the name refers to a tool or skill from the published manifest; \
+ pass kind='tool' or kind='skill' only when the same name exists in both. \
+ Pin release_tag to install from a specific IronHub release (default: latest)."
+ }
+
+ fn parameters_schema(&self) -> serde_json::Value {
+ serde_json::json!({
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "name": {
+ "type": "string",
+ "pattern": "^[a-z0-9][a-z0-9_-]*$",
+ "minLength": 1,
+ "maxLength": 64,
+ "description": "IronHub entry name, e.g. 'clickup' or 'chief-of-staff'"
+ },
+ "kind": { "type": "string", "enum": ["tool", "skill"] },
+ "release_tag": {
+ "type": "string",
+ "pattern": "^[A-Za-z0-9._-]+$",
+ "minLength": 1,
+ "maxLength": 128
+ },
+ "version": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 128,
+ "description": "Exact catalog version this install was signed for; if set, the install fails unless the current IronHub entry matches"
+ },
+ "artifact_digest": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 128,
+ "description": "Signed sha256 over the entry's artifact hashes; if set, the install fails unless the current IronHub artifact matches"
+ },
+ "force": { "type": "boolean", "default": false },
+ "acknowledge_unverified": { "type": "boolean", "default": false }
+ },
+ "required": ["name"]
+ })
+ }
+
+ fn rate_limit_config(&self) -> Option {
+ Some(crate::tools::tool::ToolRateLimitConfig {
+ requests_per_minute: 6,
+ requests_per_hour: 30,
+ })
+ }
+
+ async fn execute(
+ &self,
+ params: serde_json::Value,
+ ctx: &JobContext,
+ ) -> Result {
+ let start = std::time::Instant::now();
+ let parsed = InstallParams::from_json(¶ms)?;
+ let probe = build_installer(parsed.release_tag.as_deref(), None)?;
+ let manifest = probe
+ .fetch_manifest()
+ .await
+ .map_err(catalog_unavailable_from)?;
+ self.install_from_manifest(start, manifest, parsed, ctx)
+ .await
+ }
+
+ fn requires_approval(&self, _params: &serde_json::Value) -> ApprovalRequirement {
+ ApprovalRequirement::UnlessAutoApproved
+ }
+}
+
+pub struct IronhubSearchTool;
+
+impl IronhubSearchTool {
+ pub fn new() -> Self {
+ Self
+ }
+}
+
+impl Default for IronhubSearchTool {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
+#[async_trait]
+impl Tool for IronhubSearchTool {
+ fn name(&self) -> &str {
+ "ironhub_search"
+ }
+
+ fn description(&self) -> &str {
+ "Search the IronHub catalog by substring against entry names and descriptions. \
+ Returns matching tools and skills."
+ }
+
+ fn requires_sanitization(&self) -> bool {
+ true // IronHub catalog entries are external data
+ }
+
+ fn parameters_schema(&self) -> serde_json::Value {
+ serde_json::json!({
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "query": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 128
+ },
+ "release_tag": {
+ "type": "string",
+ "pattern": "^[A-Za-z0-9._-]+$",
+ "minLength": 1,
+ "maxLength": 128
+ }
+ },
+ "required": ["query"]
+ })
+ }
+
+ async fn execute(
+ &self,
+ params: serde_json::Value,
+ _ctx: &JobContext,
+ ) -> Result {
+ let start = std::time::Instant::now();
+ let query = require_str(¶ms, "query")?;
+ let release_tag = params
+ .get("release_tag")
+ .and_then(|v| v.as_str())
+ .map(str::to_string);
+
+ let installer = build_installer(release_tag.as_deref(), None)?;
+ let manifest = installer
+ .fetch_manifest_cached()
+ .await
+ .map_err(catalog_unavailable_from)?;
+
+ let q = query.to_ascii_lowercase();
+ let mut results: Vec = manifest
+ .tools
+ .iter()
+ .filter(|t| entry_matches(&t.name, &t.description, &q))
+ .map(tool_entry_json)
+ .collect();
+ results.extend(
+ manifest
+ .skills
+ .iter()
+ .filter(|s| entry_matches(&s.name, &s.description, &q))
+ .map(skill_entry_json),
+ );
+
+ let json = serde_json::json!({
+ "query": query,
+ "release_tag": manifest.release_tag,
+ "count": results.len(),
+ "results": results,
+ });
+ Ok(ToolOutput::success(json, start.elapsed()))
+ }
+}
+
+pub struct IronhubListTool;
+
+impl IronhubListTool {
+ pub fn new() -> Self {
+ Self
+ }
+}
+
+impl Default for IronhubListTool {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
+#[async_trait]
+impl Tool for IronhubListTool {
+ fn name(&self) -> &str {
+ "ironhub_list"
+ }
+
+ fn description(&self) -> &str {
+ "List everything available in the IronHub catalog grouped by tools and skills."
+ }
+
+ fn requires_sanitization(&self) -> bool {
+ true // IronHub catalog entries are external data
+ }
+
+ fn parameters_schema(&self) -> serde_json::Value {
+ serde_json::json!({
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "release_tag": {
+ "type": "string",
+ "pattern": "^[A-Za-z0-9._-]+$",
+ "minLength": 1,
+ "maxLength": 128
+ }
+ }
+ })
+ }
+
+ async fn execute(
+ &self,
+ params: serde_json::Value,
+ _ctx: &JobContext,
+ ) -> Result {
+ let start = std::time::Instant::now();
+ let release_tag = params
+ .get("release_tag")
+ .and_then(|v| v.as_str())
+ .map(str::to_string);
+
+ let installer = build_installer(release_tag.as_deref(), None)?;
+ let manifest = installer
+ .fetch_manifest_cached()
+ .await
+ .map_err(catalog_unavailable_from)?;
+
+ let tools: Vec = manifest.tools.iter().map(tool_entry_json).collect();
+ let skills: Vec = manifest.skills.iter().map(skill_entry_json).collect();
+ let json = serde_json::json!({
+ "release_tag": manifest.release_tag,
+ "repo": manifest.repo,
+ "counts": {
+ "tools": tools.len(),
+ "skills": skills.len(),
+ },
+ "tools": tools,
+ "skills": skills,
+ });
+ Ok(ToolOutput::success(json, start.elapsed()))
+ }
+}
+
+pub struct IronhubInfoTool;
+
+impl IronhubInfoTool {
+ pub fn new() -> Self {
+ Self
+ }
+}
+
+impl Default for IronhubInfoTool {
+ fn default() -> Self {
+ Self::new()
+ }
+}
+
+#[async_trait]
+impl Tool for IronhubInfoTool {
+ fn name(&self) -> &str {
+ "ironhub_info"
+ }
+
+ fn description(&self) -> &str {
+ "Show detailed metadata for one IronHub entry (tool or skill) including version, \
+ description, artifact URLs, and SHA-256 checksums."
+ }
+
+ fn requires_sanitization(&self) -> bool {
+ true // IronHub catalog entries are external data
+ }
+
+ fn parameters_schema(&self) -> serde_json::Value {
+ serde_json::json!({
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "name": {
+ "type": "string",
+ "pattern": "^[a-z0-9][a-z0-9_-]*$",
+ "minLength": 1,
+ "maxLength": 64
+ },
+ "release_tag": {
+ "type": "string",
+ "pattern": "^[A-Za-z0-9._-]+$",
+ "minLength": 1,
+ "maxLength": 128
+ }
+ },
+ "required": ["name"]
+ })
+ }
+
+ async fn execute(
+ &self,
+ params: serde_json::Value,
+ _ctx: &JobContext,
+ ) -> Result {
+ let start = std::time::Instant::now();
+ let name = require_str(¶ms, "name")?;
+ validate_hub_name(name)
+ .map_err(|e: anyhow::Error| ToolError::InvalidParameters(e.to_string()))?;
+ let release_tag = params
+ .get("release_tag")
+ .and_then(|v| v.as_str())
+ .map(str::to_string);
+
+ let installer = build_installer(release_tag.as_deref(), None)?;
+ let manifest = installer
+ .fetch_manifest_cached()
+ .await
+ .map_err(catalog_unavailable_from)?;
+
+ if let Some(t) = manifest.find_tool(name) {
+ let json = info_tool_json(t, &manifest.release_tag);
+ return Ok(ToolOutput::success(json, start.elapsed()));
+ }
+ if let Some(s) = manifest.find_skill(name) {
+ let json = info_skill_json(s, &manifest.release_tag);
+ return Ok(ToolOutput::success(json, start.elapsed()));
+ }
+
+ let suggestions = nearest_matches(&manifest, name);
+ if suggestions.is_empty() {
+ Err(ToolError::InvalidParameters(format!(
+ "'{name}' is not in this IronHub release"
+ )))
+ } else {
+ Err(ToolError::InvalidParameters(format!(
+ "'{name}' is not in this IronHub release. Did you mean: {}?",
+ suggestions.join(", ")
+ )))
+ }
+ }
+}
+
+pub struct IronhubRemoveTool {
+ deps: IronhubDeps,
+}
+
+impl IronhubRemoveTool {
+ pub fn new(deps: IronhubDeps) -> Self {
+ Self { deps }
+ }
+}
+
+#[async_trait]
+impl Tool for IronhubRemoveTool {
+ fn name(&self) -> &str {
+ "ironhub_remove"
+ }
+
+ fn description(&self) -> &str {
+ "Remove an installed IronHub tool by name. Deletes the tool's files and \
+ unregisters it. Skills are removed with the skill_remove tool."
+ }
+
+ fn parameters_schema(&self) -> serde_json::Value {
+ serde_json::json!({
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {
+ "name": {
+ "type": "string",
+ "pattern": "^[a-z0-9][a-z0-9_-]*$",
+ "minLength": 1,
+ "maxLength": 64
+ }
+ },
+ "required": ["name"]
+ })
+ }
+
+ async fn execute(
+ &self,
+ params: serde_json::Value,
+ ctx: &JobContext,
+ ) -> Result {
+ let start = std::time::Instant::now();
+ let name = require_str(¶ms, "name")?;
+ validate_hub_name(name)
+ .map_err(|e: anyhow::Error| ToolError::InvalidParameters(e.to_string()))?;
+
+ let message = self
+ .deps
+ .extension_manager
+ .remove(name, &ctx.user_id)
+ .await
+ .map_err(|_| {
+ ToolError::InvalidParameters(format!(
+ "'{name}' is not an installed IronHub tool. \
+ If it is a skill, remove it with the skill_remove tool."
+ ))
+ })?;
+
+ let still_present = self
+ .deps
+ .extension_manager
+ .list(Some(ExtensionKind::WasmTool), false, &ctx.user_id)
+ .await
+ .map_err(|_| {
+ ToolError::ExecutionFailed(format!(
+ "could not verify '{name}' removal from installed extensions"
+ ))
+ })?
+ .iter()
+ .any(|e| e.name.eq_ignore_ascii_case(name));
+ if still_present {
+ return Err(ToolError::ExecutionFailed(format!(
+ "'{name}' is still present after removal"
+ )));
+ }
+
+ Ok(ToolOutput::success(
+ serde_json::json!({
+ "status": "removed",
+ "name": name,
+ "message": message,
+ }),
+ start.elapsed(),
+ ))
+ }
+
+ fn requires_approval(&self, _params: &serde_json::Value) -> ApprovalRequirement {
+ ApprovalRequirement::Always
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crate::registry::{HubArtifact, HubSkillEntry, HubToolEntry, Provenance};
+
+ fn art(name: &str, ext: &str) -> HubArtifact {
+ HubArtifact {
+ url: format!(
+ "https://github.com/nearai/ironhub/releases/download/test/{}.{}",
+ name, ext
+ ),
+ size_bytes: 1024,
+ sha256: "a".repeat(64),
+ }
+ }
+
+ fn manifest_with(tools: Vec<&str>, skills: Vec<&str>) -> HubManifest {
+ HubManifest {
+ version: "1".into(),
+ generated_at: "2026-05-14T00:00:00Z".into(),
+ release_tag: "release-test".into(),
+ repo: "nearai/ironhub".into(),
+ tools: tools
+ .into_iter()
+ .map(|n| HubToolEntry {
+ name: n.into(),
+ crate_name: format!("{}-tool", n),
+ version: "0.1.0".into(),
+ description: format!("{} tool", n),
+ provenance: Provenance::Official,
+ wasm: art(n, "wasm"),
+ capabilities: art(n, "capabilities.json"),
+ })
+ .collect(),
+ skills: skills
+ .into_iter()
+ .map(|n| HubSkillEntry {
+ name: n.into(),
+ trunk: String::new(),
+ version: "0.1.0".into(),
+ description: format!("{} skill", n),
+ provenance: Provenance::Official,
+ skill_md: art(n, "SKILL.md"),
+ })
+ .collect(),
+ }
+ }
+
+ fn outcome(name: &str, with_meta: bool) -> HubInstallOutcome {
+ outcome_prov(name, with_meta, Provenance::Official)
+ }
+
+ fn outcome_prov(name: &str, with_meta: bool, provenance: Provenance) -> HubInstallOutcome {
+ HubInstallOutcome {
+ name: name.into(),
+ version: "0.1.0".into(),
+ release_tag: "release-test".into(),
+ provenance,
+ primary_path: std::path::PathBuf::from(format!("/install/{name}.wasm")),
+ metadata_path: if with_meta {
+ Some(std::path::PathBuf::from(format!(
+ "/install/{name}.capabilities.json"
+ )))
+ } else {
+ None
+ },
+ }
+ }
+
+ #[test]
+ fn search_schema_requires_query() {
+ let tool = IronhubSearchTool::new();
+ let schema = tool.parameters_schema();
+ assert_eq!(schema["required"], serde_json::json!(["query"]));
+ }
+
+ #[test]
+ fn catalog_tools_sanitize_external_output() {
+ assert!(
+ IronhubSearchTool::new().requires_sanitization(),
+ "search surfaces external catalog text and must be sanitized"
+ );
+ assert!(
+ IronhubListTool::new().requires_sanitization(),
+ "list surfaces external catalog text and must be sanitized"
+ );
+ assert!(
+ IronhubInfoTool::new().requires_sanitization(),
+ "info surfaces external catalog text and must be sanitized"
+ );
+ }
+
+ #[test]
+ fn list_schema_has_no_required_fields() {
+ let tool = IronhubListTool::new();
+ let schema = tool.parameters_schema();
+ assert!(
+ schema.get("required").is_none() || schema["required"].as_array().unwrap().is_empty()
+ );
+ }
+
+ #[test]
+ fn info_schema_requires_name() {
+ let tool = IronhubInfoTool::new();
+ let schema = tool.parameters_schema();
+ assert_eq!(schema["required"], serde_json::json!(["name"]));
+ }
+
+ #[test]
+ fn read_only_tools_default_to_never_approval() {
+ let params = serde_json::json!({});
+ assert!(matches!(
+ IronhubSearchTool::new().requires_approval(¶ms),
+ ApprovalRequirement::Never
+ ));
+ assert!(matches!(
+ IronhubListTool::new().requires_approval(¶ms),
+ ApprovalRequirement::Never
+ ));
+ assert!(matches!(
+ IronhubInfoTool::new().requires_approval(¶ms),
+ ApprovalRequirement::Never
+ ));
+ }
+
+ #[test]
+ fn classify_picks_tool_when_only_in_tools() {
+ let m = manifest_with(vec!["clickup"], vec!["chief-of-staff"]);
+ assert_eq!(classify(&m, "clickup", None).unwrap(), HubEntryKind::Tool);
+ }
+
+ #[test]
+ fn classify_picks_skill_when_only_in_skills() {
+ let m = manifest_with(vec!["clickup"], vec!["chief-of-staff"]);
+ assert_eq!(
+ classify(&m, "chief-of-staff", None).unwrap(),
+ HubEntryKind::Skill
+ );
+ }
+
+ #[test]
+ fn classify_returns_invalid_parameters_for_ambiguous() {
+ let m = manifest_with(vec!["overlap"], vec!["overlap"]);
+ let err = classify(&m, "overlap", None).expect_err("must error");
+ match err {
+ ToolError::InvalidParameters(msg) => assert!(msg.contains("disambiguate")),
+ other => panic!("expected InvalidParameters, got {other:?}"),
+ }
+ }
+
+ #[test]
+ fn classify_honors_kind_tool_override() {
+ let m = manifest_with(vec!["overlap"], vec!["overlap"]);
+ assert_eq!(
+ classify(&m, "overlap", Some(HubEntryKind::Tool)).unwrap(),
+ HubEntryKind::Tool
+ );
+ }
+
+ #[test]
+ fn classify_honors_kind_skill_override() {
+ let m = manifest_with(vec!["overlap"], vec!["overlap"]);
+ assert_eq!(
+ classify(&m, "overlap", Some(HubEntryKind::Skill)).unwrap(),
+ HubEntryKind::Skill
+ );
+ }
+
+ #[test]
+ fn classify_kind_tool_rejects_skill_only_name() {
+ let m = manifest_with(vec![], vec!["chief-of-staff"]);
+ let err = classify(&m, "chief-of-staff", Some(HubEntryKind::Tool)).expect_err("must error");
+ match err {
+ ToolError::InvalidParameters(msg) => assert!(msg.contains("not a tool")),
+ other => panic!("expected InvalidParameters, got {other:?}"),
+ }
+ }
+
+ #[test]
+ fn classify_returns_invalid_parameters_with_suggestions_for_typos() {
+ let m = manifest_with(vec!["clickup", "evm-rpc"], vec![]);
+ let err = classify(&m, "click", None).expect_err("must error");
+ match err {
+ ToolError::InvalidParameters(msg) => {
+ assert!(msg.contains("Did you mean"));
+ assert!(msg.contains("clickup"));
+ }
+ other => panic!("expected InvalidParameters, got {other:?}"),
+ }
+ }
+
+ #[test]
+ fn kind_param_rejects_invalid_string() {
+ let err = HubEntryKind::from_param("channel").expect_err("must error");
+ match err {
+ ToolError::InvalidParameters(msg) => assert!(msg.contains("kind must be")),
+ other => panic!("expected InvalidParameters, got {other:?}"),
+ }
+ }
+
+ #[test]
+ fn kind_param_accepts_tool_and_skill() {
+ assert_eq!(
+ HubEntryKind::from_param("tool").unwrap(),
+ HubEntryKind::Tool
+ );
+ assert_eq!(
+ HubEntryKind::from_param("skill").unwrap(),
+ HubEntryKind::Skill
+ );
+ }
+
+ #[test]
+ fn install_outcome_to_json_includes_required_fields() {
+ let json = install_outcome_to_json(HubEntryKind::Tool, &outcome("clickup", true));
+ assert_eq!(json["status"], "installed");
+ assert_eq!(json["kind"], "tool");
+ assert_eq!(json["name"], "clickup");
+ assert_eq!(json["version"], "0.1.0");
+ assert_eq!(json["release_tag"], "release-test");
+ assert!(
+ json["primary_path"]
+ .as_str()
+ .unwrap()
+ .contains("clickup.wasm")
+ );
+ assert!(json["metadata_path"].as_str().is_some());
+ }
+
+ #[test]
+ fn install_outcome_to_json_omits_metadata_path_when_none() {
+ let json = install_outcome_to_json(HubEntryKind::Skill, &outcome("chief-of-staff", false));
+ assert_eq!(json["kind"], "skill");
+ assert!(json.get("metadata_path").is_none());
+ }
+
+ #[test]
+ fn install_outcome_to_json_official_has_provenance_no_warning() {
+ let json = install_outcome_to_json(HubEntryKind::Tool, &outcome("clickup", true));
+ assert_eq!(json["provenance"], "official");
+ assert!(json.get("warning").is_none());
+ assert!(json.get("unverified").is_none());
+ }
+
+ #[test]
+ fn install_outcome_to_json_new_provenance_warns_and_flags_unverified() {
+ let json = install_outcome_to_json(
+ HubEntryKind::Skill,
+ &outcome_prov("indie-skill", false, Provenance::New),
+ );
+ assert_eq!(json["provenance"], "new");
+ assert_eq!(json["unverified"], true);
+ assert!(
+ json["warning"]
+ .as_str()
+ .unwrap()
+ .contains("not NEAR-vetted")
+ );
+ }
+
+ #[test]
+ fn annotate_reload_verification_flags_present_skill() {
+ let mut json =
+ install_outcome_to_json(HubEntryKind::Skill, &outcome("chief-of-staff", false));
+ annotate_reload_verification(
+ &mut json,
+ "chief-of-staff",
+ &["chief-of-staff".to_string(), "other-skill".to_string()],
+ );
+ assert_eq!(json["reload_verified"], true);
+ assert!(json.get("reload_warning").is_none());
+ }
+
+ #[test]
+ fn annotate_reload_verification_matches_case_insensitively() {
+ let mut json =
+ install_outcome_to_json(HubEntryKind::Skill, &outcome("Chief-Of-Staff", false));
+ annotate_reload_verification(&mut json, "Chief-Of-Staff", &["chief-of-staff".to_string()]);
+ assert_eq!(json["reload_verified"], true);
+ }
+
+ #[test]
+ fn annotate_reload_verification_warns_when_absent() {
+ let mut json =
+ install_outcome_to_json(HubEntryKind::Skill, &outcome("chief-of-staff", false));
+ annotate_reload_verification(&mut json, "chief-of-staff", &["other-skill".to_string()]);
+ assert_eq!(json["reload_verified"], false);
+ assert!(
+ json["reload_warning"]
+ .as_str()
+ .unwrap()
+ .contains("not present in the registry after reload")
+ );
+ }
+
+ #[test]
+ fn tool_entry_json_surfaces_provenance_and_trust_label() {
+ let entry = HubToolEntry {
+ name: "indie-tool".into(),
+ crate_name: "indie-tool".into(),
+ version: "0.1.0".into(),
+ description: "Community tool".into(),
+ provenance: Provenance::New,
+ wasm: art("indie-tool", "wasm"),
+ capabilities: art("indie-tool", "capabilities.json"),
+ };
+ let json = tool_entry_json(&entry);
+ assert_eq!(json["provenance"], "new");
+ assert_eq!(json["trust_label"], Provenance::New.trust_label());
+ assert_eq!(json["name"], "indie-tool");
+ assert_eq!(json["kind"], "tool");
+ }
+
+ #[test]
+ fn skill_entry_json_surfaces_provenance_and_trust_label() {
+ let entry = HubSkillEntry {
+ name: "indie-skill".into(),
+ trunk: String::new(),
+ version: "0.1.0".into(),
+ description: "Community skill".into(),
+ provenance: Provenance::Verified,
+ skill_md: art("indie-skill", "SKILL.md"),
+ };
+ let json = skill_entry_json(&entry);
+ assert_eq!(json["provenance"], "verified");
+ assert_eq!(json["trust_label"], Provenance::Verified.trust_label());
+ assert_eq!(json["name"], "indie-skill");
+ assert_eq!(json["kind"], "skill");
+ }
+
+ fn manifest_with_provenance(
+ tool_name: &str,
+ tool_provenance: Provenance,
+ skill_name: Option<&str>,
+ skill_provenance: Option,
+ ) -> HubManifest {
+ let mut manifest = manifest_with(vec![tool_name], skill_name.into_iter().collect());
+ if let Some(tool) = manifest.tools.first_mut() {
+ tool.provenance = tool_provenance;
+ }
+ if let (Some(skill), Some(prov)) = (manifest.skills.first_mut(), skill_provenance) {
+ skill.provenance = prov;
+ }
+ manifest
+ }
+
+ #[test]
+ fn classify_and_gate_rejects_community_unverified_without_acknowledgement() {
+ let manifest = manifest_with_provenance("indie-tool", Provenance::New, None, None);
+ let err = classify_and_gate(&manifest, "indie-tool", None, false)
+ .expect_err("community-unverified without ack must be rejected");
+ match err {
+ ToolError::InvalidParameters(msg) => {
+ assert!(
+ msg.contains("UNVERIFIED") && msg.contains("acknowledge_unverified"),
+ "error must name the gate: {msg}"
+ );
+ }
+ other => panic!("expected InvalidParameters, got {other:?}"),
+ }
+ }
+
+ #[test]
+ fn classify_and_gate_accepts_community_unverified_with_acknowledgement() {
+ let manifest = manifest_with_provenance("indie-tool", Provenance::New, None, None);
+ let (kind, provenance) = classify_and_gate(&manifest, "indie-tool", None, true)
+ .expect("community-unverified with ack must pass the gate");
+ assert_eq!(kind, HubEntryKind::Tool);
+ assert_eq!(provenance, Provenance::New);
+ }
+
+ #[test]
+ fn classify_and_gate_accepts_official_without_acknowledgement() {
+ let manifest = manifest_with_provenance("clickup", Provenance::Official, None, None);
+ let (kind, provenance) = classify_and_gate(&manifest, "clickup", None, false)
+ .expect("official content must pass the gate without ack");
+ assert_eq!(kind, HubEntryKind::Tool);
+ assert_eq!(provenance, Provenance::Official);
+ }
+
+ #[test]
+ fn classify_and_gate_rejects_community_unverified_skill_without_acknowledgement() {
+ let manifest = manifest_with_provenance(
+ "official-tool",
+ Provenance::Official,
+ Some("indie-skill"),
+ Some(Provenance::New),
+ );
+ let err = classify_and_gate(&manifest, "indie-skill", None, false)
+ .expect_err("community-unverified skill must be gated too");
+ match err {
+ ToolError::InvalidParameters(msg) => assert!(msg.contains("UNVERIFIED")),
+ other => panic!("expected InvalidParameters, got {other:?}"),
+ }
+ }
+
+ fn install_tool_with_ext_mgr() -> (IronhubInstallTool, tempfile::TempDir, tempfile::TempDir) {
+ let secrets = crate::channels::web::test_helpers::test_secrets_store();
+ let (ext_mgr, tools_dir, channels_dir) =
+ crate::channels::web::test_helpers::test_ext_mgr(secrets);
+ let tool = IronhubInstallTool::new(IronhubDeps {
+ extension_manager: ext_mgr,
+ skill_registry: None,
+ });
+ (tool, tools_dir, channels_dir)
+ }
+
+ #[tokio::test]
+ async fn install_from_manifest_rejects_provenance_new_without_acknowledgement() {
+ let (tool, _tools_dir, _channels_dir) = install_tool_with_ext_mgr();
+ let manifest = manifest_with_provenance("indie-tool", Provenance::New, None, None);
+ let parsed = InstallParams {
+ name: "indie-tool".into(),
+ kind_hint: None,
+ release_tag: None,
+ version: None,
+ artifact_digest: None,
+ force: false,
+ acknowledge_unverified: false,
+ };
+ let ctx = JobContext::with_user("test", "install gate test", "");
+ let err = tool
+ .install_from_manifest(std::time::Instant::now(), manifest, parsed, &ctx)
+ .await
+ .expect_err(
+ "Provenance::New without ack must be rejected at the execute caller boundary",
+ );
+ match err {
+ ToolError::InvalidParameters(msg) => {
+ assert!(
+ msg.contains("UNVERIFIED") && msg.contains("acknowledge_unverified"),
+ "caller-level gate error must name the flag the user has to set, got: {msg}"
+ );
+ }
+ other => panic!(
+ "execute caller must surface the gate as InvalidParameters; got {other:?} \
+ (a different error type means the gate fired downstream of the install side effect)"
+ ),
+ }
+ }
+
+ #[tokio::test]
+ async fn install_from_manifest_passes_gate_for_new_when_acknowledged() {
+ let (tool, _tools_dir, _channels_dir) = install_tool_with_ext_mgr();
+ let manifest = manifest_with_provenance("indie-tool", Provenance::New, None, None);
+ let parsed = InstallParams {
+ name: "indie-tool".into(),
+ kind_hint: None,
+ release_tag: None,
+ version: None,
+ artifact_digest: None,
+ force: false,
+ acknowledge_unverified: true,
+ };
+ let ctx = JobContext::with_user("test", "install gate ack test", "");
+ let err = tool
+ .install_from_manifest(std::time::Instant::now(), manifest, parsed, &ctx)
+ .await
+ .expect_err("fake artifact URLs make the install fail downstream of the gate");
+ assert!(
+ !matches!(&err, ToolError::InvalidParameters(m) if m.contains("UNVERIFIED")),
+ "acknowledge_unverified=true must clear the gate; got the gate rejection instead: {err:?}"
+ );
+ }
+
+ #[tokio::test]
+ async fn install_params_from_json_propagates_acknowledge_unverified() {
+ let parsed = InstallParams::from_json(&serde_json::json!({
+ "name": "indie-tool",
+ "acknowledge_unverified": true,
+ }))
+ .expect("valid params");
+ assert!(
+ parsed.acknowledge_unverified,
+ "ack flag must reach the install caller; without this, the UI ack flow is a no-op"
+ );
+ let default = InstallParams::from_json(&serde_json::json!({"name": "indie-tool"}))
+ .expect("valid params");
+ assert!(
+ !default.acknowledge_unverified,
+ "omitted ack must default to false so community content stays gated"
+ );
+ }
+
+ #[test]
+ fn install_params_from_json_propagates_version() {
+ let parsed = InstallParams::from_json(&serde_json::json!({
+ "name": "clickup",
+ "version": "1.2.3",
+ }))
+ .expect("valid params");
+ assert_eq!(
+ parsed.version.as_deref(),
+ Some("1.2.3"),
+ "signed version must reach the install caller so the catalog entry can be bound to it"
+ );
+ let default = InstallParams::from_json(&serde_json::json!({"name": "clickup"}))
+ .expect("valid params");
+ assert!(
+ default.version.is_none(),
+ "omitted version must stay None so agent/CLI installs remain version-agnostic"
+ );
+ }
+
+ #[tokio::test]
+ async fn install_from_manifest_rejects_version_mismatch() {
+ let (tool, _tools_dir, _channels_dir) = install_tool_with_ext_mgr();
+ let manifest = manifest_with_provenance("clickup", Provenance::Official, None, None);
+ let parsed = InstallParams {
+ name: "clickup".into(),
+ kind_hint: None,
+ release_tag: None,
+ version: Some("9.9.9".into()),
+ artifact_digest: None,
+ force: false,
+ acknowledge_unverified: false,
+ };
+ let ctx = JobContext::with_user("test", "version bind mismatch test", "");
+ let err = tool
+ .install_from_manifest(std::time::Instant::now(), manifest, parsed, &ctx)
+ .await
+ .expect_err("a signed version that does not match the catalog entry must be rejected before install");
+ match err {
+ ToolError::InvalidParameters(msg) => assert!(
+ msg.contains("does not match") && msg.contains("9.9.9"),
+ "version-bind rejection must name the mismatch so the gap is debuggable: {msg}"
+ ),
+ other => panic!(
+ "version mismatch must reject as InvalidParameters before the install side effect; got {other:?}"
+ ),
+ }
+ }
+
+ #[tokio::test]
+ async fn install_from_manifest_accepts_matching_version() {
+ let (tool, _tools_dir, _channels_dir) = install_tool_with_ext_mgr();
+ let manifest = manifest_with_provenance("clickup", Provenance::Official, None, None);
+ let parsed = InstallParams {
+ name: "clickup".into(),
+ kind_hint: None,
+ release_tag: None,
+ version: Some("0.1.0".into()),
+ artifact_digest: None,
+ force: false,
+ acknowledge_unverified: false,
+ };
+ let ctx = JobContext::with_user("test", "version bind match test", "");
+ let err = tool
+ .install_from_manifest(std::time::Instant::now(), manifest, parsed, &ctx)
+ .await
+ .expect_err("fake artifact URLs make the install fail downstream of the version gate");
+ assert!(
+ !matches!(&err, ToolError::InvalidParameters(m) if m.contains("does not match")),
+ "a matching version must clear the bind check; got the bind rejection instead: {err:?}"
+ );
+ }
+
+ #[test]
+ fn entry_artifact_digest_matches_cross_language_vectors() {
+ let tool_manifest = manifest_with_provenance("clickup", Provenance::Official, None, None);
+ let tool_digest = entry_artifact_digest(HubEntryKind::Tool, &tool_manifest, "clickup")
+ .expect("tool entry present");
+ assert_eq!(
+ tool_digest, "3bef8b777d4b0dc782fbba98c00b622da35e098642e8e103b94e395679b5499a",
+ "tool digest must equal sha256(wasm_sha:capabilities_sha); drift here breaks IronHub signing"
+ );
+ let skill_manifest = manifest_with_provenance(
+ "official-tool",
+ Provenance::Official,
+ Some("indie-skill"),
+ Some(Provenance::New),
+ );
+ let skill_digest =
+ entry_artifact_digest(HubEntryKind::Skill, &skill_manifest, "indie-skill")
+ .expect("skill entry present");
+ assert_eq!(
+ skill_digest, "ffe054fe7ae0cb6dc65c3af9b61d5209f439851db43d0ba5997337df154668eb",
+ "skill digest must equal sha256(skill_md_sha); drift here breaks IronHub signing"
+ );
+ }
+
+ #[tokio::test]
+ async fn install_from_manifest_rejects_artifact_digest_mismatch() {
+ let (tool, _tools_dir, _channels_dir) = install_tool_with_ext_mgr();
+ let manifest = manifest_with_provenance("clickup", Provenance::Official, None, None);
+ let parsed = InstallParams {
+ name: "clickup".into(),
+ kind_hint: None,
+ release_tag: None,
+ version: None,
+ artifact_digest: Some("0".repeat(64)),
+ force: false,
+ acknowledge_unverified: false,
+ };
+ let ctx = JobContext::with_user("test", "digest mismatch test", "");
+ let err = tool
+ .install_from_manifest(std::time::Instant::now(), manifest, parsed, &ctx)
+ .await
+ .expect_err("a signed artifact digest that does not match the catalog must be rejected before install");
+ match err {
+ ToolError::InvalidParameters(msg) => assert!(
+ msg.contains("artifact digest") && msg.contains("does not match"),
+ "digest-bind rejection must name the mismatch: {msg}"
+ ),
+ other => panic!(
+ "artifact digest mismatch must reject as InvalidParameters before the install side effect; got {other:?}"
+ ),
+ }
+ }
+
+ #[tokio::test]
+ async fn install_from_manifest_accepts_matching_artifact_digest() {
+ let (tool, _tools_dir, _channels_dir) = install_tool_with_ext_mgr();
+ let manifest = manifest_with_provenance("clickup", Provenance::Official, None, None);
+ let parsed = InstallParams {
+ name: "clickup".into(),
+ kind_hint: None,
+ release_tag: None,
+ version: None,
+ artifact_digest: Some(
+ "3bef8b777d4b0dc782fbba98c00b622da35e098642e8e103b94e395679b5499a".into(),
+ ),
+ force: false,
+ acknowledge_unverified: false,
+ };
+ let ctx = JobContext::with_user("test", "digest match test", "");
+ let err = tool
+ .install_from_manifest(std::time::Instant::now(), manifest, parsed, &ctx)
+ .await
+ .expect_err("fake artifact URLs make the install fail downstream of the digest gate");
+ assert!(
+ !matches!(&err, ToolError::InvalidParameters(m) if m.contains("artifact digest")),
+ "a matching artifact digest must clear the bind check; got the bind rejection instead: {err:?}"
+ );
+ }
+
+ #[test]
+ fn info_tool_json_includes_trust_label() {
+ let manifest = manifest_with_provenance("indie-tool", Provenance::New, None, None);
+ let entry = manifest.find_tool("indie-tool").expect("tool present");
+ let json = info_tool_json(entry, &manifest.release_tag);
+ assert_eq!(
+ json["trust_label"],
+ serde_json::json!(Provenance::New.trust_label()),
+ "ironhub_info tool detail must carry trust_label so the deep-link confirm can render it"
+ );
+ assert_eq!(json["provenance"], "new");
+ assert_eq!(json["version"], "0.1.0");
+ }
+
+ #[test]
+ fn info_skill_json_includes_trust_label() {
+ let manifest = manifest_with_provenance(
+ "official-tool",
+ Provenance::Official,
+ Some("indie-skill"),
+ Some(Provenance::New),
+ );
+ let entry = manifest.find_skill("indie-skill").expect("skill present");
+ let json = info_skill_json(entry, &manifest.release_tag);
+ assert_eq!(
+ json["trust_label"],
+ serde_json::json!(Provenance::New.trust_label()),
+ "ironhub_info skill detail must carry trust_label to match search/list output"
+ );
+ }
+
+ #[test]
+ fn catalog_unavailable_is_user_safe_external_service() {
+ let err = catalog_unavailable();
+ assert!(matches!(err, ToolError::ExternalService(_)), "got {err:?}");
+ let msg = err.to_string();
+ assert!(!msg.contains("http"));
+ assert!(!msg.contains("hub.ironclaw.com"));
+ assert!(!msg.contains('/'));
+ }
+
+ #[test]
+ fn install_error_to_tool_error_never_leaks_paths_or_detail() {
+ let already = RegistryError::AlreadyInstalled {
+ name: "clickup".into(),
+ path: std::path::PathBuf::from("/home/someone/.ironclaw/tools/clickup.wasm"),
+ };
+ let msg = install_error_to_tool_error("clickup", already).to_string();
+ assert!(!msg.contains('/'), "must not leak a path: {msg}");
+ assert!(
+ msg.contains("force=true"),
+ "AlreadyInstalled must stay actionable: {msg}"
+ );
+
+ let download = RegistryError::DownloadFailed {
+ url: "https://hub.ironclaw.com/api/catalog/artifact/secret-token".into(),
+ reason: "connection refused at /var/run/internal.sock".into(),
+ };
+ let msg = install_error_to_tool_error("clickup", download).to_string();
+ assert!(
+ !msg.contains("secret-token"),
+ "must not leak the upstream url: {msg}"
+ );
+ assert!(!msg.contains('/'), "must not leak a path or url: {msg}");
+ }
+
+ #[test]
+ fn entry_matches_lowercases_name_against_already_lowercased_query() {
+ assert!(entry_matches("ClickUp", "Task tracking", "clickup"));
+ assert!(entry_matches("clickup", "Task tracking", "click"));
+ assert!(!entry_matches("clickup", "Task tracking", "CLICK"));
+ }
+
+ #[test]
+ fn entry_matches_searches_description() {
+ assert!(entry_matches(
+ "evm-rpc",
+ "Ethereum RPC bindings",
+ "ethereum"
+ ));
+ assert!(!entry_matches("evm-rpc", "Ethereum RPC bindings", "solana"));
+ }
+
+ #[test]
+ fn nearest_matches_filters_by_substring_both_directions() {
+ let m = manifest_with(vec!["clickup", "evm-rpc", "near-rpc"], vec![]);
+ let hits = nearest_matches(&m, "rpc");
+ assert!(hits.contains(&"evm-rpc".to_string()));
+ assert!(hits.contains(&"near-rpc".to_string()));
+ assert!(!hits.contains(&"clickup".to_string()));
+ }
+
+ #[test]
+ fn install_schema_pattern_blocks_path_traversal() {
+ let tool_schema = IronhubSearchTool::new().parameters_schema();
+ let pattern = tool_schema["properties"]["release_tag"]["pattern"]
+ .as_str()
+ .expect("release_tag pattern");
+ let re = regex::Regex::new(pattern).expect("valid regex");
+ assert!(!re.is_match("../etc/passwd"));
+ assert!(!re.is_match("release with space"));
+ assert!(!re.is_match("release\nnewline"));
+ assert!(re.is_match("release-2026-05-12-24"));
+ }
+
+ #[test]
+ fn install_schema_declares_required_name() {
+ let schema = IronhubInfoTool::new().parameters_schema();
+ assert_eq!(schema["required"], serde_json::json!(["name"]));
+ let name_pattern = schema["properties"]["name"]["pattern"]
+ .as_str()
+ .expect("name pattern");
+ let re = regex::Regex::new(name_pattern).expect("valid regex");
+ assert!(re.is_match("clickup"));
+ assert!(re.is_match("chief-of-staff"));
+ assert!(!re.is_match("../etc"));
+ assert!(!re.is_match("Name"));
+ assert!(!re.is_match(""));
+ }
+
+ fn ironhub_deps_for_schema_check() -> IronhubDeps {
+ let secrets = crate::channels::web::test_helpers::test_secrets_store();
+ let (ext_mgr, tools_dir, _channels_dir) =
+ crate::channels::web::test_helpers::test_ext_mgr(secrets);
+ std::mem::forget(tools_dir);
+ IronhubDeps {
+ extension_manager: ext_mgr,
+ skill_registry: None,
+ }
+ }
+
+ #[test]
+ fn every_name_carrying_schema_accepts_underscore_and_matches_validate_hub_name() {
+ let schemas: [(&str, serde_json::Value); 3] = [
+ (
+ "ironhub_install",
+ IronhubInstallTool::new(ironhub_deps_for_schema_check()).parameters_schema(),
+ ),
+ ("ironhub_info", IronhubInfoTool::new().parameters_schema()),
+ (
+ "ironhub_remove",
+ IronhubRemoveTool::new(ironhub_deps_for_schema_check()).parameters_schema(),
+ ),
+ ];
+ for (tool_name, schema) in schemas {
+ let pattern = schema["properties"]["name"]["pattern"]
+ .as_str()
+ .unwrap_or_else(|| panic!("{tool_name}: missing properties.name.pattern"));
+ let re = regex::Regex::new(pattern)
+ .unwrap_or_else(|e| panic!("{tool_name}: pattern is not a valid regex: {e}"));
+
+ assert!(
+ re.is_match("microsoft_365"),
+ "{tool_name}: schema must accept underscore names like microsoft_365 \
+ (validate_hub_name accepts them; schema must not be tighter)"
+ );
+ assert!(
+ re.is_match("chief-of-staff"),
+ "{tool_name}: schema must accept hyphen names"
+ );
+ assert!(
+ re.is_match("clickup"),
+ "{tool_name}: schema must accept plain lowercase names"
+ );
+ assert!(
+ !re.is_match("Microsoft365"),
+ "{tool_name}: schema must reject uppercase"
+ );
+ assert!(
+ !re.is_match("../etc/passwd"),
+ "{tool_name}: schema must reject path traversal"
+ );
+ assert!(
+ !re.is_match("name with space"),
+ "{tool_name}: schema must reject spaces"
+ );
+ assert!(
+ !re.is_match(""),
+ "{tool_name}: schema must reject empty name"
+ );
+
+ assert!(
+ crate::cli::hub_install::looks_like_hub_name("microsoft_365"),
+ "shared validator must agree the schema's accepted name is also valid"
+ );
+ }
+ }
+
+ #[test]
+ fn schemas_reject_unknown_fields() {
+ for schema in [
+ IronhubSearchTool::new().parameters_schema(),
+ IronhubListTool::new().parameters_schema(),
+ IronhubInfoTool::new().parameters_schema(),
+ ] {
+ assert_eq!(
+ schema["additionalProperties"],
+ serde_json::Value::Bool(false),
+ "additionalProperties: false required to reject LLM injection of unknown fields"
+ );
+ }
+ }
+
+ async fn dispatcher_with(tool: Arc) -> Arc {
+ use crate::config::SafetyConfig;
+ use crate::db::Database;
+ use crate::db::UserRecord;
+ use crate::db::libsql::LibSqlBackend;
+ use crate::tools::dispatch::ToolDispatcher;
+ use crate::tools::registry::ToolRegistry;
+ use ironclaw_safety::SafetyLayer;
+
+ let dir = tempfile::tempdir().expect("tempdir");
+ let backend = Arc::new(
+ LibSqlBackend::new_local(&dir.path().join("test.db"))
+ .await
+ .expect("libsql backend"),
+ );
+ backend.run_migrations().await.expect("migrations");
+ let db: Arc = Arc::clone(&backend) as Arc;
+ let now = chrono::Utc::now();
+ db.create_user(&UserRecord {
+ id: "tester".to_string(),
+ email: None,
+ display_name: "tester".to_string(),
+ status: "active".to_string(),
+ role: "admin".to_string(),
+ created_at: now,
+ updated_at: now,
+ last_login_at: None,
+ created_by: None,
+ metadata: serde_json::json!({}),
+ })
+ .await
+ .expect("create user");
+
+ let registry = Arc::new(ToolRegistry::new());
+ registry.register(tool).await;
+ let safety = Arc::new(SafetyLayer::new(&SafetyConfig {
+ max_output_length: 65_536,
+ injection_check_enabled: false,
+ }));
+ std::mem::forget(dir);
+ Arc::new(ToolDispatcher::new(registry, safety, db))
+ }
+
+ #[tokio::test]
+ async fn dispatch_ironhub_search_rejects_empty_query() {
+ let dispatcher = dispatcher_with(Arc::new(IronhubSearchTool::new())).await;
+ let err = dispatcher
+ .dispatch(
+ "ironhub_search",
+ serde_json::json!({ "query": "" }),
+ "tester",
+ crate::tools::dispatch::DispatchSource::Channel("gateway".into()),
+ )
+ .await
+ .expect_err("empty query must fail schema validation");
+ assert!(
+ matches!(err, ToolError::InvalidParameters(_)),
+ "expected InvalidParameters, got {err:?}"
+ );
+ }
+
+ #[tokio::test]
+ async fn dispatch_ironhub_search_rejects_unknown_field() {
+ let dispatcher = dispatcher_with(Arc::new(IronhubSearchTool::new())).await;
+ let err = dispatcher
+ .dispatch(
+ "ironhub_search",
+ serde_json::json!({ "query": "rpc", "evil_extra_field": "exfil" }),
+ "tester",
+ crate::tools::dispatch::DispatchSource::Channel("gateway".into()),
+ )
+ .await
+ .expect_err("unknown field must fail schema validation");
+ assert!(matches!(err, ToolError::InvalidParameters(_)));
+ }
+
+ #[tokio::test]
+ async fn dispatch_ironhub_info_rejects_path_traversal_in_name() {
+ let dispatcher = dispatcher_with(Arc::new(IronhubInfoTool::new())).await;
+ let err = dispatcher
+ .dispatch(
+ "ironhub_info",
+ serde_json::json!({ "name": "../etc/passwd" }),
+ "tester",
+ crate::tools::dispatch::DispatchSource::Channel("gateway".into()),
+ )
+ .await
+ .expect_err("path traversal must fail schema validation");
+ assert!(matches!(err, ToolError::InvalidParameters(_)));
+ }
+
+ #[tokio::test]
+ async fn dispatch_ironhub_info_rejects_missing_required_name() {
+ let dispatcher = dispatcher_with(Arc::new(IronhubInfoTool::new())).await;
+ let err = dispatcher
+ .dispatch(
+ "ironhub_info",
+ serde_json::json!({}),
+ "tester",
+ crate::tools::dispatch::DispatchSource::Channel("gateway".into()),
+ )
+ .await
+ .expect_err("missing required name must fail schema validation");
+ assert!(matches!(err, ToolError::InvalidParameters(_)));
+ }
+
+ #[tokio::test]
+ async fn dispatch_ironhub_info_rejects_malformed_release_tag() {
+ let dispatcher = dispatcher_with(Arc::new(IronhubInfoTool::new())).await;
+ let err = dispatcher
+ .dispatch(
+ "ironhub_info",
+ serde_json::json!({ "name": "clickup", "release_tag": "release with space" }),
+ "tester",
+ crate::tools::dispatch::DispatchSource::Channel("gateway".into()),
+ )
+ .await
+ .expect_err("bad release_tag must fail schema validation");
+ assert!(matches!(err, ToolError::InvalidParameters(_)));
+ }
+
+ #[tokio::test]
+ async fn skill_install_dir_prefers_installed_dir_over_user_dir() {
+ let tmp = tempfile::tempdir().expect("tempdir");
+ let installed = tmp.path().join("installed");
+ let user = tmp.path().join("user");
+ std::fs::create_dir_all(&installed).expect("mkdir installed");
+ std::fs::create_dir_all(&user).expect("mkdir user");
+ let mut registry = SkillRegistry::new(user.clone())
+ .with_installed_dir(installed.clone())
+ .with_max_scan_depth(2);
+ registry.discover_all().await;
+ let registry = Arc::new(std::sync::RwLock::new(registry));
+
+ let resolved = skill_install_dir(&Some(registry.clone())).expect("resolved");
+ assert_eq!(
+ resolved, installed,
+ "IronHub skill installs must land in the Installed bucket, not the Trusted user_dir"
+ );
+ assert_ne!(
+ resolved, user,
+ "regression: skill_install_dir must not return user_dir"
+ );
+ }
+
+ fn write_fake_tool_on_disk(tools_dir: &std::path::Path, name: &str) {
+ std::fs::create_dir_all(tools_dir).expect("tools dir");
+ std::fs::write(tools_dir.join(format!("{name}.wasm")), b"not-a-real-wasm")
+ .expect("write wasm stub");
+ std::fs::write(
+ tools_dir.join(format!("{name}.capabilities.json")),
+ r#"{"description":"test stub"}"#,
+ )
+ .expect("write capabilities stub");
+ }
+
+ fn remove_tool_with_ext_mgr(ext_mgr: Arc) -> IronhubRemoveTool {
+ IronhubRemoveTool::new(IronhubDeps {
+ extension_manager: ext_mgr,
+ skill_registry: None,
+ })
+ }
+
+ #[tokio::test]
+ async fn ironhub_remove_execute_returns_removed_status_and_deletes_files() {
+ let secrets = crate::channels::web::test_helpers::test_secrets_store();
+ let (ext_mgr, tools_dir, _channels_dir) =
+ crate::channels::web::test_helpers::test_ext_mgr(secrets);
+ write_fake_tool_on_disk(tools_dir.path(), "test_remove_target");
+
+ let tool = remove_tool_with_ext_mgr(Arc::clone(&ext_mgr));
+ let ctx = JobContext::with_user("test", "remove test", "");
+
+ let output = tool
+ .execute(serde_json::json!({ "name": "test_remove_target" }), &ctx)
+ .await
+ .expect("remove of installed tool must succeed");
+
+ assert_eq!(output.result["status"], "removed");
+ assert_eq!(output.result["name"], "test_remove_target");
+ assert!(
+ output.result["message"].as_str().is_some(),
+ "message field must be present so the agent surfaces the manager's report"
+ );
+ assert!(
+ !tools_dir.path().join("test_remove_target.wasm").exists(),
+ "remove must delete the .wasm artifact from disk"
+ );
+ assert!(
+ !tools_dir
+ .path()
+ .join("test_remove_target.capabilities.json")
+ .exists(),
+ "remove must delete the .capabilities.json artifact from disk"
+ );
+ }
+
+ #[tokio::test]
+ async fn ironhub_remove_execute_rejects_invalid_name_before_touching_manager() {
+ let secrets = crate::channels::web::test_helpers::test_secrets_store();
+ let (ext_mgr, _tools_dir, _channels_dir) =
+ crate::channels::web::test_helpers::test_ext_mgr(secrets);
+ let tool = remove_tool_with_ext_mgr(ext_mgr);
+ let ctx = JobContext::with_user("test", "remove test", "");
+
+ let err = tool
+ .execute(
+ serde_json::json!({ "name": "Invalid Name With Spaces" }),
+ &ctx,
+ )
+ .await
+ .expect_err("malformed name must be rejected by validate_hub_name");
+
+ match err {
+ ToolError::InvalidParameters(msg) => assert!(
+ msg.contains("not a valid IronHub name"),
+ "validator message must surface to the caller, got: {msg}"
+ ),
+ other => panic!("expected InvalidParameters, got {other:?}"),
+ }
+ }
+
+ #[tokio::test]
+ async fn ironhub_remove_execute_maps_not_installed_to_actionable_error() {
+ let secrets = crate::channels::web::test_helpers::test_secrets_store();
+ let (ext_mgr, _tools_dir, _channels_dir) =
+ crate::channels::web::test_helpers::test_ext_mgr(secrets);
+ let tool = remove_tool_with_ext_mgr(ext_mgr);
+ let ctx = JobContext::with_user("test", "remove test", "");
+
+ let err = tool
+ .execute(serde_json::json!({ "name": "never_installed_tool" }), &ctx)
+ .await
+ .expect_err("removing a tool that is not installed must surface as an error");
+
+ match err {
+ ToolError::InvalidParameters(msg) => {
+ assert!(
+ msg.contains("not an installed IronHub tool"),
+ "error must point the caller at the right surface, got: {msg}"
+ );
+ assert!(
+ msg.contains("skill_remove"),
+ "error must hint at skill_remove for the wrong-tool case, got: {msg}"
+ );
+ }
+ other => panic!("expected InvalidParameters, got {other:?}"),
+ }
+ }
+
+ #[tokio::test]
+ async fn ironhub_remove_execute_passes_post_remove_list_verification() {
+ let secrets = crate::channels::web::test_helpers::test_secrets_store();
+ let (ext_mgr, tools_dir, _channels_dir) =
+ crate::channels::web::test_helpers::test_ext_mgr(secrets);
+ write_fake_tool_on_disk(tools_dir.path(), "verifier_target");
+
+ let pre_remove = ext_mgr
+ .list(Some(ExtensionKind::WasmTool), false, "test")
+ .await
+ .expect("pre-remove list must succeed");
+ assert!(
+ pre_remove
+ .iter()
+ .any(|e| e.name.eq_ignore_ascii_case("verifier_target")),
+ "fixture must seed verifier_target before remove runs"
+ );
+
+ let tool = remove_tool_with_ext_mgr(Arc::clone(&ext_mgr));
+ let ctx = JobContext::with_user("test", "remove test", "");
+ tool.execute(serde_json::json!({ "name": "verifier_target" }), &ctx)
+ .await
+ .expect("remove must succeed when the post-remove list is empty");
+
+ let post_remove = ext_mgr
+ .list(Some(ExtensionKind::WasmTool), false, "test")
+ .await
+ .expect("post-remove list must succeed");
+ assert!(
+ !post_remove
+ .iter()
+ .any(|e| e.name.eq_ignore_ascii_case("verifier_target")),
+ "post-remove list must be empty so the still-present guard stays silent"
+ );
+ }
+
+ #[tokio::test]
+ async fn ironhub_remove_execute_surfaces_still_present_when_orphan_remains() {
+ let secrets = crate::channels::web::test_helpers::test_secrets_store();
+ let (ext_mgr, tools_dir, _channels_dir) =
+ crate::channels::web::test_helpers::test_ext_mgr(secrets);
+ write_fake_tool_on_disk(tools_dir.path(), "stuck_tool");
+ std::fs::write(
+ tools_dir.path().join("STUCK_TOOL.wasm"),
+ b"orphan-uppercase-twin",
+ )
+ .expect("write orphan twin");
+
+ let tool = remove_tool_with_ext_mgr(Arc::clone(&ext_mgr));
+ let ctx = JobContext::with_user("test", "remove test", "");
+ let err = tool
+ .execute(serde_json::json!({ "name": "stuck_tool" }), &ctx)
+ .await
+ .expect_err("orphan twin under a different case must trip the still-present guard");
+
+ match err {
+ ToolError::ExecutionFailed(msg) => {
+ assert!(
+ msg.contains("still present after removal"),
+ "still-present guard must surface a clear message, got: {msg}"
+ );
+ assert!(
+ msg.contains("stuck_tool"),
+ "error must name the offending tool, got: {msg}"
+ );
+ }
+ other => panic!(
+ "still-present must surface as ExecutionFailed, not the manager's not-installed mapping; got {other:?}"
+ ),
+ }
+ }
+}
diff --git a/src/tools/builtin/mod.rs b/src/tools/builtin/mod.rs
index 666e5094817..32338cbe54e 100644
--- a/src/tools/builtin/mod.rs
+++ b/src/tools/builtin/mod.rs
@@ -8,6 +8,7 @@ pub mod file_history;
mod glob_tool;
mod grep_tool;
mod http;
+pub mod ironhub;
mod job;
mod json;
pub mod memory;
@@ -34,6 +35,10 @@ pub use file_history::{FileHistory, FileUndoTool, SharedFileHistory, shared_file
pub use glob_tool::GlobTool;
pub use grep_tool::GrepTool;
pub use http::{HttpTool, extract_host_from_params, extract_path_from_params};
+pub use ironhub::{
+ IronhubDeps, IronhubInfoTool, IronhubInstallTool, IronhubListTool, IronhubRemoveTool,
+ IronhubSearchTool,
+};
pub use job::{
CancelJobTool, CreateJobTool, JobEventsTool, JobPromptTool, JobStatusTool, ListJobsTool,
PromptQueue, SchedulerSlot,
diff --git a/src/tools/dispatch.rs b/src/tools/dispatch.rs
index 663cc45953f..72fdb180d69 100644
--- a/src/tools/dispatch.rs
+++ b/src/tools/dispatch.rs
@@ -22,6 +22,7 @@ use uuid::Uuid;
use crate::context::{ActionRecord, JobContext};
use crate::db::Database;
+use crate::tools::rate_limiter::RateLimitResult;
use crate::tools::registry::ToolRegistry;
use crate::tools::tool::{ToolError, ToolOutput};
use crate::tools::{prepare_tool_params, redact_params};
@@ -125,6 +126,19 @@ impl ToolDispatcher {
ToolError::ExecutionFailed(format!("tool not found: {tool_name}"))
})?;
+ // Per-tool rate limit, using the same registry limiter as the agent
+ // loop so a tool's declared rate_limit_config is honored on the
+ // gateway/CLI dispatch door too, not just agent-initiated calls.
+ if let Some(config) = tool.rate_limit_config()
+ && let RateLimitResult::Limited { retry_after, .. } = self
+ .registry
+ .rate_limiter()
+ .check_and_record(user_id, &resolved_name, &config)
+ .await
+ {
+ return Err(ToolError::RateLimited(Some(retry_after)));
+ }
+
// 1. Normalize parameters (coerce types, fill defaults).
let normalized_params = prepare_tool_params(tool.as_ref(), ¶ms);
@@ -283,7 +297,7 @@ mod integration_tests {
use crate::context::JobContext;
use crate::db::Database;
use crate::db::libsql::LibSqlBackend;
- use crate::tools::tool::{Tool, ToolError, ToolOutput};
+ use crate::tools::tool::{Tool, ToolError, ToolOutput, ToolRateLimitConfig};
use async_trait::async_trait;
use ironclaw_safety::SafetyLayer;
use std::time::Duration;
@@ -361,6 +375,36 @@ mod integration_tests {
}
}
+ /// Declares a 1-per-minute rate limit so the dispatcher's enforcement of
+ /// `rate_limit_config` can be exercised.
+ struct RateLimitedTool;
+
+ #[async_trait]
+ impl Tool for RateLimitedTool {
+ fn name(&self) -> &str {
+ "rate_limited_stub"
+ }
+ fn description(&self) -> &str {
+ "Test stub that declares a 1-per-minute rate limit."
+ }
+ fn parameters_schema(&self) -> serde_json::Value {
+ serde_json::json!({ "type": "object" })
+ }
+ fn rate_limit_config(&self) -> Option {
+ Some(ToolRateLimitConfig::new(1, 60))
+ }
+ async fn execute(
+ &self,
+ _params: serde_json::Value,
+ _ctx: &JobContext,
+ ) -> Result {
+ Ok(ToolOutput::success(
+ serde_json::json!({ "ok": true }),
+ Duration::from_millis(1),
+ ))
+ }
+ }
+
// ── Fixtures ────────────────────────────────────────────
async fn test_dispatcher() -> (
@@ -445,6 +489,38 @@ mod integration_tests {
// ── Tests ───────────────────────────────────────────────
+ #[tokio::test]
+ async fn dispatch_enforces_per_tool_rate_limit() {
+ let (dispatcher, _backend, _db, registry, _dir) = test_dispatcher().await;
+ registry.register(Arc::new(RateLimitedTool)).await;
+
+ let first = dispatcher
+ .dispatch(
+ "rate_limited_stub",
+ serde_json::json!({}),
+ "tester",
+ DispatchSource::Channel("gateway".into()),
+ )
+ .await;
+ assert!(
+ first.is_ok(),
+ "first call within the limit must succeed: {first:?}"
+ );
+
+ let second = dispatcher
+ .dispatch(
+ "rate_limited_stub",
+ serde_json::json!({}),
+ "tester",
+ DispatchSource::Channel("gateway".into()),
+ )
+ .await;
+ assert!(
+ matches!(second, Err(ToolError::RateLimited(_))),
+ "second call must be rejected by the dispatcher rate limit: {second:?}"
+ );
+ }
+
#[tokio::test]
async fn dispatch_persists_action_record_with_redacted_sensitive_params() {
let (dispatcher, backend, db, registry, _dir) = test_dispatcher().await;
diff --git a/src/tools/registry.rs b/src/tools/registry.rs
index a3db7bd36b0..832301cf7e2 100644
--- a/src/tools/registry.rs
+++ b/src/tools/registry.rs
@@ -97,6 +97,12 @@ const PROTECTED_TOOL_NAMES: &[&str] = &[
"skill_search",
"skill_install",
"skill_remove",
+ // IronHub catalog tools
+ "ironhub_install",
+ "ironhub_remove",
+ "ironhub_search",
+ "ironhub_list",
+ "ironhub_info",
// Secret tools
"secret_list",
"secret_delete",
@@ -771,6 +777,19 @@ impl ToolRegistry {
tracing::debug!("Registered 4 skill management tools");
}
+ pub fn register_ironhub_tools(&self, deps: crate::tools::builtin::IronhubDeps) {
+ use crate::tools::builtin::{
+ IronhubInfoTool, IronhubInstallTool, IronhubListTool, IronhubRemoveTool,
+ IronhubSearchTool,
+ };
+ self.register_sync(Arc::new(IronhubInstallTool::new(deps.clone())));
+ self.register_sync(Arc::new(IronhubRemoveTool::new(deps)));
+ self.register_sync(Arc::new(IronhubSearchTool::new()));
+ self.register_sync(Arc::new(IronhubListTool::new()));
+ self.register_sync(Arc::new(IronhubInfoTool::new()));
+ tracing::debug!("Registered 5 IronHub catalog tools");
+ }
+
/// Register routine management tools.
///
/// These allow the LLM to create, list, update, delete, and view history
@@ -1753,4 +1772,31 @@ mod tests {
assert!(removed.is_some(), "unregister must resolve hyphen alias");
assert!(!registry.has("my_mcp_search").await, "tool should be gone");
}
+
+ #[tokio::test]
+ async fn register_ironhub_tools_exposes_all_five_names() {
+ let secrets = crate::channels::web::test_helpers::test_secrets_store();
+ let (ext_mgr, _tools_dir, _channels_dir) =
+ crate::channels::web::test_helpers::test_ext_mgr(secrets);
+ let deps = crate::tools::builtin::IronhubDeps {
+ extension_manager: ext_mgr,
+ skill_registry: None,
+ };
+ let registry = ToolRegistry::new();
+ registry.register_ironhub_tools(deps);
+
+ let names = registry.list().await;
+ for required in [
+ "ironhub_install",
+ "ironhub_remove",
+ "ironhub_search",
+ "ironhub_list",
+ "ironhub_info",
+ ] {
+ assert!(
+ names.contains(&required.to_string()),
+ "register_ironhub_tools must register {required}; got {names:?}"
+ );
+ }
+ }
}
diff --git a/tests/cross_tenant_resource_isolation.rs b/tests/cross_tenant_resource_isolation.rs
index 020953cd0f5..4305c9843ab 100644
--- a/tests/cross_tenant_resource_isolation.rs
+++ b/tests/cross_tenant_resource_isolation.rs
@@ -109,6 +109,7 @@ async fn start_server_with_db() -> (
auth_manager: None,
chat_rate_limiter: PerUserRateLimiter::new(30, 60),
oauth_rate_limiter: PerUserRateLimiter::new(20, 60),
+ ironhub_catalog_rate_limiter: PerUserRateLimiter::new(30, 60),
registry_entries: Vec::new(),
cost_guard: None,
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
diff --git a/tests/e2e/scenarios/test_ironhub_deep_link_install.py b/tests/e2e/scenarios/test_ironhub_deep_link_install.py
new file mode 100644
index 00000000000..f9a2c337c6c
--- /dev/null
+++ b/tests/e2e/scenarios/test_ironhub_deep_link_install.py
@@ -0,0 +1,213 @@
+"""Deep-link install flow: hash parsing, verify-intent, error surfaces.
+
+Covers the browser-side flow that the in-process handler tests cannot reach:
+the user lands on `#/install/?slug=&version=&uid=&aid=&ts=&nonce=&sig=`,
+the JS in `static/js/core/routing.js` parses the params, `install.js`
+calls `POST /api/ironhub/verify-intent`, and the install card renders the
+verify result. This locks in the wire contract end to end against
+regressions in either the URL parser or the verify-intent handler.
+"""
+
+import hashlib
+import hmac
+import time
+import uuid
+
+from helpers import AUTH_TOKEN, api_post
+
+SHARED_KEY = "ihub_sk_e2e_test_shared_key_padding_xx"
+SLUG = "clickup"
+VERSION = "0.1.0"
+UID = "e2e-user"
+AID = "e2e-agent"
+ARTIFACT_DIGEST = "4e205e4f8061512d5bca40ebe50acbb93d44afa3e083981a7b434f9ee3bab6a3"
+
+
+def install_payload(
+ slug: str, version: str, uid: str, aid: str, ts: int, nonce: str, artifact_digest: str
+) -> str:
+ return f"install:{slug}:{version}:{uid}:{aid}:{ts}:{nonce}:{artifact_digest}"
+
+
+def sign_install(
+ key: str,
+ slug: str,
+ version: str,
+ uid: str,
+ aid: str,
+ ts: int,
+ nonce: str,
+ artifact_digest: str = ARTIFACT_DIGEST,
+) -> str:
+ msg = install_payload(slug, version, uid, aid, ts, nonce, artifact_digest)
+ return hmac.new(key.encode("utf-8"), msg.encode("utf-8"), hashlib.sha256).hexdigest()
+
+
+def install_hash(
+ *,
+ slug: str,
+ version: str,
+ uid: str,
+ aid: str,
+ ts: int,
+ nonce: str,
+ sig: str,
+ artifact_digest: str = ARTIFACT_DIGEST,
+) -> str:
+ return (
+ f"#/install/{slug}"
+ f"?slug={slug}"
+ f"&version={version}"
+ f"&uid={uid}"
+ f"&aid={aid}"
+ f"&ts={ts}"
+ f"&nonce={nonce}"
+ f"&sig={sig}"
+ f"&artifact_digest={artifact_digest}"
+ )
+
+
+async def _seed_signing_key(server: str) -> None:
+ """Idempotently set the IronHub signing key for the test user."""
+ response = await api_post(
+ server,
+ "/api/ironhub/signing-key",
+ json={"shared_key": SHARED_KEY},
+ timeout=10,
+ )
+ assert response.status_code == 200, (
+ f"signing-key POST failed: {response.status_code} {response.text}"
+ )
+
+
+async def test_deep_link_install_valid_signature_renders_confirm(page, ironclaw_server):
+ await _seed_signing_key(ironclaw_server)
+
+ ts = int(time.time())
+ nonce = uuid.uuid4().hex
+ sig = sign_install(SHARED_KEY, SLUG, VERSION, UID, AID, ts, nonce)
+ hash_fragment = install_hash(
+ slug=SLUG, version=VERSION, uid=UID, aid=AID, ts=ts, nonce=nonce, sig=sig
+ )
+
+ await page.goto(f"{ironclaw_server}/?token={AUTH_TOKEN}{hash_fragment}")
+
+ confirm_btn = page.locator("#ironhub-install-confirm-btn")
+ await confirm_btn.wait_for(state="visible", timeout=10000)
+ assert await confirm_btn.is_enabled(), "valid sig must yield an enabled confirm button"
+
+ card = page.locator("#tab-install .ironhub-install-card")
+ card_text = (await card.inner_text()).lower()
+ assert SLUG in card_text, f"expected slug '{SLUG}' on confirm card, got: {card_text!r}"
+
+
+async def test_deep_link_install_confirm_click_posts_to_install_endpoint(page, ironclaw_server):
+ await _seed_signing_key(ironclaw_server)
+
+ ts = int(time.time())
+ nonce = uuid.uuid4().hex
+ sig = sign_install(SHARED_KEY, SLUG, VERSION, UID, AID, ts, nonce)
+ hash_fragment = install_hash(
+ slug=SLUG, version=VERSION, uid=UID, aid=AID, ts=ts, nonce=nonce, sig=sig
+ )
+
+ install_requests = []
+
+ async def capture_install(route):
+ install_requests.append(route.request)
+ await route.fulfill(
+ status=502,
+ content_type="application/json",
+ body='{"error": "catalog unreachable in e2e"}',
+ )
+
+ await page.route("**/api/ironhub/install", capture_install)
+ await page.goto(f"{ironclaw_server}/?token={AUTH_TOKEN}{hash_fragment}")
+
+ confirm_btn = page.locator("#ironhub-install-confirm-btn")
+ await confirm_btn.wait_for(state="visible", timeout=10000)
+ assert await confirm_btn.is_enabled(), "verified deep-link must yield an enabled confirm button"
+
+ await confirm_btn.click()
+
+ deadline = time.time() + 5
+ while not install_requests and time.time() < deadline:
+ await page.wait_for_timeout(100)
+
+ assert install_requests, "clicking confirm must POST /api/ironhub/install"
+ body = install_requests[0].post_data_json or {}
+ assert body.get("slug") == SLUG, f"install body must carry slug, got: {body!r}"
+ assert body.get("version") == VERSION, f"install body must carry version, got: {body!r}"
+ assert body.get("artifact_digest") == ARTIFACT_DIGEST, (
+ f"install body must carry the signed artifact_digest, got: {body!r}"
+ )
+
+
+async def test_deep_link_install_tampered_signature_shows_mismatch(page, ironclaw_server):
+ await _seed_signing_key(ironclaw_server)
+
+ ts = int(time.time())
+ nonce = uuid.uuid4().hex
+ bad_sig = "deadbeef" * 8
+ hash_fragment = install_hash(
+ slug=SLUG, version=VERSION, uid=UID, aid=AID, ts=ts, nonce=nonce, sig=bad_sig
+ )
+
+ await page.goto(f"{ironclaw_server}/?token={AUTH_TOKEN}{hash_fragment}")
+
+ error = page.locator("#tab-install .ironhub-install-error")
+ await error.wait_for(state="visible", timeout=10000)
+ text = (await error.inner_text()).lower()
+ assert "mismatch" in text, f"expected 'mismatch' in error, got: {text!r}"
+
+
+async def test_deep_link_install_stale_timestamp_shows_drift(page, ironclaw_server):
+ await _seed_signing_key(ironclaw_server)
+
+ ts = int(time.time()) - 4000
+ nonce = uuid.uuid4().hex
+ sig = sign_install(SHARED_KEY, SLUG, VERSION, UID, AID, ts, nonce)
+ hash_fragment = install_hash(
+ slug=SLUG, version=VERSION, uid=UID, aid=AID, ts=ts, nonce=nonce, sig=sig
+ )
+
+ await page.goto(f"{ironclaw_server}/?token={AUTH_TOKEN}{hash_fragment}")
+
+ error = page.locator("#tab-install .ironhub-install-error")
+ await error.wait_for(state="visible", timeout=10000)
+ text = (await error.inner_text()).lower()
+ assert "drift" in text, f"expected 'drift' in error, got: {text!r}"
+
+
+async def test_deep_link_install_missing_params_shows_error(page, ironclaw_server):
+ """JS short-circuits before calling verify-intent when any required param is absent."""
+ await page.goto(f"{ironclaw_server}/?token={AUTH_TOKEN}#/install/{SLUG}")
+
+ error = page.locator("#tab-install .ironhub-install-error")
+ await error.wait_for(state="visible", timeout=10000)
+ confirm_btn = page.locator("#ironhub-install-confirm-btn")
+ assert not await confirm_btn.is_visible(), (
+ "confirm button must NOT render when params are missing"
+ )
+
+
+async def test_deep_link_install_replayed_nonce_is_rejected(page, ironclaw_server):
+ """The same nonce can only succeed once per user; replay returns the nonce error."""
+ await _seed_signing_key(ironclaw_server)
+
+ ts = int(time.time())
+ nonce = uuid.uuid4().hex
+ sig = sign_install(SHARED_KEY, SLUG, VERSION, UID, AID, ts, nonce)
+ hash_fragment = install_hash(
+ slug=SLUG, version=VERSION, uid=UID, aid=AID, ts=ts, nonce=nonce, sig=sig
+ )
+
+ await page.goto(f"{ironclaw_server}/?token={AUTH_TOKEN}{hash_fragment}")
+ confirm_btn = page.locator("#ironhub-install-confirm-btn")
+ await confirm_btn.wait_for(state="visible", timeout=10000)
+
+ await page.goto(f"{ironclaw_server}/?token={AUTH_TOKEN}{hash_fragment}")
+ error = page.locator("#tab-install .ironhub-install-error")
+ await error.wait_for(state="visible", timeout=10000)
+ text = (await error.inner_text()).lower()
+ assert "nonce" in text, f"expected 'nonce' in replay error, got: {text!r}"
diff --git a/tests/multi_tenant_integration.rs b/tests/multi_tenant_integration.rs
index c89eba3d9b5..eb63743441d 100644
--- a/tests/multi_tenant_integration.rs
+++ b/tests/multi_tenant_integration.rs
@@ -559,6 +559,7 @@ fn gateway_state_has_multi_tenant_fields() {
auth_manager: None,
chat_rate_limiter: PerUserRateLimiter::new(30, 60), // Multi-tenant: per-user
oauth_rate_limiter: PerUserRateLimiter::new(20, 60),
+ ironhub_catalog_rate_limiter: PerUserRateLimiter::new(30, 60),
registry_entries: Vec::new(),
cost_guard: None,
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
@@ -652,6 +653,7 @@ async fn start_owner_scoped_sender_server() -> (
auth_manager: None,
chat_rate_limiter: PerUserRateLimiter::new(30, 60),
oauth_rate_limiter: PerUserRateLimiter::new(20, 60),
+ ironhub_catalog_rate_limiter: PerUserRateLimiter::new(30, 60),
webhook_rate_limiter: RateLimiter::new(10, 60),
registry_entries: Vec::new(),
cost_guard: None,
@@ -1134,6 +1136,7 @@ async fn start_multi_user_server_with_db() -> (
auth_manager: None,
chat_rate_limiter: PerUserRateLimiter::new(30, 60),
oauth_rate_limiter: PerUserRateLimiter::new(20, 60),
+ ironhub_catalog_rate_limiter: PerUserRateLimiter::new(30, 60),
registry_entries: Vec::new(),
cost_guard: None,
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
diff --git a/tests/oauth_greeting_integration.rs b/tests/oauth_greeting_integration.rs
index a9971e3e910..097390722f6 100644
--- a/tests/oauth_greeting_integration.rs
+++ b/tests/oauth_greeting_integration.rs
@@ -86,6 +86,7 @@ mod tests {
auth_manager: None,
chat_rate_limiter: PerUserRateLimiter::new(30, 60),
oauth_rate_limiter: PerUserRateLimiter::new(20, 60),
+ ironhub_catalog_rate_limiter: PerUserRateLimiter::new(30, 60),
webhook_rate_limiter: RateLimiter::new(10, 60),
registry_entries: Vec::new(),
cost_guard: None,
diff --git a/tests/openai_compat_integration.rs b/tests/openai_compat_integration.rs
index e2ca3613792..3a6ee95d921 100644
--- a/tests/openai_compat_integration.rs
+++ b/tests/openai_compat_integration.rs
@@ -228,6 +228,8 @@ async fn start_test_server_with_provider(
oauth_rate_limiter: ironclaw::channels::web::platform::state::PerUserRateLimiter::new(
20, 60,
),
+ ironhub_catalog_rate_limiter:
+ ironclaw::channels::web::platform::state::PerUserRateLimiter::new(30, 60),
webhook_rate_limiter: ironclaw::channels::web::platform::state::RateLimiter::new(10, 60),
registry_entries: Vec::new(),
cost_guard: None,
@@ -751,6 +753,8 @@ async fn test_no_llm_provider_returns_503() {
oauth_rate_limiter: ironclaw::channels::web::platform::state::PerUserRateLimiter::new(
20, 60,
),
+ ironhub_catalog_rate_limiter:
+ ironclaw::channels::web::platform::state::PerUserRateLimiter::new(30, 60),
webhook_rate_limiter: ironclaw::channels::web::platform::state::RateLimiter::new(10, 60),
registry_entries: Vec::new(),
cost_guard: None,
diff --git a/tests/support/gateway_workflow_harness.rs b/tests/support/gateway_workflow_harness.rs
index 71e35f6f5ed..0320f139dae 100644
--- a/tests/support/gateway_workflow_harness.rs
+++ b/tests/support/gateway_workflow_harness.rs
@@ -239,6 +239,7 @@ impl GatewayWorkflowHarness {
auth_manager: None,
chat_rate_limiter: PerUserRateLimiter::new(120, 60),
oauth_rate_limiter: PerUserRateLimiter::new(20, 60),
+ ironhub_catalog_rate_limiter: PerUserRateLimiter::new(30, 60),
webhook_rate_limiter: RateLimiter::new(10, 60),
registry_entries: Vec::new(),
cost_guard: Some(Arc::clone(&components.cost_guard)),
diff --git a/tests/thread_isolation_integration.rs b/tests/thread_isolation_integration.rs
index 820d3dc9010..92efc94368c 100644
--- a/tests/thread_isolation_integration.rs
+++ b/tests/thread_isolation_integration.rs
@@ -101,6 +101,7 @@ async fn start_server_with_db() -> (
auth_manager: None,
chat_rate_limiter: PerUserRateLimiter::new(30, 60),
oauth_rate_limiter: PerUserRateLimiter::new(20, 60),
+ ironhub_catalog_rate_limiter: PerUserRateLimiter::new(30, 60),
registry_entries: Vec::new(),
cost_guard: None,
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
diff --git a/tests/ws_gateway_integration.rs b/tests/ws_gateway_integration.rs
index e1cf9570239..175eba280f1 100644
--- a/tests/ws_gateway_integration.rs
+++ b/tests/ws_gateway_integration.rs
@@ -70,6 +70,8 @@ async fn start_test_server() -> (
oauth_rate_limiter: ironclaw::channels::web::platform::state::PerUserRateLimiter::new(
20, 60,
),
+ ironhub_catalog_rate_limiter:
+ ironclaw::channels::web::platform::state::PerUserRateLimiter::new(30, 60),
webhook_rate_limiter: ironclaw::channels::web::platform::state::RateLimiter::new(10, 60),
registry_entries: Vec::new(),
cost_guard: None,