Skip to main content

ta_changeset/
plugin.rs

1// plugin.rs — Channel plugin manifest and discovery for out-of-process plugins.
2//
3// Plugins are external executables (any language) that implement a channel
4// adapter using one of two protocols:
5//   1. JSON-over-stdio: TA spawns the plugin, sends ChannelQuestion on stdin,
6//      reads DeliveryResult from stdout.
7//   2. HTTP callback: TA POSTs ChannelQuestion to a configured URL.
8//
9// Plugins are discovered from:
10//   - `.ta/plugins/channels/` (project-local)
11//   - `~/.config/ta/plugins/channels/` (user-global)
12//   - `[[channels.external]]` entries in daemon.toml (inline config)
13//
14// Each plugin directory contains a `channel.toml` manifest describing the
15// plugin's name, protocol, command, and capabilities.
16
17use std::path::{Path, PathBuf};
18
19use serde::{Deserialize, Serialize};
20
21/// Protocol used by an external channel plugin.
22#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
23#[serde(rename_all = "kebab-case")]
24pub enum PluginProtocol {
25    /// JSON-over-stdio: TA spawns the plugin process, writes ChannelQuestion
26    /// JSON to stdin, reads DeliveryResult JSON line from stdout.
27    JsonStdio,
28    /// HTTP callback: TA POSTs ChannelQuestion JSON to a URL, reads
29    /// DeliveryResult from the response body.
30    Http,
31}
32
33impl std::fmt::Display for PluginProtocol {
34    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
35        match self {
36            PluginProtocol::JsonStdio => write!(f, "json-stdio"),
37            PluginProtocol::Http => write!(f, "http"),
38        }
39    }
40}
41
42/// Parsed `channel.toml` plugin manifest.
43#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct PluginManifest {
45    /// Plugin name (e.g., "teams", "pagerduty", "custom-webhook").
46    pub name: String,
47
48    /// Plugin version (semver).
49    #[serde(default = "default_version")]
50    pub version: String,
51
52    /// Command to spawn for json-stdio plugins.
53    /// Can be a bare executable name (resolved via PATH) or a full path.
54    /// Ignored for http protocol.
55    #[serde(default)]
56    pub command: Option<String>,
57
58    /// Additional arguments to pass to the command.
59    #[serde(default)]
60    pub args: Vec<String>,
61
62    /// Communication protocol.
63    pub protocol: PluginProtocol,
64
65    /// URL to POST questions to (only for http protocol).
66    #[serde(default)]
67    pub deliver_url: Option<String>,
68
69    /// Environment variable name holding an auth token (http protocol).
70    #[serde(default)]
71    pub auth_token_env: Option<String>,
72
73    /// Capabilities this plugin supports.
74    #[serde(default = "default_capabilities")]
75    pub capabilities: Vec<String>,
76
77    /// Human-readable description.
78    #[serde(default)]
79    pub description: Option<String>,
80
81    /// Timeout in seconds for a single delivery attempt.
82    #[serde(default = "default_timeout_secs")]
83    pub timeout_secs: u64,
84
85    /// Custom build command for non-Rust plugins.
86    ///
87    /// Rust plugins default to `cargo build --release` when this is absent.
88    /// Non-Rust plugins specify their own build step:
89    ///   - Go: `"go build -o ta-channel-teams ."`
90    ///   - Python: `"pip install -e ."`
91    ///   - Node: `"npm run build"`
92    #[serde(default)]
93    pub build_command: Option<String>,
94
95    /// Minimum daemon version required by this plugin (v0.10.16).
96    /// Semver string (e.g., "0.10.0-alpha"). If set and the daemon version
97    /// is lower, plugin validation warns about incompatibility.
98    #[serde(default)]
99    pub min_daemon_version: Option<String>,
100
101    /// Source URL for remote install / upgrade (v0.10.16).
102    /// Used by `ta plugin upgrade` to fetch the latest version.
103    #[serde(default)]
104    pub source_url: Option<String>,
105}
106
107fn default_version() -> String {
108    "0.1.0".to_string()
109}
110
111fn default_capabilities() -> Vec<String> {
112    vec!["deliver_question".to_string()]
113}
114
115fn default_timeout_secs() -> u64 {
116    30
117}
118
119/// A discovered plugin with its manifest and source path.
120#[derive(Debug, Clone)]
121pub struct DiscoveredPlugin {
122    /// Parsed manifest.
123    pub manifest: PluginManifest,
124    /// Directory containing the channel.toml (the plugin root).
125    pub plugin_dir: PathBuf,
126    /// Whether this came from project-local or user-global directory.
127    pub source: PluginSource,
128}
129
130/// Where a plugin was discovered from.
131#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
132pub enum PluginSource {
133    /// `.ta/plugins/channels/` in the project root.
134    ProjectLocal,
135    /// `~/.config/ta/plugins/channels/` (user-global).
136    UserGlobal,
137    /// `[[channels.external]]` in daemon.toml (inline config).
138    InlineConfig,
139}
140
141impl std::fmt::Display for PluginSource {
142    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
143        match self {
144            PluginSource::ProjectLocal => write!(f, "project"),
145            PluginSource::UserGlobal => write!(f, "global"),
146            PluginSource::InlineConfig => write!(f, "config"),
147        }
148    }
149}
150
151/// Errors from plugin operations.
152#[derive(Debug, thiserror::Error)]
153pub enum PluginError {
154    #[error("plugin manifest not found: {path}")]
155    ManifestNotFound { path: PathBuf },
156
157    #[error("invalid plugin manifest at {path}: {reason}")]
158    InvalidManifest { path: PathBuf, reason: String },
159
160    #[error("plugin '{name}' requires command for json-stdio protocol")]
161    MissingCommand { name: String },
162
163    #[error("plugin '{name}' requires deliver_url for http protocol")]
164    MissingDeliverUrl { name: String },
165
166    #[error("duplicate plugin name '{name}' — found in {first} and {second}")]
167    DuplicateName {
168        name: String,
169        first: String,
170        second: String,
171    },
172
173    #[error("I/O error: {0}")]
174    Io(#[from] std::io::Error),
175
176    #[error("plugin install failed: {0}")]
177    InstallFailed(String),
178}
179
180impl PluginManifest {
181    /// Load a plugin manifest from a `channel.toml` file.
182    pub fn load(path: &Path) -> Result<Self, PluginError> {
183        if !path.exists() {
184            return Err(PluginError::ManifestNotFound {
185                path: path.to_path_buf(),
186            });
187        }
188        let content = std::fs::read_to_string(path)?;
189        let manifest: Self =
190            toml::from_str(&content).map_err(|e| PluginError::InvalidManifest {
191                path: path.to_path_buf(),
192                reason: e.to_string(),
193            })?;
194        manifest.validate()?;
195        Ok(manifest)
196    }
197
198    /// Validate internal consistency of the manifest.
199    pub fn validate(&self) -> Result<(), PluginError> {
200        match self.protocol {
201            PluginProtocol::JsonStdio => {
202                if self.command.is_none() {
203                    return Err(PluginError::MissingCommand {
204                        name: self.name.clone(),
205                    });
206                }
207            }
208            PluginProtocol::Http => {
209                if self.deliver_url.is_none() {
210                    return Err(PluginError::MissingDeliverUrl {
211                        name: self.name.clone(),
212                    });
213                }
214            }
215        }
216        Ok(())
217    }
218}
219
220/// Discover channel plugins from standard directories.
221///
222/// Scans both project-local and user-global plugin directories for
223/// `channel.toml` manifests. Returns all successfully parsed plugins,
224/// logging warnings for any that fail to parse.
225pub fn discover_plugins(project_root: &Path) -> Vec<DiscoveredPlugin> {
226    let mut plugins = Vec::new();
227
228    // Project-local plugins: .ta/plugins/channels/
229    let project_dir = project_root.join(".ta").join("plugins").join("channels");
230    scan_plugin_dir(&project_dir, PluginSource::ProjectLocal, &mut plugins);
231
232    // User-global plugins: ~/.config/ta/plugins/channels/
233    if let Some(config_dir) = dirs_config_dir() {
234        let global_dir = config_dir.join("ta").join("plugins").join("channels");
235        scan_plugin_dir(&global_dir, PluginSource::UserGlobal, &mut plugins);
236    }
237
238    plugins
239}
240
241/// Scan a directory for plugin subdirectories containing `channel.toml`.
242fn scan_plugin_dir(dir: &Path, source: PluginSource, plugins: &mut Vec<DiscoveredPlugin>) {
243    if !dir.is_dir() {
244        return;
245    }
246
247    let entries = match std::fs::read_dir(dir) {
248        Ok(entries) => entries,
249        Err(e) => {
250            tracing::warn!(
251                dir = %dir.display(),
252                error = %e,
253                "Failed to read plugin directory"
254            );
255            return;
256        }
257    };
258
259    for entry in entries.flatten() {
260        let path = entry.path();
261        if !path.is_dir() {
262            continue;
263        }
264
265        let manifest_path = path.join("channel.toml");
266        if !manifest_path.exists() {
267            continue;
268        }
269
270        match PluginManifest::load(&manifest_path) {
271            Ok(manifest) => {
272                tracing::debug!(
273                    plugin = %manifest.name,
274                    protocol = %manifest.protocol,
275                    source = %source,
276                    "Discovered channel plugin"
277                );
278                plugins.push(DiscoveredPlugin {
279                    manifest,
280                    plugin_dir: path,
281                    source: source.clone(),
282                });
283            }
284            Err(e) => {
285                tracing::warn!(
286                    path = %manifest_path.display(),
287                    error = %e,
288                    "Skipping invalid channel plugin"
289                );
290            }
291        }
292    }
293}
294
295/// Install a channel plugin from a source directory to the target plugin dir.
296///
297/// Copies the entire plugin directory (including channel.toml and any binaries)
298/// to the target location.
299pub fn install_plugin(
300    source: &Path,
301    project_root: &Path,
302    global: bool,
303) -> Result<DiscoveredPlugin, PluginError> {
304    // Load and validate the manifest first.
305    let manifest_path = source.join("channel.toml");
306    let manifest = PluginManifest::load(&manifest_path)?;
307
308    // Determine target directory.
309    let target_base = if global {
310        dirs_config_dir()
311            .ok_or_else(|| {
312                PluginError::InstallFailed("cannot determine user config directory".into())
313            })?
314            .join("ta")
315            .join("plugins")
316            .join("channels")
317    } else {
318        project_root.join(".ta").join("plugins").join("channels")
319    };
320
321    let target_dir = target_base.join(&manifest.name);
322
323    // Create target directory.
324    std::fs::create_dir_all(&target_dir)?;
325
326    // Copy all files from source to target.
327    copy_dir_contents(source, &target_dir)?;
328
329    // On macOS, ad-hoc sign any copied binaries so AppleSystemPolicy
330    // doesn't block execution of unsigned downloaded executables.
331    #[cfg(target_os = "macos")]
332    codesign_plugin_binaries(&target_dir);
333
334    let plugin_source = if global {
335        PluginSource::UserGlobal
336    } else {
337        PluginSource::ProjectLocal
338    };
339
340    Ok(DiscoveredPlugin {
341        manifest,
342        plugin_dir: target_dir,
343        source: plugin_source,
344    })
345}
346
347/// Recursively copy directory contents (public wrapper for plugin_resolver).
348pub fn copy_dir_contents_public(src: &Path, dst: &Path) -> Result<(), PluginError> {
349    copy_dir_contents(src, dst)
350}
351
352/// Recursively copy directory contents.
353fn copy_dir_contents(src: &Path, dst: &Path) -> Result<(), PluginError> {
354    for entry in std::fs::read_dir(src)? {
355        let entry = entry?;
356        let src_path = entry.path();
357        let dst_path = dst.join(entry.file_name());
358
359        if src_path.is_dir() {
360            std::fs::create_dir_all(&dst_path)?;
361            copy_dir_contents(&src_path, &dst_path)?;
362        } else {
363            std::fs::copy(&src_path, &dst_path)?;
364        }
365    }
366    Ok(())
367}
368
369/// macOS: ad-hoc sign all executable files in the plugin directory.
370///
371/// This prevents GateKeeper / AppleSystemPolicy from blocking execution of
372/// plugin binaries that were copied from an unsigned source directory.
373/// Uses `codesign --force --sign -` (ad-hoc, no certificate required).
374/// Failures are logged as warnings but do not abort the install.
375#[cfg(target_os = "macos")]
376fn codesign_plugin_binaries(plugin_dir: &Path) {
377    use std::os::unix::fs::PermissionsExt;
378
379    let entries = match std::fs::read_dir(plugin_dir) {
380        Ok(e) => e,
381        Err(e) => {
382            tracing::warn!(
383                dir = %plugin_dir.display(),
384                error = %e,
385                "macOS codesign: could not read plugin directory"
386            );
387            return;
388        }
389    };
390
391    for entry in entries.flatten() {
392        let path = entry.path();
393        if !path.is_file() {
394            continue;
395        }
396        // Only sign files with execute permission bits set.
397        let Ok(meta) = path.metadata() else { continue };
398        let mode = meta.permissions().mode();
399        if mode & 0o111 == 0 {
400            continue;
401        }
402
403        let status = std::process::Command::new("codesign")
404            .args(["--force", "--sign", "-", path.to_str().unwrap_or("")])
405            .status();
406
407        match status {
408            Ok(s) if s.success() => {
409                tracing::debug!(path = %path.display(), "macOS: ad-hoc signed plugin binary");
410            }
411            Ok(s) => {
412                tracing::warn!(
413                    path = %path.display(),
414                    exit_code = ?s.code(),
415                    "macOS: codesign returned non-zero; plugin may be blocked by GateKeeper"
416                );
417            }
418            Err(e) => {
419                tracing::warn!(
420                    path = %path.display(),
421                    error = %e,
422                    "macOS: codesign not available; plugin may be blocked by GateKeeper. \
423                     Install Xcode Command Line Tools: xcode-select --install"
424                );
425            }
426        }
427    }
428}
429
430/// Get the user's config directory (platform-appropriate).
431fn dirs_config_dir() -> Option<PathBuf> {
432    // Use XDG on Linux, ~/Library/Application Support on macOS, etc.
433    // Simple implementation: check XDG_CONFIG_HOME, fall back to ~/.config
434    if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") {
435        return Some(PathBuf::from(xdg));
436    }
437    std::env::var("HOME")
438        .ok()
439        .map(|home| PathBuf::from(home).join(".config"))
440}
441
442#[cfg(test)]
443mod tests {
444    use super::*;
445
446    #[test]
447    fn parse_json_stdio_manifest() {
448        let toml_str = r#"
449name = "teams"
450version = "0.1.0"
451command = "python3 ta-channel-teams.py"
452protocol = "json-stdio"
453capabilities = ["deliver_question"]
454description = "Microsoft Teams channel plugin"
455"#;
456        let manifest: PluginManifest = toml::from_str(toml_str).unwrap();
457        assert_eq!(manifest.name, "teams");
458        assert_eq!(manifest.protocol, PluginProtocol::JsonStdio);
459        assert_eq!(
460            manifest.command.as_deref(),
461            Some("python3 ta-channel-teams.py")
462        );
463        assert!(manifest.deliver_url.is_none());
464        assert!(manifest.validate().is_ok());
465    }
466
467    #[test]
468    fn parse_http_manifest() {
469        let toml_str = r#"
470name = "pagerduty"
471protocol = "http"
472deliver_url = "https://my-service.com/ta/deliver"
473auth_token_env = "TA_PAGERDUTY_TOKEN"
474"#;
475        let manifest: PluginManifest = toml::from_str(toml_str).unwrap();
476        assert_eq!(manifest.name, "pagerduty");
477        assert_eq!(manifest.protocol, PluginProtocol::Http);
478        assert_eq!(
479            manifest.deliver_url.as_deref(),
480            Some("https://my-service.com/ta/deliver")
481        );
482        assert!(manifest.validate().is_ok());
483    }
484
485    #[test]
486    fn json_stdio_requires_command() {
487        let toml_str = r#"
488name = "broken"
489protocol = "json-stdio"
490"#;
491        let manifest: PluginManifest = toml::from_str(toml_str).unwrap();
492        let err = manifest.validate().unwrap_err();
493        assert!(err.to_string().contains("requires command"));
494    }
495
496    #[test]
497    fn http_requires_deliver_url() {
498        let toml_str = r#"
499name = "broken"
500protocol = "http"
501"#;
502        let manifest: PluginManifest = toml::from_str(toml_str).unwrap();
503        let err = manifest.validate().unwrap_err();
504        assert!(err.to_string().contains("requires deliver_url"));
505    }
506
507    #[test]
508    fn default_values() {
509        let toml_str = r#"
510name = "minimal"
511command = "my-plugin"
512protocol = "json-stdio"
513"#;
514        let manifest: PluginManifest = toml::from_str(toml_str).unwrap();
515        assert_eq!(manifest.version, "0.1.0");
516        assert_eq!(manifest.capabilities, vec!["deliver_question"]);
517        assert_eq!(manifest.timeout_secs, 30);
518        assert!(manifest.args.is_empty());
519    }
520
521    #[test]
522    fn load_manifest_from_file() {
523        let dir = tempfile::tempdir().unwrap();
524        let manifest_path = dir.path().join("channel.toml");
525        std::fs::write(
526            &manifest_path,
527            r#"
528name = "test-plugin"
529command = "test-cmd"
530protocol = "json-stdio"
531"#,
532        )
533        .unwrap();
534
535        let manifest = PluginManifest::load(&manifest_path).unwrap();
536        assert_eq!(manifest.name, "test-plugin");
537    }
538
539    #[test]
540    fn load_manifest_not_found() {
541        let err = PluginManifest::load(Path::new("/nonexistent/channel.toml")).unwrap_err();
542        assert!(matches!(err, PluginError::ManifestNotFound { .. }));
543    }
544
545    #[test]
546    fn load_manifest_invalid_toml() {
547        let dir = tempfile::tempdir().unwrap();
548        let manifest_path = dir.path().join("channel.toml");
549        std::fs::write(&manifest_path, "this is not valid toml {{{").unwrap();
550
551        let err = PluginManifest::load(&manifest_path).unwrap_err();
552        assert!(matches!(err, PluginError::InvalidManifest { .. }));
553    }
554
555    #[test]
556    fn discover_plugins_in_directory() {
557        let dir = tempfile::tempdir().unwrap();
558        let plugins_dir = dir.path().join(".ta").join("plugins").join("channels");
559
560        // Create two plugin directories
561        let plugin1_dir = plugins_dir.join("teams");
562        std::fs::create_dir_all(&plugin1_dir).unwrap();
563        std::fs::write(
564            plugin1_dir.join("channel.toml"),
565            r#"
566name = "teams"
567command = "ta-channel-teams"
568protocol = "json-stdio"
569"#,
570        )
571        .unwrap();
572
573        let plugin2_dir = plugins_dir.join("pagerduty");
574        std::fs::create_dir_all(&plugin2_dir).unwrap();
575        std::fs::write(
576            plugin2_dir.join("channel.toml"),
577            r#"
578name = "pagerduty"
579protocol = "http"
580deliver_url = "https://example.com/deliver"
581"#,
582        )
583        .unwrap();
584
585        let plugins = discover_plugins(dir.path());
586        assert_eq!(plugins.len(), 2);
587
588        let names: Vec<&str> = plugins.iter().map(|p| p.manifest.name.as_str()).collect();
589        assert!(names.contains(&"teams"));
590        assert!(names.contains(&"pagerduty"));
591    }
592
593    #[test]
594    fn discover_plugins_skips_invalid() {
595        let dir = tempfile::tempdir().unwrap();
596        let plugins_dir = dir.path().join(".ta").join("plugins").join("channels");
597
598        // Valid plugin
599        let valid_dir = plugins_dir.join("good");
600        std::fs::create_dir_all(&valid_dir).unwrap();
601        std::fs::write(
602            valid_dir.join("channel.toml"),
603            r#"
604name = "good"
605command = "good-plugin"
606protocol = "json-stdio"
607"#,
608        )
609        .unwrap();
610
611        // Invalid plugin (missing required field)
612        let bad_dir = plugins_dir.join("bad");
613        std::fs::create_dir_all(&bad_dir).unwrap();
614        std::fs::write(bad_dir.join("channel.toml"), "this is broken").unwrap();
615
616        let plugins = discover_plugins(dir.path());
617        assert_eq!(plugins.len(), 1);
618        assert_eq!(plugins[0].manifest.name, "good");
619    }
620
621    #[test]
622    fn discover_plugins_empty_dir() {
623        let dir = tempfile::tempdir().unwrap();
624        let plugins = discover_plugins(dir.path());
625        assert!(plugins.is_empty());
626    }
627
628    #[test]
629    fn install_plugin_to_project() {
630        let project = tempfile::tempdir().unwrap();
631        let source = tempfile::tempdir().unwrap();
632
633        // Create a source plugin
634        std::fs::write(
635            source.path().join("channel.toml"),
636            r#"
637name = "my-plugin"
638command = "my-plugin-cmd"
639protocol = "json-stdio"
640"#,
641        )
642        .unwrap();
643        std::fs::write(source.path().join("my-plugin-cmd"), "#!/bin/bash\necho ok").unwrap();
644
645        let result = install_plugin(source.path(), project.path(), false).unwrap();
646        assert_eq!(result.manifest.name, "my-plugin");
647        assert_eq!(result.source, PluginSource::ProjectLocal);
648
649        // Verify files were copied
650        let installed_manifest = project
651            .path()
652            .join(".ta/plugins/channels/my-plugin/channel.toml");
653        assert!(installed_manifest.exists());
654    }
655
656    #[test]
657    fn plugin_protocol_display() {
658        assert_eq!(format!("{}", PluginProtocol::JsonStdio), "json-stdio");
659        assert_eq!(format!("{}", PluginProtocol::Http), "http");
660    }
661
662    #[test]
663    fn plugin_source_display() {
664        assert_eq!(format!("{}", PluginSource::ProjectLocal), "project");
665        assert_eq!(format!("{}", PluginSource::UserGlobal), "global");
666        assert_eq!(format!("{}", PluginSource::InlineConfig), "config");
667    }
668
669    #[test]
670    fn manifest_with_args() {
671        let toml_str = r#"
672name = "python-plugin"
673command = "python3"
674args = ["-u", "channel_plugin.py"]
675protocol = "json-stdio"
676"#;
677        let manifest: PluginManifest = toml::from_str(toml_str).unwrap();
678        assert_eq!(manifest.args, vec!["-u", "channel_plugin.py"]);
679    }
680
681    #[test]
682    fn manifest_with_build_command() {
683        let toml_str = r#"
684name = "go-plugin"
685command = "ta-channel-teams"
686protocol = "json-stdio"
687build_command = "go build -o ta-channel-teams ."
688"#;
689        let manifest: PluginManifest = toml::from_str(toml_str).unwrap();
690        assert_eq!(
691            manifest.build_command.as_deref(),
692            Some("go build -o ta-channel-teams .")
693        );
694    }
695
696    #[test]
697    fn manifest_without_build_command() {
698        let toml_str = r#"
699name = "rust-plugin"
700command = "ta-channel-rust"
701protocol = "json-stdio"
702"#;
703        let manifest: PluginManifest = toml::from_str(toml_str).unwrap();
704        assert!(manifest.build_command.is_none());
705    }
706
707    #[test]
708    fn plugin_error_display() {
709        let err = PluginError::MissingCommand {
710            name: "test".into(),
711        };
712        assert!(err.to_string().contains("test"));
713        assert!(err.to_string().contains("command"));
714
715        let err = PluginError::DuplicateName {
716            name: "dup".into(),
717            first: "project".into(),
718            second: "global".into(),
719        };
720        assert!(err.to_string().contains("dup"));
721    }
722}