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
42pub const ACP_AUTH_CLIENT_ID_DEFAULT: &str = "default";
44pub const ACP_AUTH_CLIENT_ID_LOCAL: &str = "acp-local";
46
47#[derive(Clone, Deserialize, Serialize)]
55pub struct AcpAuthClient {
56 pub id: String,
59 #[serde(default, skip_serializing_if = "Option::is_none")]
61 pub token: Option<String>,
62 #[serde(default, skip_serializing_if = "Option::is_none")]
64 pub token_vault_key: Option<String>,
65}
66
67impl std::fmt::Debug for AcpAuthClient {
68 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
69 f.debug_struct("AcpAuthClient")
70 .field("id", &self.id)
71 .field("token", &self.token.as_ref().map(|_| "[REDACTED]"))
72 .field("token_vault_key", &self.token_vault_key)
73 .finish()
74 }
75}
76
77fn default_acp_lsp_max_diagnostics_per_file() -> usize {
78 20
79}
80
81fn default_acp_lsp_max_diagnostic_files() -> usize {
82 5
83}
84
85fn default_acp_lsp_max_references() -> usize {
86 100
87}
88
89fn default_acp_lsp_max_workspace_symbols() -> usize {
90 50
91}
92
93fn default_acp_lsp_request_timeout_secs() -> u64 {
94 10
95}
96
97fn default_acp_elicitation_timeout_secs() -> u64 {
98 120
99}
100
101fn default_acp_terminal_timeout_secs() -> u64 {
102 120
103}
104
105fn default_acp_mcp_timeout_secs() -> u64 {
106 300
107}
108
109fn default_acp_notify_ack_timeout_ms() -> u64 {
110 5000
111}
112
113fn default_lsp_mcp_server_id() -> String {
114 "mcpls".into()
115}
116fn default_lsp_token_budget() -> usize {
117 2000
118}
119fn default_lsp_max_per_file() -> usize {
120 20
121}
122fn default_lsp_max_symbols() -> usize {
123 5
124}
125fn default_lsp_call_timeout_secs() -> u64 {
126 5
127}
128
129#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
145#[serde(rename_all = "lowercase")]
146#[non_exhaustive]
147pub enum AcpAuthMethod {
148 Agent,
150}
151
152impl<'de> serde::Deserialize<'de> for AcpAuthMethod {
153 fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
154 let s = String::deserialize(d)?;
155 match s.as_str() {
156 "agent" => Ok(Self::Agent),
157 other => Err(serde::de::Error::unknown_variant(other, &["agent"])),
158 }
159 }
160}
161
162impl std::fmt::Display for AcpAuthMethod {
163 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
164 match self {
165 Self::Agent => f.write_str("agent"),
166 }
167 }
168}
169
170fn default_acp_auth_methods() -> Vec<AcpAuthMethod> {
171 vec![AcpAuthMethod::Agent]
172}
173
174#[derive(Debug, thiserror::Error)]
176#[non_exhaustive]
177pub enum AdditionalDirError {
178 #[error("path `{0}` contains `..` traversal")]
180 Traversal(PathBuf),
181 #[error("path `{0}` is a reserved system or credentials directory")]
183 Reserved(PathBuf),
184 #[error("failed to canonicalize `{path}`: {source}")]
186 Canonicalize {
187 path: PathBuf,
188 #[source]
189 source: std::io::Error,
190 },
191}
192
193#[derive(Clone, PartialEq, Eq)]
211pub struct AdditionalDir(PathBuf);
212
213impl AdditionalDir {
214 pub fn parse(raw: impl Into<PathBuf>) -> Result<Self, AdditionalDirError> {
220 let raw: PathBuf = raw.into();
221
222 let expanded = if raw.starts_with("~") {
224 let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("/"));
225 home.join(raw.strip_prefix("~").unwrap_or(&raw))
226 } else {
227 raw.clone()
228 };
229
230 for component in expanded.components() {
232 if component == Component::ParentDir {
233 return Err(AdditionalDirError::Traversal(raw));
234 }
235 }
236
237 let canon =
238 std::fs::canonicalize(&expanded).map_err(|e| AdditionalDirError::Canonicalize {
239 path: raw.clone(),
240 source: e,
241 })?;
242
243 let reserved = reserved_prefixes();
245 for prefix in &reserved {
246 if canon.starts_with(prefix) {
247 return Err(AdditionalDirError::Reserved(canon));
248 }
249 }
250
251 Ok(Self(canon))
252 }
253
254 #[must_use]
256 pub fn as_path(&self) -> &Path {
257 &self.0
258 }
259}
260
261fn reserved_prefixes() -> Vec<PathBuf> {
262 let mut prefixes = vec![PathBuf::from("/proc"), PathBuf::from("/sys")];
263 if let Some(home) = dirs::home_dir() {
264 prefixes.push(home.join(".ssh"));
265 prefixes.push(home.join(".gnupg"));
266 prefixes.push(home.join(".aws"));
267 }
268 prefixes
269}
270
271impl std::fmt::Debug for AdditionalDir {
272 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
273 write!(f, "AdditionalDir({:?})", self.0)
274 }
275}
276
277impl std::fmt::Display for AdditionalDir {
278 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
279 write!(f, "{}", self.0.display())
280 }
281}
282
283impl Serialize for AdditionalDir {
284 fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
285 self.0.to_string_lossy().serialize(s)
286 }
287}
288
289impl<'de> serde::Deserialize<'de> for AdditionalDir {
290 fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
291 let s = String::deserialize(d)?;
292 Self::parse(s).map_err(serde::de::Error::custom)
293 }
294}
295
296#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)]
312#[serde(rename_all = "lowercase")]
313#[non_exhaustive]
314pub enum ToolDensity {
315 Compact,
317 #[default]
319 Inline,
320 Block,
322}
323
324impl ToolDensity {
325 #[must_use]
339 pub fn cycle(self) -> Self {
340 match self {
341 Self::Compact => Self::Inline,
342 Self::Inline => Self::Block,
343 Self::Block => Self::Compact,
344 }
345 }
346}
347
348#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)]
372#[serde(rename_all = "lowercase")]
373#[non_exhaustive]
374pub enum ColorMode {
375 #[default]
377 Auto,
378 Truecolor,
380 Ansi256,
382 Ansi16,
384 Never,
386}
387
388#[derive(Debug, Clone, Default, Deserialize, Serialize)]
407#[serde(default)]
408pub struct ThemeConfig {
409 pub name: String,
413 pub color_mode: ColorMode,
415}
416
417#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
433#[serde(rename_all = "lowercase")]
434pub enum Motion {
435 #[default]
437 Full,
438 Minimal,
440 Off,
442}
443
444#[allow(clippy::struct_excessive_bools)]
460#[derive(Debug, Clone, Deserialize, Serialize)]
461pub struct DelightsConfig {
462 #[serde(default = "default_true")]
464 pub stream_metrics: bool,
465 #[serde(default = "default_true")]
467 pub toasts: bool,
468 #[serde(default = "default_true")]
470 pub completion_flash: bool,
471 #[serde(default = "default_true")]
473 pub smooth_scroll: bool,
474 #[serde(default = "default_true")]
476 pub splash_shimmer: bool,
477}
478
479impl Default for DelightsConfig {
480 fn default() -> Self {
481 Self {
482 stream_metrics: true,
483 toasts: true,
484 completion_flash: true,
485 smooth_scroll: true,
486 splash_shimmer: true,
487 }
488 }
489}
490
491#[derive(Debug, Clone, Default, Deserialize, Serialize)]
513pub struct TuiConfig {
514 #[serde(default)]
517 pub show_source_labels: bool,
518 #[serde(default)]
523 pub tool_density: ToolDensity,
524 #[serde(default)]
528 pub motion: Motion,
529 #[serde(default)]
531 pub fleet: FleetConfig,
532 #[serde(default)]
534 pub theme: ThemeConfig,
535 #[serde(default)]
539 pub delights: DelightsConfig,
540 #[serde(default)]
545 pub mouse: bool,
546 #[serde(default)]
554 pub panel_sizing: PanelSizingMode,
555}
556
557#[derive(Debug, Clone, Copy, Default, Deserialize, Serialize, PartialEq, Eq)]
559#[serde(rename_all = "lowercase")]
560pub enum PanelSizingMode {
561 #[default]
565 Auto,
566 Even,
570}
571
572#[derive(Debug, Clone, Copy, Deserialize, Serialize)]
574#[serde(default)]
575pub struct FleetConfig {
576 pub refresh_interval_secs: u64,
578 pub max_sessions: u32,
580}
581
582impl Default for FleetConfig {
583 fn default() -> Self {
584 Self {
585 refresh_interval_secs: 5,
586 max_sessions: 50,
587 }
588 }
589}
590
591#[derive(Debug, Clone, Default, Deserialize, Serialize)]
593#[serde(rename_all = "lowercase")]
594#[non_exhaustive]
595pub enum AcpTransport {
596 #[default]
598 Stdio,
599 Http,
601 Both,
603}
604
605#[derive(Clone, Debug, Default, Deserialize, Serialize)]
607pub struct SubagentPresetConfig {
608 pub name: String,
610 pub command: String,
612 #[serde(default, skip_serializing_if = "Option::is_none")]
614 pub cwd: Option<PathBuf>,
615 #[serde(default = "default_subagent_handshake_timeout_secs")]
617 pub handshake_timeout_secs: u64,
618 #[serde(default = "default_subagent_prompt_timeout_secs")]
620 pub prompt_timeout_secs: u64,
621}
622
623#[derive(Clone, Debug, Default, Deserialize, Serialize)]
636pub struct AcpSubagentsConfig {
637 #[serde(default)]
639 pub enabled: bool,
640
641 #[serde(default)]
643 pub presets: Vec<SubagentPresetConfig>,
644}
645
646fn default_subagent_handshake_timeout_secs() -> u64 {
647 30
648}
649
650fn default_subagent_prompt_timeout_secs() -> u64 {
651 600
652}
653
654#[derive(Clone, Deserialize, Serialize)]
669pub struct AcpConfig {
670 #[serde(default)]
672 pub enabled: bool,
673 #[serde(default = "default_acp_agent_name")]
675 pub agent_name: String,
676 #[serde(default = "default_acp_agent_version")]
678 pub agent_version: String,
679 #[serde(default = "default_acp_max_sessions")]
681 pub max_sessions: usize,
682 #[serde(default = "default_acp_session_idle_timeout_secs")]
684 pub session_idle_timeout_secs: u64,
685 #[serde(default = "default_acp_broadcast_capacity")]
687 pub broadcast_capacity: usize,
688 #[serde(skip_serializing_if = "Option::is_none")]
690 pub permission_file: Option<std::path::PathBuf>,
691 #[serde(default)]
694 pub available_models: Vec<String>,
695 #[serde(default = "default_acp_transport")]
697 pub transport: AcpTransport,
698 #[serde(default = "default_acp_http_bind")]
700 pub http_bind: String,
701 #[serde(skip_serializing_if = "Option::is_none")]
706 pub auth_token: Option<String>,
707 #[serde(default)]
711 pub auth_clients: Vec<AcpAuthClient>,
712 #[serde(default = "default_acp_discovery_enabled")]
715 pub discovery_enabled: bool,
716 #[serde(default)]
718 pub lsp: AcpLspConfig,
719 #[serde(default)]
731 pub additional_directories: Vec<AdditionalDir>,
732 #[serde(default = "default_acp_auth_methods")]
737 pub auth_methods: Vec<AcpAuthMethod>,
738 #[serde(default = "default_true")]
743 pub message_ids_enabled: bool,
744 #[serde(default)]
746 pub subagents: AcpSubagentsConfig,
747 #[serde(default)]
749 pub timeouts: AcpTimeoutsConfig,
750 #[serde(default)]
753 pub model_config: AcpModelConfigConfig,
754}
755
756impl Default for AcpConfig {
757 fn default() -> Self {
758 Self {
759 enabled: false,
760 agent_name: default_acp_agent_name(),
761 agent_version: default_acp_agent_version(),
762 max_sessions: default_acp_max_sessions(),
763 session_idle_timeout_secs: default_acp_session_idle_timeout_secs(),
764 broadcast_capacity: default_acp_broadcast_capacity(),
765 permission_file: None,
766 available_models: Vec::new(),
767 transport: default_acp_transport(),
768 http_bind: default_acp_http_bind(),
769 auth_token: None,
770 auth_clients: Vec::new(),
771 discovery_enabled: default_acp_discovery_enabled(),
772 lsp: AcpLspConfig::default(),
773 additional_directories: Vec::new(),
774 auth_methods: default_acp_auth_methods(),
775 message_ids_enabled: true,
776 subagents: AcpSubagentsConfig::default(),
777 timeouts: AcpTimeoutsConfig::default(),
778 model_config: AcpModelConfigConfig::default(),
779 }
780 }
781}
782
783impl std::fmt::Debug for AcpConfig {
784 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
785 f.debug_struct("AcpConfig")
786 .field("enabled", &self.enabled)
787 .field("agent_name", &self.agent_name)
788 .field("agent_version", &self.agent_version)
789 .field("max_sessions", &self.max_sessions)
790 .field("session_idle_timeout_secs", &self.session_idle_timeout_secs)
791 .field("broadcast_capacity", &self.broadcast_capacity)
792 .field("permission_file", &self.permission_file)
793 .field("available_models", &self.available_models)
794 .field("transport", &self.transport)
795 .field("http_bind", &self.http_bind)
796 .field(
797 "auth_token",
798 &self.auth_token.as_ref().map(|_| "[REDACTED]"),
799 )
800 .field("auth_clients", &self.auth_clients)
801 .field("discovery_enabled", &self.discovery_enabled)
802 .field("lsp", &self.lsp)
803 .field("additional_directories", &self.additional_directories)
804 .field("auth_methods", &self.auth_methods)
805 .field("message_ids_enabled", &self.message_ids_enabled)
806 .field("subagents", &self.subagents)
807 .field("timeouts", &self.timeouts)
808 .field("model_config", &self.model_config)
809 .finish()
810 }
811}
812
813impl AcpConfig {
814 pub fn validate_auth_clients(&self) -> Result<(), String> {
826 let mut seen_ids = std::collections::HashSet::new();
827 let mut seen_inline_tokens = std::collections::HashSet::new();
828
829 if let Some(ref token) = self.auth_token {
830 if token.trim().is_empty() {
831 return Err("[acp] auth_token must not be empty or whitespace-only".to_owned());
832 }
833 seen_inline_tokens.insert(token.as_str());
834 }
835
836 for client in &self.auth_clients {
837 if client.id.is_empty() {
838 return Err("[[acp.auth_clients]] entry has an empty id".to_owned());
839 }
840 if client.id == ACP_AUTH_CLIENT_ID_DEFAULT || client.id == ACP_AUTH_CLIENT_ID_LOCAL {
841 return Err(format!(
842 "[[acp.auth_clients]] id {:?} is reserved (collides with the legacy \
843 auth_token client or the unauthenticated/stdio owner bucket)",
844 client.id
845 ));
846 }
847 if client.id.contains(':') {
848 return Err(format!(
849 "[[acp.auth_clients]] id {:?} must not contain ':'",
850 client.id
851 ));
852 }
853 if !seen_ids.insert(client.id.as_str()) {
854 return Err(format!(
855 "[[acp.auth_clients]] id {:?} is duplicated",
856 client.id
857 ));
858 }
859 match (&client.token, &client.token_vault_key) {
860 (Some(_), Some(_)) => {
861 return Err(format!(
862 "[[acp.auth_clients]] id {:?} sets both token and token_vault_key; \
863 exactly one must be set",
864 client.id
865 ));
866 }
867 (None, None) => {
868 return Err(format!(
869 "[[acp.auth_clients]] id {:?} sets neither token nor token_vault_key; \
870 exactly one must be set",
871 client.id
872 ));
873 }
874 (Some(token), None) => {
875 if token.trim().is_empty() {
876 return Err(format!(
877 "[[acp.auth_clients]] id {:?} has an empty or whitespace-only token",
878 client.id
879 ));
880 }
881 if !seen_inline_tokens.insert(token.as_str()) {
882 return Err(format!(
883 "[[acp.auth_clients]] id {:?} has a token that collides with \
884 another configured client's inline token",
885 client.id
886 ));
887 }
888 }
889 (None, Some(_)) => {}
890 }
891 }
892
893 Ok(())
894 }
895}
896
897#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)]
903#[serde(rename_all = "snake_case")]
904pub enum AcpTemperaturePreset {
905 Precise,
907 #[default]
909 Balanced,
910 Creative,
912}
913
914impl AcpTemperaturePreset {
915 #[must_use]
917 pub fn temperature(self) -> f64 {
918 match self {
919 Self::Precise => 0.2,
920 Self::Balanced => 0.7,
921 Self::Creative => 1.0,
922 }
923 }
924
925 #[must_use]
927 pub fn as_str(self) -> &'static str {
928 match self {
929 Self::Precise => "precise",
930 Self::Balanced => "balanced",
931 Self::Creative => "creative",
932 }
933 }
934}
935
936impl std::str::FromStr for AcpTemperaturePreset {
937 type Err = ();
938
939 fn from_str(s: &str) -> Result<Self, Self::Err> {
940 match s {
941 "precise" => Ok(Self::Precise),
942 "balanced" => Ok(Self::Balanced),
943 "creative" => Ok(Self::Creative),
944 _ => Err(()),
945 }
946 }
947}
948
949#[derive(Debug, Clone, Default, Deserialize, Serialize)]
962pub struct AcpModelConfigConfig {
963 #[serde(default)]
965 pub default_temperature_preset: AcpTemperaturePreset,
966}
967
968#[derive(Debug, Clone, Deserialize, Serialize)]
973pub struct AcpTimeoutsConfig {
974 #[serde(default = "default_acp_elicitation_timeout_secs")]
976 pub elicitation_secs: u64,
977 #[serde(default = "default_acp_terminal_timeout_secs")]
979 pub terminal_secs: u64,
980 #[serde(default = "default_acp_mcp_timeout_secs")]
982 pub mcp_secs: u64,
983 #[serde(default = "default_acp_notify_ack_timeout_ms")]
988 pub notify_ack_timeout_ms: u64,
989}
990
991impl Default for AcpTimeoutsConfig {
992 fn default() -> Self {
993 Self {
994 elicitation_secs: default_acp_elicitation_timeout_secs(),
995 terminal_secs: default_acp_terminal_timeout_secs(),
996 mcp_secs: default_acp_mcp_timeout_secs(),
997 notify_ack_timeout_ms: default_acp_notify_ack_timeout_ms(),
998 }
999 }
1000}
1001
1002#[derive(Debug, Clone, Deserialize, Serialize)]
1007pub struct AcpLspConfig {
1008 #[serde(default = "default_true")]
1010 pub enabled: bool,
1011 #[serde(default = "default_true")]
1013 pub auto_diagnostics_on_save: bool,
1014 #[serde(default = "default_acp_lsp_max_diagnostics_per_file")]
1016 pub max_diagnostics_per_file: usize,
1017 #[serde(default = "default_acp_lsp_max_diagnostic_files")]
1019 pub max_diagnostic_files: usize,
1020 #[serde(default = "default_acp_lsp_max_references")]
1022 pub max_references: usize,
1023 #[serde(default = "default_acp_lsp_max_workspace_symbols")]
1025 pub max_workspace_symbols: usize,
1026 #[serde(default = "default_acp_lsp_request_timeout_secs")]
1028 pub request_timeout_secs: u64,
1029}
1030
1031impl Default for AcpLspConfig {
1032 fn default() -> Self {
1033 Self {
1034 enabled: true,
1035 auto_diagnostics_on_save: true,
1036 max_diagnostics_per_file: default_acp_lsp_max_diagnostics_per_file(),
1037 max_diagnostic_files: default_acp_lsp_max_diagnostic_files(),
1038 max_references: default_acp_lsp_max_references(),
1039 max_workspace_symbols: default_acp_lsp_max_workspace_symbols(),
1040 request_timeout_secs: default_acp_lsp_request_timeout_secs(),
1041 }
1042 }
1043}
1044
1045#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
1049#[serde(rename_all = "lowercase")]
1050#[non_exhaustive]
1051pub enum DiagnosticSeverity {
1052 #[default]
1053 Error,
1054 Warning,
1055 Info,
1056 Hint,
1057}
1058
1059#[derive(Debug, Clone, Deserialize, Serialize)]
1063#[serde(default)]
1064pub struct DiagnosticsConfig {
1065 pub enabled: bool,
1067 #[serde(default = "default_lsp_max_per_file")]
1069 pub max_per_file: usize,
1070 #[serde(default)]
1072 pub min_severity: DiagnosticSeverity,
1073}
1074impl Default for DiagnosticsConfig {
1075 fn default() -> Self {
1076 Self {
1077 enabled: true,
1078 max_per_file: default_lsp_max_per_file(),
1079 min_severity: DiagnosticSeverity::default(),
1080 }
1081 }
1082}
1083
1084#[derive(Debug, Clone, Deserialize, Serialize)]
1086#[serde(default)]
1087pub struct HoverConfig {
1088 pub enabled: bool,
1090 #[serde(default = "default_lsp_max_symbols")]
1092 pub max_symbols: usize,
1093}
1094impl Default for HoverConfig {
1095 fn default() -> Self {
1096 Self {
1097 enabled: false,
1098 max_symbols: default_lsp_max_symbols(),
1099 }
1100 }
1101}
1102
1103#[derive(Debug, Clone, Deserialize, Serialize)]
1105#[serde(default)]
1106pub struct LspConfig {
1107 pub enabled: bool,
1109 #[serde(default = "default_lsp_mcp_server_id")]
1111 pub mcp_server_id: String,
1112 #[serde(default = "default_lsp_token_budget")]
1114 pub token_budget: usize,
1115 #[serde(default = "default_lsp_call_timeout_secs")]
1117 pub call_timeout_secs: u64,
1118 #[serde(default)]
1120 pub diagnostics: DiagnosticsConfig,
1121 #[serde(default)]
1123 pub hover: HoverConfig,
1124}
1125impl Default for LspConfig {
1126 fn default() -> Self {
1127 Self {
1128 enabled: false,
1129 mcp_server_id: default_lsp_mcp_server_id(),
1130 token_budget: default_lsp_token_budget(),
1131 call_timeout_secs: default_lsp_call_timeout_secs(),
1132 diagnostics: DiagnosticsConfig::default(),
1133 hover: HoverConfig::default(),
1134 }
1135 }
1136}
1137
1138#[cfg(test)]
1139mod tests {
1140 use super::*;
1141
1142 #[test]
1143 fn acp_auth_method_unknown_variant_fails() {
1144 assert!(serde_json::from_str::<AcpAuthMethod>(r#""bearer""#).is_err());
1145 assert!(serde_json::from_str::<AcpAuthMethod>(r#""envvar""#).is_err());
1146 assert!(serde_json::from_str::<AcpAuthMethod>(r#""Agent""#).is_err());
1147 }
1148
1149 #[test]
1150 fn acp_auth_method_known_variant_succeeds() {
1151 let m = serde_json::from_str::<AcpAuthMethod>(r#""agent""#).unwrap();
1152 assert_eq!(m, AcpAuthMethod::Agent);
1153 }
1154
1155 fn client(id: &str, token: &str) -> AcpAuthClient {
1158 AcpAuthClient {
1159 id: id.to_owned(),
1160 token: Some(token.to_owned()),
1161 token_vault_key: None,
1162 }
1163 }
1164
1165 #[test]
1166 fn validate_auth_clients_empty_config_ok() {
1167 assert!(AcpConfig::default().validate_auth_clients().is_ok());
1168 }
1169
1170 #[test]
1171 fn validate_auth_clients_legacy_auth_token_only_ok() {
1172 let cfg = AcpConfig {
1173 auth_token: Some("secret".to_owned()),
1174 ..AcpConfig::default()
1175 };
1176 assert!(cfg.validate_auth_clients().is_ok());
1177 }
1178
1179 #[test]
1180 fn validate_auth_clients_single_client_ok() {
1181 let cfg = AcpConfig {
1182 auth_clients: vec![client("alice", "token-a")],
1183 ..AcpConfig::default()
1184 };
1185 assert!(cfg.validate_auth_clients().is_ok());
1186 }
1187
1188 #[test]
1189 fn validate_auth_clients_coexist_with_legacy_ok() {
1190 let cfg = AcpConfig {
1191 auth_token: Some("legacy".to_owned()),
1192 auth_clients: vec![client("alice", "token-a"), client("bob", "token-b")],
1193 ..AcpConfig::default()
1194 };
1195 assert!(cfg.validate_auth_clients().is_ok());
1196 }
1197
1198 #[test]
1199 fn validate_auth_clients_rejects_reserved_id_default() {
1200 let cfg = AcpConfig {
1201 auth_clients: vec![client(ACP_AUTH_CLIENT_ID_DEFAULT, "token-a")],
1202 ..AcpConfig::default()
1203 };
1204 let err = cfg.validate_auth_clients().unwrap_err();
1205 assert!(err.contains("reserved"), "unexpected error: {err}");
1206 }
1207
1208 #[test]
1209 fn validate_auth_clients_rejects_reserved_id_acp_local() {
1210 let cfg = AcpConfig {
1211 auth_clients: vec![client(ACP_AUTH_CLIENT_ID_LOCAL, "token-a")],
1212 ..AcpConfig::default()
1213 };
1214 let err = cfg.validate_auth_clients().unwrap_err();
1215 assert!(err.contains("reserved"), "unexpected error: {err}");
1216 }
1217
1218 #[test]
1219 fn validate_auth_clients_rejects_duplicate_id() {
1220 let cfg = AcpConfig {
1221 auth_clients: vec![client("alice", "token-a"), client("alice", "token-b")],
1222 ..AcpConfig::default()
1223 };
1224 let err = cfg.validate_auth_clients().unwrap_err();
1225 assert!(err.contains("duplicated"), "unexpected error: {err}");
1226 }
1227
1228 #[test]
1229 fn validate_auth_clients_rejects_id_containing_colon() {
1230 let cfg = AcpConfig {
1231 auth_clients: vec![client("alice:2", "token-a")],
1232 ..AcpConfig::default()
1233 };
1234 let err = cfg.validate_auth_clients().unwrap_err();
1235 assert!(err.contains("':'"), "unexpected error: {err}");
1236 }
1237
1238 #[test]
1239 fn validate_auth_clients_rejects_empty_id() {
1240 let cfg = AcpConfig {
1241 auth_clients: vec![client("", "token-a")],
1242 ..AcpConfig::default()
1243 };
1244 let err = cfg.validate_auth_clients().unwrap_err();
1245 assert!(err.contains("empty id"), "unexpected error: {err}");
1246 }
1247
1248 #[test]
1249 fn validate_auth_clients_rejects_neither_token_nor_vault_key() {
1250 let cfg = AcpConfig {
1251 auth_clients: vec![AcpAuthClient {
1252 id: "alice".to_owned(),
1253 token: None,
1254 token_vault_key: None,
1255 }],
1256 ..AcpConfig::default()
1257 };
1258 let err = cfg.validate_auth_clients().unwrap_err();
1259 assert!(err.contains("neither"), "unexpected error: {err}");
1260 }
1261
1262 #[test]
1263 fn validate_auth_clients_rejects_both_token_and_vault_key() {
1264 let cfg = AcpConfig {
1265 auth_clients: vec![AcpAuthClient {
1266 id: "alice".to_owned(),
1267 token: Some("token-a".to_owned()),
1268 token_vault_key: Some("ZEPH_ACP_TOKEN_ALICE".to_owned()),
1269 }],
1270 ..AcpConfig::default()
1271 };
1272 let err = cfg.validate_auth_clients().unwrap_err();
1273 assert!(err.contains("both"), "unexpected error: {err}");
1274 }
1275
1276 #[test]
1277 fn validate_auth_clients_vault_key_only_ok() {
1278 let cfg = AcpConfig {
1279 auth_clients: vec![AcpAuthClient {
1280 id: "alice".to_owned(),
1281 token: None,
1282 token_vault_key: Some("ZEPH_ACP_TOKEN_ALICE".to_owned()),
1283 }],
1284 ..AcpConfig::default()
1285 };
1286 assert!(cfg.validate_auth_clients().is_ok());
1287 }
1288
1289 #[test]
1290 fn validate_auth_clients_rejects_duplicate_inline_tokens_across_clients() {
1291 let cfg = AcpConfig {
1292 auth_clients: vec![client("alice", "shared"), client("bob", "shared")],
1293 ..AcpConfig::default()
1294 };
1295 let err = cfg.validate_auth_clients().unwrap_err();
1296 assert!(err.contains("collides"), "unexpected error: {err}");
1297 }
1298
1299 #[test]
1300 fn validate_auth_clients_rejects_inline_token_colliding_with_legacy_default_token() {
1301 let cfg = AcpConfig {
1302 auth_token: Some("shared".to_owned()),
1303 auth_clients: vec![client("alice", "shared")],
1304 ..AcpConfig::default()
1305 };
1306 let err = cfg.validate_auth_clients().unwrap_err();
1307 assert!(err.contains("collides"), "unexpected error: {err}");
1308 }
1309
1310 #[test]
1311 fn validate_auth_clients_rejects_empty_legacy_auth_token() {
1312 let cfg = AcpConfig {
1313 auth_token: Some(String::new()),
1314 ..AcpConfig::default()
1315 };
1316 let err = cfg.validate_auth_clients().unwrap_err();
1317 assert!(err.contains("empty"), "unexpected error: {err}");
1318 }
1319
1320 #[test]
1321 fn validate_auth_clients_rejects_whitespace_only_legacy_auth_token() {
1322 let cfg = AcpConfig {
1323 auth_token: Some(" ".to_owned()),
1324 ..AcpConfig::default()
1325 };
1326 let err = cfg.validate_auth_clients().unwrap_err();
1327 assert!(err.contains("empty"), "unexpected error: {err}");
1328 }
1329
1330 #[test]
1331 fn validate_auth_clients_rejects_empty_inline_client_token() {
1332 let cfg = AcpConfig {
1333 auth_clients: vec![client("alice", "")],
1334 ..AcpConfig::default()
1335 };
1336 let err = cfg.validate_auth_clients().unwrap_err();
1337 assert!(err.contains("empty"), "unexpected error: {err}");
1338 }
1339
1340 #[test]
1341 fn validate_auth_clients_rejects_whitespace_only_inline_client_token() {
1342 let cfg = AcpConfig {
1343 auth_clients: vec![client("alice", " ")],
1344 ..AcpConfig::default()
1345 };
1346 let err = cfg.validate_auth_clients().unwrap_err();
1347 assert!(err.contains("empty"), "unexpected error: {err}");
1348 }
1349
1350 #[test]
1351 fn additional_dir_rejects_dotdot_traversal() {
1352 let result = AdditionalDir::parse(std::path::PathBuf::from("/tmp/../etc"));
1353 assert!(
1354 matches!(result, Err(AdditionalDirError::Traversal(_))),
1355 "expected Traversal, got {result:?}"
1356 );
1357 }
1358
1359 #[test]
1360 fn additional_dir_rejects_proc() {
1361 if !std::path::Path::new("/proc").exists() {
1363 return;
1364 }
1365 let result = AdditionalDir::parse(std::path::PathBuf::from("/proc/self"));
1366 assert!(
1367 matches!(result, Err(AdditionalDirError::Reserved(_))),
1368 "expected Reserved, got {result:?}"
1369 );
1370 }
1371
1372 #[test]
1373 fn additional_dir_rejects_ssh() {
1374 let home = std::env::var("HOME").unwrap_or_else(|_| "/root".to_owned());
1375 let ssh = std::path::PathBuf::from(format!("{home}/.ssh"));
1376 if !ssh.exists() {
1377 return;
1378 }
1379 let result = AdditionalDir::parse(ssh.clone());
1380 assert!(
1381 matches!(result, Err(AdditionalDirError::Reserved(_))),
1382 "expected Reserved for {ssh:?}, got {result:?}"
1383 );
1384 }
1385
1386 #[test]
1387 fn additional_dir_accepts_tmp() {
1388 let tmp = std::env::temp_dir();
1389 match AdditionalDir::parse(tmp.clone()) {
1391 Ok(dir) => {
1392 assert!(dir.as_path().is_absolute());
1394 }
1395 Err(AdditionalDirError::Canonicalize { .. }) => {
1396 }
1398 Err(e) => panic!("unexpected error for {tmp:?}: {e:?}"),
1399 }
1400 }
1401}