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

Loading settings...
+
@@ -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) + '

' + + '' + ); +} + +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 = + ''; + confirmDisabled = ' disabled'; + } + + renderIronhubInstallState( + '

' + escapeHtml(I18n.t('ironhub.install.confirmTitle')) + '

' + + '

' + + escapeHtml(I18n.t('ironhub.install.fromHub')) + '

' + + rows + + warning + + ackCheckbox + + '
' + + '' + + '' + + '
' + ); + 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 })) + '

' + + '' + ); + }).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 + ? '' + : ''; + return '' + + '
' + + '
' + + '' + + '
' + + escapeHtml(I18n.t('ironhub.signingKey.description')) + '
' + + statusHtml + + '
' + + '
' + + '' + + '' + + revoke + + '
' + + '
'; +} + +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,