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}
61fn default_lsp_mcp_server_id() -> String {
62 "mcpls".into()
63}
64fn default_lsp_token_budget() -> usize {
65 2000
66}
67fn default_lsp_max_per_file() -> usize {
68 20
69}
70fn default_lsp_max_symbols() -> usize {
71 5
72}
73fn default_lsp_call_timeout_secs() -> u64 {
74 5
75}
76
77#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
93#[serde(rename_all = "lowercase")]
94pub enum AcpAuthMethod {
95 Agent,
97}
98
99impl<'de> serde::Deserialize<'de> for AcpAuthMethod {
100 fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
101 let s = String::deserialize(d)?;
102 match s.as_str() {
103 "agent" => Ok(Self::Agent),
104 other => Err(serde::de::Error::unknown_variant(other, &["agent"])),
105 }
106 }
107}
108
109impl std::fmt::Display for AcpAuthMethod {
110 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
111 match self {
112 Self::Agent => f.write_str("agent"),
113 }
114 }
115}
116
117fn default_acp_auth_methods() -> Vec<AcpAuthMethod> {
118 vec![AcpAuthMethod::Agent]
119}
120
121#[derive(Debug, thiserror::Error)]
123pub enum AdditionalDirError {
124 #[error("path `{0}` contains `..` traversal")]
126 Traversal(PathBuf),
127 #[error("path `{0}` is a reserved system or credentials directory")]
129 Reserved(PathBuf),
130 #[error("failed to canonicalize `{path}`: {source}")]
132 Canonicalize {
133 path: PathBuf,
134 #[source]
135 source: std::io::Error,
136 },
137}
138
139#[derive(Clone, PartialEq, Eq)]
157pub struct AdditionalDir(PathBuf);
158
159impl AdditionalDir {
160 pub fn parse(raw: impl Into<PathBuf>) -> Result<Self, AdditionalDirError> {
166 let raw: PathBuf = raw.into();
167
168 let expanded = if raw.starts_with("~") {
170 let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("/"));
171 home.join(raw.strip_prefix("~").unwrap_or(&raw))
172 } else {
173 raw.clone()
174 };
175
176 for component in expanded.components() {
178 if component == Component::ParentDir {
179 return Err(AdditionalDirError::Traversal(raw));
180 }
181 }
182
183 let canon =
184 std::fs::canonicalize(&expanded).map_err(|e| AdditionalDirError::Canonicalize {
185 path: raw.clone(),
186 source: e,
187 })?;
188
189 let reserved = reserved_prefixes();
191 for prefix in &reserved {
192 if canon.starts_with(prefix) {
193 return Err(AdditionalDirError::Reserved(canon));
194 }
195 }
196
197 Ok(Self(canon))
198 }
199
200 #[must_use]
202 pub fn as_path(&self) -> &Path {
203 &self.0
204 }
205}
206
207fn reserved_prefixes() -> Vec<PathBuf> {
208 let mut prefixes = vec![PathBuf::from("/proc"), PathBuf::from("/sys")];
209 if let Some(home) = dirs::home_dir() {
210 prefixes.push(home.join(".ssh"));
211 prefixes.push(home.join(".gnupg"));
212 prefixes.push(home.join(".aws"));
213 }
214 prefixes
215}
216
217impl std::fmt::Debug for AdditionalDir {
218 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
219 write!(f, "AdditionalDir({:?})", self.0)
220 }
221}
222
223impl std::fmt::Display for AdditionalDir {
224 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
225 write!(f, "{}", self.0.display())
226 }
227}
228
229impl Serialize for AdditionalDir {
230 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
231 self.0.to_string_lossy().serialize(s)
232 }
233}
234
235impl<'de> serde::Deserialize<'de> for AdditionalDir {
236 fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
237 let s = String::deserialize(d)?;
238 Self::parse(s).map_err(serde::de::Error::custom)
239 }
240}
241
242#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)]
258#[serde(rename_all = "lowercase")]
259pub enum ToolDensity {
260 Compact,
262 #[default]
264 Inline,
265 Block,
267}
268
269impl ToolDensity {
270 #[must_use]
284 pub fn cycle(self) -> Self {
285 match self {
286 Self::Compact => Self::Inline,
287 Self::Inline => Self::Block,
288 Self::Block => Self::Compact,
289 }
290 }
291}
292
293#[derive(Debug, Clone, Copy, Default, Deserialize, Serialize)]
303pub struct TuiConfig {
304 #[serde(default)]
307 pub show_source_labels: bool,
308 #[serde(default)]
313 pub tool_density: ToolDensity,
314 #[serde(default)]
316 pub fleet: FleetConfig,
317}
318
319#[derive(Debug, Clone, Copy, Deserialize, Serialize)]
321#[serde(default)]
322pub struct FleetConfig {
323 pub refresh_interval_secs: u64,
325 pub max_sessions: u32,
327}
328
329impl Default for FleetConfig {
330 fn default() -> Self {
331 Self {
332 refresh_interval_secs: 5,
333 max_sessions: 50,
334 }
335 }
336}
337
338#[derive(Debug, Clone, Default, Deserialize, Serialize)]
340#[serde(rename_all = "lowercase")]
341pub enum AcpTransport {
342 #[default]
344 Stdio,
345 Http,
347 Both,
349}
350
351#[derive(Clone, Debug, Default, Deserialize, Serialize)]
353pub struct SubagentPresetConfig {
354 pub name: String,
356 pub command: String,
358 #[serde(default, skip_serializing_if = "Option::is_none")]
360 pub cwd: Option<PathBuf>,
361 #[serde(default = "default_subagent_handshake_timeout_secs")]
363 pub handshake_timeout_secs: u64,
364 #[serde(default = "default_subagent_prompt_timeout_secs")]
366 pub prompt_timeout_secs: u64,
367}
368
369#[derive(Clone, Debug, Default, Deserialize, Serialize)]
382pub struct AcpSubagentsConfig {
383 #[serde(default)]
385 pub enabled: bool,
386
387 #[serde(default)]
389 pub presets: Vec<SubagentPresetConfig>,
390}
391
392fn default_subagent_handshake_timeout_secs() -> u64 {
393 30
394}
395
396fn default_subagent_prompt_timeout_secs() -> u64 {
397 600
398}
399
400#[derive(Clone, Deserialize, Serialize)]
415pub struct AcpConfig {
416 #[serde(default)]
418 pub enabled: bool,
419 #[serde(default = "default_acp_agent_name")]
421 pub agent_name: String,
422 #[serde(default = "default_acp_agent_version")]
424 pub agent_version: String,
425 #[serde(default = "default_acp_max_sessions")]
427 pub max_sessions: usize,
428 #[serde(default = "default_acp_session_idle_timeout_secs")]
430 pub session_idle_timeout_secs: u64,
431 #[serde(default = "default_acp_broadcast_capacity")]
433 pub broadcast_capacity: usize,
434 #[serde(skip_serializing_if = "Option::is_none")]
436 pub permission_file: Option<std::path::PathBuf>,
437 #[serde(default)]
440 pub available_models: Vec<String>,
441 #[serde(default = "default_acp_transport")]
443 pub transport: AcpTransport,
444 #[serde(default = "default_acp_http_bind")]
446 pub http_bind: String,
447 #[serde(skip_serializing_if = "Option::is_none")]
452 pub auth_token: Option<String>,
453 #[serde(default = "default_acp_discovery_enabled")]
456 pub discovery_enabled: bool,
457 #[serde(default)]
459 pub lsp: AcpLspConfig,
460 #[serde(default)]
472 pub additional_directories: Vec<AdditionalDir>,
473 #[serde(default = "default_acp_auth_methods")]
478 pub auth_methods: Vec<AcpAuthMethod>,
479 #[serde(default = "default_true")]
484 pub message_ids_enabled: bool,
485 #[serde(default)]
487 pub subagents: AcpSubagentsConfig,
488}
489
490impl Default for AcpConfig {
491 fn default() -> Self {
492 Self {
493 enabled: false,
494 agent_name: default_acp_agent_name(),
495 agent_version: default_acp_agent_version(),
496 max_sessions: default_acp_max_sessions(),
497 session_idle_timeout_secs: default_acp_session_idle_timeout_secs(),
498 broadcast_capacity: default_acp_broadcast_capacity(),
499 permission_file: None,
500 available_models: Vec::new(),
501 transport: default_acp_transport(),
502 http_bind: default_acp_http_bind(),
503 auth_token: None,
504 discovery_enabled: default_acp_discovery_enabled(),
505 lsp: AcpLspConfig::default(),
506 additional_directories: Vec::new(),
507 auth_methods: default_acp_auth_methods(),
508 message_ids_enabled: true,
509 subagents: AcpSubagentsConfig::default(),
510 }
511 }
512}
513
514impl std::fmt::Debug for AcpConfig {
515 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
516 f.debug_struct("AcpConfig")
517 .field("enabled", &self.enabled)
518 .field("agent_name", &self.agent_name)
519 .field("agent_version", &self.agent_version)
520 .field("max_sessions", &self.max_sessions)
521 .field("session_idle_timeout_secs", &self.session_idle_timeout_secs)
522 .field("broadcast_capacity", &self.broadcast_capacity)
523 .field("permission_file", &self.permission_file)
524 .field("available_models", &self.available_models)
525 .field("transport", &self.transport)
526 .field("http_bind", &self.http_bind)
527 .field(
528 "auth_token",
529 &self.auth_token.as_ref().map(|_| "[REDACTED]"),
530 )
531 .field("discovery_enabled", &self.discovery_enabled)
532 .field("lsp", &self.lsp)
533 .field("additional_directories", &self.additional_directories)
534 .field("auth_methods", &self.auth_methods)
535 .field("message_ids_enabled", &self.message_ids_enabled)
536 .field("subagents", &self.subagents)
537 .finish()
538 }
539}
540
541#[derive(Debug, Clone, Deserialize, Serialize)]
546pub struct AcpLspConfig {
547 #[serde(default = "default_true")]
549 pub enabled: bool,
550 #[serde(default = "default_true")]
552 pub auto_diagnostics_on_save: bool,
553 #[serde(default = "default_acp_lsp_max_diagnostics_per_file")]
555 pub max_diagnostics_per_file: usize,
556 #[serde(default = "default_acp_lsp_max_diagnostic_files")]
558 pub max_diagnostic_files: usize,
559 #[serde(default = "default_acp_lsp_max_references")]
561 pub max_references: usize,
562 #[serde(default = "default_acp_lsp_max_workspace_symbols")]
564 pub max_workspace_symbols: usize,
565 #[serde(default = "default_acp_lsp_request_timeout_secs")]
567 pub request_timeout_secs: u64,
568}
569
570impl Default for AcpLspConfig {
571 fn default() -> Self {
572 Self {
573 enabled: true,
574 auto_diagnostics_on_save: true,
575 max_diagnostics_per_file: default_acp_lsp_max_diagnostics_per_file(),
576 max_diagnostic_files: default_acp_lsp_max_diagnostic_files(),
577 max_references: default_acp_lsp_max_references(),
578 max_workspace_symbols: default_acp_lsp_max_workspace_symbols(),
579 request_timeout_secs: default_acp_lsp_request_timeout_secs(),
580 }
581 }
582}
583
584#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
588#[serde(rename_all = "lowercase")]
589pub enum DiagnosticSeverity {
590 #[default]
591 Error,
592 Warning,
593 Info,
594 Hint,
595}
596
597#[derive(Debug, Clone, Deserialize, Serialize)]
601#[serde(default)]
602pub struct DiagnosticsConfig {
603 pub enabled: bool,
605 #[serde(default = "default_lsp_max_per_file")]
607 pub max_per_file: usize,
608 #[serde(default)]
610 pub min_severity: DiagnosticSeverity,
611}
612impl Default for DiagnosticsConfig {
613 fn default() -> Self {
614 Self {
615 enabled: true,
616 max_per_file: default_lsp_max_per_file(),
617 min_severity: DiagnosticSeverity::default(),
618 }
619 }
620}
621
622#[derive(Debug, Clone, Deserialize, Serialize)]
624#[serde(default)]
625pub struct HoverConfig {
626 pub enabled: bool,
628 #[serde(default = "default_lsp_max_symbols")]
630 pub max_symbols: usize,
631}
632impl Default for HoverConfig {
633 fn default() -> Self {
634 Self {
635 enabled: false,
636 max_symbols: default_lsp_max_symbols(),
637 }
638 }
639}
640
641#[derive(Debug, Clone, Deserialize, Serialize)]
643#[serde(default)]
644pub struct LspConfig {
645 pub enabled: bool,
647 #[serde(default = "default_lsp_mcp_server_id")]
649 pub mcp_server_id: String,
650 #[serde(default = "default_lsp_token_budget")]
652 pub token_budget: usize,
653 #[serde(default = "default_lsp_call_timeout_secs")]
655 pub call_timeout_secs: u64,
656 #[serde(default)]
658 pub diagnostics: DiagnosticsConfig,
659 #[serde(default)]
661 pub hover: HoverConfig,
662}
663impl Default for LspConfig {
664 fn default() -> Self {
665 Self {
666 enabled: false,
667 mcp_server_id: default_lsp_mcp_server_id(),
668 token_budget: default_lsp_token_budget(),
669 call_timeout_secs: default_lsp_call_timeout_secs(),
670 diagnostics: DiagnosticsConfig::default(),
671 hover: HoverConfig::default(),
672 }
673 }
674}
675
676#[cfg(test)]
677mod tests {
678 use super::*;
679
680 #[test]
681 fn acp_auth_method_unknown_variant_fails() {
682 assert!(serde_json::from_str::<AcpAuthMethod>(r#""bearer""#).is_err());
683 assert!(serde_json::from_str::<AcpAuthMethod>(r#""envvar""#).is_err());
684 assert!(serde_json::from_str::<AcpAuthMethod>(r#""Agent""#).is_err());
685 }
686
687 #[test]
688 fn acp_auth_method_known_variant_succeeds() {
689 let m = serde_json::from_str::<AcpAuthMethod>(r#""agent""#).unwrap();
690 assert_eq!(m, AcpAuthMethod::Agent);
691 }
692
693 #[test]
694 fn additional_dir_rejects_dotdot_traversal() {
695 let result = AdditionalDir::parse(std::path::PathBuf::from("/tmp/../etc"));
696 assert!(
697 matches!(result, Err(AdditionalDirError::Traversal(_))),
698 "expected Traversal, got {result:?}"
699 );
700 }
701
702 #[test]
703 fn additional_dir_rejects_proc() {
704 if !std::path::Path::new("/proc").exists() {
706 return;
707 }
708 let result = AdditionalDir::parse(std::path::PathBuf::from("/proc/self"));
709 assert!(
710 matches!(result, Err(AdditionalDirError::Reserved(_))),
711 "expected Reserved, got {result:?}"
712 );
713 }
714
715 #[test]
716 fn additional_dir_rejects_ssh() {
717 let home = std::env::var("HOME").unwrap_or_else(|_| "/root".to_owned());
718 let ssh = std::path::PathBuf::from(format!("{home}/.ssh"));
719 if !ssh.exists() {
720 return;
721 }
722 let result = AdditionalDir::parse(ssh.clone());
723 assert!(
724 matches!(result, Err(AdditionalDirError::Reserved(_))),
725 "expected Reserved for {ssh:?}, got {result:?}"
726 );
727 }
728
729 #[test]
730 fn additional_dir_accepts_tmp() {
731 let tmp = std::env::temp_dir();
732 match AdditionalDir::parse(tmp.clone()) {
734 Ok(dir) => {
735 assert!(dir.as_path().is_absolute());
737 }
738 Err(AdditionalDirError::Canonicalize { .. }) => {
739 }
741 Err(e) => panic!("unexpected error for {tmp:?}: {e:?}"),
742 }
743 }
744}