use std::path::{Component, Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::defaults::default_true;
fn default_acp_agent_name() -> String {
"zeph".to_owned()
}
fn default_acp_agent_version() -> String {
env!("CARGO_PKG_VERSION").to_owned()
}
fn default_acp_max_sessions() -> usize {
4
}
fn default_acp_session_idle_timeout_secs() -> u64 {
1800
}
fn default_acp_broadcast_capacity() -> usize {
256
}
fn default_acp_transport() -> AcpTransport {
AcpTransport::Stdio
}
fn default_acp_http_bind() -> String {
"127.0.0.1:9800".to_owned()
}
fn default_acp_discovery_enabled() -> bool {
true
}
fn default_acp_lsp_max_diagnostics_per_file() -> usize {
20
}
fn default_acp_lsp_max_diagnostic_files() -> usize {
5
}
fn default_acp_lsp_max_references() -> usize {
100
}
fn default_acp_lsp_max_workspace_symbols() -> usize {
50
}
fn default_acp_lsp_request_timeout_secs() -> u64 {
10
}
fn default_lsp_mcp_server_id() -> String {
"mcpls".into()
}
fn default_lsp_token_budget() -> usize {
2000
}
fn default_lsp_max_per_file() -> usize {
20
}
fn default_lsp_max_symbols() -> usize {
5
}
fn default_lsp_call_timeout_secs() -> u64 {
5
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum AcpAuthMethod {
Agent,
}
impl<'de> serde::Deserialize<'de> for AcpAuthMethod {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let s = String::deserialize(d)?;
match s.as_str() {
"agent" => Ok(Self::Agent),
other => Err(serde::de::Error::unknown_variant(other, &["agent"])),
}
}
}
impl std::fmt::Display for AcpAuthMethod {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Agent => f.write_str("agent"),
}
}
}
fn default_acp_auth_methods() -> Vec<AcpAuthMethod> {
vec![AcpAuthMethod::Agent]
}
#[derive(Debug, thiserror::Error)]
pub enum AdditionalDirError {
#[error("path `{0}` contains `..` traversal")]
Traversal(PathBuf),
#[error("path `{0}` is a reserved system or credentials directory")]
Reserved(PathBuf),
#[error("failed to canonicalize `{path}`: {source}")]
Canonicalize {
path: PathBuf,
#[source]
source: std::io::Error,
},
}
#[derive(Clone, PartialEq, Eq)]
pub struct AdditionalDir(PathBuf);
impl AdditionalDir {
pub fn parse(raw: impl Into<PathBuf>) -> Result<Self, AdditionalDirError> {
let raw: PathBuf = raw.into();
let expanded = if raw.starts_with("~") {
let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("/"));
home.join(raw.strip_prefix("~").unwrap_or(&raw))
} else {
raw.clone()
};
for component in expanded.components() {
if component == Component::ParentDir {
return Err(AdditionalDirError::Traversal(raw));
}
}
let canon =
std::fs::canonicalize(&expanded).map_err(|e| AdditionalDirError::Canonicalize {
path: raw.clone(),
source: e,
})?;
let reserved = reserved_prefixes();
for prefix in &reserved {
if canon.starts_with(prefix) {
return Err(AdditionalDirError::Reserved(canon));
}
}
Ok(Self(canon))
}
#[must_use]
pub fn as_path(&self) -> &Path {
&self.0
}
}
fn reserved_prefixes() -> Vec<PathBuf> {
let mut prefixes = vec![PathBuf::from("/proc"), PathBuf::from("/sys")];
if let Some(home) = dirs::home_dir() {
prefixes.push(home.join(".ssh"));
prefixes.push(home.join(".gnupg"));
prefixes.push(home.join(".aws"));
}
prefixes
}
impl std::fmt::Debug for AdditionalDir {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "AdditionalDir({:?})", self.0)
}
}
impl std::fmt::Display for AdditionalDir {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0.display())
}
}
impl Serialize for AdditionalDir {
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
self.0.to_string_lossy().serialize(s)
}
}
impl<'de> serde::Deserialize<'de> for AdditionalDir {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let s = String::deserialize(d)?;
Self::parse(s).map_err(serde::de::Error::custom)
}
}
#[derive(Debug, Clone, Copy, Default, Deserialize, Serialize)]
pub struct TuiConfig {
#[serde(default)]
pub show_source_labels: bool,
}
#[derive(Debug, Clone, Default, Deserialize, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum AcpTransport {
#[default]
Stdio,
Http,
Both,
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
pub struct SubagentPresetConfig {
pub name: String,
pub command: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cwd: Option<PathBuf>,
#[serde(default = "default_subagent_handshake_timeout_secs")]
pub handshake_timeout_secs: u64,
#[serde(default = "default_subagent_prompt_timeout_secs")]
pub prompt_timeout_secs: u64,
}
#[derive(Clone, Debug, Default, Deserialize, Serialize)]
pub struct AcpSubagentsConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub presets: Vec<SubagentPresetConfig>,
}
fn default_subagent_handshake_timeout_secs() -> u64 {
30
}
fn default_subagent_prompt_timeout_secs() -> u64 {
600
}
#[derive(Clone, Deserialize, Serialize)]
pub struct AcpConfig {
#[serde(default)]
pub enabled: bool,
#[serde(default = "default_acp_agent_name")]
pub agent_name: String,
#[serde(default = "default_acp_agent_version")]
pub agent_version: String,
#[serde(default = "default_acp_max_sessions")]
pub max_sessions: usize,
#[serde(default = "default_acp_session_idle_timeout_secs")]
pub session_idle_timeout_secs: u64,
#[serde(default = "default_acp_broadcast_capacity")]
pub broadcast_capacity: usize,
#[serde(skip_serializing_if = "Option::is_none")]
pub permission_file: Option<std::path::PathBuf>,
#[serde(default)]
pub available_models: Vec<String>,
#[serde(default = "default_acp_transport")]
pub transport: AcpTransport,
#[serde(default = "default_acp_http_bind")]
pub http_bind: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub auth_token: Option<String>,
#[serde(default = "default_acp_discovery_enabled")]
pub discovery_enabled: bool,
#[serde(default)]
pub lsp: AcpLspConfig,
#[serde(default)]
pub additional_directories: Vec<AdditionalDir>,
#[serde(default = "default_acp_auth_methods")]
pub auth_methods: Vec<AcpAuthMethod>,
#[serde(default = "default_true")]
pub message_ids_enabled: bool,
#[serde(default)]
pub subagents: AcpSubagentsConfig,
}
impl Default for AcpConfig {
fn default() -> Self {
Self {
enabled: false,
agent_name: default_acp_agent_name(),
agent_version: default_acp_agent_version(),
max_sessions: default_acp_max_sessions(),
session_idle_timeout_secs: default_acp_session_idle_timeout_secs(),
broadcast_capacity: default_acp_broadcast_capacity(),
permission_file: None,
available_models: Vec::new(),
transport: default_acp_transport(),
http_bind: default_acp_http_bind(),
auth_token: None,
discovery_enabled: default_acp_discovery_enabled(),
lsp: AcpLspConfig::default(),
additional_directories: Vec::new(),
auth_methods: default_acp_auth_methods(),
message_ids_enabled: true,
subagents: AcpSubagentsConfig::default(),
}
}
}
impl std::fmt::Debug for AcpConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AcpConfig")
.field("enabled", &self.enabled)
.field("agent_name", &self.agent_name)
.field("agent_version", &self.agent_version)
.field("max_sessions", &self.max_sessions)
.field("session_idle_timeout_secs", &self.session_idle_timeout_secs)
.field("broadcast_capacity", &self.broadcast_capacity)
.field("permission_file", &self.permission_file)
.field("available_models", &self.available_models)
.field("transport", &self.transport)
.field("http_bind", &self.http_bind)
.field(
"auth_token",
&self.auth_token.as_ref().map(|_| "[REDACTED]"),
)
.field("discovery_enabled", &self.discovery_enabled)
.field("lsp", &self.lsp)
.field("additional_directories", &self.additional_directories)
.field("auth_methods", &self.auth_methods)
.field("message_ids_enabled", &self.message_ids_enabled)
.field("subagents", &self.subagents)
.finish()
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct AcpLspConfig {
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default = "default_true")]
pub auto_diagnostics_on_save: bool,
#[serde(default = "default_acp_lsp_max_diagnostics_per_file")]
pub max_diagnostics_per_file: usize,
#[serde(default = "default_acp_lsp_max_diagnostic_files")]
pub max_diagnostic_files: usize,
#[serde(default = "default_acp_lsp_max_references")]
pub max_references: usize,
#[serde(default = "default_acp_lsp_max_workspace_symbols")]
pub max_workspace_symbols: usize,
#[serde(default = "default_acp_lsp_request_timeout_secs")]
pub request_timeout_secs: u64,
}
impl Default for AcpLspConfig {
fn default() -> Self {
Self {
enabled: true,
auto_diagnostics_on_save: true,
max_diagnostics_per_file: default_acp_lsp_max_diagnostics_per_file(),
max_diagnostic_files: default_acp_lsp_max_diagnostic_files(),
max_references: default_acp_lsp_max_references(),
max_workspace_symbols: default_acp_lsp_max_workspace_symbols(),
request_timeout_secs: default_acp_lsp_request_timeout_secs(),
}
}
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum DiagnosticSeverity {
#[default]
Error,
Warning,
Info,
Hint,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default)]
pub struct DiagnosticsConfig {
pub enabled: bool,
#[serde(default = "default_lsp_max_per_file")]
pub max_per_file: usize,
#[serde(default)]
pub min_severity: DiagnosticSeverity,
}
impl Default for DiagnosticsConfig {
fn default() -> Self {
Self {
enabled: true,
max_per_file: default_lsp_max_per_file(),
min_severity: DiagnosticSeverity::default(),
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default)]
pub struct HoverConfig {
pub enabled: bool,
#[serde(default = "default_lsp_max_symbols")]
pub max_symbols: usize,
}
impl Default for HoverConfig {
fn default() -> Self {
Self {
enabled: false,
max_symbols: default_lsp_max_symbols(),
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default)]
pub struct LspConfig {
pub enabled: bool,
#[serde(default = "default_lsp_mcp_server_id")]
pub mcp_server_id: String,
#[serde(default = "default_lsp_token_budget")]
pub token_budget: usize,
#[serde(default = "default_lsp_call_timeout_secs")]
pub call_timeout_secs: u64,
#[serde(default)]
pub diagnostics: DiagnosticsConfig,
#[serde(default)]
pub hover: HoverConfig,
}
impl Default for LspConfig {
fn default() -> Self {
Self {
enabled: false,
mcp_server_id: default_lsp_mcp_server_id(),
token_budget: default_lsp_token_budget(),
call_timeout_secs: default_lsp_call_timeout_secs(),
diagnostics: DiagnosticsConfig::default(),
hover: HoverConfig::default(),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn acp_auth_method_unknown_variant_fails() {
assert!(serde_json::from_str::<AcpAuthMethod>(r#""bearer""#).is_err());
assert!(serde_json::from_str::<AcpAuthMethod>(r#""envvar""#).is_err());
assert!(serde_json::from_str::<AcpAuthMethod>(r#""Agent""#).is_err());
}
#[test]
fn acp_auth_method_known_variant_succeeds() {
let m = serde_json::from_str::<AcpAuthMethod>(r#""agent""#).unwrap();
assert_eq!(m, AcpAuthMethod::Agent);
}
#[test]
fn additional_dir_rejects_dotdot_traversal() {
let result = AdditionalDir::parse(std::path::PathBuf::from("/tmp/../etc"));
assert!(
matches!(result, Err(AdditionalDirError::Traversal(_))),
"expected Traversal, got {result:?}"
);
}
#[test]
fn additional_dir_rejects_proc() {
if !std::path::Path::new("/proc").exists() {
return;
}
let result = AdditionalDir::parse(std::path::PathBuf::from("/proc/self"));
assert!(
matches!(result, Err(AdditionalDirError::Reserved(_))),
"expected Reserved, got {result:?}"
);
}
#[test]
fn additional_dir_rejects_ssh() {
let home = std::env::var("HOME").unwrap_or_else(|_| "/root".to_owned());
let ssh = std::path::PathBuf::from(format!("{home}/.ssh"));
if !ssh.exists() {
return;
}
let result = AdditionalDir::parse(ssh.clone());
assert!(
matches!(result, Err(AdditionalDirError::Reserved(_))),
"expected Reserved for {ssh:?}, got {result:?}"
);
}
#[test]
fn additional_dir_accepts_tmp() {
let tmp = std::env::temp_dir();
match AdditionalDir::parse(tmp.clone()) {
Ok(dir) => {
assert!(dir.as_path().is_absolute());
}
Err(AdditionalDirError::Canonicalize { .. }) => {
}
Err(e) => panic!("unexpected error for {tmp:?}: {e:?}"),
}
}
}