1use std::path::Path;
80use std::time::Duration;
81
82use colored::Colorize;
83
84use crate::admin_cli::{
85 confirm, guard_id_segment, server_error_message, truncate, unreachable, ConnArgs,
86};
87
88const REQUEST_TIMEOUT: Duration = Duration::from_secs(15);
90
91const PLUGIN_MUTATE_TIMEOUT: Duration = Duration::from_secs(120);
95
96pub(crate) fn detect_source(
129 spec: &str,
130 sha256: Option<&str>,
131 allow_unverified: bool,
132 allow_untrusted_host: bool,
133 allow_unsigned: bool,
134 insecure: bool,
135) -> anyhow::Result<serde_json::Value> {
136 if spec.starts_with("http://") || spec.starts_with("https://") {
137 let mut v = serde_json::json!({ "type": "url", "url": spec });
138 if let Some(sha) = sha256 {
139 v["sha256"] = serde_json::Value::String(sha.to_string());
140 }
141 if allow_unverified {
142 v["allow_unverified"] = serde_json::Value::Bool(true);
143 }
144 if allow_untrusted_host {
145 v["allow_untrusted_host"] = serde_json::Value::Bool(true);
146 }
147 if allow_unsigned {
148 v["allow_unsigned"] = serde_json::Value::Bool(true);
149 }
150 if insecure {
151 v["insecure"] = serde_json::Value::Bool(true);
157 v["allow_untrusted_host"] = serde_json::Value::Bool(true);
158 v["allow_unsigned"] = serde_json::Value::Bool(true);
159 v["allow_unverified"] = serde_json::Value::Bool(true);
160 }
161 return Ok(v);
162 }
163
164 let path = Path::new(spec);
165 let metadata = std::fs::metadata(path)
166 .map_err(|e| anyhow::anyhow!("cannot read '{spec}': {e} (expected a directory, a .tar.gz/.tgz/.zip archive, or an http(s):// URL)"))?;
167 let abs = std::fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
168
169 if metadata.is_dir() {
170 if sha256.is_some() {
171 anyhow::bail!("--sha256 only applies to a URL source, not a local directory");
172 }
173 if allow_unverified {
174 anyhow::bail!("--allow-unverified only applies to a URL source, not a local directory");
175 }
176 if allow_untrusted_host {
177 anyhow::bail!(
178 "--allow-untrusted-host only applies to a URL source, not a local directory"
179 );
180 }
181 if allow_unsigned {
182 anyhow::bail!("--allow-unsigned only applies to a URL source, not a local directory");
183 }
184 if insecure {
185 anyhow::bail!("--insecure only applies to a URL source, not a local directory");
186 }
187 return Ok(serde_json::json!({ "type": "local_dir", "path": abs }));
188 }
189
190 let lower = spec.to_ascii_lowercase();
191 if metadata.is_file()
192 && (lower.ends_with(".tar.gz") || lower.ends_with(".tgz") || lower.ends_with(".zip"))
193 {
194 if sha256.is_some() {
195 anyhow::bail!("--sha256 only applies to a URL source, not a local archive");
196 }
197 if allow_unverified {
198 anyhow::bail!("--allow-unverified only applies to a URL source, not a local archive");
199 }
200 if allow_untrusted_host {
201 anyhow::bail!(
202 "--allow-untrusted-host only applies to a URL source, not a local archive"
203 );
204 }
205 if allow_unsigned {
206 anyhow::bail!("--allow-unsigned only applies to a URL source, not a local archive");
207 }
208 if insecure {
209 anyhow::bail!("--insecure only applies to a URL source, not a local archive");
210 }
211 return Ok(serde_json::json!({ "type": "local_archive", "path": abs }));
212 }
213
214 anyhow::bail!(
215 "'{spec}' is neither a directory, a recognized archive (.tar.gz/.tgz/.zip), nor an http(s):// URL"
216 )
217}
218
219pub async fn install(
231 conn: ConnArgs,
232 source_spec: &str,
233 sha256: Option<&str>,
234 allow_unverified: bool,
235 allow_untrusted_host: bool,
236 allow_unsigned: bool,
237 insecure: bool,
238) -> anyhow::Result<()> {
239 let source = detect_source(
240 source_spec,
241 sha256,
242 allow_unverified,
243 allow_untrusted_host,
244 allow_unsigned,
245 insecure,
246 )?;
247 let base = conn.api_base();
248 let url = format!("{base}/plugins/install");
249 let resp = reqwest::Client::new()
250 .post(&url)
251 .timeout(PLUGIN_MUTATE_TIMEOUT)
252 .json(&serde_json::json!({ "source": source }))
253 .send()
254 .await
255 .map_err(|e| unreachable(&base, e))?;
256 let status = resp.status();
257 let body: serde_json::Value = resp.json().await.unwrap_or(serde_json::Value::Null);
258 if status.is_success() {
259 let id_suffix = body
260 .get("id")
261 .and_then(|s| s.as_str())
262 .or_else(|| {
263 body.get("plugin")
264 .and_then(|p| p.get("id"))
265 .and_then(|s| s.as_str())
266 })
267 .map(|id| format!(" '{id}'"))
268 .unwrap_or_default();
269 println!(
270 "{} plugin{id_suffix} installed from '{source_spec}'",
271 "✓".green()
272 );
273 Ok(())
274 } else if status.as_u16() == 409 {
275 anyhow::bail!(
276 "plugin already installed {} — use `bamboo plugin update <id> <path-or-url>` to reinstall/upgrade it",
277 server_error_message(&body)
278 );
279 } else if status.as_u16() == 422 {
280 anyhow::bail!("unsupported platform {}", server_error_message(&body));
281 } else if status.as_u16() == 403 {
282 anyhow::bail!(
283 "install refused (source trust) {} — for an untrusted host, add it to \
284 `plugin_trust.trusted_hosts` in config.json or pass --allow-untrusted-host; for an \
285 unsigned/untrusted-signature bundle, pass --allow-unsigned; or skip all three trust \
286 checks at once with --insecure (only for sources you fully trust)",
287 server_error_message(&body)
288 );
289 } else {
290 anyhow::bail!(
291 "install failed: HTTP {status} {}",
292 server_error_message(&body)
293 );
294 }
295}
296
297pub async fn list(conn: ConnArgs, json: bool) -> anyhow::Result<()> {
299 let base = conn.api_base();
300 let url = format!("{base}/plugins");
301 let resp = reqwest::Client::new()
302 .get(&url)
303 .timeout(REQUEST_TIMEOUT)
304 .send()
305 .await
306 .map_err(|e| unreachable(&base, e))?;
307 if !resp.status().is_success() {
308 anyhow::bail!("GET {url} -> HTTP {}", resp.status());
309 }
310 let v: serde_json::Value = resp.json().await?;
311 if json {
312 println!("{}", serde_json::to_string_pretty(&v)?);
313 return Ok(());
314 }
315
316 let plugins = v.get("plugins").and_then(|p| p.as_array());
317 let plugins = match plugins {
318 Some(p) if !p.is_empty() => p,
319 _ => {
320 println!("(no plugins installed)");
321 return Ok(());
322 }
323 };
324
325 println!(
326 "{:<20} {:<10} {:<12} {:>4} {:>4} {:>4} {:>4} SOURCE",
327 "ID", "VERSION", "STATUS", "MCP", "SKL", "PST", "WFL"
328 );
329 for p in plugins {
330 let id = p.get("id").and_then(|x| x.as_str()).unwrap_or("?");
331 let version = p.get("version").and_then(|x| x.as_str()).unwrap_or("-");
332 let status = p.get("status").and_then(|x| x.as_str()).unwrap_or("?");
333 let registered = p.get("registered");
334 let count = |key: &str| {
335 registered
336 .and_then(|r| r.get(key))
337 .and_then(|a| a.as_array())
338 .map(|a| a.len())
339 .unwrap_or(0)
340 };
341 println!(
342 "{:<20} {:<10} {:<12} {:>4} {:>4} {:>4} {:>4} {}",
343 truncate(id, 20),
344 truncate(version, 10),
345 truncate(status, 12),
346 count("mcp_server_ids"),
347 count("skill_dirs"),
348 count("preset_ids"),
349 count("workflow_filenames"),
350 truncate(&format_source(p.get("source")), 50)
351 );
352 }
353 println!("\n{} plugin(s).", plugins.len());
354 Ok(())
355}
356
357fn format_source(source: Option<&serde_json::Value>) -> String {
359 let Some(source) = source else {
360 return "-".to_string();
361 };
362 match source.get("type").and_then(|t| t.as_str()) {
363 Some("local_dir") => format!(
364 "local_dir:{}",
365 source.get("path").and_then(|p| p.as_str()).unwrap_or("?")
366 ),
367 Some("local_archive") => format!(
368 "local_archive:{}",
369 source.get("path").and_then(|p| p.as_str()).unwrap_or("?")
370 ),
371 Some("url") => format!(
372 "url:{}",
373 source.get("url").and_then(|u| u.as_str()).unwrap_or("?")
374 ),
375 _ => source.to_string(),
376 }
377}
378
379pub async fn remove(conn: ConnArgs, id: &str, yes: bool) -> anyhow::Result<()> {
384 guard_id_segment("plugin id", id)?;
385 if !yes
386 && !confirm(&format!(
387 "Remove plugin '{id}'? This uninstalls it and deletes its registered capabilities."
388 ))?
389 {
390 println!("aborted (nothing removed).");
391 return Ok(());
392 }
393 let base = conn.api_base();
394 let url = format!("{base}/plugins/{id}");
395 let resp = reqwest::Client::new()
396 .delete(&url)
397 .timeout(PLUGIN_MUTATE_TIMEOUT)
398 .send()
399 .await
400 .map_err(|e| unreachable(&base, e))?;
401 let status = resp.status();
402 let body: serde_json::Value = resp.json().await.unwrap_or(serde_json::Value::Null);
403 if status.is_success() {
404 println!("{} plugin '{id}' removed", "✓".green());
405 Ok(())
406 } else if status.as_u16() == 404 {
407 anyhow::bail!("plugin '{id}' not found (check `bamboo plugin list`)");
408 } else {
409 anyhow::bail!(
410 "remove failed: HTTP {status} {}",
411 server_error_message(&body)
412 );
413 }
414}
415
416#[allow(clippy::too_many_arguments)]
422pub async fn update(
423 conn: ConnArgs,
424 id: &str,
425 source_spec: &str,
426 sha256: Option<&str>,
427 allow_unverified: bool,
428 allow_untrusted_host: bool,
429 allow_unsigned: bool,
430 insecure: bool,
431) -> anyhow::Result<()> {
432 guard_id_segment("plugin id", id)?;
433 let source = detect_source(
434 source_spec,
435 sha256,
436 allow_unverified,
437 allow_untrusted_host,
438 allow_unsigned,
439 insecure,
440 )?;
441 let base = conn.api_base();
442 let url = format!("{base}/plugins/{id}/update");
443 let resp = reqwest::Client::new()
444 .post(&url)
445 .timeout(PLUGIN_MUTATE_TIMEOUT)
446 .json(&serde_json::json!({ "source": source }))
447 .send()
448 .await
449 .map_err(|e| unreachable(&base, e))?;
450 let status = resp.status();
451 let body: serde_json::Value = resp.json().await.unwrap_or(serde_json::Value::Null);
452 if status.is_success() {
453 println!("{} plugin '{id}' updated from '{source_spec}'", "✓".green());
454 Ok(())
455 } else if status.as_u16() == 404 {
456 anyhow::bail!("plugin '{id}' not found (check `bamboo plugin list`)");
457 } else if status.as_u16() == 422 {
458 anyhow::bail!("unsupported platform {}", server_error_message(&body));
459 } else if status.as_u16() == 403 {
460 anyhow::bail!(
461 "update refused (source trust) {} — for an untrusted host, add it to \
462 `plugin_trust.trusted_hosts` in config.json or pass --allow-untrusted-host; for an \
463 unsigned/untrusted-signature bundle, pass --allow-unsigned; or skip all three trust \
464 checks at once with --insecure (only for sources you fully trust)",
465 server_error_message(&body)
466 );
467 } else {
468 anyhow::bail!(
469 "update failed: HTTP {status} {}",
470 server_error_message(&body)
471 );
472 }
473}
474
475#[cfg(test)]
476mod tests {
477 use super::*;
478
479 #[test]
480 fn detect_source_recognizes_http_and_https_urls() {
481 let v = detect_source(
482 "https://example.com/plugin.tar.gz",
483 None,
484 false,
485 false,
486 false,
487 false,
488 )
489 .unwrap();
490 assert_eq!(v["type"], "url");
491 assert_eq!(v["url"], "https://example.com/plugin.tar.gz");
492 assert!(v.get("sha256").is_none());
493 assert!(v.get("allow_unverified").is_none());
494 assert!(v.get("allow_untrusted_host").is_none());
495 assert!(v.get("allow_unsigned").is_none());
496
497 let v = detect_source(
498 "http://example.com/plugin.tar.gz",
499 Some("deadbeef"),
500 false,
501 false,
502 false,
503 false,
504 )
505 .unwrap();
506 assert_eq!(v["type"], "url");
507 assert_eq!(v["sha256"], "deadbeef");
508 assert!(v.get("allow_unverified").is_none());
509 }
510
511 #[test]
512 fn detect_source_url_carries_allow_unverified_when_set() {
513 let v = detect_source(
514 "https://example.com/plugin.tar.gz",
515 None,
516 true,
517 false,
518 false,
519 false,
520 )
521 .unwrap();
522 assert_eq!(v["type"], "url");
523 assert!(v.get("sha256").is_none());
524 assert_eq!(v["allow_unverified"], true);
525 }
526
527 #[test]
528 fn detect_source_url_carries_both_sha256_and_allow_unverified() {
529 let v = detect_source(
533 "https://example.com/plugin.tar.gz",
534 Some("deadbeef"),
535 true,
536 false,
537 false,
538 false,
539 )
540 .unwrap();
541 assert_eq!(v["sha256"], "deadbeef");
542 assert_eq!(v["allow_unverified"], true);
543 }
544
545 #[test]
546 fn detect_source_url_carries_allow_untrusted_host_when_set() {
547 let v = detect_source(
548 "https://evil.example.com/plugin.tar.gz",
549 None,
550 true,
551 true,
552 false,
553 false,
554 )
555 .unwrap();
556 assert_eq!(v["type"], "url");
557 assert_eq!(v["allow_untrusted_host"], true);
558 assert!(v.get("allow_unsigned").is_none());
559 }
560
561 #[test]
562 fn detect_source_url_carries_allow_unsigned_when_set() {
563 let v = detect_source(
564 "https://example.com/plugin.tar.gz",
565 None,
566 true,
567 false,
568 true,
569 false,
570 )
571 .unwrap();
572 assert_eq!(v["type"], "url");
573 assert!(v.get("allow_untrusted_host").is_none());
574 assert_eq!(v["allow_unsigned"], true);
575 }
576
577 #[test]
578 fn detect_source_url_carries_all_four_flags_together() {
579 let v = detect_source(
580 "https://example.com/plugin.tar.gz",
581 Some("deadbeef"),
582 true,
583 true,
584 true,
585 false,
586 )
587 .unwrap();
588 assert_eq!(v["sha256"], "deadbeef");
589 assert_eq!(v["allow_unverified"], true);
590 assert_eq!(v["allow_untrusted_host"], true);
591 assert_eq!(v["allow_unsigned"], true);
592 }
593
594 #[test]
595 fn detect_source_recognizes_local_dir() {
596 let dir = tempfile::tempdir().unwrap();
597 let v = detect_source(
598 dir.path().to_str().unwrap(),
599 None,
600 false,
601 false,
602 false,
603 false,
604 )
605 .unwrap();
606 assert_eq!(v["type"], "local_dir");
607 assert_eq!(
608 v["path"].as_str().unwrap(),
609 dir.path().canonicalize().unwrap().to_str().unwrap()
610 );
611 }
612
613 #[test]
614 fn detect_source_rejects_sha256_for_local_dir() {
615 let dir = tempfile::tempdir().unwrap();
616 let err = detect_source(
617 dir.path().to_str().unwrap(),
618 Some("deadbeef"),
619 false,
620 false,
621 false,
622 false,
623 )
624 .unwrap_err();
625 assert!(err.to_string().contains("--sha256"));
626 }
627
628 #[test]
629 fn detect_source_rejects_allow_unverified_for_local_dir() {
630 let dir = tempfile::tempdir().unwrap();
631 let err = detect_source(
632 dir.path().to_str().unwrap(),
633 None,
634 true,
635 false,
636 false,
637 false,
638 )
639 .unwrap_err();
640 assert!(err.to_string().contains("--allow-unverified"));
641 }
642
643 #[test]
644 fn detect_source_rejects_allow_untrusted_host_for_local_dir() {
645 let dir = tempfile::tempdir().unwrap();
646 let err = detect_source(
647 dir.path().to_str().unwrap(),
648 None,
649 false,
650 true,
651 false,
652 false,
653 )
654 .unwrap_err();
655 assert!(err.to_string().contains("--allow-untrusted-host"));
656 }
657
658 #[test]
659 fn detect_source_rejects_allow_unsigned_for_local_dir() {
660 let dir = tempfile::tempdir().unwrap();
661 let err = detect_source(
662 dir.path().to_str().unwrap(),
663 None,
664 false,
665 false,
666 true,
667 false,
668 )
669 .unwrap_err();
670 assert!(err.to_string().contains("--allow-unsigned"));
671 }
672
673 #[test]
674 fn detect_source_recognizes_archives_by_extension() {
675 let dir = tempfile::tempdir().unwrap();
676 for name in ["plugin.tar.gz", "plugin.tgz", "plugin.zip"] {
677 let path = dir.path().join(name);
678 std::fs::write(&path, b"fake archive bytes").unwrap();
679 let v =
680 detect_source(path.to_str().unwrap(), None, false, false, false, false).unwrap();
681 assert_eq!(v["type"], "local_archive", "{name}");
682 }
683 }
684
685 #[test]
686 fn detect_source_rejects_sha256_for_local_archive() {
687 let dir = tempfile::tempdir().unwrap();
688 let path = dir.path().join("plugin.tar.gz");
689 std::fs::write(&path, b"fake archive bytes").unwrap();
690 let err = detect_source(
691 path.to_str().unwrap(),
692 Some("deadbeef"),
693 false,
694 false,
695 false,
696 false,
697 )
698 .unwrap_err();
699 assert!(err.to_string().contains("--sha256"));
700 }
701
702 #[test]
703 fn detect_source_rejects_allow_unverified_for_local_archive() {
704 let dir = tempfile::tempdir().unwrap();
705 let path = dir.path().join("plugin.tar.gz");
706 std::fs::write(&path, b"fake archive bytes").unwrap();
707 let err =
708 detect_source(path.to_str().unwrap(), None, true, false, false, false).unwrap_err();
709 assert!(err.to_string().contains("--allow-unverified"));
710 }
711
712 #[test]
713 fn detect_source_rejects_allow_untrusted_host_for_local_archive() {
714 let dir = tempfile::tempdir().unwrap();
715 let path = dir.path().join("plugin.tar.gz");
716 std::fs::write(&path, b"fake archive bytes").unwrap();
717 let err =
718 detect_source(path.to_str().unwrap(), None, false, true, false, false).unwrap_err();
719 assert!(err.to_string().contains("--allow-untrusted-host"));
720 }
721
722 #[test]
723 fn detect_source_rejects_allow_unsigned_for_local_archive() {
724 let dir = tempfile::tempdir().unwrap();
725 let path = dir.path().join("plugin.tar.gz");
726 std::fs::write(&path, b"fake archive bytes").unwrap();
727 let err =
728 detect_source(path.to_str().unwrap(), None, false, false, true, false).unwrap_err();
729 assert!(err.to_string().contains("--allow-unsigned"));
730 }
731
732 #[test]
733 fn detect_source_rejects_unrecognized_file_extension() {
734 let dir = tempfile::tempdir().unwrap();
735 let path = dir.path().join("plugin.txt");
736 std::fs::write(&path, b"not an archive").unwrap();
737 let err =
738 detect_source(path.to_str().unwrap(), None, false, false, false, false).unwrap_err();
739 assert!(err.to_string().contains("neither a directory"));
740 }
741
742 #[test]
743 fn detect_source_rejects_missing_path() {
744 let err = detect_source(
745 "/no/such/path/should/exist/anywhere",
746 None,
747 false,
748 false,
749 false,
750 false,
751 )
752 .unwrap_err();
753 assert!(err.to_string().contains("cannot read"));
754 }
755
756 #[test]
761 fn detect_source_insecure_implies_all_three_allow_flags() {
762 let v = detect_source(
766 "https://example.com/my-plugin.tar.gz",
767 None,
768 false,
769 false,
770 false,
771 true,
772 )
773 .unwrap();
774 assert_eq!(v["type"], "url");
775 assert_eq!(v["insecure"], true);
776 assert_eq!(v["allow_untrusted_host"], true);
777 assert_eq!(v["allow_unsigned"], true);
778 assert_eq!(v["allow_unverified"], true);
779 assert!(v.get("sha256").is_none());
780 }
781
782 #[test]
783 fn detect_source_insecure_with_explicit_sha256_keeps_the_checksum() {
784 let v = detect_source(
789 "https://example.com/my-plugin.tar.gz",
790 Some("deadbeef"),
791 false,
792 false,
793 false,
794 true,
795 )
796 .unwrap();
797 assert_eq!(v["insecure"], true);
798 assert_eq!(v["sha256"], "deadbeef");
799 assert_eq!(v["allow_untrusted_host"], true);
800 assert_eq!(v["allow_unsigned"], true);
801 assert_eq!(v["allow_unverified"], true);
802 }
803
804 #[test]
805 fn detect_source_rejects_insecure_for_local_dir() {
806 let dir = tempfile::tempdir().unwrap();
807 let err = detect_source(
808 dir.path().to_str().unwrap(),
809 None,
810 false,
811 false,
812 false,
813 true,
814 )
815 .unwrap_err();
816 assert!(err.to_string().contains("--insecure"));
817 }
818
819 #[test]
820 fn detect_source_rejects_insecure_for_local_archive() {
821 let dir = tempfile::tempdir().unwrap();
822 let path = dir.path().join("plugin.tar.gz");
823 std::fs::write(&path, b"fake archive bytes").unwrap();
824 let err =
825 detect_source(path.to_str().unwrap(), None, false, false, false, true).unwrap_err();
826 assert!(err.to_string().contains("--insecure"));
827 }
828
829 #[test]
830 fn format_source_renders_each_kind() {
831 assert_eq!(
832 format_source(Some(
833 &serde_json::json!({"type":"local_dir","path":"/tmp/x"})
834 )),
835 "local_dir:/tmp/x"
836 );
837 assert_eq!(
838 format_source(Some(
839 &serde_json::json!({"type":"local_archive","path":"/tmp/x.tar.gz"})
840 )),
841 "local_archive:/tmp/x.tar.gz"
842 );
843 assert_eq!(
844 format_source(Some(
845 &serde_json::json!({"type":"url","url":"https://example.com/x.tar.gz"})
846 )),
847 "url:https://example.com/x.tar.gz"
848 );
849 assert_eq!(format_source(None), "-");
850 }
851}