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, Default, Deserialize, Serialize)]
251pub struct TuiConfig {
252 #[serde(default)]
255 pub show_source_labels: bool,
256}
257
258#[derive(Debug, Clone, Default, Deserialize, Serialize)]
260#[serde(rename_all = "lowercase")]
261pub enum AcpTransport {
262 #[default]
264 Stdio,
265 Http,
267 Both,
269}
270
271#[derive(Clone, Debug, Default, Deserialize, Serialize)]
273pub struct SubagentPresetConfig {
274 pub name: String,
276 pub command: String,
278 #[serde(default, skip_serializing_if = "Option::is_none")]
280 pub cwd: Option<PathBuf>,
281 #[serde(default = "default_subagent_handshake_timeout_secs")]
283 pub handshake_timeout_secs: u64,
284 #[serde(default = "default_subagent_prompt_timeout_secs")]
286 pub prompt_timeout_secs: u64,
287}
288
289#[derive(Clone, Debug, Default, Deserialize, Serialize)]
302pub struct AcpSubagentsConfig {
303 #[serde(default)]
305 pub enabled: bool,
306
307 #[serde(default)]
309 pub presets: Vec<SubagentPresetConfig>,
310}
311
312fn default_subagent_handshake_timeout_secs() -> u64 {
313 30
314}
315
316fn default_subagent_prompt_timeout_secs() -> u64 {
317 600
318}
319
320#[derive(Clone, Deserialize, Serialize)]
335pub struct AcpConfig {
336 #[serde(default)]
338 pub enabled: bool,
339 #[serde(default = "default_acp_agent_name")]
341 pub agent_name: String,
342 #[serde(default = "default_acp_agent_version")]
344 pub agent_version: String,
345 #[serde(default = "default_acp_max_sessions")]
347 pub max_sessions: usize,
348 #[serde(default = "default_acp_session_idle_timeout_secs")]
350 pub session_idle_timeout_secs: u64,
351 #[serde(default = "default_acp_broadcast_capacity")]
353 pub broadcast_capacity: usize,
354 #[serde(skip_serializing_if = "Option::is_none")]
356 pub permission_file: Option<std::path::PathBuf>,
357 #[serde(default)]
360 pub available_models: Vec<String>,
361 #[serde(default = "default_acp_transport")]
363 pub transport: AcpTransport,
364 #[serde(default = "default_acp_http_bind")]
366 pub http_bind: String,
367 #[serde(skip_serializing_if = "Option::is_none")]
372 pub auth_token: Option<String>,
373 #[serde(default = "default_acp_discovery_enabled")]
376 pub discovery_enabled: bool,
377 #[serde(default)]
379 pub lsp: AcpLspConfig,
380 #[serde(default)]
392 pub additional_directories: Vec<AdditionalDir>,
393 #[serde(default = "default_acp_auth_methods")]
398 pub auth_methods: Vec<AcpAuthMethod>,
399 #[serde(default = "default_true")]
404 pub message_ids_enabled: bool,
405 #[serde(default)]
407 pub subagents: AcpSubagentsConfig,
408}
409
410impl Default for AcpConfig {
411 fn default() -> Self {
412 Self {
413 enabled: false,
414 agent_name: default_acp_agent_name(),
415 agent_version: default_acp_agent_version(),
416 max_sessions: default_acp_max_sessions(),
417 session_idle_timeout_secs: default_acp_session_idle_timeout_secs(),
418 broadcast_capacity: default_acp_broadcast_capacity(),
419 permission_file: None,
420 available_models: Vec::new(),
421 transport: default_acp_transport(),
422 http_bind: default_acp_http_bind(),
423 auth_token: None,
424 discovery_enabled: default_acp_discovery_enabled(),
425 lsp: AcpLspConfig::default(),
426 additional_directories: Vec::new(),
427 auth_methods: default_acp_auth_methods(),
428 message_ids_enabled: true,
429 subagents: AcpSubagentsConfig::default(),
430 }
431 }
432}
433
434impl std::fmt::Debug for AcpConfig {
435 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
436 f.debug_struct("AcpConfig")
437 .field("enabled", &self.enabled)
438 .field("agent_name", &self.agent_name)
439 .field("agent_version", &self.agent_version)
440 .field("max_sessions", &self.max_sessions)
441 .field("session_idle_timeout_secs", &self.session_idle_timeout_secs)
442 .field("broadcast_capacity", &self.broadcast_capacity)
443 .field("permission_file", &self.permission_file)
444 .field("available_models", &self.available_models)
445 .field("transport", &self.transport)
446 .field("http_bind", &self.http_bind)
447 .field(
448 "auth_token",
449 &self.auth_token.as_ref().map(|_| "[REDACTED]"),
450 )
451 .field("discovery_enabled", &self.discovery_enabled)
452 .field("lsp", &self.lsp)
453 .field("additional_directories", &self.additional_directories)
454 .field("auth_methods", &self.auth_methods)
455 .field("message_ids_enabled", &self.message_ids_enabled)
456 .field("subagents", &self.subagents)
457 .finish()
458 }
459}
460
461#[derive(Debug, Clone, Deserialize, Serialize)]
466pub struct AcpLspConfig {
467 #[serde(default = "default_true")]
469 pub enabled: bool,
470 #[serde(default = "default_true")]
472 pub auto_diagnostics_on_save: bool,
473 #[serde(default = "default_acp_lsp_max_diagnostics_per_file")]
475 pub max_diagnostics_per_file: usize,
476 #[serde(default = "default_acp_lsp_max_diagnostic_files")]
478 pub max_diagnostic_files: usize,
479 #[serde(default = "default_acp_lsp_max_references")]
481 pub max_references: usize,
482 #[serde(default = "default_acp_lsp_max_workspace_symbols")]
484 pub max_workspace_symbols: usize,
485 #[serde(default = "default_acp_lsp_request_timeout_secs")]
487 pub request_timeout_secs: u64,
488}
489
490impl Default for AcpLspConfig {
491 fn default() -> Self {
492 Self {
493 enabled: true,
494 auto_diagnostics_on_save: true,
495 max_diagnostics_per_file: default_acp_lsp_max_diagnostics_per_file(),
496 max_diagnostic_files: default_acp_lsp_max_diagnostic_files(),
497 max_references: default_acp_lsp_max_references(),
498 max_workspace_symbols: default_acp_lsp_max_workspace_symbols(),
499 request_timeout_secs: default_acp_lsp_request_timeout_secs(),
500 }
501 }
502}
503
504#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
508#[serde(rename_all = "lowercase")]
509pub enum DiagnosticSeverity {
510 #[default]
511 Error,
512 Warning,
513 Info,
514 Hint,
515}
516
517#[derive(Debug, Clone, Deserialize, Serialize)]
521#[serde(default)]
522pub struct DiagnosticsConfig {
523 pub enabled: bool,
525 #[serde(default = "default_lsp_max_per_file")]
527 pub max_per_file: usize,
528 #[serde(default)]
530 pub min_severity: DiagnosticSeverity,
531}
532impl Default for DiagnosticsConfig {
533 fn default() -> Self {
534 Self {
535 enabled: true,
536 max_per_file: default_lsp_max_per_file(),
537 min_severity: DiagnosticSeverity::default(),
538 }
539 }
540}
541
542#[derive(Debug, Clone, Deserialize, Serialize)]
544#[serde(default)]
545pub struct HoverConfig {
546 pub enabled: bool,
548 #[serde(default = "default_lsp_max_symbols")]
550 pub max_symbols: usize,
551}
552impl Default for HoverConfig {
553 fn default() -> Self {
554 Self {
555 enabled: false,
556 max_symbols: default_lsp_max_symbols(),
557 }
558 }
559}
560
561#[derive(Debug, Clone, Deserialize, Serialize)]
563#[serde(default)]
564pub struct LspConfig {
565 pub enabled: bool,
567 #[serde(default = "default_lsp_mcp_server_id")]
569 pub mcp_server_id: String,
570 #[serde(default = "default_lsp_token_budget")]
572 pub token_budget: usize,
573 #[serde(default = "default_lsp_call_timeout_secs")]
575 pub call_timeout_secs: u64,
576 #[serde(default)]
578 pub diagnostics: DiagnosticsConfig,
579 #[serde(default)]
581 pub hover: HoverConfig,
582}
583impl Default for LspConfig {
584 fn default() -> Self {
585 Self {
586 enabled: false,
587 mcp_server_id: default_lsp_mcp_server_id(),
588 token_budget: default_lsp_token_budget(),
589 call_timeout_secs: default_lsp_call_timeout_secs(),
590 diagnostics: DiagnosticsConfig::default(),
591 hover: HoverConfig::default(),
592 }
593 }
594}
595
596#[cfg(test)]
597mod tests {
598 use super::*;
599
600 #[test]
601 fn acp_auth_method_unknown_variant_fails() {
602 assert!(serde_json::from_str::<AcpAuthMethod>(r#""bearer""#).is_err());
603 assert!(serde_json::from_str::<AcpAuthMethod>(r#""envvar""#).is_err());
604 assert!(serde_json::from_str::<AcpAuthMethod>(r#""Agent""#).is_err());
605 }
606
607 #[test]
608 fn acp_auth_method_known_variant_succeeds() {
609 let m = serde_json::from_str::<AcpAuthMethod>(r#""agent""#).unwrap();
610 assert_eq!(m, AcpAuthMethod::Agent);
611 }
612
613 #[test]
614 fn additional_dir_rejects_dotdot_traversal() {
615 let result = AdditionalDir::parse(std::path::PathBuf::from("/tmp/../etc"));
616 assert!(
617 matches!(result, Err(AdditionalDirError::Traversal(_))),
618 "expected Traversal, got {result:?}"
619 );
620 }
621
622 #[test]
623 fn additional_dir_rejects_proc() {
624 if !std::path::Path::new("/proc").exists() {
626 return;
627 }
628 let result = AdditionalDir::parse(std::path::PathBuf::from("/proc/self"));
629 assert!(
630 matches!(result, Err(AdditionalDirError::Reserved(_))),
631 "expected Reserved, got {result:?}"
632 );
633 }
634
635 #[test]
636 fn additional_dir_rejects_ssh() {
637 let home = std::env::var("HOME").unwrap_or_else(|_| "/root".to_owned());
638 let ssh = std::path::PathBuf::from(format!("{home}/.ssh"));
639 if !ssh.exists() {
640 return;
641 }
642 let result = AdditionalDir::parse(ssh.clone());
643 assert!(
644 matches!(result, Err(AdditionalDirError::Reserved(_))),
645 "expected Reserved for {ssh:?}, got {result:?}"
646 );
647 }
648
649 #[test]
650 fn additional_dir_accepts_tmp() {
651 let tmp = std::env::temp_dir();
652 match AdditionalDir::parse(tmp.clone()) {
654 Ok(dir) => {
655 assert!(dir.as_path().is_absolute());
657 }
658 Err(AdditionalDirError::Canonicalize { .. }) => {
659 }
661 Err(e) => panic!("unexpected error for {tmp:?}: {e:?}"),
662 }
663 }
664}