1use std::path::{Component, Path, PathBuf};
5
6use serde::{Deserialize, Serialize};
7
8use crate::defaults::default_true;
9
10fn default_acp_agent_name() -> String {
11 "zeph".to_owned()
12}
13
14fn default_acp_agent_version() -> String {
15 env!("CARGO_PKG_VERSION").to_owned()
16}
17
18fn default_acp_max_sessions() -> usize {
19 4
20}
21
22fn default_acp_session_idle_timeout_secs() -> u64 {
23 1800
24}
25
26fn default_acp_broadcast_capacity() -> usize {
27 256
28}
29
30fn default_acp_transport() -> AcpTransport {
31 AcpTransport::Stdio
32}
33
34fn default_acp_http_bind() -> String {
35 "127.0.0.1:9800".to_owned()
36}
37
38fn default_acp_discovery_enabled() -> bool {
39 true
40}
41
42fn default_acp_lsp_max_diagnostics_per_file() -> usize {
43 20
44}
45
46fn default_acp_lsp_max_diagnostic_files() -> usize {
47 5
48}
49
50fn default_acp_lsp_max_references() -> usize {
51 100
52}
53
54fn default_acp_lsp_max_workspace_symbols() -> usize {
55 50
56}
57
58fn default_acp_lsp_request_timeout_secs() -> u64 {
59 10
60}
61
62fn default_acp_elicitation_timeout_secs() -> u64 {
63 120
64}
65
66fn default_acp_terminal_timeout_secs() -> u64 {
67 120
68}
69
70fn default_acp_mcp_timeout_secs() -> u64 {
71 300
72}
73
74fn default_acp_notify_ack_timeout_ms() -> u64 {
75 5000
76}
77
78fn default_lsp_mcp_server_id() -> String {
79 "mcpls".into()
80}
81fn default_lsp_token_budget() -> usize {
82 2000
83}
84fn default_lsp_max_per_file() -> usize {
85 20
86}
87fn default_lsp_max_symbols() -> usize {
88 5
89}
90fn default_lsp_call_timeout_secs() -> u64 {
91 5
92}
93
94#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
110#[serde(rename_all = "lowercase")]
111#[non_exhaustive]
112pub enum AcpAuthMethod {
113 Agent,
115}
116
117impl<'de> serde::Deserialize<'de> for AcpAuthMethod {
118 fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
119 let s = String::deserialize(d)?;
120 match s.as_str() {
121 "agent" => Ok(Self::Agent),
122 other => Err(serde::de::Error::unknown_variant(other, &["agent"])),
123 }
124 }
125}
126
127impl std::fmt::Display for AcpAuthMethod {
128 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
129 match self {
130 Self::Agent => f.write_str("agent"),
131 }
132 }
133}
134
135fn default_acp_auth_methods() -> Vec<AcpAuthMethod> {
136 vec![AcpAuthMethod::Agent]
137}
138
139#[derive(Debug, thiserror::Error)]
141#[non_exhaustive]
142pub enum AdditionalDirError {
143 #[error("path `{0}` contains `..` traversal")]
145 Traversal(PathBuf),
146 #[error("path `{0}` is a reserved system or credentials directory")]
148 Reserved(PathBuf),
149 #[error("failed to canonicalize `{path}`: {source}")]
151 Canonicalize {
152 path: PathBuf,
153 #[source]
154 source: std::io::Error,
155 },
156}
157
158#[derive(Clone, PartialEq, Eq)]
176pub struct AdditionalDir(PathBuf);
177
178impl AdditionalDir {
179 pub fn parse(raw: impl Into<PathBuf>) -> Result<Self, AdditionalDirError> {
185 let raw: PathBuf = raw.into();
186
187 let expanded = if raw.starts_with("~") {
189 let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("/"));
190 home.join(raw.strip_prefix("~").unwrap_or(&raw))
191 } else {
192 raw.clone()
193 };
194
195 for component in expanded.components() {
197 if component == Component::ParentDir {
198 return Err(AdditionalDirError::Traversal(raw));
199 }
200 }
201
202 let canon =
203 std::fs::canonicalize(&expanded).map_err(|e| AdditionalDirError::Canonicalize {
204 path: raw.clone(),
205 source: e,
206 })?;
207
208 let reserved = reserved_prefixes();
210 for prefix in &reserved {
211 if canon.starts_with(prefix) {
212 return Err(AdditionalDirError::Reserved(canon));
213 }
214 }
215
216 Ok(Self(canon))
217 }
218
219 #[must_use]
221 pub fn as_path(&self) -> &Path {
222 &self.0
223 }
224}
225
226fn reserved_prefixes() -> Vec<PathBuf> {
227 let mut prefixes = vec![PathBuf::from("/proc"), PathBuf::from("/sys")];
228 if let Some(home) = dirs::home_dir() {
229 prefixes.push(home.join(".ssh"));
230 prefixes.push(home.join(".gnupg"));
231 prefixes.push(home.join(".aws"));
232 }
233 prefixes
234}
235
236impl std::fmt::Debug for AdditionalDir {
237 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
238 write!(f, "AdditionalDir({:?})", self.0)
239 }
240}
241
242impl std::fmt::Display for AdditionalDir {
243 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
244 write!(f, "{}", self.0.display())
245 }
246}
247
248impl Serialize for AdditionalDir {
249 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
250 self.0.to_string_lossy().serialize(s)
251 }
252}
253
254impl<'de> serde::Deserialize<'de> for AdditionalDir {
255 fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
256 let s = String::deserialize(d)?;
257 Self::parse(s).map_err(serde::de::Error::custom)
258 }
259}
260
261#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)]
277#[serde(rename_all = "lowercase")]
278#[non_exhaustive]
279pub enum ToolDensity {
280 Compact,
282 #[default]
284 Inline,
285 Block,
287}
288
289impl ToolDensity {
290 #[must_use]
304 pub fn cycle(self) -> Self {
305 match self {
306 Self::Compact => Self::Inline,
307 Self::Inline => Self::Block,
308 Self::Block => Self::Compact,
309 }
310 }
311}
312
313#[derive(Debug, Clone, Copy, Default, Deserialize, Serialize)]
323pub struct TuiConfig {
324 #[serde(default)]
327 pub show_source_labels: bool,
328 #[serde(default)]
333 pub tool_density: ToolDensity,
334 #[serde(default)]
336 pub fleet: FleetConfig,
337}
338
339#[derive(Debug, Clone, Copy, Deserialize, Serialize)]
341#[serde(default)]
342pub struct FleetConfig {
343 pub refresh_interval_secs: u64,
345 pub max_sessions: u32,
347}
348
349impl Default for FleetConfig {
350 fn default() -> Self {
351 Self {
352 refresh_interval_secs: 5,
353 max_sessions: 50,
354 }
355 }
356}
357
358#[derive(Debug, Clone, Default, Deserialize, Serialize)]
360#[serde(rename_all = "lowercase")]
361#[non_exhaustive]
362pub enum AcpTransport {
363 #[default]
365 Stdio,
366 Http,
368 Both,
370}
371
372#[derive(Clone, Debug, Default, Deserialize, Serialize)]
374pub struct SubagentPresetConfig {
375 pub name: String,
377 pub command: String,
379 #[serde(default, skip_serializing_if = "Option::is_none")]
381 pub cwd: Option<PathBuf>,
382 #[serde(default = "default_subagent_handshake_timeout_secs")]
384 pub handshake_timeout_secs: u64,
385 #[serde(default = "default_subagent_prompt_timeout_secs")]
387 pub prompt_timeout_secs: u64,
388}
389
390#[derive(Clone, Debug, Default, Deserialize, Serialize)]
403pub struct AcpSubagentsConfig {
404 #[serde(default)]
406 pub enabled: bool,
407
408 #[serde(default)]
410 pub presets: Vec<SubagentPresetConfig>,
411}
412
413fn default_subagent_handshake_timeout_secs() -> u64 {
414 30
415}
416
417fn default_subagent_prompt_timeout_secs() -> u64 {
418 600
419}
420
421#[derive(Clone, Deserialize, Serialize)]
436pub struct AcpConfig {
437 #[serde(default)]
439 pub enabled: bool,
440 #[serde(default = "default_acp_agent_name")]
442 pub agent_name: String,
443 #[serde(default = "default_acp_agent_version")]
445 pub agent_version: String,
446 #[serde(default = "default_acp_max_sessions")]
448 pub max_sessions: usize,
449 #[serde(default = "default_acp_session_idle_timeout_secs")]
451 pub session_idle_timeout_secs: u64,
452 #[serde(default = "default_acp_broadcast_capacity")]
454 pub broadcast_capacity: usize,
455 #[serde(skip_serializing_if = "Option::is_none")]
457 pub permission_file: Option<std::path::PathBuf>,
458 #[serde(default)]
461 pub available_models: Vec<String>,
462 #[serde(default = "default_acp_transport")]
464 pub transport: AcpTransport,
465 #[serde(default = "default_acp_http_bind")]
467 pub http_bind: String,
468 #[serde(skip_serializing_if = "Option::is_none")]
473 pub auth_token: Option<String>,
474 #[serde(default = "default_acp_discovery_enabled")]
477 pub discovery_enabled: bool,
478 #[serde(default)]
480 pub lsp: AcpLspConfig,
481 #[serde(default)]
493 pub additional_directories: Vec<AdditionalDir>,
494 #[serde(default = "default_acp_auth_methods")]
499 pub auth_methods: Vec<AcpAuthMethod>,
500 #[serde(default = "default_true")]
505 pub message_ids_enabled: bool,
506 #[serde(default)]
508 pub subagents: AcpSubagentsConfig,
509 #[serde(default)]
511 pub timeouts: AcpTimeoutsConfig,
512}
513
514impl Default for AcpConfig {
515 fn default() -> Self {
516 Self {
517 enabled: false,
518 agent_name: default_acp_agent_name(),
519 agent_version: default_acp_agent_version(),
520 max_sessions: default_acp_max_sessions(),
521 session_idle_timeout_secs: default_acp_session_idle_timeout_secs(),
522 broadcast_capacity: default_acp_broadcast_capacity(),
523 permission_file: None,
524 available_models: Vec::new(),
525 transport: default_acp_transport(),
526 http_bind: default_acp_http_bind(),
527 auth_token: None,
528 discovery_enabled: default_acp_discovery_enabled(),
529 lsp: AcpLspConfig::default(),
530 additional_directories: Vec::new(),
531 auth_methods: default_acp_auth_methods(),
532 message_ids_enabled: true,
533 subagents: AcpSubagentsConfig::default(),
534 timeouts: AcpTimeoutsConfig::default(),
535 }
536 }
537}
538
539impl std::fmt::Debug for AcpConfig {
540 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
541 f.debug_struct("AcpConfig")
542 .field("enabled", &self.enabled)
543 .field("agent_name", &self.agent_name)
544 .field("agent_version", &self.agent_version)
545 .field("max_sessions", &self.max_sessions)
546 .field("session_idle_timeout_secs", &self.session_idle_timeout_secs)
547 .field("broadcast_capacity", &self.broadcast_capacity)
548 .field("permission_file", &self.permission_file)
549 .field("available_models", &self.available_models)
550 .field("transport", &self.transport)
551 .field("http_bind", &self.http_bind)
552 .field(
553 "auth_token",
554 &self.auth_token.as_ref().map(|_| "[REDACTED]"),
555 )
556 .field("discovery_enabled", &self.discovery_enabled)
557 .field("lsp", &self.lsp)
558 .field("additional_directories", &self.additional_directories)
559 .field("auth_methods", &self.auth_methods)
560 .field("message_ids_enabled", &self.message_ids_enabled)
561 .field("subagents", &self.subagents)
562 .field("timeouts", &self.timeouts)
563 .finish()
564 }
565}
566
567#[derive(Debug, Clone, Deserialize, Serialize)]
572pub struct AcpTimeoutsConfig {
573 #[serde(default = "default_acp_elicitation_timeout_secs")]
575 pub elicitation_secs: u64,
576 #[serde(default = "default_acp_terminal_timeout_secs")]
578 pub terminal_secs: u64,
579 #[serde(default = "default_acp_mcp_timeout_secs")]
581 pub mcp_secs: u64,
582 #[serde(default = "default_acp_notify_ack_timeout_ms")]
587 pub notify_ack_timeout_ms: u64,
588}
589
590impl Default for AcpTimeoutsConfig {
591 fn default() -> Self {
592 Self {
593 elicitation_secs: default_acp_elicitation_timeout_secs(),
594 terminal_secs: default_acp_terminal_timeout_secs(),
595 mcp_secs: default_acp_mcp_timeout_secs(),
596 notify_ack_timeout_ms: default_acp_notify_ack_timeout_ms(),
597 }
598 }
599}
600
601#[derive(Debug, Clone, Deserialize, Serialize)]
606pub struct AcpLspConfig {
607 #[serde(default = "default_true")]
609 pub enabled: bool,
610 #[serde(default = "default_true")]
612 pub auto_diagnostics_on_save: bool,
613 #[serde(default = "default_acp_lsp_max_diagnostics_per_file")]
615 pub max_diagnostics_per_file: usize,
616 #[serde(default = "default_acp_lsp_max_diagnostic_files")]
618 pub max_diagnostic_files: usize,
619 #[serde(default = "default_acp_lsp_max_references")]
621 pub max_references: usize,
622 #[serde(default = "default_acp_lsp_max_workspace_symbols")]
624 pub max_workspace_symbols: usize,
625 #[serde(default = "default_acp_lsp_request_timeout_secs")]
627 pub request_timeout_secs: u64,
628}
629
630impl Default for AcpLspConfig {
631 fn default() -> Self {
632 Self {
633 enabled: true,
634 auto_diagnostics_on_save: true,
635 max_diagnostics_per_file: default_acp_lsp_max_diagnostics_per_file(),
636 max_diagnostic_files: default_acp_lsp_max_diagnostic_files(),
637 max_references: default_acp_lsp_max_references(),
638 max_workspace_symbols: default_acp_lsp_max_workspace_symbols(),
639 request_timeout_secs: default_acp_lsp_request_timeout_secs(),
640 }
641 }
642}
643
644#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
648#[serde(rename_all = "lowercase")]
649#[non_exhaustive]
650pub enum DiagnosticSeverity {
651 #[default]
652 Error,
653 Warning,
654 Info,
655 Hint,
656}
657
658#[derive(Debug, Clone, Deserialize, Serialize)]
662#[serde(default)]
663pub struct DiagnosticsConfig {
664 pub enabled: bool,
666 #[serde(default = "default_lsp_max_per_file")]
668 pub max_per_file: usize,
669 #[serde(default)]
671 pub min_severity: DiagnosticSeverity,
672}
673impl Default for DiagnosticsConfig {
674 fn default() -> Self {
675 Self {
676 enabled: true,
677 max_per_file: default_lsp_max_per_file(),
678 min_severity: DiagnosticSeverity::default(),
679 }
680 }
681}
682
683#[derive(Debug, Clone, Deserialize, Serialize)]
685#[serde(default)]
686pub struct HoverConfig {
687 pub enabled: bool,
689 #[serde(default = "default_lsp_max_symbols")]
691 pub max_symbols: usize,
692}
693impl Default for HoverConfig {
694 fn default() -> Self {
695 Self {
696 enabled: false,
697 max_symbols: default_lsp_max_symbols(),
698 }
699 }
700}
701
702#[derive(Debug, Clone, Deserialize, Serialize)]
704#[serde(default)]
705pub struct LspConfig {
706 pub enabled: bool,
708 #[serde(default = "default_lsp_mcp_server_id")]
710 pub mcp_server_id: String,
711 #[serde(default = "default_lsp_token_budget")]
713 pub token_budget: usize,
714 #[serde(default = "default_lsp_call_timeout_secs")]
716 pub call_timeout_secs: u64,
717 #[serde(default)]
719 pub diagnostics: DiagnosticsConfig,
720 #[serde(default)]
722 pub hover: HoverConfig,
723}
724impl Default for LspConfig {
725 fn default() -> Self {
726 Self {
727 enabled: false,
728 mcp_server_id: default_lsp_mcp_server_id(),
729 token_budget: default_lsp_token_budget(),
730 call_timeout_secs: default_lsp_call_timeout_secs(),
731 diagnostics: DiagnosticsConfig::default(),
732 hover: HoverConfig::default(),
733 }
734 }
735}
736
737#[cfg(test)]
738mod tests {
739 use super::*;
740
741 #[test]
742 fn acp_auth_method_unknown_variant_fails() {
743 assert!(serde_json::from_str::<AcpAuthMethod>(r#""bearer""#).is_err());
744 assert!(serde_json::from_str::<AcpAuthMethod>(r#""envvar""#).is_err());
745 assert!(serde_json::from_str::<AcpAuthMethod>(r#""Agent""#).is_err());
746 }
747
748 #[test]
749 fn acp_auth_method_known_variant_succeeds() {
750 let m = serde_json::from_str::<AcpAuthMethod>(r#""agent""#).unwrap();
751 assert_eq!(m, AcpAuthMethod::Agent);
752 }
753
754 #[test]
755 fn additional_dir_rejects_dotdot_traversal() {
756 let result = AdditionalDir::parse(std::path::PathBuf::from("/tmp/../etc"));
757 assert!(
758 matches!(result, Err(AdditionalDirError::Traversal(_))),
759 "expected Traversal, got {result:?}"
760 );
761 }
762
763 #[test]
764 fn additional_dir_rejects_proc() {
765 if !std::path::Path::new("/proc").exists() {
767 return;
768 }
769 let result = AdditionalDir::parse(std::path::PathBuf::from("/proc/self"));
770 assert!(
771 matches!(result, Err(AdditionalDirError::Reserved(_))),
772 "expected Reserved, got {result:?}"
773 );
774 }
775
776 #[test]
777 fn additional_dir_rejects_ssh() {
778 let home = std::env::var("HOME").unwrap_or_else(|_| "/root".to_owned());
779 let ssh = std::path::PathBuf::from(format!("{home}/.ssh"));
780 if !ssh.exists() {
781 return;
782 }
783 let result = AdditionalDir::parse(ssh.clone());
784 assert!(
785 matches!(result, Err(AdditionalDirError::Reserved(_))),
786 "expected Reserved for {ssh:?}, got {result:?}"
787 );
788 }
789
790 #[test]
791 fn additional_dir_accepts_tmp() {
792 let tmp = std::env::temp_dir();
793 match AdditionalDir::parse(tmp.clone()) {
795 Ok(dir) => {
796 assert!(dir.as_path().is_absolute());
798 }
799 Err(AdditionalDirError::Canonicalize { .. }) => {
800 }
802 Err(e) => panic!("unexpected error for {tmp:?}: {e:?}"),
803 }
804 }
805}