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