返回 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 DshPreviewRequest {
184 /// DeepSeek Harness bundle package directory; relative paths resolve
185 /// against the workspace.
186 pub(super) path: String,
187 }
188
189 #[derive(Debug, Serialize)]
190 pub(super) struct DshPreviewResponse {
191 /// Pass to `POST /v1/apps/plugins/install` as `source`, with
192 /// `content_hash` as `expected_content_hash`, to install exactly this
193 /// reviewed bundle.
194 pub(super) install_source: String,
195 pub(super) content_hash: String,
196 pub(super) conversion: crate::plugins::install::dsh::DshConversion,
197 }
198
199 #[derive(Debug, Deserialize)]
200 pub(super) struct TrustPluginRequest {
201 /// Review token from `GET /v1/apps/plugins/{selector}`. Required: trust
202 /// is an explicit confirmation bound to both SHA-256 receipts.
203 pub(super) token: String,
204 }
205
206 #[derive(Debug, Deserialize)]
207 pub(super) struct AddMarketplaceRequest {
208 pub(super) name: String,
209 /// LOCAL catalog document path (kimi/claude/codex/codewhale format,
210 /// auto-detected). Never fetched over the network.
211 pub(super) path: String,
212 }
213
214 #[derive(Debug, Deserialize)]
215 pub(super) struct InstallMarketplaceCandidateRequest {
216 pub(super) candidate: String,
217 }
218
219 // ---------------------------------------------------------------------------
220 // Shared helpers
221 // ---------------------------------------------------------------------------
222
223 fn registry_for_state(state: &RuntimeApiState) -> Arc<crate::plugins::PluginRegistry> {
224 state
225 .plugin_discovery
226 .registry_for_workspace(&state.workspace)
227 }
228
229 fn diagnostic_entry(diagnostic: &PluginDiagnostic) -> PluginDiagnosticEntry {
230 PluginDiagnosticEntry {
231 level: match diagnostic.level {
232 PluginDiagnosticLevel::Warning => "warning",
233 PluginDiagnosticLevel::Error => "error",
234 },
235 code: diagnostic.code.to_string(),
236 message: diagnostic.message.clone(),
237 path: diagnostic.path.as_ref().map(|p| p.display().to_string()),
238 }
239 }
240
241 fn inventory_summary(plugin: &LoadedPlugin) -> PluginInventorySummary {
242 let inventory = &plugin.inventory;
243 PluginInventorySummary {
244 skills: inventory.skills,
245 mcp_servers: inventory.mcp_servers,
246 stdio_mcp_servers: inventory.stdio_mcp_servers,
247 remote_mcp_servers: inventory.remote_mcp_servers,
248 commands: inventory.commands,
249 agents: inventory.agents,
250 hooks: inventory.hooks,
251 lsp: inventory.lsp,
252 native: inventory.native,
253 filesystem_roots: inventory.filesystem_roots.clone(),
254 network_hosts: inventory.network_hosts.clone(),
255 lifecycle_mutation: inventory.lifecycle_mutation,
256 }
257 }
258
259 fn plugin_summary(plugin: &LoadedPlugin) -> PluginSummaryEntry {
260 PluginSummaryEntry {
261 id: plugin.id.as_str().to_string(),
262 name: plugin.name().to_string(),
263 display_name: plugin.manifest.plugin.display_name.clone(),
264 icon: plugin.manifest.plugin.icon.clone(),
265 author: plugin.manifest.plugin.author.clone(),
266 homepage: plugin.manifest.plugin.homepage.clone(),
267 platforms: plugin
268 .manifest
269 .when
270 .as_ref()
271 .and_then(|when| when.os.clone())
272 .unwrap_or_default(),
273 version: plugin.manifest.plugin.version.clone(),
274 description: plugin.manifest.plugin.description.clone(),
275 scope: plugin.scope.as_str(),
276 origin: plugin.origin.as_str(),
277 path: plugin.canonical_root.display().to_string(),
278 state: plugin.state_label(),
279 enabled: plugin.enabled,
280 trust_status: plugin.trust_status.as_str(),
281 active: plugin.active(),
282 compatibility: plugin.compatibility().as_str(),
283 inventory: inventory_summary(plugin),
284 content_hash: plugin.content_hash.clone(),
285 capability_hash: plugin.capability_hash.clone(),
286 state_generation: plugin.state_generation,
287 diagnostics: plugin.diagnostics.iter().map(diagnostic_entry).collect(),
288 }
289 }
290
291 fn mcp_server_review(name: &str, cfg: &crate::mcp::McpServerConfig) -> PluginMcpServerReview {
292 let mut env_keys: Vec<String> = cfg.env.keys().cloned().collect();
293 env_keys.sort();
294 let mut header_keys: Vec<String> = cfg.headers.keys().cloned().collect();
295 header_keys.sort();
296 PluginMcpServerReview {
297 name: name.to_string(),
298 kind: if cfg.url.is_some() { "remote" } else { "stdio" },
299 command: cfg.command.clone(),
300 args: cfg.args.clone(),
301 url: cfg
302 .url
303 .as_deref()
304 .map(crate::doctor::structural_url_authority),
305 env_keys,
306 header_keys,
307 }
308 }
309
310 fn file_stem(path: &std::path::Path) -> String {
311 path.file_stem()
312 .map(|stem| stem.to_string_lossy().into_owned())
313 .unwrap_or_else(|| path.display().to_string())
314 }
315
316 fn review_payload(plugin: &LoadedPlugin) -> PluginReviewPayload {
317 let mut mcp_servers: Vec<_> = plugin
318 .manifest
319 .mcp_servers
320 .as_ref()
321 .map(|servers| {
322 servers
323 .iter()
324 .map(|(name, cfg)| mcp_server_review(name, cfg))
325 .collect()
326 })
327 .unwrap_or_default();
328 mcp_servers.sort_by(|a, b| a.name.cmp(&b.name));
329 let mut commands: Vec<_> = plugin
330 .components
331 .commands
332 .iter()
333 .map(|p| file_stem(p))
334 .collect();
335 commands.sort();
336 let mut agents: Vec<_> = plugin
337 .components
338 .agents
339 .iter()
340 .map(|p| file_stem(p))
341 .collect();
342 agents.sort();
343 let mut hooks: Vec<_> = plugin
344 .components
345 .hooks
346 .iter()
347 .map(|p| file_stem(p))
348 .collect();
349 hooks.sort();
350 let mut skills: Vec<_> = plugin
351 .skill_snapshots
352 .iter()
353 .map(|skill| PluginSkillReview {
354 name: skill.name.clone(),
355 description: skill.description.clone(),
356 })
357 .collect();
358 skills.sort_by(|a, b| a.name.cmp(&b.name));
359
360 PluginReviewPayload {
361 token: plugin.review_token(),
362 capabilities: plugin.inventory.supported_labels(),
363 unsupported_capabilities: plugin.inventory.unsupported_labels(),
364 filesystem_roots: plugin.inventory.filesystem_roots.clone(),
365 network_hosts: plugin.inventory.network_hosts.clone(),
366 lifecycle_mutation: plugin.inventory.lifecycle_mutation,
367 mcp_servers,
368 skills,
369 commands,
370 agents,
371 hooks,
372 }
373 }
374
375 fn plugin_detail(plugin: &LoadedPlugin) -> PluginDetailResponse {
376 PluginDetailResponse {
377 summary: plugin_summary(plugin),
378 repository: plugin.manifest.plugin.repository.clone(),
379 license: plugin.manifest.plugin.license.clone(),
380 keywords: plugin.manifest.plugin.keywords.clone(),
381 staged: plugin.staged_root.is_some(),
382 review: review_payload(plugin),
383 }
384 }
385
386 fn find_plugin(state: &RuntimeApiState, selector: &str) -> Result<LoadedPlugin, ApiError> {
387 registry_for_state(state)
388 .get(selector)
389 .cloned()
390 .ok_or_else(|| ApiError::not_found(format!("plugin '{selector}' not found")))
391 }
392
393 /// Execute an install/update/uninstall through the reviewed mutation
394 /// controller using the server's own config for network policy, then
395 /// invalidate the MCP pool so merged plugin servers reload on next use.
396 async fn run_plugin_mutation(
397 state: &RuntimeApiState,
398 request: PluginMutationRequest,
399 ) -> Result<PluginMutationResponse, ApiError> {
400 let network = {
401 let config = state.config.read();
402 config
403 .network
404 .clone()
405 .map(|policy| policy.into_runtime())
406 .unwrap_or_default()
407 };
408 let ctx = PluginMutationContext {
409 network: &network,
410 max_size: crate::plugins::install::DEFAULT_MAX_SIZE_BYTES,
411 };
412 let mut registry = (*registry_for_state(state)).clone();
413 let receipt = crate::plugins::mutation::execute(request, &ctx, &mut registry)
414 .await
415 .map_err(|error| {
416 if error
417 .downcast_ref::<crate::plugins::install::PluginNameConflict>()
418 .is_some()
419 {
420 ApiError::conflict(format!("plugin mutation failed: {error:#}"))
421 } else {
422 ApiError::internal(format!("plugin mutation failed: {error:#}"))
423 }
424 })?;
425
426 // Policy outcomes are not server errors: report the blocked host with
427 // the same wording the skill lifecycle API uses.
428 let outcome = match &receipt.outcome {
429 PluginMutationOutcome::NeedsApproval(host) => {
430 return Err(ApiError::forbidden(format!(
431 "network access to '{host}' requires explicit approval; \
432 approve the host in your network policy before installing this plugin"
433 )));
434 }
435 PluginMutationOutcome::NetworkDenied(host) => {
436 return Err(ApiError::forbidden(format!(
437 "network access to '{host}' was denied by the active network policy"
438 )));
439 }
440 PluginMutationOutcome::Installed => "installed",
441 PluginMutationOutcome::Updated => "updated",
442 PluginMutationOutcome::NoChange => "no_change",
443 PluginMutationOutcome::Uninstalled => "uninstalled",
444 };
445
446 // Mutations can change merged plugin MCP servers. Advance the existing
447 // shared generation so each captured workspace pool reloads on next use.
448 state
449 .workspace_scopes
450 .mcp_generation
451 .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
452
453 let plugin = registry_for_state(state)
454 .get(receipt.name.as_str())
455 .map(plugin_summary);
456 let note = match receipt.outcome {
457 PluginMutationOutcome::Installed => Some(
458 "Installed disabled and untrusted. Open this plugin's detail \
459 to review its capabilities, then trust and enable it.",
460 ),
461 PluginMutationOutcome::Updated => Some(
462 "Content changed; the previous trust receipt no longer matches. \
463 Review and trust it again before enabling.",
464 ),
465 _ => None,
466 };
467 Ok(PluginMutationResponse {
468 outcome,
469 name: receipt.name.clone(),
470 path: receipt.path.as_ref().map(|p| p.display().to_string()),
471 content_hash: receipt.installed_content_hash.or(receipt.content_hash),
472 note,
473 plugin,
474 })
475 }
476
477 /// Run a registry state mutation (`trust`/`enable`/`disable`/`revoke`)
478 /// against a fresh registry, then invalidate the MCP pool. Trust is the only
479 /// one with a precondition beyond the registry's own checks: the request
480 /// token must match the bundle's review token.
481 async fn run_registry_mutation(
482 state: &RuntimeApiState,
483 selector: &str,
484 mutation: RegistryMutation<'_>,
485 ) -> Result<PluginActionResponse, ApiError> {
486 let registry = registry_for_state(state);
487 if let RegistryMutation::Trust { token } = &mutation {
488 let Some(plugin) = registry.get(selector) else {
489 return Err(ApiError::not_found(format!(
490 "plugin '{selector}' not found"
491 )));
492 };
493 if token != &plugin.review_token() {
494 return Err(ApiError::bad_request(
495 "review token does not match this bundle's content and capability set; \
496 reload this plugin's detail and confirm the current review token",
497 ));
498 }
499 }
500
501 let action = match mutation {
502 RegistryMutation::Trust { .. } => "trusted",
503 RegistryMutation::Enable => "enabled",
504 RegistryMutation::Disable => "disabled",
505 RegistryMutation::Revoke => "trust-revoked",
506 };
507
508 let mut registry = (*registry).clone();
509 let result = match mutation {
510 RegistryMutation::Trust { .. } => registry.trust(selector),
511 RegistryMutation::Enable => registry.enable(selector),
512 RegistryMutation::Disable => registry.disable(selector),
513 RegistryMutation::Revoke => registry.revoke_trust(selector),
514 };
515 result.map_err(|error| {
516 ApiError::conflict(format!("{action} failed for '{selector}': {error}"))
517 })?;
518
519 state
520 .workspace_scopes
521 .mcp_generation
522 .fetch_add(1, std::sync::atomic::Ordering::SeqCst);
523
524 let fresh = registry_for_state(state);
525 crate::extension_host::plugins_changed(Arc::clone(&fresh));
526 let Some(plugin) = fresh.get(selector) else {
527 return Ok(PluginActionResponse {
528 name: selector.to_string(),
529 action,
530 state: "removed",
531 note: None,
532 });
533 };
534 let note = match (action, plugin.state_label()) {
535 ("enabled", "enabled-untrusted") => Some(
536 "enabled-untrusted: the bundle is not trusted; open this plugin's \
537 detail, review its capabilities and trust it first",
538 ),
539 ("enabled", _) => {
540 let inactive = plugin.inventory.unsupported_labels();
541 (!inactive.is_empty()).then_some(
542 "supported declarative components are active; inventory-only \
543 capabilities stay inactive",
544 )
545 }
546 _ => None,
547 };
548 Ok(PluginActionResponse {
549 name: selector.to_string(),
550 action,
551 state: plugin.state_label(),
552 note,
553 })
554 }
555
556 enum RegistryMutation<'a> {
557 Trust { token: &'a str },
558 Enable,
559 Disable,
560 Revoke,
561 }
562
563 fn open_marketplace_store(state: &RuntimeApiState) -> Result<MarketplaceStore, ApiError> {
564 MarketplaceStore::open(registry_for_state(state).state_path()).ok_or_else(|| {
565 ApiError::internal(
566 "this plugin registry has no persistence store; \
567 marketplace catalogs cannot be saved",
568 )
569 })
570 }
571
572 fn load_marketplace_state(
573 store: &MarketplaceStore,
574 ) -> Result<crate::plugins::marketplace::store::MarketplaceState, ApiError> {
575 store.load().map_err(|error| {
576 ApiError::internal(format!(
577 "marketplace state is fail-closed and will not be rewritten: {error}"
578 ))
579 })
580 }
581
582 // ---------------------------------------------------------------------------
583 // Marketplace DTOs
584 // ---------------------------------------------------------------------------
585
586 #[derive(Debug, Serialize)]
587 pub(super) struct MarketplaceInstallPlanEntry {
588 pub(super) installable: bool,
589 pub(super) spec: Option<String>,
590 pub(super) source_kind: Option<String>,
591 pub(super) reason: Option<String>,
592 }
593
594 #[derive(Debug, Serialize)]
595 pub(super) struct MarketplaceCandidateEntry {
596 pub(super) name: String,
597 pub(super) display_name: Option<String>,
598 pub(super) icon: Option<String>,
599 pub(super) platforms: Vec<String>,
600 pub(super) description: Option<String>,
601 pub(super) version: Option<String>,
602 pub(super) author: Option<String>,
603 pub(super) homepage: Option<String>,
604 pub(super) repository: Option<String>,
605 pub(super) license: Option<String>,
606 pub(super) keywords: Vec<String>,
607 pub(super) categories: Vec<String>,
608 pub(super) tier: String,
609 pub(super) compatibility: Option<&'static str>,
610 pub(super) install: MarketplaceInstallPlanEntry,
611 /// Name occupancy, not an assertion that the catalog and local bytes match.
612 pub(super) existing_plugin: Option<PluginSummaryEntry>,
613 pub(super) diagnostics: Vec<PluginDiagnosticEntry>,
614 }
615
616 #[derive(Debug, Serialize)]
617 pub(super) struct MarketplaceCatalogEntry {
618 pub(super) name: String,
619 pub(super) display_name: Option<String>,
620 pub(super) description: Option<String>,
621 pub(super) format: &'static str,
622 pub(super) tier: String,
623 pub(super) added_at: String,
624 pub(super) source_path: String,
625 pub(super) candidate_count: usize,
626 pub(super) warning_count: usize,
627 pub(super) error_count: usize,
628 pub(super) diagnostics: Vec<PluginDiagnosticEntry>,
629 pub(super) candidates: Vec<MarketplaceCandidateEntry>,
630 }
631
632 #[derive(Debug, Serialize)]
633 pub(super) struct MarketplacesResponse {
634 pub(super) marketplaces: Vec<MarketplaceCatalogEntry>,
635 }
636
637 #[derive(Debug, Serialize)]
638 pub(super) struct MarketplaceActionResponse {
639 pub(super) name: String,
640 pub(super) action: &'static str,
641 pub(super) candidate_count: Option<usize>,
642 pub(super) warning_count: Option<usize>,
643 }
644
645 fn marketplace_candidate_entry(
646 entry: &crate::plugins::marketplace::store::StoredMarketplaceCatalog,
647 candidate: &crate::plugins::marketplace::types::MarketplaceCandidate,
648 registry: &crate::plugins::PluginRegistry,
649 ) -> MarketplaceCandidateEntry {
650 let mut existing_plugin = None;
651 let install = match resolve_candidate_install(entry, candidate, registry) {
652 CatalogInstallResolution::Supported { spec, source_kind } => MarketplaceInstallPlanEntry {
653 installable: true,
654 spec: Some(spec),
655 source_kind: Some(source_kind),
656 reason: None,
657 },
658 CatalogInstallResolution::AlreadyPresent { plugin, reason } => {
659 existing_plugin = Some(plugin_summary(plugin));
660 MarketplaceInstallPlanEntry {
661 installable: false,
662 spec: None,
663 source_kind: None,
664 reason: Some(reason),
665 }
666 }
667 CatalogInstallResolution::Unsupported { reason } => MarketplaceInstallPlanEntry {
668 installable: false,
669 spec: None,
670 source_kind: None,
671 reason: Some(reason),
672 },
673 CatalogInstallResolution::HasErrors { diagnostics } => MarketplaceInstallPlanEntry {
674 installable: false,
675 spec: None,
676 source_kind: None,
677 reason: Some(format!("candidate has parse errors: {diagnostics}")),
678 },
679 };
680 MarketplaceCandidateEntry {
681 name: candidate.name.clone(),
682 display_name: candidate.display_name.clone(),
683 icon: candidate.icon.clone(),
684 platforms: candidate
685 .when
686 .as_ref()
687 .and_then(|when| when.os.clone())
688 .unwrap_or_default(),
689 description: candidate.description.clone(),
690 version: candidate.version.clone(),
691 author: candidate.author.clone(),
692 homepage: candidate.homepage.clone(),
693 repository: candidate.repository.clone(),
694 license: candidate.license.clone(),
695 keywords: candidate.keywords.clone(),
696 categories: candidate.categories.clone(),
697 tier: candidate.provenance.tier.to_string(),
698 compatibility: candidate.compatibility.as_ref().map(|c| c.as_str()),
699 install,
700 existing_plugin,
701 diagnostics: candidate
702 .diagnostics
703 .iter()
704 .map(|d| PluginDiagnosticEntry {
705 level: match d.level {
706 PluginDiagnosticLevel::Warning => "warning",
707 PluginDiagnosticLevel::Error => "error",
708 },
709 code: d.code.to_string(),
710 message: d.message.clone(),
711 path: None,
712 })
713 .collect(),
714 }
715 }
716
717 fn marketplace_catalog_entry(
718 name: &str,
719 entry: &crate::plugins::marketplace::store::StoredMarketplaceCatalog,
720 registry: &crate::plugins::PluginRegistry,
721 ) -> MarketplaceCatalogEntry {
722 MarketplaceCatalogEntry {
723 name: name.to_string(),
724 display_name: entry.catalog.display_name.clone(),
725 description: entry.catalog.description.clone(),
726 format: entry.catalog.format.as_str(),
727 tier: entry.catalog.provenance.tier.to_string(),
728 added_at: entry.added_at.clone(),
729 source_path: entry.source_path.clone(),
730 candidate_count: entry.catalog.total_candidates(),
731 warning_count: entry.catalog.warning_count(),
732 error_count: entry.catalog.error_count(),
733 diagnostics: entry
734 .catalog
735 .diagnostics
736 .iter()
737 .map(|d| PluginDiagnosticEntry {
738 level: match d.level {
739 PluginDiagnosticLevel::Warning => "warning",
740 PluginDiagnosticLevel::Error => "error",
741 },
742 code: d.code.to_string(),
743 message: d.message.clone(),
744 path: None,
745 })
746 .collect(),
747 candidates: entry
748 .catalog
749 .candidates
750 .iter()
751 .map(|candidate| marketplace_candidate_entry(entry, candidate, registry))
752 .collect(),
753 }
754 }
755
756 // ---------------------------------------------------------------------------
757 // Handlers — plugins
758 // ---------------------------------------------------------------------------
759
760 /// `GET /v1/apps/plugins`
761 pub(super) async fn list_plugins(
762 State(state): State<RuntimeApiState>,
763 ) -> Result<Json<PluginsResponse>, ApiError> {
764 let registry = registry_for_state(&state);
765 Ok(Json(PluginsResponse {
766 workspace: state.workspace.display().to_string(),
767 plugins: registry.list().iter().map(|p| plugin_summary(p)).collect(),
768 registry_diagnostics: registry
769 .diagnostics()
770 .iter()
771 .map(diagnostic_entry)
772 .collect(),
773 validation_clean: registry.validation_is_clean(),
774 }))
775 }
776
777 /// `GET /v1/apps/plugins/{selector}`
778 pub(super) async fn get_plugin(
779 State(state): State<RuntimeApiState>,
780 Path(selector): Path<String>,
781 ) -> Result<Json<PluginDetailResponse>, ApiError> {
782 Ok(Json(plugin_detail(&find_plugin(&state, &selector)?)))
783 }
784
785 /// `POST /v1/apps/plugins/install`
786 pub(super) async fn install_plugin_api(
787 State(state): State<RuntimeApiState>,
788 Json(req): Json<InstallPluginRequest>,
789 ) -> Result<(StatusCode, Json<PluginMutationResponse>), ApiError> {
790 let source =
791 crate::plugins::install::PluginInstallSource::parse(&req.source).map_err(|error| {
792 ApiError::bad_request(format!(
793 "invalid plugin install source '{}': {error:#}; expected a local \
794 path, github:owner/repo, or an HTTPS tarball URL",
795 req.source
796 ))
797 })?;
798 let request = match req.expected_content_hash {
799 Some(expected) => PluginMutationRequest::InstallExact {
800 source,
801 expected_content_hash: expected,
802 },
803 None => PluginMutationRequest::Install { source },
804 };
805 let response = run_plugin_mutation(&state, request).await?;
806 Ok((StatusCode::CREATED, Json(response)))
807 }
808
809 /// `POST /v1/apps/plugins/import/dsh/preview`: convert a DeepSeek Harness
810 /// bundle package into scratch and return its receipt and review hash.
811 /// Nothing is installed; the install endpoint does that with the same hash.
812 pub(super) async fn preview_dsh_plugin_api(
813 State(state): State<RuntimeApiState>,
814 Json(req): Json<DshPreviewRequest>,
815 ) -> Result<Json<DshPreviewResponse>, ApiError> {
816 let requested = std::path::PathBuf::from(req.path.trim());
817 let path = if requested.is_absolute() {
818 requested
819 } else {
820 state.workspace.join(requested)
821 };
822 let preview = tokio::task::spawn_blocking(move || {
823 let canonical = path
824 .canonicalize()
825 .map_err(|_| format!("DSH package not found at {}", path.display()))?;
826 crate::plugins::install::preview_dsh(&canonical)
827 .map(|(conversion, content_hash)| (canonical, conversion, content_hash))
828 .map_err(|error| format!("{error:#}"))
829 })
830 .await
831 .map_err(|error| ApiError::internal(format!("DSH preview task failed: {error}")))?;
832 let (canonical, conversion, content_hash) =
833 preview.map_err(|error| ApiError::bad_request(format!("DSH import refused: {error}")))?;
834 Ok(Json(DshPreviewResponse {
835 install_source: format!("dsh:{}", canonical.display()),
836 content_hash,
837 conversion,
838 }))
839 }
840
841 /// `POST /v1/apps/plugins/{selector}/update`
842 pub(super) async fn update_plugin_api(
843 State(state): State<RuntimeApiState>,
844 Path(selector): Path<String>,
845 ) -> Result<Json<PluginMutationResponse>, ApiError> {
846 find_plugin(&state, &selector)?;
847 Ok(Json(
848 run_plugin_mutation(
849 &state,
850 PluginMutationRequest::Update {
851 selector: selector.clone(),
852 },
853 )
854 .await?,
855 ))
856 }
857
858 /// `DELETE /v1/apps/plugins/{selector}`
859 pub(super) async fn uninstall_plugin_api(
860 State(state): State<RuntimeApiState>,
861 Path(selector): Path<String>,
862 ) -> Result<Json<PluginMutationResponse>, ApiError> {
863 find_plugin(&state, &selector)?;
864 Ok(Json(
865 run_plugin_mutation(
866 &state,
867 PluginMutationRequest::Uninstall {
868 selector: selector.clone(),
869 },
870 )
871 .await?,
872 ))
873 }
874
875 /// `POST /v1/apps/plugins/{selector}/trust`
876 pub(super) async fn trust_plugin_api(
877 State(state): State<RuntimeApiState>,
878 Path(selector): Path<String>,
879 Json(req): Json<TrustPluginRequest>,
880 ) -> Result<Json<PluginActionResponse>, ApiError> {
881 Ok(Json(
882 run_registry_mutation(
883 &state,
884 &selector,
885 RegistryMutation::Trust { token: &req.token },
886 )
887 .await?,
888 ))
889 }
890
891 /// `POST /v1/apps/plugins/{selector}/enable`
892 pub(super) async fn enable_plugin_api(
893 State(state): State<RuntimeApiState>,
894 Path(selector): Path<String>,
895 ) -> Result<Json<PluginActionResponse>, ApiError> {
896 Ok(Json(
897 run_registry_mutation(&state, &selector, RegistryMutation::Enable).await?,
898 ))
899 }
900
901 /// `POST /v1/apps/plugins/{selector}/disable`
902 pub(super) async fn disable_plugin_api(
903 State(state): State<RuntimeApiState>,
904 Path(selector): Path<String>,
905 ) -> Result<Json<PluginActionResponse>, ApiError> {
906 Ok(Json(
907 run_registry_mutation(&state, &selector, RegistryMutation::Disable).await?,
908 ))
909 }
910
911 /// `POST /v1/apps/plugins/{selector}/revoke`
912 pub(super) async fn revoke_plugin_api(
913 State(state): State<RuntimeApiState>,
914 Path(selector): Path<String>,
915 ) -> Result<Json<PluginActionResponse>, ApiError> {
916 Ok(Json(
917 run_registry_mutation(&state, &selector, RegistryMutation::Revoke).await?,
918 ))
919 }
920
921 // ---------------------------------------------------------------------------
922 // Handlers — marketplaces
923 // ---------------------------------------------------------------------------
924
925 /// `GET /v1/apps/marketplaces`
926 pub(super) async fn list_marketplaces(
927 State(state): State<RuntimeApiState>,
928 ) -> Result<Json<MarketplacesResponse>, ApiError> {
929 let store = open_marketplace_store(&state)?;
930 let marketplace_state = load_marketplace_state(&store)?;
931 let registry = registry_for_state(&state);
932 Ok(Json(MarketplacesResponse {
933 marketplaces: marketplace_state
934 .catalogs()
935 .iter()
936 .map(|(name, entry)| marketplace_catalog_entry(name, entry, &registry))
937 .collect(),
938 }))
939 }
940
941 /// `GET /v1/apps/marketplaces/{name}`
942 pub(super) async fn get_marketplace(
943 State(state): State<RuntimeApiState>,
944 Path(name): Path<String>,
945 ) -> Result<Json<MarketplaceCatalogEntry>, ApiError> {
946 let store = open_marketplace_store(&state)?;
947 let marketplace_state = load_marketplace_state(&store)?;
948 let entry = marketplace_state
949 .get(&name)
950 .ok_or_else(|| ApiError::not_found(format!("marketplace '{name}' not found")))?;
951 Ok(Json(marketplace_catalog_entry(
952 &name,
953 entry,
954 &registry_for_state(&state),
955 )))
956 }
957
958 /// `POST /v1/apps/marketplaces`
959 pub(super) async fn add_marketplace(
960 State(state): State<RuntimeApiState>,
961 Json(req): Json<AddMarketplaceRequest>,
962 ) -> Result<(StatusCode, Json<MarketplaceActionResponse>), ApiError> {
963 let store = open_marketplace_store(&state)?;
964 let loaded = load_catalog_document(&req.name, &state.workspace, &req.path)
965 .map_err(ApiError::bad_request)?;
966 store
967 .add(&loaded.entry.catalog.id.clone(), loaded.entry)
968 .map_err(ApiError::conflict)?;
969 Ok((
970 StatusCode::CREATED,
971 Json(MarketplaceActionResponse {
972 name: req.name,
973 action: "added",
974 candidate_count: Some(loaded.candidate_count),
975 warning_count: Some(loaded.warning_count),
976 }),
977 ))
978 }
979
980 /// `DELETE /v1/apps/marketplaces/{name}`
981 pub(super) async fn remove_marketplace(
982 State(state): State<RuntimeApiState>,
983 Path(name): Path<String>,
984 ) -> Result<Json<MarketplaceActionResponse>, ApiError> {
985 let store = open_marketplace_store(&state)?;
986 let removed = store
987 .remove(&name)
988 .map_err(|error| ApiError::internal(format!("remove marketplace: {error}")))?;
989 if !removed {
990 return Err(ApiError::not_found(format!(
991 "marketplace '{name}' not found"
992 )));
993 }
994 Ok(Json(MarketplaceActionResponse {
995 name,
996 action: "removed",
997 candidate_count: None,
998 warning_count: None,
999 }))
1000 }
1001
1002 /// `POST /v1/apps/marketplaces/{name}/install`
1003 ///
1004 /// Resolves the stored candidate through the shared plan resolver, then
1005 /// routes through the reviewed installer exactly like
1006 /// `POST /v1/apps/plugins/install`.
1007 pub(super) async fn install_marketplace_candidate_api(
1008 State(state): State<RuntimeApiState>,
1009 Path(name): Path<String>,
1010 Json(req): Json<InstallMarketplaceCandidateRequest>,
1011 ) -> Result<(StatusCode, Json<PluginMutationResponse>), ApiError> {
1012 let store = open_marketplace_store(&state)?;
1013 let marketplace_state = load_marketplace_state(&store)?;
1014 let entry = marketplace_state
1015 .get(&name)
1016 .ok_or_else(|| ApiError::not_found(format!("marketplace '{name}' not found")))?;
1017 let candidate = entry
1018 .catalog
1019 .candidate_by_name(&req.candidate)
1020 .ok_or_else(|| {
1021 ApiError::not_found(format!(
1022 "candidate '{}' not found in marketplace '{name}'",
1023 req.candidate
1024 ))
1025 })?;
1026 let registry = registry_for_state(&state);
1027 match resolve_candidate_install(entry, candidate, &registry) {
1028 CatalogInstallResolution::Supported { spec, .. } => {
1029 let response = run_plugin_mutation(
1030 &state,
1031 PluginMutationRequest::Install {
1032 source: crate::plugins::install::PluginInstallSource::parse(&spec).map_err(
1033 |error| {
1034 ApiError::internal(format!(
1035 "resolved install spec '{spec}' no longer parses: {error:#}"
1036 ))
1037 },
1038 )?,
1039 },
1040 )
1041 .await?;
1042 Ok((StatusCode::CREATED, Json(response)))
1043 }
1044 CatalogInstallResolution::AlreadyPresent { reason, .. } => Err(ApiError::conflict(reason)),
1045 CatalogInstallResolution::Unsupported { reason } => Err(ApiError::conflict(format!(
1046 "candidate '{}' cannot be installed by Codewhale: {reason}",
1047 req.candidate
1048 ))),
1049 CatalogInstallResolution::HasErrors { diagnostics } => Err(ApiError::conflict(format!(
1050 "candidate '{}' has parse errors and cannot be installed: {diagnostics}",
1051 req.candidate
1052 ))),
1053 }
1054 }
1055
1056 #[cfg(test)]
1057 mod review_tests {
1058 use super::mcp_server_review;
1059
1060 #[test]
1061 fn plugin_mcp_review_omits_url_credentials_without_changing_the_bundle() {
1062 for (raw, expected) in [
1063 (
1064 "https://review-user:review-password@mcp.example.invalid:8443/review-path?arbitrary=review-query#review-fragment",
1065 "https://mcp.example.invalid:8443",
1066 ),
1067 (
1068 "http://[::1]:9000/mcp?token=review-query",
1069 "http://[::1]:9000",
1070 ),
1071 (
1072 "https://mcp.example.invalid/mcp",
1073 "https://mcp.example.invalid",
1074 ),
1075 (
1076 "not a URL review-secret",
1077 "unparseable (configured value omitted)",
1078 ),
1079 (
1080 "data:text/plain,review-secret",
1081 "unparseable (configured value omitted)",
1082 ),
1083 ] {
1084 let cfg: crate::mcp::McpServerConfig = serde_json::from_value(serde_json::json!({
1085 "url": raw,
1086 "env": { "REVIEW_ENV": "review-env-value" },
1087 "headers": { "Authorization": "review-header-value" }
1088 }))
1089 .unwrap();
1090 let review = mcp_server_review("demo", &cfg);
1091 assert_eq!(review.url.as_deref(), Some(expected));
1092 assert_eq!(review.kind, "remote");
1093 assert_eq!(review.env_keys, ["REVIEW_ENV"]);
1094 assert_eq!(review.header_keys, ["Authorization"]);
1095 let payload = serde_json::to_string(&review).unwrap();
1096 for secret in [
1097 "review-user",
1098 "review-password",
1099 "review-path",
1100 "review-query",
1101 "review-fragment",
1102 "review-secret",
1103 "review-env-value",
1104 "review-header-value",
1105 ] {
1106 assert!(!payload.contains(secret), "review exposed {secret}");
1107 }
1108 // Display redaction must not change the endpoint used at execution
1109 // or the manifest from which the trust receipt is derived.
1110 assert_eq!(cfg.url.as_deref(), Some(raw));
1111 }
1112 let stdio: crate::mcp::McpServerConfig = serde_json::from_value(serde_json::json!({
1113 "command": "npx", "args": ["demo-server"]
1114 }))
1115 .unwrap();
1116 let review = mcp_server_review("stdio", &stdio);
1117 assert_eq!(review.kind, "stdio");
1118 assert_eq!(review.url, None);
1119 assert_eq!(review.command, stdio.command);
1120 assert_eq!(review.args, stdio.args);
1121 }
1122 }
1123
1123 lines RUST