返回 CodeWhale
plugins.rs
根目录 / crates / tui / src / runtime_api / plugins.rs
1 //! Plugin bundle and marketplace management over the Runtime API.
2 //!
3 //! `GET /v1/apps/plugins` and `GET /v1/apps/plugins/{selector}` expose the
4 //! same registry the TUI reads, with the same honest state vocabulary
5 //! (`active`, `enabled-untrusted`, `unstaged`, …) and the same capability
6 //! inventory a terminal review shows. Mutations run through the exact
7 //! reviewed paths the TUI uses — `plugins::mutation::execute` for
8 //! install/update/uninstall (installs always land disabled and untrusted)
9 //! and the registry's hash-bound receipt flow for trust/enable/disable —
10 //! so a GUI client can never bypass a review the TUI would require.
11 //!
12 //! Marketplace endpoints share `plugins::marketplace::document` with the
13 //! `/plugin marketplace` command: local catalog documents only, tiers are
14 //! display-only, and installs route through the same reviewed installer.
15
16 use std::sync::Arc;
17
18 use axum::Json;
19 use axum::extract::{Path, State};
20 use axum::http::StatusCode;
21 use serde::{Deserialize, Serialize};
22
23 use crate::plugins::marketplace::document::{
24 CatalogInstallResolution, load_catalog_document, resolve_candidate_install,
25 };
26 use crate::plugins::marketplace::store::MarketplaceStore;
27 use crate::plugins::mutation::{
28 PluginMutationContext, PluginMutationOutcome, PluginMutationRequest,
29 };
30 use crate::plugins::types::{LoadedPlugin, PluginDiagnostic, PluginDiagnosticLevel};
31
32 use super::{ApiError, RuntimeApiState};
33
34 // ---------------------------------------------------------------------------
35 // Response shapes
36 // ---------------------------------------------------------------------------
37
38 #[derive(Debug, Serialize)]
39 pub(super) struct PluginInventorySummary {
40 pub(super) skills: usize,
41 pub(super) mcp_servers: usize,
42 pub(super) stdio_mcp_servers: usize,
43 pub(super) remote_mcp_servers: usize,
44 pub(super) commands: usize,
45 pub(super) agents: usize,
46 pub(super) hooks: usize,
47 pub(super) lsp: usize,
48 pub(super) native: usize,
49 pub(super) filesystem_roots: Vec<String>,
50 pub(super) network_hosts: Vec<String>,
51 pub(super) lifecycle_mutation: bool,
52 }
53
54 #[derive(Debug, Serialize)]
55 pub(super) struct PluginDiagnosticEntry {
56 pub(super) level: &'static str,
57 pub(super) code: String,
58 pub(super) message: String,
59 pub(super) path: Option<String>,
60 }
61
62 #[derive(Debug, Serialize)]
63 pub(super) struct PluginSummaryEntry {
64 pub(super) id: String,
65 pub(super) name: String,
66 pub(super) display_name: Option<String>,
67 pub(super) icon: Option<String>,
68 pub(super) author: Option<String>,
69 pub(super) homepage: Option<String>,
70 pub(super) platforms: Vec<String>,
71 pub(super) version: String,
72 pub(super) description: Option<String>,
73 pub(super) scope: &'static str,
74 pub(super) origin: &'static str,
75 pub(super) path: String,
76 pub(super) state: &'static str,
77 pub(super) enabled: bool,
78 pub(super) trust_status: &'static str,
79 pub(super) active: bool,
80 pub(super) compatibility: &'static str,
81 pub(super) inventory: PluginInventorySummary,
82 pub(super) content_hash: String,
83 pub(super) capability_hash: String,
84 pub(super) state_generation: u64,
85 pub(super) diagnostics: Vec<PluginDiagnosticEntry>,
86 }
87
88 #[derive(Debug, Serialize)]
89 pub(super) struct PluginsResponse {
90 pub(super) workspace: String,
91 pub(super) plugins: Vec<PluginSummaryEntry>,
92 pub(super) registry_diagnostics: Vec<PluginDiagnosticEntry>,
93 pub(super) validation_clean: bool,
94 }
95
96 /// One reviewed-plugin MCP server in the trust-review payload. Environment
97 /// and header maps expose only key names; URLs expose only their network
98 /// authority. Command and argument text remain the bundle's declared launch
99 /// instructions for review, so bundles should pass credentials through env.
100 #[derive(Debug, Serialize)]
101 pub(super) struct PluginMcpServerReview {
102 pub(super) name: String,
103 pub(super) kind: &'static str,
104 pub(super) command: Option<String>,
105 pub(super) args: Vec<String>,
106 pub(super) url: Option<String>,
107 pub(super) env_keys: Vec<String>,
108 pub(super) header_keys: Vec<String>,
109 }
110
111 #[derive(Debug, Serialize)]
112 pub(super) struct PluginSkillReview {
113 pub(super) name: String,
114 pub(super) description: String,
115 }
116
117 /// The capability review a human approves (or rejects) before trusting a
118 /// bundle. Structured so a GUI renders it without parsing prose.
119 #[derive(Debug, Serialize)]
120 pub(super) struct PluginReviewPayload {
121 /// Confirmation token binding a trust call to this exact content and
122 /// capability set (`POST .../trust {"token": ...}`).
123 pub(super) token: String,
124 pub(super) capabilities: Vec<&'static str>,
125 pub(super) unsupported_capabilities: Vec<&'static str>,
126 pub(super) filesystem_roots: Vec<String>,
127 pub(super) network_hosts: Vec<String>,
128 pub(super) lifecycle_mutation: bool,
129 pub(super) mcp_servers: Vec<PluginMcpServerReview>,
130 pub(super) skills: Vec<PluginSkillReview>,
131 pub(super) commands: Vec<String>,
132 pub(super) agents: Vec<String>,
133 pub(super) hooks: Vec<String>,
134 }
135
136 #[derive(Debug, Serialize)]
137 pub(super) struct PluginDetailResponse {
138 #[serde(flatten)]
139 pub(super) summary: PluginSummaryEntry,
140 pub(super) repository: Option<String>,
141 pub(super) license: Option<String>,
142 pub(super) keywords: Vec<String>,
143 pub(super) staged: bool,
144 pub(super) review: PluginReviewPayload,
145 }
146
147 #[derive(Debug, Serialize)]
148 pub(super) struct PluginMutationResponse {
149 pub(super) outcome: &'static str,
150 pub(super) name: String,
151 pub(super) path: Option<String>,
152 pub(super) content_hash: Option<String>,
153 pub(super) note: Option<&'static str>,
154 /// Fresh post-mutation state of the affected bundle, when it still
155 /// exists (uninstall removes it).
156 pub(super) plugin: Option<PluginSummaryEntry>,
157 }
158
159 #[derive(Debug, Serialize)]
160 pub(super) struct PluginActionResponse {
161 pub(super) name: String,
162 pub(super) action: &'static str,
163 pub(super) state: &'static str,
164 pub(super) note: Option<&'static str>,
165 }
166
167 // ---------------------------------------------------------------------------
168 // Request shapes
169 // ---------------------------------------------------------------------------
170
171 #[derive(Debug, Deserialize)]
172 pub(super) struct InstallPluginRequest {
173 /// Install spec accepted by `PluginInstallSource::parse`: a local path
174 /// (plain or `path:<dir>`), `github:owner/repo`, or an HTTPS tarball URL.
175 pub(super) source: String,
176 /// When present, the install is refused (and rolled back) unless the
177 /// installed tree matches this reviewed content hash.
178 #[serde(default)]
179 pub(super) expected_content_hash: Option<String>,
180 }
181
182 #[derive(Debug, Deserialize)]
183 pub(super) struct TrustPluginRequest {
184 /// Review token from `GET /v1/apps/plugins/{selector}`. Required: trust
185 /// is an explicit confirmation bound to both SHA-256 receipts.
186 pub(super) token: String,
187 }
188
189 #[derive(Debug, Deserialize)]
190 pub(super) struct AddMarketplaceRequest {
191 pub(super) name: String,
192 /// LOCAL catalog document path (kimi/claude/codex/codewhale format,
193 /// auto-detected). Never fetched over the network.
194 pub(super) path: String,
195 }
196
197 #[derive(Debug, Deserialize)]
198 pub(super) struct InstallMarketplaceCandidateRequest {
199 pub(super) candidate: String,
200 }
201
202 // ---------------------------------------------------------------------------
203 // Shared helpers
204 // ---------------------------------------------------------------------------
205
206 fn registry_for_state(state: &RuntimeApiState) -> Arc<crate::plugins::PluginRegistry> {
207 state
208 .plugin_discovery
209 .registry_for_workspace(&state.workspace)
210 }
211
212 fn diagnostic_entry(diagnostic: &PluginDiagnostic) -> PluginDiagnosticEntry {
213 PluginDiagnosticEntry {
214 level: match diagnostic.level {
215 PluginDiagnosticLevel::Warning => "warning",
216 PluginDiagnosticLevel::Error => "error",
217 },
218 code: diagnostic.code.to_string(),
219 message: diagnostic.message.clone(),
220 path: diagnostic.path.as_ref().map(|p| p.display().to_string()),
221 }
222 }
223
224 fn inventory_summary(plugin: &LoadedPlugin) -> PluginInventorySummary {
225 let inventory = &plugin.inventory;
226 PluginInventorySummary {
227 skills: inventory.skills,
228 mcp_servers: inventory.mcp_servers,
229 stdio_mcp_servers: inventory.stdio_mcp_servers,
230 remote_mcp_servers: inventory.remote_mcp_servers,
231 commands: inventory.commands,
232 agents: inventory.agents,
233 hooks: inventory.hooks,
234 lsp: inventory.lsp,
235 native: inventory.native,
236 filesystem_roots: inventory.filesystem_roots.clone(),
237 network_hosts: inventory.network_hosts.clone(),
238 lifecycle_mutation: inventory.lifecycle_mutation,
239 }
240 }
241
242 fn plugin_summary(plugin: &LoadedPlugin) -> PluginSummaryEntry {
243 PluginSummaryEntry {
244 id: plugin.id.as_str().to_string(),
245 name: plugin.name().to_string(),
246 display_name: plugin.manifest.plugin.display_name.clone(),
247 icon: plugin.manifest.plugin.icon.clone(),
248 author: plugin.manifest.plugin.author.clone(),
249 homepage: plugin.manifest.plugin.homepage.clone(),
250 platforms: plugin
251 .manifest
252 .when
253 .as_ref()
254 .and_then(|when| when.os.clone())
255 .unwrap_or_default(),
256 version: plugin.manifest.plugin.version.clone(),
257 description: plugin.manifest.plugin.description.clone(),
258 scope: plugin.scope.as_str(),
259 origin: plugin.origin.as_str(),
260 path: plugin.canonical_root.display().to_string(),
261 state: plugin.state_label(),
262 enabled: plugin.enabled,
263 trust_status: plugin.trust_status.as_str(),
264 active: plugin.active(),
265 compatibility: plugin.compatibility().as_str(),
266 inventory: inventory_summary(plugin),
267 content_hash: plugin.content_hash.clone(),
268 capability_hash: plugin.capability_hash.clone(),
269 state_generation: plugin.state_generation,
270 diagnostics: plugin.diagnostics.iter().map(diagnostic_entry).collect(),
271 }
272 }
273
274 fn mcp_server_review(name: &str, cfg: &crate::mcp::McpServerConfig) -> PluginMcpServerReview {
275 let mut env_keys: Vec<String> = cfg.env.keys().cloned().collect();
276 env_keys.sort();
277 let mut header_keys: Vec<String> = cfg.headers.keys().cloned().collect();
278 header_keys.sort();
279 PluginMcpServerReview {
280 name: name.to_string(),
281 kind: if cfg.url.is_some() { "remote" } else { "stdio" },
282 command: cfg.command.clone(),
283 args: cfg.args.clone(),
284 url: cfg
285 .url
286 .as_deref()
287 .map(crate::doctor::structural_url_authority),
288 env_keys,
289 header_keys,
290 }
291 }
292
293 fn file_stem(path: &std::path::Path) -> String {
294 path.file_stem()
295 .map(|stem| stem.to_string_lossy().into_owned())
296 .unwrap_or_else(|| path.display().to_string())
297 }
298
299 fn review_payload(plugin: &LoadedPlugin) -> PluginReviewPayload {
300 let mut mcp_servers: Vec<_> = plugin
301 .manifest
302 .mcp_servers
303 .as_ref()
304 .map(|servers| {
305 servers
306 .iter()
307 .map(|(name, cfg)| mcp_server_review(name, cfg))
308 .collect()
309 })
310 .unwrap_or_default();
311 mcp_servers.sort_by(|a, b| a.name.cmp(&b.name));
312 let mut commands: Vec<_> = plugin
313 .components
314 .commands
315 .iter()
316 .map(|p| file_stem(p))
317 .collect();
318 commands.sort();
319 let mut agents: Vec<_> = plugin
320 .components
321 .agents
322 .iter()
323 .map(|p| file_stem(p))
324 .collect();
325 agents.sort();
326 let mut hooks: Vec<_> = plugin
327 .components
328 .hooks
329 .iter()
330 .map(|p| file_stem(p))
331 .collect();
332 hooks.sort();
333 let mut skills: Vec<_> = plugin
334 .skill_snapshots
335 .iter()
336 .map(|skill| PluginSkillReview {
337 name: skill.name.clone(),
338 description: skill.description.clone(),
339 })
340 .collect();
341 skills.sort_by(|a, b| a.name.cmp(&b.name));
342
343 PluginReviewPayload {
344 token: plugin.review_token(),
345 capabilities: plugin.inventory.supported_labels(),
346 unsupported_capabilities: plugin.inventory.unsupported_labels(),
347 filesystem_roots: plugin.inventory.filesystem_roots.clone(),
348 network_hosts: plugin.inventory.network_hosts.clone(),
349 lifecycle_mutation: plugin.inventory.lifecycle_mutation,
350 mcp_servers,
351 skills,
352 commands,
353 agents,
354 hooks,
355 }
356 }
357
358 fn plugin_detail(plugin: &LoadedPlugin) -> PluginDetailResponse {
359 PluginDetailResponse {
360 summary: plugin_summary(plugin),
361 repository: plugin.manifest.plugin.repository.clone(),
362 license: plugin.manifest.plugin.license.clone(),
363 keywords: plugin.manifest.plugin.keywords.clone(),
364 staged: plugin.staged_root.is_some(),
365 review: review_payload(plugin),
366 }
367 }
368
369 fn find_plugin(state: &RuntimeApiState, selector: &str) -> Result<LoadedPlugin, ApiError> {
370 registry_for_state(state)
371 .get(selector)
372 .cloned()
373 .ok_or_else(|| ApiError::not_found(format!("plugin '{selector}' not found")))
374 }
375
376 /// Execute an install/update/uninstall through the reviewed mutation
377 /// controller using the server's own config for network policy, then
378 /// invalidate the MCP pool so merged plugin servers reload on next use.
379 async fn run_plugin_mutation(
380 state: &RuntimeApiState,
381 request: PluginMutationRequest,
382 ) -> Result<PluginMutationResponse, ApiError> {
383 let network = {
384 let config = state.config.read();
385 config
386 .network
387 .clone()
388 .map(|policy| policy.into_runtime())
389 .unwrap_or_default()
390 };
391 let ctx = PluginMutationContext {
392 network: &network,
393 max_size: crate::plugins::install::DEFAULT_MAX_SIZE_BYTES,
394 };
395 let mut registry = (*registry_for_state(state)).clone();
396 let receipt = crate::plugins::mutation::execute(request, &ctx, &mut registry)
397 .await
398 .map_err(|error| {
399 if error
400 .downcast_ref::<crate::plugins::install::PluginNameConflict>()
401 .is_some()
402 {
403 ApiError::conflict(format!("plugin mutation failed: {error:#}"))
404 } else {
405 ApiError::internal(format!("plugin mutation failed: {error:#}"))
406 }
407 })?;
408
409 // Policy outcomes are not server errors: report the blocked host with
410 // the same wording the skill lifecycle API uses.
411 let outcome = match &receipt.outcome {
412 PluginMutationOutcome::NeedsApproval(host) => {
413 return Err(ApiError::forbidden(format!(
414 "network access to '{host}' requires explicit approval; \
415 approve the host in your network policy before installing this plugin"
416 )));
417 }
418 PluginMutationOutcome::NetworkDenied(host) => {
419 return Err(ApiError::forbidden(format!(
420 "network access to '{host}' was denied by the active network policy"
421 )));
422 }
423 PluginMutationOutcome::Installed => "installed",
424 PluginMutationOutcome::Updated => "updated",
425 PluginMutationOutcome::NoChange => "no_change",
426 PluginMutationOutcome::Uninstalled => "uninstalled",
427 };
428
429 // Mutations can change merged plugin MCP servers; drop the cached pool
430 // exactly like the MCP config write endpoints do.
431 *state.mcp_pool.lock().await = None;
432
433 let plugin = registry_for_state(state)
434 .get(receipt.name.as_str())
435 .map(plugin_summary);
436 let note = match receipt.outcome {
437 PluginMutationOutcome::Installed => Some(
438 "Installed disabled and untrusted. Open this plugin's detail \
439 to review its capabilities, then trust and enable it.",
440 ),
441 PluginMutationOutcome::Updated => Some(
442 "Content changed; the previous trust receipt no longer matches. \
443 Review and trust it again before enabling.",
444 ),
445 _ => None,
446 };
447 Ok(PluginMutationResponse {
448 outcome,
449 name: receipt.name.clone(),
450 path: receipt.path.as_ref().map(|p| p.display().to_string()),
451 content_hash: receipt.installed_content_hash.or(receipt.content_hash),
452 note,
453 plugin,
454 })
455 }
456
457 /// Run a registry state mutation (`trust`/`enable`/`disable`/`revoke`)
458 /// against a fresh registry, then invalidate the MCP pool. Trust is the only
459 /// one with a precondition beyond the registry's own checks: the request
460 /// token must match the bundle's review token.
461 async fn run_registry_mutation(
462 state: &RuntimeApiState,
463 selector: &str,
464 mutation: RegistryMutation<'_>,
465 ) -> Result<PluginActionResponse, ApiError> {
466 let registry = registry_for_state(state);
467 if let RegistryMutation::Trust { token } = &mutation {
468 let Some(plugin) = registry.get(selector) else {
469 return Err(ApiError::not_found(format!(
470 "plugin '{selector}' not found"
471 )));
472 };
473 if token != &plugin.review_token() {
474 return Err(ApiError::bad_request(
475 "review token does not match this bundle's content and capability set; \
476 reload this plugin's detail and confirm the current review token",
477 ));
478 }
479 }
480
481 let action = match mutation {
482 RegistryMutation::Trust { .. } => "trusted",
483 RegistryMutation::Enable => "enabled",
484 RegistryMutation::Disable => "disabled",
485 RegistryMutation::Revoke => "trust-revoked",
486 };
487
488 let mut registry = (*registry).clone();
489 let result = match mutation {
490 RegistryMutation::Trust { .. } => registry.trust(selector),
491 RegistryMutation::Enable => registry.enable(selector),
492 RegistryMutation::Disable => registry.disable(selector),
493 RegistryMutation::Revoke => registry.revoke_trust(selector),
494 };
495 result.map_err(|error| {
496 ApiError::conflict(format!("{action} failed for '{selector}': {error}"))
497 })?;
498
499 *state.mcp_pool.lock().await = None;
500
501 let fresh = registry_for_state(state);
502 let Some(plugin) = fresh.get(selector) else {
503 return Ok(PluginActionResponse {
504 name: selector.to_string(),
505 action,
506 state: "removed",
507 note: None,
508 });
509 };
510 let note = match (action, plugin.state_label()) {
511 ("enabled", "enabled-untrusted") => Some(
512 "enabled-untrusted: the bundle is not trusted; open this plugin's \
513 detail, review its capabilities and trust it first",
514 ),
515 ("enabled", _) => {
516 let inactive = plugin.inventory.unsupported_labels();
517 (!inactive.is_empty()).then_some(
518 "supported declarative components are active; inventory-only \
519 capabilities stay inactive",
520 )
521 }
522 _ => None,
523 };
524 Ok(PluginActionResponse {
525 name: selector.to_string(),
526 action,
527 state: plugin.state_label(),
528 note,
529 })
530 }
531
532 enum RegistryMutation<'a> {
533 Trust { token: &'a str },
534 Enable,
535 Disable,
536 Revoke,
537 }
538
539 fn open_marketplace_store(state: &RuntimeApiState) -> Result<MarketplaceStore, ApiError> {
540 MarketplaceStore::open(registry_for_state(state).state_path()).ok_or_else(|| {
541 ApiError::internal(
542 "this plugin registry has no persistence store; \
543 marketplace catalogs cannot be saved",
544 )
545 })
546 }
547
548 fn load_marketplace_state(
549 store: &MarketplaceStore,
550 ) -> Result<crate::plugins::marketplace::store::MarketplaceState, ApiError> {
551 store.load().map_err(|error| {
552 ApiError::internal(format!(
553 "marketplace state is fail-closed and will not be rewritten: {error}"
554 ))
555 })
556 }
557
558 // ---------------------------------------------------------------------------
559 // Marketplace DTOs
560 // ---------------------------------------------------------------------------
561
562 #[derive(Debug, Serialize)]
563 pub(super) struct MarketplaceInstallPlanEntry {
564 pub(super) installable: bool,
565 pub(super) spec: Option<String>,
566 pub(super) source_kind: Option<String>,
567 pub(super) reason: Option<String>,
568 }
569
570 #[derive(Debug, Serialize)]
571 pub(super) struct MarketplaceCandidateEntry {
572 pub(super) name: String,
573 pub(super) display_name: Option<String>,
574 pub(super) icon: Option<String>,
575 pub(super) platforms: Vec<String>,
576 pub(super) description: Option<String>,
577 pub(super) version: Option<String>,
578 pub(super) author: Option<String>,
579 pub(super) homepage: Option<String>,
580 pub(super) repository: Option<String>,
581 pub(super) license: Option<String>,
582 pub(super) keywords: Vec<String>,
583 pub(super) categories: Vec<String>,
584 pub(super) tier: String,
585 pub(super) compatibility: Option<&'static str>,
586 pub(super) install: MarketplaceInstallPlanEntry,
587 /// Name occupancy, not an assertion that the catalog and local bytes match.
588 pub(super) existing_plugin: Option<PluginSummaryEntry>,
589 pub(super) diagnostics: Vec<PluginDiagnosticEntry>,
590 }
591
592 #[derive(Debug, Serialize)]
593 pub(super) struct MarketplaceCatalogEntry {
594 pub(super) name: String,
595 pub(super) display_name: Option<String>,
596 pub(super) description: Option<String>,
597 pub(super) format: &'static str,
598 pub(super) tier: String,
599 pub(super) added_at: String,
600 pub(super) source_path: String,
601 pub(super) candidate_count: usize,
602 pub(super) warning_count: usize,
603 pub(super) error_count: usize,
604 pub(super) diagnostics: Vec<PluginDiagnosticEntry>,
605 pub(super) candidates: Vec<MarketplaceCandidateEntry>,
606 }
607
608 #[derive(Debug, Serialize)]
609 pub(super) struct MarketplacesResponse {
610 pub(super) marketplaces: Vec<MarketplaceCatalogEntry>,
611 }
612
613 #[derive(Debug, Serialize)]
614 pub(super) struct MarketplaceActionResponse {
615 pub(super) name: String,
616 pub(super) action: &'static str,
617 pub(super) candidate_count: Option<usize>,
618 pub(super) warning_count: Option<usize>,
619 }
620
621 fn marketplace_candidate_entry(
622 entry: &crate::plugins::marketplace::store::StoredMarketplaceCatalog,
623 candidate: &crate::plugins::marketplace::types::MarketplaceCandidate,
624 registry: &crate::plugins::PluginRegistry,
625 ) -> MarketplaceCandidateEntry {
626 let mut existing_plugin = None;
627 let install = match resolve_candidate_install(entry, candidate, registry) {
628 CatalogInstallResolution::Supported { spec, source_kind } => MarketplaceInstallPlanEntry {
629 installable: true,
630 spec: Some(spec),
631 source_kind: Some(source_kind),
632 reason: None,
633 },
634 CatalogInstallResolution::AlreadyPresent { plugin, reason } => {
635 existing_plugin = Some(plugin_summary(plugin));
636 MarketplaceInstallPlanEntry {
637 installable: false,
638 spec: None,
639 source_kind: None,
640 reason: Some(reason),
641 }
642 }
643 CatalogInstallResolution::Unsupported { reason } => MarketplaceInstallPlanEntry {
644 installable: false,
645 spec: None,
646 source_kind: None,
647 reason: Some(reason),
648 },
649 CatalogInstallResolution::HasErrors { diagnostics } => MarketplaceInstallPlanEntry {
650 installable: false,
651 spec: None,
652 source_kind: None,
653 reason: Some(format!("candidate has parse errors: {diagnostics}")),
654 },
655 };
656 MarketplaceCandidateEntry {
657 name: candidate.name.clone(),
658 display_name: candidate.display_name.clone(),
659 icon: candidate.icon.clone(),
660 platforms: candidate
661 .when
662 .as_ref()
663 .and_then(|when| when.os.clone())
664 .unwrap_or_default(),
665 description: candidate.description.clone(),
666 version: candidate.version.clone(),
667 author: candidate.author.clone(),
668 homepage: candidate.homepage.clone(),
669 repository: candidate.repository.clone(),
670 license: candidate.license.clone(),
671 keywords: candidate.keywords.clone(),
672 categories: candidate.categories.clone(),
673 tier: candidate.provenance.tier.to_string(),
674 compatibility: candidate.compatibility.as_ref().map(|c| c.as_str()),
675 install,
676 existing_plugin,
677 diagnostics: candidate
678 .diagnostics
679 .iter()
680 .map(|d| PluginDiagnosticEntry {
681 level: match d.level {
682 PluginDiagnosticLevel::Warning => "warning",
683 PluginDiagnosticLevel::Error => "error",
684 },
685 code: d.code.to_string(),
686 message: d.message.clone(),
687 path: None,
688 })
689 .collect(),
690 }
691 }
692
693 fn marketplace_catalog_entry(
694 name: &str,
695 entry: &crate::plugins::marketplace::store::StoredMarketplaceCatalog,
696 registry: &crate::plugins::PluginRegistry,
697 ) -> MarketplaceCatalogEntry {
698 MarketplaceCatalogEntry {
699 name: name.to_string(),
700 display_name: entry.catalog.display_name.clone(),
701 description: entry.catalog.description.clone(),
702 format: entry.catalog.format.as_str(),
703 tier: entry.catalog.provenance.tier.to_string(),
704 added_at: entry.added_at.clone(),
705 source_path: entry.source_path.clone(),
706 candidate_count: entry.catalog.total_candidates(),
707 warning_count: entry.catalog.warning_count(),
708 error_count: entry.catalog.error_count(),
709 diagnostics: entry
710 .catalog
711 .diagnostics
712 .iter()
713 .map(|d| PluginDiagnosticEntry {
714 level: match d.level {
715 PluginDiagnosticLevel::Warning => "warning",
716 PluginDiagnosticLevel::Error => "error",
717 },
718 code: d.code.to_string(),
719 message: d.message.clone(),
720 path: None,
721 })
722 .collect(),
723 candidates: entry
724 .catalog
725 .candidates
726 .iter()
727 .map(|candidate| marketplace_candidate_entry(entry, candidate, registry))
728 .collect(),
729 }
730 }
731
732 // ---------------------------------------------------------------------------
733 // Handlers — plugins
734 // ---------------------------------------------------------------------------
735
736 /// `GET /v1/apps/plugins`
737 pub(super) async fn list_plugins(
738 State(state): State<RuntimeApiState>,
739 ) -> Result<Json<PluginsResponse>, ApiError> {
740 let registry = registry_for_state(&state);
741 Ok(Json(PluginsResponse {
742 workspace: state.workspace.display().to_string(),
743 plugins: registry.list().iter().map(|p| plugin_summary(p)).collect(),
744 registry_diagnostics: registry
745 .diagnostics()
746 .iter()
747 .map(diagnostic_entry)
748 .collect(),
749 validation_clean: registry.validation_is_clean(),
750 }))
751 }
752
753 /// `GET /v1/apps/plugins/{selector}`
754 pub(super) async fn get_plugin(
755 State(state): State<RuntimeApiState>,
756 Path(selector): Path<String>,
757 ) -> Result<Json<PluginDetailResponse>, ApiError> {
758 Ok(Json(plugin_detail(&find_plugin(&state, &selector)?)))
759 }
760
761 /// `POST /v1/apps/plugins/install`
762 pub(super) async fn install_plugin_api(
763 State(state): State<RuntimeApiState>,
764 Json(req): Json<InstallPluginRequest>,
765 ) -> Result<(StatusCode, Json<PluginMutationResponse>), ApiError> {
766 let source =
767 crate::plugins::install::PluginInstallSource::parse(&req.source).map_err(|error| {
768 ApiError::bad_request(format!(
769 "invalid plugin install source '{}': {error:#}; expected a local \
770 path, github:owner/repo, or an HTTPS tarball URL",
771 req.source
772 ))
773 })?;
774 let request = match req.expected_content_hash {
775 Some(expected) => PluginMutationRequest::InstallExact {
776 source,
777 expected_content_hash: expected,
778 },
779 None => PluginMutationRequest::Install { source },
780 };
781 let response = run_plugin_mutation(&state, request).await?;
782 Ok((StatusCode::CREATED, Json(response)))
783 }
784
785 /// `POST /v1/apps/plugins/{selector}/update`
786 pub(super) async fn update_plugin_api(
787 State(state): State<RuntimeApiState>,
788 Path(selector): Path<String>,
789 ) -> Result<Json<PluginMutationResponse>, ApiError> {
790 find_plugin(&state, &selector)?;
791 Ok(Json(
792 run_plugin_mutation(
793 &state,
794 PluginMutationRequest::Update {
795 selector: selector.clone(),
796 },
797 )
798 .await?,
799 ))
800 }
801
802 /// `DELETE /v1/apps/plugins/{selector}`
803 pub(super) async fn uninstall_plugin_api(
804 State(state): State<RuntimeApiState>,
805 Path(selector): Path<String>,
806 ) -> Result<Json<PluginMutationResponse>, ApiError> {
807 find_plugin(&state, &selector)?;
808 Ok(Json(
809 run_plugin_mutation(
810 &state,
811 PluginMutationRequest::Uninstall {
812 selector: selector.clone(),
813 },
814 )
815 .await?,
816 ))
817 }
818
819 /// `POST /v1/apps/plugins/{selector}/trust`
820 pub(super) async fn trust_plugin_api(
821 State(state): State<RuntimeApiState>,
822 Path(selector): Path<String>,
823 Json(req): Json<TrustPluginRequest>,
824 ) -> Result<Json<PluginActionResponse>, ApiError> {
825 Ok(Json(
826 run_registry_mutation(
827 &state,
828 &selector,
829 RegistryMutation::Trust { token: &req.token },
830 )
831 .await?,
832 ))
833 }
834
835 /// `POST /v1/apps/plugins/{selector}/enable`
836 pub(super) async fn enable_plugin_api(
837 State(state): State<RuntimeApiState>,
838 Path(selector): Path<String>,
839 ) -> Result<Json<PluginActionResponse>, ApiError> {
840 Ok(Json(
841 run_registry_mutation(&state, &selector, RegistryMutation::Enable).await?,
842 ))
843 }
844
845 /// `POST /v1/apps/plugins/{selector}/disable`
846 pub(super) async fn disable_plugin_api(
847 State(state): State<RuntimeApiState>,
848 Path(selector): Path<String>,
849 ) -> Result<Json<PluginActionResponse>, ApiError> {
850 Ok(Json(
851 run_registry_mutation(&state, &selector, RegistryMutation::Disable).await?,
852 ))
853 }
854
855 /// `POST /v1/apps/plugins/{selector}/revoke`
856 pub(super) async fn revoke_plugin_api(
857 State(state): State<RuntimeApiState>,
858 Path(selector): Path<String>,
859 ) -> Result<Json<PluginActionResponse>, ApiError> {
860 Ok(Json(
861 run_registry_mutation(&state, &selector, RegistryMutation::Revoke).await?,
862 ))
863 }
864
865 // ---------------------------------------------------------------------------
866 // Handlers — marketplaces
867 // ---------------------------------------------------------------------------
868
869 /// `GET /v1/apps/marketplaces`
870 pub(super) async fn list_marketplaces(
871 State(state): State<RuntimeApiState>,
872 ) -> Result<Json<MarketplacesResponse>, ApiError> {
873 let store = open_marketplace_store(&state)?;
874 let marketplace_state = load_marketplace_state(&store)?;
875 let registry = registry_for_state(&state);
876 Ok(Json(MarketplacesResponse {
877 marketplaces: marketplace_state
878 .catalogs()
879 .iter()
880 .map(|(name, entry)| marketplace_catalog_entry(name, entry, &registry))
881 .collect(),
882 }))
883 }
884
885 /// `GET /v1/apps/marketplaces/{name}`
886 pub(super) async fn get_marketplace(
887 State(state): State<RuntimeApiState>,
888 Path(name): Path<String>,
889 ) -> Result<Json<MarketplaceCatalogEntry>, ApiError> {
890 let store = open_marketplace_store(&state)?;
891 let marketplace_state = load_marketplace_state(&store)?;
892 let entry = marketplace_state
893 .get(&name)
894 .ok_or_else(|| ApiError::not_found(format!("marketplace '{name}' not found")))?;
895 Ok(Json(marketplace_catalog_entry(
896 &name,
897 entry,
898 &registry_for_state(&state),
899 )))
900 }
901
902 /// `POST /v1/apps/marketplaces`
903 pub(super) async fn add_marketplace(
904 State(state): State<RuntimeApiState>,
905 Json(req): Json<AddMarketplaceRequest>,
906 ) -> Result<(StatusCode, Json<MarketplaceActionResponse>), ApiError> {
907 let store = open_marketplace_store(&state)?;
908 let loaded = load_catalog_document(&req.name, &state.workspace, &req.path)
909 .map_err(ApiError::bad_request)?;
910 store
911 .add(&loaded.entry.catalog.id.clone(), loaded.entry)
912 .map_err(ApiError::conflict)?;
913 Ok((
914 StatusCode::CREATED,
915 Json(MarketplaceActionResponse {
916 name: req.name,
917 action: "added",
918 candidate_count: Some(loaded.candidate_count),
919 warning_count: Some(loaded.warning_count),
920 }),
921 ))
922 }
923
924 /// `DELETE /v1/apps/marketplaces/{name}`
925 pub(super) async fn remove_marketplace(
926 State(state): State<RuntimeApiState>,
927 Path(name): Path<String>,
928 ) -> Result<Json<MarketplaceActionResponse>, ApiError> {
929 let store = open_marketplace_store(&state)?;
930 let removed = store
931 .remove(&name)
932 .map_err(|error| ApiError::internal(format!("remove marketplace: {error}")))?;
933 if !removed {
934 return Err(ApiError::not_found(format!(
935 "marketplace '{name}' not found"
936 )));
937 }
938 Ok(Json(MarketplaceActionResponse {
939 name,
940 action: "removed",
941 candidate_count: None,
942 warning_count: None,
943 }))
944 }
945
946 /// `POST /v1/apps/marketplaces/{name}/install`
947 ///
948 /// Resolves the stored candidate through the shared plan resolver, then
949 /// routes through the reviewed installer exactly like
950 /// `POST /v1/apps/plugins/install`.
951 pub(super) async fn install_marketplace_candidate_api(
952 State(state): State<RuntimeApiState>,
953 Path(name): Path<String>,
954 Json(req): Json<InstallMarketplaceCandidateRequest>,
955 ) -> Result<(StatusCode, Json<PluginMutationResponse>), ApiError> {
956 let store = open_marketplace_store(&state)?;
957 let marketplace_state = load_marketplace_state(&store)?;
958 let entry = marketplace_state
959 .get(&name)
960 .ok_or_else(|| ApiError::not_found(format!("marketplace '{name}' not found")))?;
961 let candidate = entry
962 .catalog
963 .candidate_by_name(&req.candidate)
964 .ok_or_else(|| {
965 ApiError::not_found(format!(
966 "candidate '{}' not found in marketplace '{name}'",
967 req.candidate
968 ))
969 })?;
970 let registry = registry_for_state(&state);
971 match resolve_candidate_install(entry, candidate, &registry) {
972 CatalogInstallResolution::Supported { spec, .. } => {
973 let response = run_plugin_mutation(
974 &state,
975 PluginMutationRequest::Install {
976 source: crate::plugins::install::PluginInstallSource::parse(&spec).map_err(
977 |error| {
978 ApiError::internal(format!(
979 "resolved install spec '{spec}' no longer parses: {error:#}"
980 ))
981 },
982 )?,
983 },
984 )
985 .await?;
986 Ok((StatusCode::CREATED, Json(response)))
987 }
988 CatalogInstallResolution::AlreadyPresent { reason, .. } => Err(ApiError::conflict(reason)),
989 CatalogInstallResolution::Unsupported { reason } => Err(ApiError::conflict(format!(
990 "candidate '{}' cannot be installed by Codewhale: {reason}",
991 req.candidate
992 ))),
993 CatalogInstallResolution::HasErrors { diagnostics } => Err(ApiError::conflict(format!(
994 "candidate '{}' has parse errors and cannot be installed: {diagnostics}",
995 req.candidate
996 ))),
997 }
998 }
999
1000 #[cfg(test)]
1001 mod review_tests {
1002 use super::mcp_server_review;
1003
1004 #[test]
1005 fn plugin_mcp_review_omits_url_credentials_without_changing_the_bundle() {
1006 for (raw, expected) in [
1007 (
1008 "https://review-user:review-password@mcp.example.invalid:8443/review-path?arbitrary=review-query#review-fragment",
1009 "https://mcp.example.invalid:8443",
1010 ),
1011 (
1012 "http://[::1]:9000/mcp?token=review-query",
1013 "http://[::1]:9000",
1014 ),
1015 (
1016 "https://mcp.example.invalid/mcp",
1017 "https://mcp.example.invalid",
1018 ),
1019 (
1020 "not a URL review-secret",
1021 "unparseable (configured value omitted)",
1022 ),
1023 (
1024 "data:text/plain,review-secret",
1025 "unparseable (configured value omitted)",
1026 ),
1027 ] {
1028 let cfg: crate::mcp::McpServerConfig = serde_json::from_value(serde_json::json!({
1029 "url": raw,
1030 "env": { "REVIEW_ENV": "review-env-value" },
1031 "headers": { "Authorization": "review-header-value" }
1032 }))
1033 .unwrap();
1034 let review = mcp_server_review("demo", &cfg);
1035 assert_eq!(review.url.as_deref(), Some(expected));
1036 assert_eq!(review.kind, "remote");
1037 assert_eq!(review.env_keys, ["REVIEW_ENV"]);
1038 assert_eq!(review.header_keys, ["Authorization"]);
1039 let payload = serde_json::to_string(&review).unwrap();
1040 for secret in [
1041 "review-user",
1042 "review-password",
1043 "review-path",
1044 "review-query",
1045 "review-fragment",
1046 "review-secret",
1047 "review-env-value",
1048 "review-header-value",
1049 ] {
1050 assert!(!payload.contains(secret), "review exposed {secret}");
1051 }
1052 // Display redaction must not change the endpoint used at execution
1053 // or the manifest from which the trust receipt is derived.
1054 assert_eq!(cfg.url.as_deref(), Some(raw));
1055 }
1056 let stdio: crate::mcp::McpServerConfig = serde_json::from_value(serde_json::json!({
1057 "command": "npx", "args": ["demo-server"]
1058 }))
1059 .unwrap();
1060 let review = mcp_server_review("stdio", &stdio);
1061 assert_eq!(review.kind, "stdio");
1062 assert_eq!(review.url, None);
1063 assert_eq!(review.command, stdio.command);
1064 assert_eq!(review.args, stdio.args);
1065 }
1066 }
1067
1067 lines RUST