Skip to main content

zeph_config/
ui.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use 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
42/// Reserved `[[acp.auth_clients]]` id colliding with the synthesized legacy `auth_token` client.
43pub const ACP_AUTH_CLIENT_ID_DEFAULT: &str = "default";
44/// Reserved `[[acp.auth_clients]]` id colliding with the unauthenticated/stdio owner bucket.
45pub const ACP_AUTH_CLIENT_ID_LOCAL: &str = "acp-local";
46
47/// A single named ACP HTTP/WS bearer-token credential (#5868).
48///
49/// Each entry authenticates one `Authorization: Bearer <token>` value and, on match, becomes
50/// the request's owner identity for ACP session-persistence scoping (`owner_key`). Exactly one
51/// of `token` / `token_vault_key` must be set — `token` is inline (parity with the legacy
52/// `[acp] auth_token` field), `token_vault_key` resolves the secret from the age vault at
53/// startup, mirroring `[serve] auth_token_vault_key`.
54#[derive(Clone, Deserialize, Serialize)]
55pub struct AcpAuthClient {
56    /// Stable owner label surviving token rotation. Must be non-empty, unique among
57    /// `auth_clients`, and must not be `"default"` or `"acp-local"` (reserved sentinels).
58    pub id: String,
59    /// Inline bearer token. Mutually exclusive with `token_vault_key`.
60    #[serde(default, skip_serializing_if = "Option::is_none")]
61    pub token: Option<String>,
62    /// Vault key to resolve the bearer token from at startup. Mutually exclusive with `token`.
63    #[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/// Auth methods recognised by Zeph's ACP handler.
130///
131/// PR 4 MVP restricts this to `Agent` only. Future variants (`EnvVar`, `Terminal`) will
132/// be added in follow-up issues with their sub-struct payloads.
133///
134/// # Examples
135///
136/// ```rust
137/// use zeph_config::AcpAuthMethod;
138/// use serde_json;
139///
140/// let m: AcpAuthMethod = serde_json::from_str(r#""agent""#).unwrap();
141/// assert_eq!(m, AcpAuthMethod::Agent);
142/// assert!(serde_json::from_str::<AcpAuthMethod>(r#""envvar""#).is_err());
143/// ```
144#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
145#[serde(rename_all = "lowercase")]
146#[non_exhaustive]
147pub enum AcpAuthMethod {
148    /// Vault-backed agent auth — the sole supported method in PR 4.
149    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/// Error returned when parsing an [`AdditionalDir`] fails.
175#[derive(Debug, thiserror::Error)]
176#[non_exhaustive]
177pub enum AdditionalDirError {
178    /// The raw path contains a `..` component.
179    #[error("path `{0}` contains `..` traversal")]
180    Traversal(PathBuf),
181    /// The canonical path is a reserved system or credentials location.
182    #[error("path `{0}` is a reserved system or credentials directory")]
183    Reserved(PathBuf),
184    /// `std::fs::canonicalize` failed.
185    #[error("failed to canonicalize `{path}`: {source}")]
186    Canonicalize {
187        path: PathBuf,
188        #[source]
189        source: std::io::Error,
190    },
191}
192
193/// A single entry in the `acp.additional_directories` policy allowlist.
194///
195/// Constructed via [`Self::parse`], which:
196/// 1. Rejects any path containing a `..` component (component-aware check).
197/// 2. Expands a leading `~` to the user's home directory.
198/// 3. Calls `std::fs::canonicalize`.
199/// 4. Rejects paths prefixed by `/proc`, `/sys`, `{HOME}/.ssh`, `{HOME}/.gnupg`, or `{HOME}/.aws`.
200///
201/// # Examples
202///
203/// ```rust,no_run
204/// use zeph_config::AdditionalDir;
205///
206/// let dir = AdditionalDir::parse("/tmp/workspace").unwrap();
207/// assert!(dir.as_path().is_absolute());
208/// assert!(AdditionalDir::parse("/proc/self").is_err());
209/// ```
210#[derive(Clone, PartialEq, Eq)]
211pub struct AdditionalDir(PathBuf);
212
213impl AdditionalDir {
214    /// Parse and validate a raw path as a policy allowlist entry.
215    ///
216    /// # Errors
217    ///
218    /// Returns [`AdditionalDirError`] on traversal, reserved prefix, or canonicalization failure.
219    pub fn parse(raw: impl Into<PathBuf>) -> Result<Self, AdditionalDirError> {
220        let raw: PathBuf = raw.into();
221
222        // Expand leading `~`.
223        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        // Reject `..` components (component-aware, not string-based).
231        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        // Reject reserved locations.
244        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    /// Returns the canonicalized path.
255    #[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/// Controls how much detail is shown for tool-call messages in the chat view.
297///
298/// Cycled with the `c` key at runtime; persisted in `[tui].tool_density`.
299///
300/// # Examples
301///
302/// ```rust
303/// use zeph_config::ToolDensity;
304///
305/// let d = ToolDensity::default();
306/// assert_eq!(d, ToolDensity::Inline);
307/// assert_eq!(d.cycle(), ToolDensity::Block);
308/// assert_eq!(ToolDensity::Block.cycle(), ToolDensity::Compact);
309/// assert_eq!(ToolDensity::Compact.cycle(), ToolDensity::Inline);
310/// ```
311#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)]
312#[serde(rename_all = "lowercase")]
313#[non_exhaustive]
314pub enum ToolDensity {
315    /// Single-line summary only (tool name + line count, no output body).
316    Compact,
317    /// Command line + head/tail-truncated output (default).
318    #[default]
319    Inline,
320    /// Full output body without truncation.
321    Block,
322}
323
324impl ToolDensity {
325    /// Advance to the next density level, wrapping around.
326    ///
327    /// `Compact` → `Inline` → `Block` → `Compact`.
328    ///
329    /// # Examples
330    ///
331    /// ```rust
332    /// use zeph_config::ToolDensity;
333    ///
334    /// assert_eq!(ToolDensity::Compact.cycle(), ToolDensity::Inline);
335    /// assert_eq!(ToolDensity::Inline.cycle(), ToolDensity::Block);
336    /// assert_eq!(ToolDensity::Block.cycle(), ToolDensity::Compact);
337    /// ```
338    #[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/// Terminal colour capability override for the TUI theme system.
349///
350/// `Auto` runs OS-level detection at startup; any other value forces the specified mode
351/// and skips detection entirely. Resolution is performed once at TUI startup and stored in
352/// the TUI `App` theme.
353///
354/// # Example (TOML)
355///
356/// ```toml
357/// [tui.theme]
358/// color_mode = "truecolor"   # force 24-bit even if $COLORTERM is unset
359/// ```
360///
361/// # Examples
362///
363/// ```rust
364/// use zeph_config::ColorMode;
365///
366/// let mode: ColorMode = toml::from_str("value = \"auto\"")
367///     .map(|t: toml::Table| t["value"].clone().try_into().unwrap())
368///     .unwrap();
369/// assert_eq!(mode, ColorMode::Auto);
370/// ```
371#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)]
372#[serde(rename_all = "lowercase")]
373#[non_exhaustive]
374pub enum ColorMode {
375    /// Run terminal capability detection at startup (default).
376    #[default]
377    Auto,
378    /// Force 24-bit RGB output; skip capability detection.
379    Truecolor,
380    /// Force RGB → xterm-256 downgrade.
381    Ansi256,
382    /// Force RGB → ANSI-16 downgrade.
383    Ansi16,
384    /// Strip all colour; retain text modifiers only (equivalent to `NO_COLOR`).
385    Never,
386}
387
388/// Theme configuration nested under `[tui.theme]` in TOML.
389///
390/// # Example (TOML)
391///
392/// ```toml
393/// [tui.theme]
394/// name = "zephyr"
395/// color_mode = "auto"
396/// ```
397///
398/// # Examples
399///
400/// ```rust
401/// use zeph_config::ThemeConfig;
402///
403/// let cfg = ThemeConfig::default();
404/// assert_eq!(cfg.name, "");
405/// ```
406#[derive(Debug, Clone, Default, Deserialize, Serialize)]
407#[serde(default)]
408pub struct ThemeConfig {
409    /// Named theme preset (e.g. `"zephyr"`, `"gruvbox-dark"`).
410    ///
411    /// Empty string resolves to the `zephyr` built-in preset.
412    pub name: String,
413    /// Terminal colour capability override. Default: `auto` (detect at runtime).
414    pub color_mode: ColorMode,
415}
416
417/// Controls how much animation the TUI renders.
418///
419/// Set via `[tui] motion = "full" | "minimal" | "off"` in TOML.
420/// Default: `full`.
421///
422/// - `full` — wave animation on the input separator row while busy, no breeze spinner.
423/// - `minimal` — animated breeze spinner (current behaviour before #5096), no wave.
424/// - `off` — no animation at all; input row is frame-invariant even while busy.
425///
426/// # Example (TOML)
427///
428/// ```toml
429/// [tui]
430/// motion = "minimal"
431/// ```
432#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)]
433#[serde(rename_all = "lowercase")]
434pub enum Motion {
435    /// Wave animation on the input separator row while busy.
436    #[default]
437    Full,
438    /// Animated breeze spinner, no wave.
439    Minimal,
440    /// No animation; input row is frame-invariant.
441    Off,
442}
443
444/// Micro-delight toggles for the TUI dashboard (#5104).
445///
446/// All features default to `true`. The `motion = off` setting in [`TuiConfig`]
447/// acts as a master kill-switch that overrides every individual toggle.
448///
449/// # Example (TOML)
450///
451/// ```toml
452/// [tui.delights]
453/// stream_metrics   = true   # tok/s during streaming + TTFT in status bar
454/// toasts           = true   # ephemeral overlay notifications
455/// completion_flash = true   # accent tint on finished tool groups
456/// smooth_scroll    = true   # eased multi-frame scroll on page jumps
457/// splash_shimmer   = true   # one-shot gradient sweep across the wordmark
458/// ```
459#[allow(clippy::struct_excessive_bools)]
460#[derive(Debug, Clone, Deserialize, Serialize)]
461pub struct DelightsConfig {
462    /// Show tok/s during streaming and TTFT after each turn in the status bar.
463    #[serde(default = "default_true")]
464    pub stream_metrics: bool,
465    /// Ephemeral toast notifications (theme switched, copied, task done).
466    #[serde(default = "default_true")]
467    pub toasts: bool,
468    /// One-frame accent tint when a tool group finishes.
469    #[serde(default = "default_true")]
470    pub completion_flash: bool,
471    /// Eased multi-frame interpolation on page scroll.
472    #[serde(default = "default_true")]
473    pub smooth_scroll: bool,
474    /// One-shot gradient shimmer across the splash wordmark at startup.
475    #[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/// TUI (terminal user interface) configuration, nested under `[tui]` in TOML.
492///
493/// # Example (TOML)
494///
495/// ```toml
496/// [tui]
497/// show_source_labels = true
498/// tool_density = "inline"
499/// motion = "full"
500///
501/// [tui.theme]
502/// name = "zephyr"
503/// color_mode = "auto"
504///
505/// [tui.delights]
506/// stream_metrics   = true
507/// toasts           = true
508/// completion_flash = true
509/// smooth_scroll    = true
510/// splash_shimmer   = true
511/// ```
512#[derive(Debug, Clone, Default, Deserialize, Serialize)]
513pub struct TuiConfig {
514    /// Show memory source labels (episodic / semantic / graph) in the message view.
515    /// Default: `false`.
516    #[serde(default)]
517    pub show_source_labels: bool,
518    /// Default tool-output density applied at startup.
519    ///
520    /// Runtime changes via the `c` key are not persisted back to config.
521    /// Default: `inline`.
522    #[serde(default)]
523    pub tool_density: ToolDensity,
524    /// Animation budget for the input separator row.
525    ///
526    /// `full` = wave (default), `minimal` = breeze spinner, `off` = static.
527    #[serde(default)]
528    pub motion: Motion,
529    /// Fleet panel configuration (auto-refresh interval and max sessions displayed).
530    #[serde(default)]
531    pub fleet: FleetConfig,
532    /// Theme and colour capability configuration.
533    #[serde(default)]
534    pub theme: ThemeConfig,
535    /// Micro-delight toggles (tok/s, toasts, flash, scroll, shimmer). All default `true`.
536    ///
537    /// `motion = off` overrides all toggles regardless of their individual values.
538    #[serde(default)]
539    pub delights: DelightsConfig,
540    /// Enable opt-in mouse capture at startup.
541    ///
542    /// When `true`, the terminal forwards scroll-wheel, click, and drag events to
543    /// the TUI. Text selection via Shift+drag still works. Default: `false`.
544    #[serde(default)]
545    pub mouse: bool,
546}
547
548/// Configuration for the TUI fleet panel (#3884).
549#[derive(Debug, Clone, Copy, Deserialize, Serialize)]
550#[serde(default)]
551pub struct FleetConfig {
552    /// How often the fleet panel polls the database for updated session data (seconds).
553    pub refresh_interval_secs: u64,
554    /// Maximum number of sessions to display in the fleet panel.
555    pub max_sessions: u32,
556}
557
558impl Default for FleetConfig {
559    fn default() -> Self {
560        Self {
561            refresh_interval_secs: 5,
562            max_sessions: 50,
563        }
564    }
565}
566
567/// ACP server transport mode.
568#[derive(Debug, Clone, Default, Deserialize, Serialize)]
569#[serde(rename_all = "lowercase")]
570#[non_exhaustive]
571pub enum AcpTransport {
572    /// JSON-RPC over stdin/stdout (default, IDE embedding).
573    #[default]
574    Stdio,
575    /// JSON-RPC over HTTP+SSE and WebSocket.
576    Http,
577    /// Both stdio and HTTP transports active simultaneously.
578    Both,
579}
580
581/// Configuration for a named sub-agent preset in `[[acp.subagents.presets]]`.
582#[derive(Clone, Debug, Default, Deserialize, Serialize)]
583pub struct SubagentPresetConfig {
584    /// Identifier used to reference this preset by name.
585    pub name: String,
586    /// Shell command string to spawn the sub-agent (e.g. `"cargo run -- --acp"`).
587    pub command: String,
588    /// Optional working directory for the spawned subprocess.
589    #[serde(default, skip_serializing_if = "Option::is_none")]
590    pub cwd: Option<PathBuf>,
591    /// Timeout in seconds for the `initialize` + `session/new` handshake. Default: 30.
592    #[serde(default = "default_subagent_handshake_timeout_secs")]
593    pub handshake_timeout_secs: u64,
594    /// Timeout in seconds for a single prompt round-trip. Default: 600.
595    #[serde(default = "default_subagent_prompt_timeout_secs")]
596    pub prompt_timeout_secs: u64,
597}
598
599/// Configuration block for the `[acp.subagents]` TOML section.
600///
601/// # Example
602///
603/// ```toml
604/// [acp.subagents]
605/// enabled = true
606///
607/// [[acp.subagents.presets]]
608/// name = "inner"
609/// command = "cargo run --quiet -- --acp"
610/// ```
611#[derive(Clone, Debug, Default, Deserialize, Serialize)]
612pub struct AcpSubagentsConfig {
613    /// Whether sub-agent spawning is enabled at runtime. Default: `false`.
614    #[serde(default)]
615    pub enabled: bool,
616
617    /// Named presets available via CLI (`zeph acp subagent list`) and TUI palette.
618    #[serde(default)]
619    pub presets: Vec<SubagentPresetConfig>,
620}
621
622fn default_subagent_handshake_timeout_secs() -> u64 {
623    30
624}
625
626fn default_subagent_prompt_timeout_secs() -> u64 {
627    600
628}
629
630/// ACP (Agent Communication Protocol) server configuration, nested under `[acp]` in TOML.
631///
632/// When `enabled = true`, Zeph exposes an ACP endpoint that IDE integrations (e.g. Zed, VS Code)
633/// can connect to for conversational coding assistance. Supports stdio and HTTP transports.
634///
635/// # Example (TOML)
636///
637/// ```toml
638/// [acp]
639/// enabled = true
640/// transport = "stdio"
641/// agent_name = "zeph"
642/// max_sessions = 4
643/// ```
644#[derive(Clone, Deserialize, Serialize)]
645pub struct AcpConfig {
646    /// Enable the ACP server. Default: `false`.
647    #[serde(default)]
648    pub enabled: bool,
649    /// Agent name advertised in the ACP `initialize` response. Default: `"zeph"`.
650    #[serde(default = "default_acp_agent_name")]
651    pub agent_name: String,
652    /// Agent version advertised in the ACP `initialize` response. Default: crate version.
653    #[serde(default = "default_acp_agent_version")]
654    pub agent_version: String,
655    /// Maximum number of concurrent ACP sessions. Default: `4`.
656    #[serde(default = "default_acp_max_sessions")]
657    pub max_sessions: usize,
658    /// Seconds of inactivity before an idle session is closed. Default: `1800`.
659    #[serde(default = "default_acp_session_idle_timeout_secs")]
660    pub session_idle_timeout_secs: u64,
661    /// Broadcast channel capacity for streaming events. Default: `256`.
662    #[serde(default = "default_acp_broadcast_capacity")]
663    pub broadcast_capacity: usize,
664    /// Path to the ACP permission TOML file controlling per-session tool access.
665    #[serde(skip_serializing_if = "Option::is_none")]
666    pub permission_file: Option<std::path::PathBuf>,
667    /// List of `{provider}:{model}` identifiers advertised to the IDE for model switching.
668    /// Example: `["claude:claude-sonnet-4-5", "ollama:llama3"]`
669    #[serde(default)]
670    pub available_models: Vec<String>,
671    /// Transport mode: "stdio" (default), "http", or "both".
672    #[serde(default = "default_acp_transport")]
673    pub transport: AcpTransport,
674    /// Bind address for the HTTP transport.
675    #[serde(default = "default_acp_http_bind")]
676    pub http_bind: String,
677    /// Bearer token for HTTP and WebSocket transport authentication.
678    /// When set, all /acp and /acp/ws requests must include `Authorization: Bearer <token>`.
679    /// Omit for local unauthenticated access. TLS termination is assumed to be handled by a
680    /// reverse proxy.
681    #[serde(skip_serializing_if = "Option::is_none")]
682    pub auth_token: Option<String>,
683    /// Named ACP HTTP/WS bearer-token clients (#5868), for genuine multi-tenant/multi-window
684    /// isolation of persisted session listing. Coexists with the legacy `auth_token` field,
685    /// which is synthesized as a client with id `"default"`. See [`AcpAuthClient`].
686    #[serde(default)]
687    pub auth_clients: Vec<AcpAuthClient>,
688    /// Whether to serve the /.well-known/acp.json agent discovery manifest.
689    /// Only effective when transport is "http" or "both". Default: true.
690    #[serde(default = "default_acp_discovery_enabled")]
691    pub discovery_enabled: bool,
692    /// LSP extension configuration (`[acp.lsp]`).
693    #[serde(default)]
694    pub lsp: AcpLspConfig,
695    /// Allowlist of workspace directories that ACP clients may reference in session requests.
696    ///
697    /// Paths are canonicalized at config load; traversal (`..`) and reserved locations
698    /// (`/proc`, `/sys`, `~/.ssh`, `~/.gnupg`, `~/.aws`) are rejected with an error.
699    /// An empty list means clients may not request any additional directories beyond the
700    /// session `cwd`.
701    ///
702    /// This is a **policy** allowlist, not a protocol advertisement: the agent never returns
703    /// `additional_directories` in any response; instead it validates each session request's
704    /// `additional_directories` field against this list and rejects with `invalid_params`
705    /// on any violation.
706    #[serde(default)]
707    pub additional_directories: Vec<AdditionalDir>,
708    /// Auth methods advertised in the ACP `initialize` response.
709    ///
710    /// PR 4 MVP accepts only `"agent"`. Config load fails on any other value so drift
711    /// from the schema is detected at startup rather than silently ignored.
712    #[serde(default = "default_acp_auth_methods")]
713    pub auth_methods: Vec<AcpAuthMethod>,
714    /// Echo `PromptRequest.message_id` onto `PromptResponse.user_message_id` and every
715    /// streamed chunk, enabling IDE-side correlation.
716    ///
717    /// Requires the `unstable-message-id` feature. Default: `true`.
718    #[serde(default = "default_true")]
719    pub message_ids_enabled: bool,
720    /// Sub-agent delegation configuration (`[acp.subagents]`).
721    #[serde(default)]
722    pub subagents: AcpSubagentsConfig,
723    /// Timeout configuration for ACP operations (`[acp.timeouts]`).
724    #[serde(default)]
725    pub timeouts: AcpTimeoutsConfig,
726    /// Model-related configuration parameters (`[acp.model_config]`), advertised to IDE
727    /// clients via the `model_config` `session/set_config_option` category (schema 1.1.0+).
728    #[serde(default)]
729    pub model_config: AcpModelConfigConfig,
730}
731
732impl Default for AcpConfig {
733    fn default() -> Self {
734        Self {
735            enabled: false,
736            agent_name: default_acp_agent_name(),
737            agent_version: default_acp_agent_version(),
738            max_sessions: default_acp_max_sessions(),
739            session_idle_timeout_secs: default_acp_session_idle_timeout_secs(),
740            broadcast_capacity: default_acp_broadcast_capacity(),
741            permission_file: None,
742            available_models: Vec::new(),
743            transport: default_acp_transport(),
744            http_bind: default_acp_http_bind(),
745            auth_token: None,
746            auth_clients: Vec::new(),
747            discovery_enabled: default_acp_discovery_enabled(),
748            lsp: AcpLspConfig::default(),
749            additional_directories: Vec::new(),
750            auth_methods: default_acp_auth_methods(),
751            message_ids_enabled: true,
752            subagents: AcpSubagentsConfig::default(),
753            timeouts: AcpTimeoutsConfig::default(),
754            model_config: AcpModelConfigConfig::default(),
755        }
756    }
757}
758
759impl std::fmt::Debug for AcpConfig {
760    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
761        f.debug_struct("AcpConfig")
762            .field("enabled", &self.enabled)
763            .field("agent_name", &self.agent_name)
764            .field("agent_version", &self.agent_version)
765            .field("max_sessions", &self.max_sessions)
766            .field("session_idle_timeout_secs", &self.session_idle_timeout_secs)
767            .field("broadcast_capacity", &self.broadcast_capacity)
768            .field("permission_file", &self.permission_file)
769            .field("available_models", &self.available_models)
770            .field("transport", &self.transport)
771            .field("http_bind", &self.http_bind)
772            .field(
773                "auth_token",
774                &self.auth_token.as_ref().map(|_| "[REDACTED]"),
775            )
776            .field("auth_clients", &self.auth_clients)
777            .field("discovery_enabled", &self.discovery_enabled)
778            .field("lsp", &self.lsp)
779            .field("additional_directories", &self.additional_directories)
780            .field("auth_methods", &self.auth_methods)
781            .field("message_ids_enabled", &self.message_ids_enabled)
782            .field("subagents", &self.subagents)
783            .field("timeouts", &self.timeouts)
784            .field("model_config", &self.model_config)
785            .finish()
786    }
787}
788
789impl AcpConfig {
790    /// Validate `auth_token` / `auth_clients` coexistence (#5868).
791    ///
792    /// Checks only what is decidable from the config file alone — `id` uniqueness, the
793    /// reserved-sentinel rule, exactly-one-of `token`/`token_vault_key` per entry, non-empty
794    /// inline tokens, and duplicate *inline* tokens (including the legacy `auth_token`).
795    /// Vault-resolved tokens are cross-checked for collisions at startup instead (after the
796    /// vault is unlocked), since that value isn't known here.
797    ///
798    /// # Errors
799    ///
800    /// Returns a human-readable message describing the first violation found.
801    pub fn validate_auth_clients(&self) -> Result<(), String> {
802        let mut seen_ids = std::collections::HashSet::new();
803        let mut seen_inline_tokens = std::collections::HashSet::new();
804
805        if let Some(ref token) = self.auth_token {
806            if token.trim().is_empty() {
807                return Err("[acp] auth_token must not be empty or whitespace-only".to_owned());
808            }
809            seen_inline_tokens.insert(token.as_str());
810        }
811
812        for client in &self.auth_clients {
813            if client.id.is_empty() {
814                return Err("[[acp.auth_clients]] entry has an empty id".to_owned());
815            }
816            if client.id == ACP_AUTH_CLIENT_ID_DEFAULT || client.id == ACP_AUTH_CLIENT_ID_LOCAL {
817                return Err(format!(
818                    "[[acp.auth_clients]] id {:?} is reserved (collides with the legacy \
819                     auth_token client or the unauthenticated/stdio owner bucket)",
820                    client.id
821                ));
822            }
823            if client.id.contains(':') {
824                return Err(format!(
825                    "[[acp.auth_clients]] id {:?} must not contain ':'",
826                    client.id
827                ));
828            }
829            if !seen_ids.insert(client.id.as_str()) {
830                return Err(format!(
831                    "[[acp.auth_clients]] id {:?} is duplicated",
832                    client.id
833                ));
834            }
835            match (&client.token, &client.token_vault_key) {
836                (Some(_), Some(_)) => {
837                    return Err(format!(
838                        "[[acp.auth_clients]] id {:?} sets both token and token_vault_key; \
839                         exactly one must be set",
840                        client.id
841                    ));
842                }
843                (None, None) => {
844                    return Err(format!(
845                        "[[acp.auth_clients]] id {:?} sets neither token nor token_vault_key; \
846                         exactly one must be set",
847                        client.id
848                    ));
849                }
850                (Some(token), None) => {
851                    if token.trim().is_empty() {
852                        return Err(format!(
853                            "[[acp.auth_clients]] id {:?} has an empty or whitespace-only token",
854                            client.id
855                        ));
856                    }
857                    if !seen_inline_tokens.insert(token.as_str()) {
858                        return Err(format!(
859                            "[[acp.auth_clients]] id {:?} has a token that collides with \
860                             another configured client's inline token",
861                            client.id
862                        ));
863                    }
864                }
865                (None, Some(_)) => {}
866            }
867        }
868
869        Ok(())
870    }
871}
872
873/// Sampling-temperature preset for ACP `model_config` session options.
874///
875/// Maps a discrete, IDE-friendly selector (`"precise"` | `"balanced"` | `"creative"`) onto a
876/// concrete sampling temperature, since the ACP `SessionConfigOption` select type only
877/// supports discrete values, not a free-form numeric input.
878#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)]
879#[serde(rename_all = "snake_case")]
880pub enum AcpTemperaturePreset {
881    /// Low temperature (0.2) — more deterministic, focused completions.
882    Precise,
883    /// Moderate temperature (0.7) — balanced determinism and variety. Default.
884    #[default]
885    Balanced,
886    /// High temperature (1.0) — more varied, exploratory completions.
887    Creative,
888}
889
890impl AcpTemperaturePreset {
891    /// Returns the concrete sampling temperature for this preset.
892    #[must_use]
893    pub fn temperature(self) -> f64 {
894        match self {
895            Self::Precise => 0.2,
896            Self::Balanced => 0.7,
897            Self::Creative => 1.0,
898        }
899    }
900
901    /// Returns the ACP wire identifier for this preset (`"precise"` | `"balanced"` | `"creative"`).
902    #[must_use]
903    pub fn as_str(self) -> &'static str {
904        match self {
905            Self::Precise => "precise",
906            Self::Balanced => "balanced",
907            Self::Creative => "creative",
908        }
909    }
910}
911
912impl std::str::FromStr for AcpTemperaturePreset {
913    type Err = ();
914
915    fn from_str(s: &str) -> Result<Self, Self::Err> {
916        match s {
917            "precise" => Ok(Self::Precise),
918            "balanced" => Ok(Self::Balanced),
919            "creative" => Ok(Self::Creative),
920            _ => Err(()),
921        }
922    }
923}
924
925/// Model-related configuration parameters configuration, nested under `[acp.model_config]`.
926///
927/// Backs the ACP `model_config` `session/set_config_option` category (schema 1.1.0+), which is
928/// distinct from the `model` category: `model` selects which model is active, `model_config`
929/// adjusts a parameter (e.g. sampling temperature) of the currently selected model.
930///
931/// # Example (TOML)
932///
933/// ```toml
934/// [acp.model_config]
935/// default_temperature_preset = "balanced"
936/// ```
937#[derive(Debug, Clone, Default, Deserialize, Serialize)]
938pub struct AcpModelConfigConfig {
939    /// Default sampling-temperature preset applied to new ACP sessions. Default: `"balanced"`.
940    #[serde(default)]
941    pub default_temperature_preset: AcpTemperaturePreset,
942}
943
944/// Timeout configuration for ACP operations.
945///
946/// These values replace the previously hardcoded 120-second defaults for terminal
947/// and elicitation operations, and the 300-second default for MCP bridge calls.
948#[derive(Debug, Clone, Deserialize, Serialize)]
949pub struct AcpTimeoutsConfig {
950    /// Timeout in seconds for elicitation requests sent to the IDE. Default: 120.
951    #[serde(default = "default_acp_elicitation_timeout_secs")]
952    pub elicitation_secs: u64,
953    /// Timeout in seconds for terminal command execution. Default: 120.
954    #[serde(default = "default_acp_terminal_timeout_secs")]
955    pub terminal_secs: u64,
956    /// Timeout in seconds for MCP bridge operations. Default: 300.
957    #[serde(default = "default_acp_mcp_timeout_secs")]
958    pub mcp_secs: u64,
959    /// Maximum time in milliseconds to wait for a notification ack from the IDE client.
960    ///
961    /// If the IDE client does not acknowledge a session notification within this window,
962    /// `send_notification` returns an error instead of blocking indefinitely. Default: 5000.
963    #[serde(default = "default_acp_notify_ack_timeout_ms")]
964    pub notify_ack_timeout_ms: u64,
965}
966
967impl Default for AcpTimeoutsConfig {
968    fn default() -> Self {
969        Self {
970            elicitation_secs: default_acp_elicitation_timeout_secs(),
971            terminal_secs: default_acp_terminal_timeout_secs(),
972            mcp_secs: default_acp_mcp_timeout_secs(),
973            notify_ack_timeout_ms: default_acp_notify_ack_timeout_ms(),
974        }
975    }
976}
977
978/// Configuration for the ACP LSP extension.
979///
980/// Controls LSP code intelligence features when connected to an IDE that advertises
981/// `meta["lsp"]` capability during ACP `initialize`.
982#[derive(Debug, Clone, Deserialize, Serialize)]
983pub struct AcpLspConfig {
984    /// Enable LSP extension when the IDE supports it. Default: `true`.
985    #[serde(default = "default_true")]
986    pub enabled: bool,
987    /// Automatically fetch diagnostics when `lsp/didSave` notification is received.
988    #[serde(default = "default_true")]
989    pub auto_diagnostics_on_save: bool,
990    /// Maximum diagnostics to accept per file. Default: 20.
991    #[serde(default = "default_acp_lsp_max_diagnostics_per_file")]
992    pub max_diagnostics_per_file: usize,
993    /// Maximum files in `DiagnosticsCache` (LRU eviction). Default: 5.
994    #[serde(default = "default_acp_lsp_max_diagnostic_files")]
995    pub max_diagnostic_files: usize,
996    /// Maximum reference locations returned. Default: 100.
997    #[serde(default = "default_acp_lsp_max_references")]
998    pub max_references: usize,
999    /// Maximum workspace symbol search results. Default: 50.
1000    #[serde(default = "default_acp_lsp_max_workspace_symbols")]
1001    pub max_workspace_symbols: usize,
1002    /// Timeout in seconds for LSP `ext_method` calls. Default: 10.
1003    #[serde(default = "default_acp_lsp_request_timeout_secs")]
1004    pub request_timeout_secs: u64,
1005}
1006
1007impl Default for AcpLspConfig {
1008    fn default() -> Self {
1009        Self {
1010            enabled: true,
1011            auto_diagnostics_on_save: true,
1012            max_diagnostics_per_file: default_acp_lsp_max_diagnostics_per_file(),
1013            max_diagnostic_files: default_acp_lsp_max_diagnostic_files(),
1014            max_references: default_acp_lsp_max_references(),
1015            max_workspace_symbols: default_acp_lsp_max_workspace_symbols(),
1016            request_timeout_secs: default_acp_lsp_request_timeout_secs(),
1017        }
1018    }
1019}
1020
1021// ── LSP context injection ─────────────────────────────────────────────────────
1022
1023/// Minimum diagnostic severity to include in LSP context injection.
1024#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)]
1025#[serde(rename_all = "lowercase")]
1026#[non_exhaustive]
1027pub enum DiagnosticSeverity {
1028    #[default]
1029    Error,
1030    Warning,
1031    Info,
1032    Hint,
1033}
1034
1035/// Configuration for the diagnostics-on-save hook (`[agent.lsp.diagnostics]`).
1036///
1037/// Flood control relies on `token_budget` in [`LspConfig`], not a per-file count.
1038#[derive(Debug, Clone, Deserialize, Serialize)]
1039#[serde(default)]
1040pub struct DiagnosticsConfig {
1041    /// Enable automatic diagnostics fetching after the `write` tool.
1042    pub enabled: bool,
1043    /// Maximum diagnostics entries per file.
1044    #[serde(default = "default_lsp_max_per_file")]
1045    pub max_per_file: usize,
1046    /// Minimum severity to include.
1047    #[serde(default)]
1048    pub min_severity: DiagnosticSeverity,
1049}
1050impl Default for DiagnosticsConfig {
1051    fn default() -> Self {
1052        Self {
1053            enabled: true,
1054            max_per_file: default_lsp_max_per_file(),
1055            min_severity: DiagnosticSeverity::default(),
1056        }
1057    }
1058}
1059
1060/// Configuration for the hover-on-read hook (`[agent.lsp.hover]`).
1061#[derive(Debug, Clone, Deserialize, Serialize)]
1062#[serde(default)]
1063pub struct HoverConfig {
1064    /// Enable hover info pre-fetch after the `read` tool. Disabled by default.
1065    pub enabled: bool,
1066    /// Maximum hover entries per file (Rust-only for MVP).
1067    #[serde(default = "default_lsp_max_symbols")]
1068    pub max_symbols: usize,
1069}
1070impl Default for HoverConfig {
1071    fn default() -> Self {
1072        Self {
1073            enabled: false,
1074            max_symbols: default_lsp_max_symbols(),
1075        }
1076    }
1077}
1078
1079/// Top-level LSP context injection configuration (`[agent.lsp]` TOML section).
1080#[derive(Debug, Clone, Deserialize, Serialize)]
1081#[serde(default)]
1082pub struct LspConfig {
1083    /// Enable LSP context injection hooks.
1084    pub enabled: bool,
1085    /// MCP server ID to route LSP calls through (default: "mcpls").
1086    #[serde(default = "default_lsp_mcp_server_id")]
1087    pub mcp_server_id: String,
1088    /// Maximum tokens to spend on injected LSP context per turn.
1089    #[serde(default = "default_lsp_token_budget")]
1090    pub token_budget: usize,
1091    /// Timeout in seconds for each MCP LSP call.
1092    #[serde(default = "default_lsp_call_timeout_secs")]
1093    pub call_timeout_secs: u64,
1094    /// Diagnostics-on-save hook configuration.
1095    #[serde(default)]
1096    pub diagnostics: DiagnosticsConfig,
1097    /// Hover-on-read hook configuration.
1098    #[serde(default)]
1099    pub hover: HoverConfig,
1100}
1101impl Default for LspConfig {
1102    fn default() -> Self {
1103        Self {
1104            enabled: false,
1105            mcp_server_id: default_lsp_mcp_server_id(),
1106            token_budget: default_lsp_token_budget(),
1107            call_timeout_secs: default_lsp_call_timeout_secs(),
1108            diagnostics: DiagnosticsConfig::default(),
1109            hover: HoverConfig::default(),
1110        }
1111    }
1112}
1113
1114#[cfg(test)]
1115mod tests {
1116    use super::*;
1117
1118    #[test]
1119    fn acp_auth_method_unknown_variant_fails() {
1120        assert!(serde_json::from_str::<AcpAuthMethod>(r#""bearer""#).is_err());
1121        assert!(serde_json::from_str::<AcpAuthMethod>(r#""envvar""#).is_err());
1122        assert!(serde_json::from_str::<AcpAuthMethod>(r#""Agent""#).is_err());
1123    }
1124
1125    #[test]
1126    fn acp_auth_method_known_variant_succeeds() {
1127        let m = serde_json::from_str::<AcpAuthMethod>(r#""agent""#).unwrap();
1128        assert_eq!(m, AcpAuthMethod::Agent);
1129    }
1130
1131    // ── AcpConfig::validate_auth_clients (#5868) ──────────────────────────────
1132
1133    fn client(id: &str, token: &str) -> AcpAuthClient {
1134        AcpAuthClient {
1135            id: id.to_owned(),
1136            token: Some(token.to_owned()),
1137            token_vault_key: None,
1138        }
1139    }
1140
1141    #[test]
1142    fn validate_auth_clients_empty_config_ok() {
1143        assert!(AcpConfig::default().validate_auth_clients().is_ok());
1144    }
1145
1146    #[test]
1147    fn validate_auth_clients_legacy_auth_token_only_ok() {
1148        let cfg = AcpConfig {
1149            auth_token: Some("secret".to_owned()),
1150            ..AcpConfig::default()
1151        };
1152        assert!(cfg.validate_auth_clients().is_ok());
1153    }
1154
1155    #[test]
1156    fn validate_auth_clients_single_client_ok() {
1157        let cfg = AcpConfig {
1158            auth_clients: vec![client("alice", "token-a")],
1159            ..AcpConfig::default()
1160        };
1161        assert!(cfg.validate_auth_clients().is_ok());
1162    }
1163
1164    #[test]
1165    fn validate_auth_clients_coexist_with_legacy_ok() {
1166        let cfg = AcpConfig {
1167            auth_token: Some("legacy".to_owned()),
1168            auth_clients: vec![client("alice", "token-a"), client("bob", "token-b")],
1169            ..AcpConfig::default()
1170        };
1171        assert!(cfg.validate_auth_clients().is_ok());
1172    }
1173
1174    #[test]
1175    fn validate_auth_clients_rejects_reserved_id_default() {
1176        let cfg = AcpConfig {
1177            auth_clients: vec![client(ACP_AUTH_CLIENT_ID_DEFAULT, "token-a")],
1178            ..AcpConfig::default()
1179        };
1180        let err = cfg.validate_auth_clients().unwrap_err();
1181        assert!(err.contains("reserved"), "unexpected error: {err}");
1182    }
1183
1184    #[test]
1185    fn validate_auth_clients_rejects_reserved_id_acp_local() {
1186        let cfg = AcpConfig {
1187            auth_clients: vec![client(ACP_AUTH_CLIENT_ID_LOCAL, "token-a")],
1188            ..AcpConfig::default()
1189        };
1190        let err = cfg.validate_auth_clients().unwrap_err();
1191        assert!(err.contains("reserved"), "unexpected error: {err}");
1192    }
1193
1194    #[test]
1195    fn validate_auth_clients_rejects_duplicate_id() {
1196        let cfg = AcpConfig {
1197            auth_clients: vec![client("alice", "token-a"), client("alice", "token-b")],
1198            ..AcpConfig::default()
1199        };
1200        let err = cfg.validate_auth_clients().unwrap_err();
1201        assert!(err.contains("duplicated"), "unexpected error: {err}");
1202    }
1203
1204    #[test]
1205    fn validate_auth_clients_rejects_id_containing_colon() {
1206        let cfg = AcpConfig {
1207            auth_clients: vec![client("alice:2", "token-a")],
1208            ..AcpConfig::default()
1209        };
1210        let err = cfg.validate_auth_clients().unwrap_err();
1211        assert!(err.contains("':'"), "unexpected error: {err}");
1212    }
1213
1214    #[test]
1215    fn validate_auth_clients_rejects_empty_id() {
1216        let cfg = AcpConfig {
1217            auth_clients: vec![client("", "token-a")],
1218            ..AcpConfig::default()
1219        };
1220        let err = cfg.validate_auth_clients().unwrap_err();
1221        assert!(err.contains("empty id"), "unexpected error: {err}");
1222    }
1223
1224    #[test]
1225    fn validate_auth_clients_rejects_neither_token_nor_vault_key() {
1226        let cfg = AcpConfig {
1227            auth_clients: vec![AcpAuthClient {
1228                id: "alice".to_owned(),
1229                token: None,
1230                token_vault_key: None,
1231            }],
1232            ..AcpConfig::default()
1233        };
1234        let err = cfg.validate_auth_clients().unwrap_err();
1235        assert!(err.contains("neither"), "unexpected error: {err}");
1236    }
1237
1238    #[test]
1239    fn validate_auth_clients_rejects_both_token_and_vault_key() {
1240        let cfg = AcpConfig {
1241            auth_clients: vec![AcpAuthClient {
1242                id: "alice".to_owned(),
1243                token: Some("token-a".to_owned()),
1244                token_vault_key: Some("ZEPH_ACP_TOKEN_ALICE".to_owned()),
1245            }],
1246            ..AcpConfig::default()
1247        };
1248        let err = cfg.validate_auth_clients().unwrap_err();
1249        assert!(err.contains("both"), "unexpected error: {err}");
1250    }
1251
1252    #[test]
1253    fn validate_auth_clients_vault_key_only_ok() {
1254        let cfg = AcpConfig {
1255            auth_clients: vec![AcpAuthClient {
1256                id: "alice".to_owned(),
1257                token: None,
1258                token_vault_key: Some("ZEPH_ACP_TOKEN_ALICE".to_owned()),
1259            }],
1260            ..AcpConfig::default()
1261        };
1262        assert!(cfg.validate_auth_clients().is_ok());
1263    }
1264
1265    #[test]
1266    fn validate_auth_clients_rejects_duplicate_inline_tokens_across_clients() {
1267        let cfg = AcpConfig {
1268            auth_clients: vec![client("alice", "shared"), client("bob", "shared")],
1269            ..AcpConfig::default()
1270        };
1271        let err = cfg.validate_auth_clients().unwrap_err();
1272        assert!(err.contains("collides"), "unexpected error: {err}");
1273    }
1274
1275    #[test]
1276    fn validate_auth_clients_rejects_inline_token_colliding_with_legacy_default_token() {
1277        let cfg = AcpConfig {
1278            auth_token: Some("shared".to_owned()),
1279            auth_clients: vec![client("alice", "shared")],
1280            ..AcpConfig::default()
1281        };
1282        let err = cfg.validate_auth_clients().unwrap_err();
1283        assert!(err.contains("collides"), "unexpected error: {err}");
1284    }
1285
1286    #[test]
1287    fn validate_auth_clients_rejects_empty_legacy_auth_token() {
1288        let cfg = AcpConfig {
1289            auth_token: Some(String::new()),
1290            ..AcpConfig::default()
1291        };
1292        let err = cfg.validate_auth_clients().unwrap_err();
1293        assert!(err.contains("empty"), "unexpected error: {err}");
1294    }
1295
1296    #[test]
1297    fn validate_auth_clients_rejects_whitespace_only_legacy_auth_token() {
1298        let cfg = AcpConfig {
1299            auth_token: Some("   ".to_owned()),
1300            ..AcpConfig::default()
1301        };
1302        let err = cfg.validate_auth_clients().unwrap_err();
1303        assert!(err.contains("empty"), "unexpected error: {err}");
1304    }
1305
1306    #[test]
1307    fn validate_auth_clients_rejects_empty_inline_client_token() {
1308        let cfg = AcpConfig {
1309            auth_clients: vec![client("alice", "")],
1310            ..AcpConfig::default()
1311        };
1312        let err = cfg.validate_auth_clients().unwrap_err();
1313        assert!(err.contains("empty"), "unexpected error: {err}");
1314    }
1315
1316    #[test]
1317    fn validate_auth_clients_rejects_whitespace_only_inline_client_token() {
1318        let cfg = AcpConfig {
1319            auth_clients: vec![client("alice", "   ")],
1320            ..AcpConfig::default()
1321        };
1322        let err = cfg.validate_auth_clients().unwrap_err();
1323        assert!(err.contains("empty"), "unexpected error: {err}");
1324    }
1325
1326    #[test]
1327    fn additional_dir_rejects_dotdot_traversal() {
1328        let result = AdditionalDir::parse(std::path::PathBuf::from("/tmp/../etc"));
1329        assert!(
1330            matches!(result, Err(AdditionalDirError::Traversal(_))),
1331            "expected Traversal, got {result:?}"
1332        );
1333    }
1334
1335    #[test]
1336    fn additional_dir_rejects_proc() {
1337        // /proc must exist on Linux CI; skip on macOS if not present.
1338        if !std::path::Path::new("/proc").exists() {
1339            return;
1340        }
1341        let result = AdditionalDir::parse(std::path::PathBuf::from("/proc/self"));
1342        assert!(
1343            matches!(result, Err(AdditionalDirError::Reserved(_))),
1344            "expected Reserved, got {result:?}"
1345        );
1346    }
1347
1348    #[test]
1349    fn additional_dir_rejects_ssh() {
1350        let home = std::env::var("HOME").unwrap_or_else(|_| "/root".to_owned());
1351        let ssh = std::path::PathBuf::from(format!("{home}/.ssh"));
1352        if !ssh.exists() {
1353            return;
1354        }
1355        let result = AdditionalDir::parse(ssh.clone());
1356        assert!(
1357            matches!(result, Err(AdditionalDirError::Reserved(_))),
1358            "expected Reserved for {ssh:?}, got {result:?}"
1359        );
1360    }
1361
1362    #[test]
1363    fn additional_dir_accepts_tmp() {
1364        let tmp = std::env::temp_dir();
1365        // tempdir always exists; /tmp is not reserved.
1366        match AdditionalDir::parse(tmp.clone()) {
1367            Ok(dir) => {
1368                // canonicalized path stored correctly
1369                assert!(dir.as_path().is_absolute());
1370            }
1371            Err(AdditionalDirError::Canonicalize { .. }) => {
1372                // temp_dir may be a symlink that canonicalizes to something else — acceptable
1373            }
1374            Err(e) => panic!("unexpected error for {tmp:?}: {e:?}"),
1375        }
1376    }
1377}