Skip to main content

par_term_acp/
agents.rs

1use std::collections::HashMap;
2use std::path::Path;
3#[cfg(test)]
4use std::path::PathBuf;
5
6use serde::Deserialize;
7
8/// Agent configuration loaded from TOML.
9#[derive(Debug, Clone, Deserialize)]
10pub struct AgentConfig {
11    pub identity: String,
12    pub name: String,
13    pub short_name: String,
14    #[serde(default = "default_protocol")]
15    pub protocol: String,
16    #[serde(default = "default_type")]
17    pub r#type: String,
18    #[serde(default)]
19    pub active: Option<bool>,
20    pub run_command: HashMap<String, String>,
21    #[serde(default)]
22    pub env: HashMap<String, String>,
23    /// Optional command to install the ACP connector for this agent.
24    #[serde(default)]
25    pub install_command: Option<String>,
26    #[serde(default)]
27    pub actions: HashMap<String, HashMap<String, ActionConfig>>,
28    /// Whether the ACP connector binary was found in PATH during discovery.
29    /// Populated by [`discover_agents`], not deserialized.
30    #[serde(skip)]
31    pub connector_installed: bool,
32}
33
34fn default_protocol() -> String {
35    "acp".to_string()
36}
37
38fn default_type() -> String {
39    "coding".to_string()
40}
41
42/// Configuration for an agent action.
43#[derive(Debug, Clone, Deserialize)]
44pub struct ActionConfig {
45    pub command: Option<String>,
46    pub description: Option<String>,
47}
48
49impl AgentConfig {
50    /// Returns the run command for the current platform.
51    /// Falls back to the wildcard `"*"` entry if the platform-specific key is absent.
52    pub fn run_command_for_platform(&self) -> Option<&str> {
53        let platform = if cfg!(target_os = "macos") {
54            "macos"
55        } else if cfg!(target_os = "windows") {
56            "windows"
57        } else {
58            "linux"
59        };
60        self.run_command
61            .get(platform)
62            .or_else(|| self.run_command.get("*"))
63            .map(|s| s.as_str())
64    }
65
66    /// Returns whether this agent is active. Defaults to `true` if not specified.
67    pub fn is_active(&self) -> bool {
68        self.active.unwrap_or(true)
69    }
70
71    /// Check if the run command binary exists in `PATH` and update
72    /// [`connector_installed`](Self::connector_installed).
73    pub fn detect_connector(&mut self) {
74        self.connector_installed = self
75            .run_command_for_platform()
76            .map(|cmd| {
77                // Extract the first token (the binary name)
78                let binary = cmd.split_whitespace().next().unwrap_or("");
79                binary_in_path(binary)
80            })
81            .unwrap_or(false);
82    }
83}
84
85/// Check whether a binary name exists in any directory on `PATH`.
86fn binary_in_path(binary: &str) -> bool {
87    resolve_binary_in_path(binary).is_some()
88}
89
90/// Resolve a binary name to its absolute path by searching `PATH`.
91///
92/// Returns `None` if the binary is not found or PATH is not set.
93pub fn resolve_binary_in_path(binary: &str) -> Option<std::path::PathBuf> {
94    resolve_binary_in_path_str(binary, &std::env::var("PATH").ok()?)
95}
96
97/// Resolve a binary name to its absolute path by searching the given PATH string.
98pub fn resolve_binary_in_path_str(binary: &str, path_var: &str) -> Option<std::path::PathBuf> {
99    if binary.is_empty() {
100        return None;
101    }
102    // If it's already an absolute path, just check it exists.
103    let path = std::path::Path::new(binary);
104    if path.is_absolute() {
105        return if path.is_file() {
106            Some(path.to_path_buf())
107        } else {
108            None
109        };
110    }
111    for dir in std::env::split_paths(path_var) {
112        let candidate = dir.join(binary);
113        if candidate.is_file() {
114            return Some(candidate);
115        }
116    }
117    None
118}
119
120/// Known-good shell basenames that are accepted by [`resolve_shell_path`] and
121/// [`connect`]-style callers.
122///
123/// This allowlist prevents an attacker-controlled `$SHELL` environment variable
124/// from causing par-term to execute an arbitrary binary when probing for `PATH`.
125///
126/// Only the **basename** of the shell binary is checked (e.g. `zsh` from
127/// `/usr/local/bin/zsh`). The full path is preserved for the spawn call so that
128/// non-standard installation prefixes (e.g. Homebrew `/opt/homebrew/bin/zsh`)
129/// still work, while names like `../../usr/bin/evil` are rejected.
130const KNOWN_SHELLS: &[&str] = &[
131    "sh", "bash", "zsh", "fish", "dash", "ksh", "tcsh", "csh", "elvish", "nu",
132];
133
134/// Validate that `shell_path` is an acceptable shell binary.
135///
136/// Returns `true` when the **basename** of `shell_path` is in [`KNOWN_SHELLS`].
137/// The full path may be absolute or relative; only the last component is checked.
138///
139/// # Security
140///
141/// This is a defense-in-depth measure against a tampered `$SHELL` environment
142/// variable. It does not verify that the binary at the path is actually a shell —
143/// that would require executing it. The goal is to prevent obviously malicious
144/// values (e.g. a path to a custom binary, or a shell name with embedded flags).
145pub fn is_known_shell(shell_path: &str) -> bool {
146    let basename = std::path::Path::new(shell_path)
147        .file_name()
148        .and_then(|n| n.to_str())
149        .unwrap_or("");
150    KNOWN_SHELLS.contains(&basename)
151}
152
153/// Get the full PATH from the user's login interactive shell.
154///
155/// This is necessary because app-bundle launches (Finder, Dock, Spotlight)
156/// start with a minimal environment.  The user's shell profile (`.bashrc`,
157/// `.zshrc`) often configures PATH inside an interactive-only guard
158/// (`case $- in *i*) ...`), so a non-interactive login shell (`-lc`) won't
159/// pick up tools installed via nvm, homebrew, etc.
160///
161/// We spawn `$SHELL -lic 'printf "%s" "$PATH"'` which is both login (`-l`)
162/// and interactive (`-i`), causing all profile files to be sourced.  Because
163/// stdio is piped (no tty), readline does not emit control sequences.
164///
165/// # Security
166///
167/// The value of `$SHELL` is validated against [`KNOWN_SHELLS`] before use.
168/// If the value is absent, empty, or not in the allowlist, `/bin/sh` is used
169/// as a safe fallback. This prevents a tampered environment variable from
170/// causing an arbitrary binary to be executed.
171pub fn resolve_shell_path() -> Option<String> {
172    let raw_shell = std::env::var("SHELL").unwrap_or_default();
173    let shell = if !raw_shell.is_empty() && is_known_shell(&raw_shell) {
174        raw_shell
175    } else {
176        if !raw_shell.is_empty() {
177            log::warn!(
178                "resolve_shell_path: $SHELL={raw_shell:?} is not in the known-shells allowlist; \
179                 falling back to /bin/sh"
180            );
181        }
182        "/bin/sh".to_string()
183    };
184    let output = std::process::Command::new(&shell)
185        .args(["-lic", r#"printf "%s" "$PATH""#])
186        .stdin(std::process::Stdio::null())
187        .stdout(std::process::Stdio::piped())
188        .stderr(std::process::Stdio::null())
189        .output()
190        .ok()?;
191    if output.status.success() {
192        let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
193        if !path.is_empty() {
194            return Some(path);
195        }
196    }
197    None
198}
199
200/// Discover available agents from bundled and user config directories.
201///
202/// Bundled agents are loaded from the `agents/` directory next to the executable.
203/// User agents are loaded from `<user_config_dir>/agents/` and override bundled
204/// agents with the same identity. Inactive agents are filtered out.
205/// Default agent configurations embedded at compile time.
206/// These ensure agents are always available regardless of
207/// installation method or launch context (e.g., macOS app bundle).
208const EMBEDDED_AGENTS: &[&str] = &[
209    r#"
210identity = "claude.com"
211name = "Claude Code"
212short_name = "claude"
213protocol = "acp"
214type = "coding"
215install_command = "npm install -g @zed-industries/claude-agent-acp"
216
217[run_command]
218"*" = "claude-agent-acp"
219"#,
220    r#"
221identity = "openai.com"
222name = "Codex CLI"
223short_name = "codex"
224protocol = "acp"
225type = "coding"
226install_command = "npm install -g @zed-industries/codex-acp"
227
228[run_command]
229"*" = "npx @zed-industries/codex-acp"
230"#,
231    r#"
232identity = "geminicli.com"
233name = "Gemini CLI"
234short_name = "gemini"
235protocol = "acp"
236type = "coding"
237
238[run_command]
239"*" = "gemini --experimental-acp"
240"#,
241    r#"
242identity = "copilot.github.com"
243name = "Copilot"
244short_name = "copilot"
245protocol = "acp"
246type = "coding"
247
248[run_command]
249"*" = "copilot --acp"
250"#,
251    r#"
252identity = "ampcode.com"
253name = "Amp (AmpCode)"
254short_name = "amp"
255protocol = "acp"
256type = "coding"
257
258[run_command]
259"*" = "npx -y amp-acp"
260"#,
261    r#"
262identity = "augmentcode.com"
263name = "Auggie (Augment Code)"
264short_name = "auggie"
265protocol = "acp"
266type = "coding"
267
268[run_command]
269"*" = "auggie --acp"
270"#,
271    r#"
272identity = "docker.com"
273name = "Docker cagent"
274short_name = "cagent"
275protocol = "acp"
276type = "coding"
277
278[run_command]
279"*" = "cagent acp"
280"#,
281    r#"
282identity = "openhands.dev"
283name = "OpenHands"
284short_name = "openhands"
285protocol = "acp"
286type = "coding"
287
288[run_command]
289"*" = "openhands acp"
290"#,
291];
292
293/// The set of built-in agent identities defined in [`EMBEDDED_AGENTS`].
294///
295/// Used by [`load_agents_from_dir`] to detect when a user-supplied TOML file
296/// overrides a built-in identity and emit a security warning.
297const BUILT_IN_IDENTITIES: &[&str] = &[
298    "claude.com",
299    "openai.com",
300    "geminicli.com",
301    "copilot.github.com",
302    "ampcode.com",
303    "augmentcode.com",
304    "docker.com",
305    "openhands.dev",
306];
307
308pub fn discover_agents(user_config_dir: &Path) -> Vec<AgentConfig> {
309    let mut agents = Vec::new();
310
311    // 1. Load embedded default agents (always available)
312    for embedded in EMBEDDED_AGENTS {
313        if let Ok(config) = toml::from_str::<AgentConfig>(embedded) {
314            agents.push(config);
315        }
316    }
317
318    // 2. Load bundled agents from next to the executable (installed app)
319    let bundled_dir = std::env::current_exe()
320        .ok()
321        .and_then(|p| p.parent().map(|p| p.join("agents")));
322    if let Some(ref dir) = bundled_dir {
323        load_agents_from_dir(dir, &mut agents, false);
324    }
325
326    // 3. Load user agents (override bundled/embedded with same identity).
327    //
328    // SEC-003: User-config-dir agents are **trusted user code** — they are
329    // loaded from `<user_config_dir>/agents/` and executed with the same
330    // privileges as par-term itself. No cryptographic integrity verification
331    // is performed. A warning is emitted when a user TOML overrides a
332    // built-in identity so that the change is visible in logs.
333    let user_agents_dir = user_config_dir.join("agents");
334    load_agents_from_dir(&user_agents_dir, &mut agents, true);
335
336    agents.retain(|a| a.is_active());
337
338    // Detect which agents have their connector binary available in PATH.
339    for agent in &mut agents {
340        agent.detect_connector();
341    }
342
343    agents
344}
345
346/// Load all `.toml` agent config files from a directory.
347/// If an agent with the same identity already exists in the list, it is replaced.
348///
349/// When `is_user_config` is `true`, a warning is logged whenever a loaded
350/// agent replaces a built-in identity. This makes it visible in logs when
351/// user-supplied TOML alters trusted built-in agent definitions (SEC-003).
352fn load_agents_from_dir(dir: &Path, agents: &mut Vec<AgentConfig>, is_user_config: bool) {
353    if !dir.exists() {
354        return;
355    }
356    let Ok(entries) = std::fs::read_dir(dir) else {
357        return;
358    };
359    for entry in entries.flatten() {
360        let path = entry.path();
361        if path.extension().is_some_and(|ext| ext == "toml") {
362            match std::fs::read_to_string(&path) {
363                Ok(content) => match toml::from_str::<AgentConfig>(&content) {
364                    Ok(config) => {
365                        // SEC-003: Warn when a user-config-dir agent overrides a built-in identity.
366                        // User agents are trusted user code but the override should be visible in logs.
367                        if is_user_config && BUILT_IN_IDENTITIES.contains(&config.identity.as_str())
368                        {
369                            log::warn!(
370                                "ACP agent config '{}' overrides built-in identity '{}'.\n\
371                                 User-config-dir agents are executed with par-term's privileges.\n\
372                                 Verify that '{}' is a trusted file you created intentionally.",
373                                path.display(),
374                                config.identity,
375                                path.display(),
376                            );
377                        }
378                        // Remove any existing agent with the same identity (allows user override)
379                        agents.retain(|a| a.identity != config.identity);
380                        agents.push(config);
381                    }
382                    Err(e) => {
383                        log::error!("Failed to parse agent config {}: {e}", path.display());
384                    }
385                },
386                Err(e) => log::error!("Failed to read agent config {}: {e}", path.display()),
387            }
388        }
389    }
390}
391
392#[cfg(test)]
393mod tests {
394    use super::*;
395
396    #[test]
397    fn test_parse_agent_toml() {
398        let toml_str = r#"
399identity = "claude.com"
400name = "Claude Code"
401short_name = "claude"
402protocol = "acp"
403type = "coding"
404
405[run_command]
406"*" = "claude-agent-acp"
407macos = "claude-agent-acp"
408"#;
409        let config: AgentConfig = toml::from_str(toml_str).unwrap();
410        assert_eq!(config.identity, "claude.com");
411        assert_eq!(config.name, "Claude Code");
412        assert_eq!(config.short_name, "claude");
413        assert_eq!(config.protocol, "acp");
414        assert_eq!(config.r#type, "coding");
415        assert!(config.is_active());
416        assert!(config.run_command_for_platform().is_some());
417    }
418
419    #[test]
420    fn test_inactive_agent() {
421        let toml_str = r#"
422identity = "test.agent"
423name = "Test"
424short_name = "test"
425active = false
426
427[run_command]
428"*" = "test-agent"
429"#;
430        let config: AgentConfig = toml::from_str(toml_str).unwrap();
431        assert!(!config.is_active());
432    }
433
434    #[test]
435    fn test_default_protocol_and_type() {
436        let toml_str = r#"
437identity = "minimal.agent"
438name = "Minimal"
439short_name = "min"
440
441[run_command]
442"*" = "minimal-agent"
443"#;
444        let config: AgentConfig = toml::from_str(toml_str).unwrap();
445        assert_eq!(config.protocol, "acp");
446        assert_eq!(config.r#type, "coding");
447    }
448
449    #[test]
450    fn test_platform_fallback_to_wildcard() {
451        let toml_str = r#"
452identity = "wildcard.agent"
453name = "Wildcard"
454short_name = "wc"
455
456[run_command]
457"*" = "wildcard-cmd"
458"#;
459        let config: AgentConfig = toml::from_str(toml_str).unwrap();
460        assert_eq!(config.run_command_for_platform(), Some("wildcard-cmd"));
461    }
462
463    #[test]
464    fn test_all_embedded_agents_parse() {
465        for (i, toml_str) in EMBEDDED_AGENTS.iter().enumerate() {
466            let config = toml::from_str::<AgentConfig>(toml_str)
467                .unwrap_or_else(|e| panic!("Embedded agent {i} failed to parse: {e}"));
468            assert!(!config.identity.is_empty(), "Agent {i} has empty identity");
469            assert!(!config.name.is_empty(), "Agent {i} has empty name");
470            assert!(
471                !config.short_name.is_empty(),
472                "Agent {i} has empty short_name"
473            );
474            assert!(
475                config.run_command_for_platform().is_some(),
476                "Agent {} ({}) has no run command for this platform",
477                i,
478                config.identity
479            );
480        }
481    }
482
483    #[test]
484    fn test_embedded_agents_include_known_identities() {
485        let agents: Vec<AgentConfig> = EMBEDDED_AGENTS
486            .iter()
487            .map(|s| toml::from_str(s).unwrap())
488            .collect();
489        let identities: Vec<&str> = agents.iter().map(|a| a.identity.as_str()).collect();
490        assert!(identities.contains(&"claude.com"), "Missing claude.com");
491        assert!(
492            identities.contains(&"openai.com"),
493            "Missing openai.com (codex)"
494        );
495        assert!(
496            identities.contains(&"geminicli.com"),
497            "Missing geminicli.com (gemini)"
498        );
499    }
500
501    #[test]
502    fn test_discover_agents_nonexistent_dir() {
503        let dir = PathBuf::from("/tmp/par_term_test_nonexistent_agents_dir");
504        let agents = discover_agents(&dir);
505        // May find agents from cwd or bundled dir; just verify no panic.
506        // The nonexistent user config dir itself contributes nothing.
507        for agent in &agents {
508            assert!(agent.is_active());
509        }
510    }
511
512    #[test]
513    fn test_discover_agents_from_temp_dir() {
514        let tmp_dir = tempfile::tempdir().unwrap();
515        let agents_dir = tmp_dir.path().join("agents");
516        std::fs::create_dir_all(&agents_dir).unwrap();
517
518        let toml_content = r#"
519identity = "test.disco"
520name = "Discovery Test"
521short_name = "disco"
522
523[run_command]
524"*" = "disco-agent"
525"#;
526        std::fs::write(agents_dir.join("test.disco.toml"), toml_content).unwrap();
527
528        let agents = discover_agents(tmp_dir.path());
529        let disco = agents.iter().find(|a| a.identity == "test.disco");
530        assert!(
531            disco.is_some(),
532            "Expected test.disco agent to be discovered"
533        );
534        assert_eq!(disco.unwrap().name, "Discovery Test");
535    }
536
537    #[test]
538    fn test_discover_agents_filters_inactive() {
539        let tmp_dir = tempfile::tempdir().unwrap();
540        let agents_dir = tmp_dir.path().join("agents");
541        std::fs::create_dir_all(&agents_dir).unwrap();
542
543        let active_toml = r#"
544identity = "active.agent"
545name = "Active"
546short_name = "act"
547
548[run_command]
549"*" = "active-cmd"
550"#;
551        let inactive_toml = r#"
552identity = "inactive.agent"
553name = "Inactive"
554short_name = "inact"
555active = false
556
557[run_command]
558"*" = "inactive-cmd"
559"#;
560        std::fs::write(agents_dir.join("active.toml"), active_toml).unwrap();
561        std::fs::write(agents_dir.join("inactive.toml"), inactive_toml).unwrap();
562
563        let agents = discover_agents(tmp_dir.path());
564        assert!(
565            agents.iter().any(|a| a.identity == "active.agent"),
566            "Expected active.agent to be present"
567        );
568        assert!(
569            !agents.iter().any(|a| a.identity == "inactive.agent"),
570            "Expected inactive.agent to be filtered out"
571        );
572    }
573
574    #[test]
575    fn test_binary_in_path_finds_common_binary() {
576        // "ls" should be available on all platforms
577        assert!(binary_in_path("ls"));
578    }
579
580    #[test]
581    fn test_binary_in_path_not_found() {
582        assert!(!binary_in_path("nonexistent-binary-12345"));
583    }
584
585    #[test]
586    fn test_binary_in_path_empty() {
587        assert!(!binary_in_path(""));
588    }
589
590    #[test]
591    fn test_detect_connector_for_known_binary() {
592        let mut config: AgentConfig = toml::from_str(
593            r#"
594identity = "test.agent"
595name = "Test"
596short_name = "test"
597
598[run_command]
599"*" = "ls"
600"#,
601        )
602        .unwrap();
603        config.detect_connector();
604        assert!(config.connector_installed);
605    }
606
607    #[test]
608    fn test_detect_connector_for_unknown_binary() {
609        let mut config: AgentConfig = toml::from_str(
610            r#"
611identity = "test.agent"
612name = "Test"
613short_name = "test"
614
615[run_command]
616"*" = "nonexistent-binary-12345"
617"#,
618        )
619        .unwrap();
620        config.detect_connector();
621        assert!(!config.connector_installed);
622    }
623
624    #[test]
625    fn test_detect_connector_extracts_first_token() {
626        let mut config: AgentConfig = toml::from_str(
627            r#"
628identity = "test.agent"
629name = "Test"
630short_name = "test"
631
632[run_command]
633"*" = "ls --some-flag"
634"#,
635        )
636        .unwrap();
637        config.detect_connector();
638        assert!(config.connector_installed);
639    }
640}