1use std::path::{Path, PathBuf};
44
45use super::{render_slots, shell_quote};
46
47pub const MARKETPLACE_NAME_SLOT: &str = "__TAPES_MARKETPLACE_NAME__";
51
52pub const MARKETPLACE_DISPLAY_NAME_SLOT: &str = "__TAPES_MARKETPLACE_DISPLAY_NAME__";
54
55pub const MARKETPLACE_PLUGIN_NAME_SLOT: &str = "__TAPES_PLUGIN_NAME__";
59
60pub const MARKETPLACE_PLUGIN_SOURCE_PATH_SLOT: &str = "__TAPES_PLUGIN_SOURCE_PATH__";
65
66pub const MARKETPLACE_MANIFEST_TEMPLATE: &str = include_str!(concat!(
74 env!("CARGO_MANIFEST_DIR"),
75 "/assets/codex-app/marketplace.json"
76));
77
78pub const MARKETPLACE_MANIFEST_PATH: &str = ".agents/plugins/marketplace.json";
81
82#[derive(Debug, Clone, Copy, PartialEq, Eq)]
88#[non_exhaustive]
89pub struct MarketplaceIdentity<'a> {
90 pub name: &'a str,
94 pub plugin_name: &'a str,
97 pub display_name: &'a str,
99}
100
101impl<'a> MarketplaceIdentity<'a> {
102 #[must_use]
105 pub const fn new(name: &'a str, plugin_name: &'a str) -> Self {
106 Self {
107 name,
108 plugin_name,
109 display_name: name,
110 }
111 }
112
113 #[must_use]
115 pub const fn with_display_name(mut self, display_name: &'a str) -> Self {
116 self.display_name = display_name;
117 self
118 }
119}
120
121#[must_use]
123pub fn plugin_source_dir(plugin_name: &str) -> PathBuf {
124 Path::new("plugins").join(plugin_name)
125}
126
127#[must_use]
130pub fn plugin_manifest_path(plugin_name: &str) -> PathBuf {
131 plugin_source_dir(plugin_name)
132 .join(".codex-plugin")
133 .join("plugin.json")
134}
135
136#[must_use]
140pub fn hooks_manifest_path(plugin_name: &str) -> PathBuf {
141 plugin_source_dir(plugin_name)
142 .join("hooks")
143 .join("hooks.json")
144}
145
146#[must_use]
150pub fn plugin_spec(plugin_name: &str, marketplace_name: &str) -> String {
151 format!("{plugin_name}@{marketplace_name}")
152}
153
154#[must_use]
160pub fn render_marketplace_manifest(identity: &MarketplaceIdentity) -> String {
161 let source_path = format!("./{}", plugin_source_dir(identity.plugin_name).display());
162 render_slots(
163 MARKETPLACE_MANIFEST_TEMPLATE,
164 &[
165 (MARKETPLACE_NAME_SLOT, identity.name),
166 (MARKETPLACE_DISPLAY_NAME_SLOT, identity.display_name),
167 (MARKETPLACE_PLUGIN_NAME_SLOT, identity.plugin_name),
168 (MARKETPLACE_PLUGIN_SOURCE_PATH_SLOT, &source_path),
169 ],
170 )
171}
172
173#[must_use]
183pub fn plugin_disabled_in_config(config_text: &str, plugin_spec: &str) -> bool {
184 use toml_edit::{Document, Item};
185
186 let Ok(document) = config_text.parse::<Document>() else {
187 return false;
188 };
189 document
190 .get("plugins")
191 .and_then(Item::as_table_like)
192 .and_then(|plugins| plugins.get(plugin_spec))
193 .and_then(Item::as_table_like)
194 .and_then(|plugin| plugin.get("enabled"))
195 .and_then(Item::as_bool)
196 == Some(false)
197}
198
199#[derive(Debug, Clone, Copy, PartialEq, Eq)]
206#[non_exhaustive]
207pub enum InstallGoal {
208 Install,
213 Refresh,
216 Verify,
219}
220
221#[derive(Debug, Clone, PartialEq, Eq)]
223#[non_exhaustive]
224pub enum MarketplaceOutcome {
225 Added,
227 AlreadyAdded,
229 Replaced {
237 marketplace_name: String,
239 },
240 Failed {
242 detail: String,
244 },
245}
246
247impl MarketplaceOutcome {
248 #[must_use]
250 pub fn describe(&self) -> String {
251 match self {
252 Self::Added => "added".to_owned(),
253 Self::AlreadyAdded => "already added".to_owned(),
254 Self::Replaced { marketplace_name } => format!(
255 "replaced an existing '{marketplace_name}' marketplace that pointed at a \
256 different source"
257 ),
258 Self::Failed { detail } => format!("failed: {detail}"),
259 }
260 }
261
262 #[must_use]
265 pub fn failed(&self) -> bool {
266 matches!(self, Self::Failed { .. })
267 }
268}
269
270#[derive(Debug, Clone, PartialEq, Eq)]
272#[non_exhaustive]
273pub enum InstallOutcome {
274 Installed,
276 AlreadyInstalled,
279 Refreshed,
281 Failed {
283 detail: String,
285 },
286 RemovedNotReinstalled {
290 detail: String,
292 },
293 Skipped,
295}
296
297impl InstallOutcome {
298 #[must_use]
300 pub fn describe(&self) -> String {
301 match self {
302 Self::Installed => "installed".to_owned(),
303 Self::AlreadyInstalled => "already installed".to_owned(),
304 Self::Refreshed => "refreshed to the new bundled version".to_owned(),
305 Self::Failed { detail } | Self::RemovedNotReinstalled { detail } => {
306 format!("failed: {detail}")
307 }
308 Self::Skipped => "skipped (marketplace registration failed)".to_owned(),
309 }
310 }
311
312 #[must_use]
315 pub fn needs_manual_retry(&self) -> bool {
316 matches!(
317 self,
318 Self::Failed { .. } | Self::RemovedNotReinstalled { .. } | Self::Skipped
319 )
320 }
321
322 #[must_use]
326 pub fn confirmed_delivery(&self) -> bool {
327 matches!(self, Self::Installed | Self::Refreshed)
328 }
329}
330
331#[derive(Debug, Clone, PartialEq, Eq)]
343pub enum ManagerRun {
344 CliAbsent,
348 SkippedDisabled,
359 Steps {
361 marketplace: MarketplaceOutcome,
363 install: InstallOutcome,
365 },
366}
367
368pub const SKIPPED_DISABLED_REASON: &str = "the plugin is disabled in Codex config; enable it in the app, then install again \
374 (installing now would force-re-enable it)";
375
376#[derive(Debug, Clone, PartialEq, Eq)]
378pub struct PluginManager {
379 codex_program: PathBuf,
380 marketplace_root: PathBuf,
381 marketplace_name: String,
382 plugin_name: String,
383}
384
385impl PluginManager {
386 #[must_use]
396 pub fn new(
397 codex_program: impl Into<PathBuf>,
398 marketplace_root: impl Into<PathBuf>,
399 marketplace_name: impl Into<String>,
400 plugin_name: impl Into<String>,
401 ) -> Self {
402 Self {
403 codex_program: codex_program.into(),
404 marketplace_root: marketplace_root.into(),
405 marketplace_name: marketplace_name.into(),
406 plugin_name: plugin_name.into(),
407 }
408 }
409
410 #[must_use]
412 pub fn marketplace_root(&self) -> &Path {
413 &self.marketplace_root
414 }
415
416 #[must_use]
418 pub fn plugin_spec(&self) -> String {
419 plugin_spec(&self.plugin_name, &self.marketplace_name)
420 }
421
422 #[must_use]
430 pub fn manual_commands(&self) -> [String; 2] {
431 [
432 format!(
433 "codex plugin marketplace add {}",
434 shell_quote(&self.marketplace_root.to_string_lossy())
435 ),
436 self.install_command(),
437 ]
438 }
439
440 #[must_use]
444 pub fn install_command(&self) -> String {
445 format!("codex plugin add {}", shell_quote(&self.plugin_spec()))
446 }
447
448 #[must_use]
459 pub fn register(&self, goal: InstallGoal, plugin_disabled: bool) -> ManagerRun {
460 if plugin_disabled {
461 return ManagerRun::SkippedDisabled;
462 }
463 let Some(marketplace) = self.register_marketplace() else {
464 return ManagerRun::CliAbsent;
465 };
466 let install = if marketplace.failed() {
467 InstallOutcome::Skipped
468 } else {
469 self.install(goal)
470 };
471 ManagerRun::Steps {
472 marketplace,
473 install,
474 }
475 }
476
477 fn register_marketplace(&self) -> Option<MarketplaceOutcome> {
485 match self.run_marketplace_add() {
486 Invocation::Missing => None,
487 Invocation::Ran { success: true, .. } => Some(MarketplaceOutcome::Added),
488 Invocation::Ran { detail, .. } => {
489 let lowered = detail.to_ascii_lowercase();
490 if lowered.contains("different source") {
491 Some(self.replace_marketplace())
492 } else if says_already(&lowered) {
493 Some(MarketplaceOutcome::AlreadyAdded)
494 } else {
495 Some(MarketplaceOutcome::Failed { detail })
496 }
497 }
498 }
499 }
500
501 fn replace_marketplace(&self) -> MarketplaceOutcome {
502 let name = &self.marketplace_name;
503 match self.run(&["plugin", "marketplace", "remove", name]) {
504 Invocation::Missing => {
505 return MarketplaceOutcome::Failed {
506 detail: CLI_VANISHED.to_owned(),
507 };
508 }
509 Invocation::Ran {
510 success: false,
511 detail,
512 } => {
513 return MarketplaceOutcome::Failed {
514 detail: format!(
515 "an existing '{name}' marketplace points at a different source and \
516 `codex plugin marketplace remove {name}` failed: {detail}"
517 ),
518 };
519 }
520 Invocation::Ran { success: true, .. } => {}
521 }
522 match self.run_marketplace_add() {
523 Invocation::Ran { success: true, .. } => MarketplaceOutcome::Replaced {
524 marketplace_name: name.clone(),
525 },
526 Invocation::Ran { detail, .. } => MarketplaceOutcome::Failed {
527 detail: format!(
528 "removed the previous '{name}' marketplace but re-adding the managed one \
529 failed: {detail}"
530 ),
531 },
532 Invocation::Missing => MarketplaceOutcome::Failed {
533 detail: CLI_VANISHED.to_owned(),
534 },
535 }
536 }
537
538 fn install(&self, goal: InstallGoal) -> InstallOutcome {
548 match self.run_plugin_add() {
549 Invocation::Missing => InstallOutcome::Failed {
550 detail: CLI_VANISHED.to_owned(),
551 },
552 Invocation::Ran { success: true, .. } => {
553 if goal == InstallGoal::Refresh {
554 InstallOutcome::Refreshed
555 } else {
556 InstallOutcome::Installed
557 }
558 }
559 Invocation::Ran { detail, .. } => {
560 if says_already(&detail.to_ascii_lowercase()) {
561 match goal {
562 InstallGoal::Verify => InstallOutcome::AlreadyInstalled,
563 InstallGoal::Install | InstallGoal::Refresh => self.force_refresh(),
564 }
565 } else {
566 InstallOutcome::Failed { detail }
567 }
568 }
569 }
570 }
571
572 fn force_refresh(&self) -> InstallOutcome {
580 let spec = self.plugin_spec();
581 match self.run(&["plugin", "remove", &spec]) {
582 Invocation::Missing => {
583 return InstallOutcome::Failed {
584 detail: CLI_VANISHED.to_owned(),
585 };
586 }
587 Invocation::Ran {
588 success: false,
589 detail,
590 } => {
591 if !says_nothing_to_remove(&detail.to_ascii_lowercase()) {
593 return InstallOutcome::Failed {
594 detail: format!(
595 "the installed plugin is stale and `codex plugin remove` failed: \
596 {detail}"
597 ),
598 };
599 }
600 }
601 Invocation::Ran { success: true, .. } => {}
602 }
603 match self.run_plugin_add() {
604 Invocation::Ran { success: true, .. } => InstallOutcome::Refreshed,
605 Invocation::Ran { detail, .. } => {
606 if says_already(&detail.to_ascii_lowercase()) {
607 InstallOutcome::Failed {
608 detail: "codex plugin add still reports an existing install after \
609 remove; refresh manually"
610 .to_owned(),
611 }
612 } else {
613 InstallOutcome::RemovedNotReinstalled { detail }
614 }
615 }
616 Invocation::Missing => InstallOutcome::RemovedNotReinstalled {
617 detail: CLI_VANISHED.to_owned(),
618 },
619 }
620 }
621
622 fn run_marketplace_add(&self) -> Invocation {
623 let root = self.marketplace_root.clone();
624 let mut command = std::process::Command::new(&self.codex_program);
625 command.args(["plugin", "marketplace", "add"]).arg(root);
626 run_invocation(command)
627 }
628
629 fn run_plugin_add(&self) -> Invocation {
630 self.run(&["plugin", "add", &self.plugin_spec()])
631 }
632
633 fn run(&self, args: &[&str]) -> Invocation {
634 let mut command = std::process::Command::new(&self.codex_program);
635 command.args(args);
636 run_invocation(command)
637 }
638}
639
640const CLI_VANISHED: &str = "codex CLI disappeared between commands";
643
644enum Invocation {
646 Missing,
648 Ran { success: bool, detail: String },
650}
651
652fn says_already(lowered_detail: &str) -> bool {
659 ["already added", "already installed", "already exists"]
660 .iter()
661 .any(|phrase| lowered_detail.contains(phrase))
662}
663
664fn says_nothing_to_remove(lowered_detail: &str) -> bool {
667 ["not installed", "not configured", "already removed"]
668 .iter()
669 .any(|phrase| lowered_detail.contains(phrase))
670}
671
672fn run_invocation(mut command: std::process::Command) -> Invocation {
680 let output = match command.stdin(std::process::Stdio::null()).output() {
681 Ok(output) => output,
682 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
683 return Invocation::Missing;
684 }
685 Err(error) => {
686 return Invocation::Ran {
687 success: false,
688 detail: error.to_string(),
689 };
690 }
691 };
692 if output.status.success() {
693 return Invocation::Ran {
694 success: true,
695 detail: String::new(),
696 };
697 }
698 Invocation::Ran {
699 success: false,
700 detail: summarize_output(&output),
701 }
702}
703
704fn summarize_output(output: &std::process::Output) -> String {
709 let stderr = String::from_utf8_lossy(&output.stderr);
710 let stdout = String::from_utf8_lossy(&output.stdout);
711 let mut detail = stderr
712 .lines()
713 .chain(stdout.lines())
714 .map(str::trim)
715 .filter(|line| !line.is_empty())
716 .collect::<Vec<_>>()
717 .join("; ");
718 if detail.chars().count() > MAX_DETAIL_CHARS {
719 detail = detail.chars().take(MAX_DETAIL_CHARS).collect::<String>() + "…";
720 }
721 if detail.is_empty() {
722 detail = format!("exited with {}", output.status);
723 }
724 detail
725}
726
727const MAX_DETAIL_CHARS: usize = 200;
729
730#[cfg(test)]
731#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
732mod tests {
733 use super::*;
734 use crate::plugin::codex_app::{HookPluginIdentity, render_plugin_manifest};
735
736 fn identity() -> MarketplaceIdentity<'static> {
737 MarketplaceIdentity::new("acme", "acme-codex").with_display_name("Acme")
738 }
739
740 fn manager(codex_program: PathBuf, root: &Path) -> PluginManager {
741 PluginManager::new(
742 codex_program,
743 root.join("marketplace"),
744 "acme",
745 "acme-codex",
746 )
747 }
748
749 fn missing_codex(root: &Path) -> PathBuf {
750 root.join("codex-not-installed")
751 }
752
753 #[cfg(unix)]
776 fn write_codex_shim(root: &Path, body: &str) -> PathBuf {
777 use std::os::unix::fs::PermissionsExt;
778
779 let log = root.join("invocations.log");
780 let path = root.join("codex");
781 std::fs::write(
782 &path,
783 format!("#!/bin/sh\necho \"$@\" >> \"{}\"\n{body}\n", log.display()),
784 )
785 .unwrap();
786 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
787
788 for attempt in 1.. {
789 match std::process::Command::new(&path).output() {
790 Ok(_) => break,
791 Err(err)
792 if err.kind() == std::io::ErrorKind::ExecutableFileBusy && attempt < 100 =>
793 {
794 std::thread::sleep(std::time::Duration::from_millis(5));
795 }
796 Err(err) => panic!("warm-up exec of {} failed: {err}", path.display()),
797 }
798 }
799 let _ = std::fs::remove_file(&log);
800 path
801 }
802
803 #[cfg(unix)]
804 fn shim_log(root: &Path) -> Vec<String> {
805 std::fs::read_to_string(root.join("invocations.log"))
806 .unwrap_or_default()
807 .lines()
808 .map(str::to_owned)
809 .collect()
810 }
811
812 #[cfg(unix)]
813 fn add_marketplace(root: &Path) -> String {
814 format!(
815 "plugin marketplace add {}",
816 root.join("marketplace").display()
817 )
818 }
819
820 #[test]
821 fn the_rendered_marketplace_offers_the_plugin_at_the_path_the_helpers_name() {
822 let rendered = render_marketplace_manifest(&identity());
823 let parsed: serde_json::Value = serde_json::from_str(&rendered).unwrap();
824
825 assert!(!rendered.contains("__TAPES_"), "{rendered}");
826 assert_eq!(parsed["name"], "acme");
827 assert_eq!(parsed["interface"]["displayName"], "Acme");
828 let plugins = parsed["plugins"].as_array().unwrap();
829 assert_eq!(plugins.len(), 1);
830 assert_eq!(plugins[0]["name"], "acme-codex");
831 assert_eq!(plugins[0]["source"]["source"], "local");
832
833 let offered = plugins[0]["source"]["path"].as_str().unwrap();
836 let offered = Path::new(offered.trim_start_matches("./"));
837 assert_eq!(offered, plugin_source_dir("acme-codex"));
838 for path in [
839 plugin_manifest_path("acme-codex"),
840 hooks_manifest_path("acme-codex"),
841 ] {
842 assert!(
843 path.starts_with(offered),
844 "{} escapes {offered:?}",
845 path.display()
846 );
847 }
848 }
849
850 #[test]
855 fn the_offered_name_the_manifest_name_and_the_spec_agree() {
856 let marketplace: serde_json::Value =
857 serde_json::from_str(&render_marketplace_manifest(&identity())).unwrap();
858 let manifest: serde_json::Value = serde_json::from_str(&render_plugin_manifest(
859 &HookPluginIdentity::new("acme-codex", "1.0.0"),
860 ))
861 .unwrap();
862
863 assert_eq!(marketplace["plugins"][0]["name"], manifest["name"]);
864 assert_eq!(
865 plugin_spec("acme-codex", "acme"),
866 format!(
867 "{}@{}",
868 marketplace["plugins"][0]["name"].as_str().unwrap(),
869 marketplace["name"].as_str().unwrap()
870 )
871 );
872 }
873
874 #[test]
877 fn a_minimal_marketplace_identity_fills_every_slot() {
878 let rendered = render_marketplace_manifest(&MarketplaceIdentity::new("bare", "bare-codex"));
879 let parsed: serde_json::Value = serde_json::from_str(&rendered).unwrap();
880
881 assert!(!rendered.contains("__TAPES_"), "{rendered}");
882 assert_eq!(parsed["interface"]["displayName"], "bare");
883 }
884
885 #[test]
887 fn the_marketplace_template_carries_no_vendor_branding() {
888 let lowered = MARKETPLACE_MANIFEST_TEMPLATE.to_ascii_lowercase();
889 for token in ["paper", "papercompute", "tapesctl"] {
890 assert!(!lowered.contains(token), "the template mentions {token:?}");
891 }
892 }
893
894 #[test]
895 fn every_marketplace_slot_is_filled_and_none_is_unknown() {
896 for slot in [
897 MARKETPLACE_NAME_SLOT,
898 MARKETPLACE_DISPLAY_NAME_SLOT,
899 MARKETPLACE_PLUGIN_NAME_SLOT,
900 MARKETPLACE_PLUGIN_SOURCE_PATH_SLOT,
901 ] {
902 assert!(
903 MARKETPLACE_MANIFEST_TEMPLATE.contains(&format!("\"{slot}\"")),
904 "template is missing slot {slot}"
905 );
906 }
907 assert_eq!(MARKETPLACE_MANIFEST_TEMPLATE.matches("__TAPES_").count(), 4);
908 }
909
910 #[test]
911 fn an_absent_cli_is_reported_rather_than_failed() {
912 let root = tempfile::tempdir().unwrap();
913 let manager = manager(missing_codex(root.path()), root.path());
914
915 for goal in [
916 InstallGoal::Install,
917 InstallGoal::Refresh,
918 InstallGoal::Verify,
919 ] {
920 assert_eq!(manager.register(goal, false), ManagerRun::CliAbsent);
921 }
922 }
923
924 #[cfg(unix)]
925 #[test]
926 fn a_clean_run_adds_the_marketplace_then_the_plugin() {
927 let root = tempfile::tempdir().unwrap();
928 let manager = manager(write_codex_shim(root.path(), "exit 0"), root.path());
929
930 let run = manager.register(InstallGoal::Install, false);
931
932 assert_eq!(
933 run,
934 ManagerRun::Steps {
935 marketplace: MarketplaceOutcome::Added,
936 install: InstallOutcome::Installed,
937 }
938 );
939 assert_eq!(
940 shim_log(root.path()),
941 vec![
942 add_marketplace(root.path()),
943 "plugin add acme-codex@acme".to_owned(),
944 ]
945 );
946 }
947
948 #[cfg(unix)]
949 #[test]
950 fn already_wording_is_trusted_only_when_the_caller_confirmed_delivery() {
951 let root = tempfile::tempdir().unwrap();
952 let manager = manager(
953 write_codex_shim(
954 root.path(),
955 "echo 'error: marketplace already exists' >&2\nexit 1",
956 ),
957 root.path(),
958 );
959
960 let run = manager.register(InstallGoal::Verify, false);
961
962 assert_eq!(
963 run,
964 ManagerRun::Steps {
965 marketplace: MarketplaceOutcome::AlreadyAdded,
966 install: InstallOutcome::AlreadyInstalled,
967 }
968 );
969 }
970
971 #[cfg(unix)]
974 #[test]
975 fn unrecognised_failure_wording_skips_the_install() {
976 let root = tempfile::tempdir().unwrap();
977 let manager = manager(
978 write_codex_shim(root.path(), "echo 'boom: no permission' >&2\nexit 2"),
979 root.path(),
980 );
981
982 let run = manager.register(InstallGoal::Install, false);
983
984 assert_eq!(
985 run,
986 ManagerRun::Steps {
987 marketplace: MarketplaceOutcome::Failed {
988 detail: "boom: no permission".to_owned()
989 },
990 install: InstallOutcome::Skipped,
991 }
992 );
993 assert_eq!(
994 shim_log(root.path()).len(),
995 1,
996 "the plugin add must not run"
997 );
998 }
999
1000 #[cfg(unix)]
1003 #[test]
1004 fn a_cooperative_add_refreshes_without_the_remove_fallback() {
1005 let root = tempfile::tempdir().unwrap();
1006 let manager = manager(write_codex_shim(root.path(), "exit 0"), root.path());
1007
1008 let run = manager.register(InstallGoal::Refresh, false);
1009
1010 assert_eq!(
1011 run,
1012 ManagerRun::Steps {
1013 marketplace: MarketplaceOutcome::Added,
1014 install: InstallOutcome::Refreshed,
1015 }
1016 );
1017 assert_eq!(
1018 shim_log(root.path()),
1019 vec![
1020 add_marketplace(root.path()),
1021 "plugin add acme-codex@acme".to_owned(),
1022 ]
1023 );
1024 }
1025
1026 #[cfg(unix)]
1027 fn add_is_sticky_until_removed(root: &Path) -> PathBuf {
1028 write_codex_shim(
1029 root,
1030 &format!(
1031 "case \"$*\" in\n \
1032 *'plugin remove'*) touch \"{removed}\"; exit 0 ;;\n \
1033 *'plugin add'*) if [ -f \"{removed}\" ]; then exit 0; \
1034 else echo 'plugin is already installed' >&2; exit 1; fi ;;\n \
1035 *) exit 0 ;;\nesac",
1036 removed = root.join("removed.sentinel").display()
1037 ),
1038 )
1039 }
1040
1041 #[cfg(unix)]
1042 #[test]
1043 fn an_uncooperative_add_falls_back_to_remove_then_re_add() {
1044 let root = tempfile::tempdir().unwrap();
1045 let manager = manager(add_is_sticky_until_removed(root.path()), root.path());
1046
1047 let run = manager.register(InstallGoal::Refresh, false);
1048
1049 assert_eq!(
1050 run,
1051 ManagerRun::Steps {
1052 marketplace: MarketplaceOutcome::Added,
1053 install: InstallOutcome::Refreshed,
1054 }
1055 );
1056 assert_eq!(
1057 shim_log(root.path()),
1058 vec![
1059 add_marketplace(root.path()),
1060 "plugin add acme-codex@acme".to_owned(),
1061 "plugin remove acme-codex@acme".to_owned(),
1062 "plugin add acme-codex@acme".to_owned(),
1063 ],
1064 "fallback order must be add, remove, re-add"
1065 );
1066 }
1067
1068 #[cfg(unix)]
1071 #[test]
1072 fn an_unconfirmed_existing_install_is_forced_fresh() {
1073 let root = tempfile::tempdir().unwrap();
1074 let manager = manager(add_is_sticky_until_removed(root.path()), root.path());
1075
1076 let run = manager.register(InstallGoal::Install, false);
1077
1078 assert!(
1079 matches!(
1080 run,
1081 ManagerRun::Steps {
1082 install: InstallOutcome::Refreshed,
1083 ..
1084 }
1085 ),
1086 "{run:?}"
1087 );
1088 }
1089
1090 #[cfg(unix)]
1091 #[test]
1092 fn a_failed_remove_keeps_the_stale_install_in_place() {
1093 let root = tempfile::tempdir().unwrap();
1094 let manager = manager(
1095 write_codex_shim(
1096 root.path(),
1097 "case \"$*\" in\n \
1098 *'plugin remove'*) echo 'remove blew up' >&2; exit 2 ;;\n \
1099 *'plugin add'*) echo 'plugin is already installed' >&2; exit 1 ;;\n \
1100 *) exit 0 ;;\nesac",
1101 ),
1102 root.path(),
1103 );
1104
1105 let run = manager.register(InstallGoal::Refresh, false);
1106
1107 let ManagerRun::Steps { install, .. } = run else {
1108 panic!("expected steps");
1109 };
1110 let InstallOutcome::Failed { detail } = install else {
1111 panic!("expected a plain failure, got {install:?}");
1112 };
1113 assert!(detail.contains("codex plugin remove"), "{detail}");
1114 assert!(detail.contains("remove blew up"), "{detail}");
1115 assert_eq!(
1116 shim_log(root.path())
1117 .iter()
1118 .filter(|line| line.starts_with("plugin add"))
1119 .count(),
1120 1,
1121 "no re-add may follow a failed remove"
1122 );
1123 }
1124
1125 #[cfg(unix)]
1128 #[test]
1129 fn a_remove_that_had_nothing_to_remove_proceeds_to_the_re_add() {
1130 let root = tempfile::tempdir().unwrap();
1131 let manager = manager(
1132 write_codex_shim(
1133 root.path(),
1134 &format!(
1135 "case \"$*\" in\n \
1136 *'plugin remove'*) touch \"{done}\"; echo 'plugin is not installed' >&2; \
1137 exit 1 ;;\n \
1138 *'plugin add'*) if [ -f \"{done}\" ]; then exit 0; \
1139 else echo 'plugin is already installed' >&2; exit 1; fi ;;\n \
1140 *) exit 0 ;;\nesac",
1141 done = root.path().join("removed.sentinel").display()
1142 ),
1143 ),
1144 root.path(),
1145 );
1146
1147 let run = manager.register(InstallGoal::Refresh, false);
1148
1149 assert!(
1150 matches!(
1151 run,
1152 ManagerRun::Steps {
1153 install: InstallOutcome::Refreshed,
1154 ..
1155 }
1156 ),
1157 "{run:?}"
1158 );
1159 }
1160
1161 #[cfg(unix)]
1163 #[test]
1164 fn a_failed_re_add_after_a_successful_remove_says_so() {
1165 let root = tempfile::tempdir().unwrap();
1166 let manager = manager(
1167 write_codex_shim(
1168 root.path(),
1169 &format!(
1170 "case \"$*\" in\n \
1171 *'plugin remove'*) touch \"{removed}\"; exit 0 ;;\n \
1172 *'plugin add'*) if [ -f \"{removed}\" ]; then echo 'network exploded' >&2; \
1173 exit 2; else echo 'plugin is already installed' >&2; exit 1; fi ;;\n \
1174 *) exit 0 ;;\nesac",
1175 removed = root.path().join("removed.sentinel").display()
1176 ),
1177 ),
1178 root.path(),
1179 );
1180
1181 let run = manager.register(InstallGoal::Refresh, false);
1182
1183 let ManagerRun::Steps { install, .. } = run else {
1184 panic!("expected steps");
1185 };
1186 assert_eq!(
1187 install,
1188 InstallOutcome::RemovedNotReinstalled {
1189 detail: "network exploded".to_owned()
1190 }
1191 );
1192 assert!(install.needs_manual_retry());
1193 assert!(!install.confirmed_delivery());
1194 }
1195
1196 #[cfg(unix)]
1200 #[test]
1201 fn a_same_named_marketplace_at_another_source_is_replaced() {
1202 let root = tempfile::tempdir().unwrap();
1203 let manager = manager(
1204 write_codex_shim(
1205 root.path(),
1206 &format!(
1207 "case \"$*\" in\n \
1208 *'plugin marketplace remove'*) touch \"{removed}\"; exit 0 ;;\n \
1209 *'plugin marketplace add'*) if [ -f \"{removed}\" ]; then exit 0; \
1210 else echo \"Error: marketplace 'acme' is already added from a different \
1211 source; remove it before adding this source\" >&2; exit 1; fi ;;\n \
1212 *) exit 0 ;;\nesac",
1213 removed = root.path().join("mkt-removed.sentinel").display()
1214 ),
1215 ),
1216 root.path(),
1217 );
1218
1219 let run = manager.register(InstallGoal::Install, false);
1220
1221 assert_eq!(
1222 run,
1223 ManagerRun::Steps {
1224 marketplace: MarketplaceOutcome::Replaced {
1225 marketplace_name: "acme".to_owned()
1226 },
1227 install: InstallOutcome::Installed,
1228 }
1229 );
1230 assert_eq!(
1231 shim_log(root.path()),
1232 vec![
1233 add_marketplace(root.path()),
1234 "plugin marketplace remove acme".to_owned(),
1235 add_marketplace(root.path()),
1236 "plugin add acme-codex@acme".to_owned(),
1237 ],
1238 "collision order must be add, remove, re-add, plugin add"
1239 );
1240 }
1241
1242 #[cfg(unix)]
1243 #[test]
1244 fn a_collision_whose_removal_fails_is_reported_with_both_reasons() {
1245 let root = tempfile::tempdir().unwrap();
1246 let manager = manager(
1247 write_codex_shim(
1248 root.path(),
1249 "case \"$*\" in\n \
1250 *'plugin marketplace remove'*) echo 'permission denied' >&2; exit 2 ;;\n \
1251 *'plugin marketplace add'*) echo \"Error: marketplace 'acme' is already added \
1252 from a different source; remove it before adding this source\" >&2; exit 1 ;;\n \
1253 *) exit 0 ;;\nesac",
1254 ),
1255 root.path(),
1256 );
1257
1258 let run = manager.register(InstallGoal::Install, false);
1259
1260 let ManagerRun::Steps {
1261 marketplace,
1262 install,
1263 } = run
1264 else {
1265 panic!("expected steps");
1266 };
1267 let MarketplaceOutcome::Failed { detail } = marketplace else {
1268 panic!("expected a failure, got {marketplace:?}");
1269 };
1270 assert!(detail.contains("different source"), "{detail}");
1271 assert!(detail.contains("permission denied"), "{detail}");
1272 assert_eq!(install, InstallOutcome::Skipped);
1273 }
1274
1275 #[cfg(unix)]
1285 #[test]
1286 fn a_disabled_plugin_leaves_someone_elses_marketplace_alone() {
1287 let root = tempfile::tempdir().unwrap();
1288 let manager = manager(
1289 write_codex_shim(
1290 root.path(),
1291 &format!(
1292 "case \"$*\" in\n \
1293 *'plugin marketplace remove'*) touch \"{removed}\"; exit 0 ;;\n \
1294 *'plugin marketplace add'*) if [ -f \"{removed}\" ]; then exit 0; \
1295 else echo \"Error: marketplace 'acme' is already added from a different \
1296 source; remove it before adding this source\" >&2; exit 1; fi ;;\n \
1297 *) exit 0 ;;\nesac",
1298 removed = root.path().join("mkt-removed.sentinel").display()
1299 ),
1300 ),
1301 root.path(),
1302 );
1303
1304 let run = manager.register(InstallGoal::Install, true);
1305
1306 assert_eq!(run, ManagerRun::SkippedDisabled);
1307 assert!(
1308 shim_log(root.path()).is_empty(),
1309 "a disabled plugin must run no codex command at all, got {:?}",
1310 shim_log(root.path())
1311 );
1312 }
1313
1314 #[cfg(unix)]
1322 #[test]
1323 fn a_printed_command_survives_shell_word_splitting() {
1324 let awkward = std::path::PathBuf::from("/tmp/two words/it's here/$HOME`x`;rm -rf/plugin");
1325 let manager = PluginManager::new("codex", &awkward, "acme", "acme-codex");
1326
1327 let words = shell_words(&manager.manual_commands()[0]);
1328
1329 assert_eq!(
1330 words,
1331 vec![
1332 "codex".to_owned(),
1333 "plugin".to_owned(),
1334 "marketplace".to_owned(),
1335 "add".to_owned(),
1336 awkward.display().to_string(),
1337 ],
1338 "the marketplace path did not survive as one word"
1339 );
1340 }
1341
1342 #[cfg(unix)]
1344 fn shell_words(command: &str) -> Vec<String> {
1345 let output = std::process::Command::new("/bin/sh")
1346 .arg("-c")
1347 .arg(format!("set -- {command}\nprintf '%s\\n' \"$@\""))
1348 .output()
1349 .unwrap();
1350 assert!(
1351 output.status.success(),
1352 "the printed command is not even parseable by /bin/sh: {}",
1353 String::from_utf8_lossy(&output.stderr)
1354 );
1355 String::from_utf8(output.stdout)
1356 .unwrap()
1357 .lines()
1358 .map(str::to_owned)
1359 .collect()
1360 }
1361
1362 #[cfg(unix)]
1365 #[test]
1366 fn a_silent_failure_still_carries_a_detail() {
1367 let root = tempfile::tempdir().unwrap();
1368 let manager = manager(write_codex_shim(root.path(), "exit 3"), root.path());
1369
1370 let run = manager.register(InstallGoal::Install, false);
1371
1372 let ManagerRun::Steps { marketplace, .. } = run else {
1373 panic!("expected steps");
1374 };
1375 let MarketplaceOutcome::Failed { detail } = marketplace else {
1376 panic!("expected a failure, got {marketplace:?}");
1377 };
1378 assert!(detail.contains("exited with"), "{detail}");
1379 }
1380
1381 #[cfg(unix)]
1382 #[test]
1383 fn a_long_failure_detail_is_bounded() {
1384 let root = tempfile::tempdir().unwrap();
1385 let manager = manager(
1386 write_codex_shim(
1387 root.path(),
1388 "yes x | head -c 5000 | tr -d '\\n' >&2; exit 1",
1389 ),
1390 root.path(),
1391 );
1392
1393 let run = manager.register(InstallGoal::Install, false);
1394
1395 let ManagerRun::Steps { marketplace, .. } = run else {
1396 panic!("expected steps");
1397 };
1398 let MarketplaceOutcome::Failed { detail } = marketplace else {
1399 panic!("expected a failure, got {marketplace:?}");
1400 };
1401 assert_eq!(detail.chars().count(), MAX_DETAIL_CHARS + 1, "{detail}");
1402 assert!(detail.ends_with('…'), "{detail}");
1403 }
1404
1405 #[test]
1406 fn the_manual_commands_are_the_commands_a_run_would_have_issued() {
1407 let root = tempfile::tempdir().unwrap();
1408 let manager = manager(missing_codex(root.path()), root.path());
1409
1410 assert_eq!(
1414 manager.manual_commands(),
1415 [
1416 format!(
1417 "codex plugin marketplace add {}",
1418 manager.marketplace_root().display()
1419 ),
1420 "codex plugin add acme-codex@acme".to_owned(),
1421 ]
1422 );
1423 assert_eq!(manager.install_command(), manager.manual_commands()[1]);
1424 }
1425
1426 #[test]
1427 fn only_an_explicit_false_reads_as_disabled() {
1428 let spec = plugin_spec("acme-codex", "acme");
1429
1430 assert!(plugin_disabled_in_config(
1431 &format!("[plugins.\"{spec}\"]\nenabled = false\n"),
1432 &spec
1433 ));
1434 assert!(!plugin_disabled_in_config(
1435 &format!("[plugins.\"{spec}\"]\nenabled = true\n"),
1436 &spec
1437 ));
1438 assert!(!plugin_disabled_in_config("", &spec));
1441 assert!(!plugin_disabled_in_config(
1442 &format!("[plugins.\"{spec}\"]\n"),
1443 &spec
1444 ));
1445 assert!(!plugin_disabled_in_config(
1446 "[plugins.\"other@acme\"]\nenabled = false\n",
1447 &spec
1448 ));
1449 assert!(!plugin_disabled_in_config("not = [valid\n", &spec));
1450 }
1451
1452 #[test]
1453 fn describes_cover_every_outcome_without_leaking_a_debug_shape() {
1454 for outcome in [
1455 MarketplaceOutcome::Added,
1456 MarketplaceOutcome::AlreadyAdded,
1457 MarketplaceOutcome::Replaced {
1458 marketplace_name: "acme".to_owned(),
1459 },
1460 MarketplaceOutcome::Failed {
1461 detail: "boom".to_owned(),
1462 },
1463 ] {
1464 let described = outcome.describe();
1465 assert!(!described.is_empty());
1466 assert!(!described.contains('{'), "{described}");
1467 }
1468 for outcome in [
1469 InstallOutcome::Installed,
1470 InstallOutcome::AlreadyInstalled,
1471 InstallOutcome::Refreshed,
1472 InstallOutcome::Failed {
1473 detail: "boom".to_owned(),
1474 },
1475 InstallOutcome::RemovedNotReinstalled {
1476 detail: "boom".to_owned(),
1477 },
1478 InstallOutcome::Skipped,
1479 ] {
1480 let described = outcome.describe();
1481 assert!(!described.is_empty());
1482 assert!(!described.contains('{'), "{described}");
1483 }
1484 }
1485
1486 #[test]
1490 fn only_a_proven_copy_counts_as_delivered() {
1491 assert!(InstallOutcome::Installed.confirmed_delivery());
1492 assert!(InstallOutcome::Refreshed.confirmed_delivery());
1493 for outcome in [
1494 InstallOutcome::AlreadyInstalled,
1495 InstallOutcome::Failed {
1496 detail: String::new(),
1497 },
1498 InstallOutcome::RemovedNotReinstalled {
1499 detail: String::new(),
1500 },
1501 InstallOutcome::Skipped,
1502 ] {
1503 assert!(!outcome.confirmed_delivery(), "{outcome:?}");
1504 }
1505 }
1506}