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    /// Side-panel vertical sizing strategy (#6675).
547    ///
548    /// `auto` (default) sizes each unpinned side panel from its own content; `even`
549    /// approximates the pre-#6675 behavior of splitting the column equally regardless of
550    /// content (same total per slot; exact per-slot remainder placement can differ from the
551    /// old cassowary-based split — see `zeph_tui::layout::PanelDemand`'s docs). Runtime-
552    /// togglable via `/panel_sizing [auto|even]`.
553    #[serde(default)]
554    pub panel_sizing: PanelSizingMode,
555}
556
557/// Side-panel vertical sizing strategy (see [`TuiConfig::panel_sizing`], #6675).
558#[derive(Debug, Clone, Copy, Default, Deserialize, Serialize, PartialEq, Eq)]
559#[serde(rename_all = "lowercase")]
560pub enum PanelSizingMode {
561    /// Size each unpinned side panel from its own content (`desired_height`), via a
562    /// max-min fair water-filling allocator. Leftover space stays blank at the bottom of
563    /// the column. Default.
564    #[default]
565    Auto,
566    /// Approximates pre-#6675 behavior: unpinned panels split the column evenly, regardless
567    /// of content (same total height per slot; exact remainder placement can differ from
568    /// the old cassowary-based split).
569    Even,
570}
571
572/// Configuration for the TUI fleet panel (#3884).
573#[derive(Debug, Clone, Copy, Deserialize, Serialize)]
574#[serde(default)]
575pub struct FleetConfig {
576    /// How often the fleet panel polls the database for updated session data (seconds).
577    pub refresh_interval_secs: u64,
578    /// Maximum number of sessions to display in the fleet panel.
579    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/// ACP server transport mode.
592#[derive(Debug, Clone, Default, Deserialize, Serialize)]
593#[serde(rename_all = "lowercase")]
594#[non_exhaustive]
595pub enum AcpTransport {
596    /// JSON-RPC over stdin/stdout (default, IDE embedding).
597    #[default]
598    Stdio,
599    /// JSON-RPC over HTTP+SSE and WebSocket.
600    Http,
601    /// Both stdio and HTTP transports active simultaneously.
602    Both,
603}
604
605/// Configuration for a named sub-agent preset in `[[acp.subagents.presets]]`.
606#[derive(Clone, Debug, Default, Deserialize, Serialize)]
607pub struct SubagentPresetConfig {
608    /// Identifier used to reference this preset by name.
609    pub name: String,
610    /// Shell command string to spawn the sub-agent (e.g. `"cargo run -- --acp"`).
611    pub command: String,
612    /// Optional working directory for the spawned subprocess.
613    #[serde(default, skip_serializing_if = "Option::is_none")]
614    pub cwd: Option<PathBuf>,
615    /// Timeout in seconds for the `initialize` + `session/new` handshake. Default: 30.
616    #[serde(default = "default_subagent_handshake_timeout_secs")]
617    pub handshake_timeout_secs: u64,
618    /// Timeout in seconds for a single prompt round-trip. Default: 600.
619    #[serde(default = "default_subagent_prompt_timeout_secs")]
620    pub prompt_timeout_secs: u64,
621}
622
623/// Configuration block for the `[acp.subagents]` TOML section.
624///
625/// # Example
626///
627/// ```toml
628/// [acp.subagents]
629/// enabled = true
630///
631/// [[acp.subagents.presets]]
632/// name = "inner"
633/// command = "cargo run --quiet -- --acp"
634/// ```
635#[derive(Clone, Debug, Default, Deserialize, Serialize)]
636pub struct AcpSubagentsConfig {
637    /// Whether sub-agent spawning is enabled at runtime. Default: `false`.
638    #[serde(default)]
639    pub enabled: bool,
640
641    /// Named presets available via CLI (`zeph acp subagent list`) and TUI palette.
642    #[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/// ACP (Agent Communication Protocol) server configuration, nested under `[acp]` in TOML.
655///
656/// When `enabled = true`, Zeph exposes an ACP endpoint that IDE integrations (e.g. Zed, VS Code)
657/// can connect to for conversational coding assistance. Supports stdio and HTTP transports.
658///
659/// # Example (TOML)
660///
661/// ```toml
662/// [acp]
663/// enabled = true
664/// transport = "stdio"
665/// agent_name = "zeph"
666/// max_sessions = 4
667/// ```
668#[derive(Clone, Deserialize, Serialize)]
669pub struct AcpConfig {
670    /// Enable the ACP server. Default: `false`.
671    #[serde(default)]
672    pub enabled: bool,
673    /// Agent name advertised in the ACP `initialize` response. Default: `"zeph"`.
674    #[serde(default = "default_acp_agent_name")]
675    pub agent_name: String,
676    /// Agent version advertised in the ACP `initialize` response. Default: crate version.
677    #[serde(default = "default_acp_agent_version")]
678    pub agent_version: String,
679    /// Maximum number of concurrent ACP sessions. Default: `4`.
680    #[serde(default = "default_acp_max_sessions")]
681    pub max_sessions: usize,
682    /// Seconds of inactivity before an idle session is closed. Default: `1800`.
683    #[serde(default = "default_acp_session_idle_timeout_secs")]
684    pub session_idle_timeout_secs: u64,
685    /// Broadcast channel capacity for streaming events. Default: `256`.
686    #[serde(default = "default_acp_broadcast_capacity")]
687    pub broadcast_capacity: usize,
688    /// Path to the ACP permission TOML file controlling per-session tool access.
689    #[serde(skip_serializing_if = "Option::is_none")]
690    pub permission_file: Option<std::path::PathBuf>,
691    /// List of `{provider}:{model}` identifiers advertised to the IDE for model switching.
692    /// Example: `["claude:claude-sonnet-4-5", "ollama:llama3"]`
693    #[serde(default)]
694    pub available_models: Vec<String>,
695    /// Transport mode: "stdio" (default), "http", or "both".
696    #[serde(default = "default_acp_transport")]
697    pub transport: AcpTransport,
698    /// Bind address for the HTTP transport.
699    #[serde(default = "default_acp_http_bind")]
700    pub http_bind: String,
701    /// Bearer token for HTTP and WebSocket transport authentication.
702    /// When set, all /acp and /acp/ws requests must include `Authorization: Bearer <token>`.
703    /// Omit for local unauthenticated access. TLS termination is assumed to be handled by a
704    /// reverse proxy.
705    #[serde(skip_serializing_if = "Option::is_none")]
706    pub auth_token: Option<String>,
707    /// Named ACP HTTP/WS bearer-token clients (#5868), for genuine multi-tenant/multi-window
708    /// isolation of persisted session listing. Coexists with the legacy `auth_token` field,
709    /// which is synthesized as a client with id `"default"`. See [`AcpAuthClient`].
710    #[serde(default)]
711    pub auth_clients: Vec<AcpAuthClient>,
712    /// Whether to serve the /.well-known/acp.json agent discovery manifest.
713    /// Only effective when transport is "http" or "both". Default: true.
714    #[serde(default = "default_acp_discovery_enabled")]
715    pub discovery_enabled: bool,
716    /// LSP extension configuration (`[acp.lsp]`).
717    #[serde(default)]
718    pub lsp: AcpLspConfig,
719    /// Allowlist of workspace directories that ACP clients may reference in session requests.
720    ///
721    /// Paths are canonicalized at config load; traversal (`..`) and reserved locations
722    /// (`/proc`, `/sys`, `~/.ssh`, `~/.gnupg`, `~/.aws`) are rejected with an error.
723    /// An empty list means clients may not request any additional directories beyond the
724    /// session `cwd`.
725    ///
726    /// This is a **policy** allowlist, not a protocol advertisement: the agent never returns
727    /// `additional_directories` in any response; instead it validates each session request's
728    /// `additional_directories` field against this list and rejects with `invalid_params`
729    /// on any violation.
730    #[serde(default)]
731    pub additional_directories: Vec<AdditionalDir>,
732    /// Auth methods advertised in the ACP `initialize` response.
733    ///
734    /// PR 4 MVP accepts only `"agent"`. Config load fails on any other value so drift
735    /// from the schema is detected at startup rather than silently ignored.
736    #[serde(default = "default_acp_auth_methods")]
737    pub auth_methods: Vec<AcpAuthMethod>,
738    /// Echo `PromptRequest.message_id` onto `PromptResponse.user_message_id` and every
739    /// streamed chunk, enabling IDE-side correlation.
740    ///
741    /// Requires the `unstable-message-id` feature. Default: `true`.
742    #[serde(default = "default_true")]
743    pub message_ids_enabled: bool,
744    /// Sub-agent delegation configuration (`[acp.subagents]`).
745    #[serde(default)]
746    pub subagents: AcpSubagentsConfig,
747    /// Timeout configuration for ACP operations (`[acp.timeouts]`).
748    #[serde(default)]
749    pub timeouts: AcpTimeoutsConfig,
750    /// Model-related configuration parameters (`[acp.model_config]`), advertised to IDE
751    /// clients via the `model_config` `session/set_config_option` category (schema 1.1.0+).
752    #[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    /// Validate `auth_token` / `auth_clients` coexistence (#5868).
815    ///
816    /// Checks only what is decidable from the config file alone — `id` uniqueness, the
817    /// reserved-sentinel rule, exactly-one-of `token`/`token_vault_key` per entry, non-empty
818    /// inline tokens, and duplicate *inline* tokens (including the legacy `auth_token`).
819    /// Vault-resolved tokens are cross-checked for collisions at startup instead (after the
820    /// vault is unlocked), since that value isn't known here.
821    ///
822    /// # Errors
823    ///
824    /// Returns a human-readable message describing the first violation found.
825    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/// Sampling-temperature preset for ACP `model_config` session options.
898///
899/// Maps a discrete, IDE-friendly selector (`"precise"` | `"balanced"` | `"creative"`) onto a
900/// concrete sampling temperature, since the ACP `SessionConfigOption` select type only
901/// supports discrete values, not a free-form numeric input.
902#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize, Serialize)]
903#[serde(rename_all = "snake_case")]
904pub enum AcpTemperaturePreset {
905    /// Low temperature (0.2) — more deterministic, focused completions.
906    Precise,
907    /// Moderate temperature (0.7) — balanced determinism and variety. Default.
908    #[default]
909    Balanced,
910    /// High temperature (1.0) — more varied, exploratory completions.
911    Creative,
912}
913
914impl AcpTemperaturePreset {
915    /// Returns the concrete sampling temperature for this preset.
916    #[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    /// Returns the ACP wire identifier for this preset (`"precise"` | `"balanced"` | `"creative"`).
926    #[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/// Model-related configuration parameters configuration, nested under `[acp.model_config]`.
950///
951/// Backs the ACP `model_config` `session/set_config_option` category (schema 1.1.0+), which is
952/// distinct from the `model` category: `model` selects which model is active, `model_config`
953/// adjusts a parameter (e.g. sampling temperature) of the currently selected model.
954///
955/// # Example (TOML)
956///
957/// ```toml
958/// [acp.model_config]
959/// default_temperature_preset = "balanced"
960/// ```
961#[derive(Debug, Clone, Default, Deserialize, Serialize)]
962pub struct AcpModelConfigConfig {
963    /// Default sampling-temperature preset applied to new ACP sessions. Default: `"balanced"`.
964    #[serde(default)]
965    pub default_temperature_preset: AcpTemperaturePreset,
966}
967
968/// Timeout configuration for ACP operations.
969///
970/// These values replace the previously hardcoded 120-second defaults for terminal
971/// and elicitation operations, and the 300-second default for MCP bridge calls.
972#[derive(Debug, Clone, Deserialize, Serialize)]
973pub struct AcpTimeoutsConfig {
974    /// Timeout in seconds for elicitation requests sent to the IDE. Default: 120.
975    #[serde(default = "default_acp_elicitation_timeout_secs")]
976    pub elicitation_secs: u64,
977    /// Timeout in seconds for terminal command execution. Default: 120.
978    #[serde(default = "default_acp_terminal_timeout_secs")]
979    pub terminal_secs: u64,
980    /// Timeout in seconds for MCP bridge operations. Default: 300.
981    #[serde(default = "default_acp_mcp_timeout_secs")]
982    pub mcp_secs: u64,
983    /// Maximum time in milliseconds to wait for a notification ack from the IDE client.
984    ///
985    /// If the IDE client does not acknowledge a session notification within this window,
986    /// `send_notification` returns an error instead of blocking indefinitely. Default: 5000.
987    #[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/// Configuration for the ACP LSP extension.
1003///
1004/// Controls LSP code intelligence features when connected to an IDE that advertises
1005/// `meta["lsp"]` capability during ACP `initialize`.
1006#[derive(Debug, Clone, Deserialize, Serialize)]
1007pub struct AcpLspConfig {
1008    /// Enable LSP extension when the IDE supports it. Default: `true`.
1009    #[serde(default = "default_true")]
1010    pub enabled: bool,
1011    /// Automatically fetch diagnostics when `lsp/didSave` notification is received.
1012    #[serde(default = "default_true")]
1013    pub auto_diagnostics_on_save: bool,
1014    /// Maximum diagnostics to accept per file. Default: 20.
1015    #[serde(default = "default_acp_lsp_max_diagnostics_per_file")]
1016    pub max_diagnostics_per_file: usize,
1017    /// Maximum files in `DiagnosticsCache` (LRU eviction). Default: 5.
1018    #[serde(default = "default_acp_lsp_max_diagnostic_files")]
1019    pub max_diagnostic_files: usize,
1020    /// Maximum reference locations returned. Default: 100.
1021    #[serde(default = "default_acp_lsp_max_references")]
1022    pub max_references: usize,
1023    /// Maximum workspace symbol search results. Default: 50.
1024    #[serde(default = "default_acp_lsp_max_workspace_symbols")]
1025    pub max_workspace_symbols: usize,
1026    /// Timeout in seconds for LSP `ext_method` calls. Default: 10.
1027    #[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// ── LSP context injection ─────────────────────────────────────────────────────
1046
1047/// Minimum diagnostic severity to include in LSP context injection.
1048#[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/// Configuration for the diagnostics-on-save hook (`[agent.lsp.diagnostics]`).
1060///
1061/// Flood control relies on `token_budget` in [`LspConfig`], not a per-file count.
1062#[derive(Debug, Clone, Deserialize, Serialize)]
1063#[serde(default)]
1064pub struct DiagnosticsConfig {
1065    /// Enable automatic diagnostics fetching after the `write` tool.
1066    pub enabled: bool,
1067    /// Maximum diagnostics entries per file.
1068    #[serde(default = "default_lsp_max_per_file")]
1069    pub max_per_file: usize,
1070    /// Minimum severity to include.
1071    #[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/// Configuration for the hover-on-read hook (`[agent.lsp.hover]`).
1085#[derive(Debug, Clone, Deserialize, Serialize)]
1086#[serde(default)]
1087pub struct HoverConfig {
1088    /// Enable hover info pre-fetch after the `read` tool. Disabled by default.
1089    pub enabled: bool,
1090    /// Maximum hover entries per file (Rust-only for MVP).
1091    #[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/// Top-level LSP context injection configuration (`[agent.lsp]` TOML section).
1104#[derive(Debug, Clone, Deserialize, Serialize)]
1105#[serde(default)]
1106pub struct LspConfig {
1107    /// Enable LSP context injection hooks.
1108    pub enabled: bool,
1109    /// MCP server ID to route LSP calls through (default: "mcpls").
1110    #[serde(default = "default_lsp_mcp_server_id")]
1111    pub mcp_server_id: String,
1112    /// Maximum tokens to spend on injected LSP context per turn.
1113    #[serde(default = "default_lsp_token_budget")]
1114    pub token_budget: usize,
1115    /// Timeout in seconds for each MCP LSP call.
1116    #[serde(default = "default_lsp_call_timeout_secs")]
1117    pub call_timeout_secs: u64,
1118    /// Diagnostics-on-save hook configuration.
1119    #[serde(default)]
1120    pub diagnostics: DiagnosticsConfig,
1121    /// Hover-on-read hook configuration.
1122    #[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    // ── AcpConfig::validate_auth_clients (#5868) ──────────────────────────────
1156
1157    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        // /proc must exist on Linux CI; skip on macOS if not present.
1362        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        // tempdir always exists; /tmp is not reserved.
1390        match AdditionalDir::parse(tmp.clone()) {
1391            Ok(dir) => {
1392                // canonicalized path stored correctly
1393                assert!(dir.as_path().is_absolute());
1394            }
1395            Err(AdditionalDirError::Canonicalize { .. }) => {
1396                // temp_dir may be a symlink that canonicalizes to something else — acceptable
1397            }
1398            Err(e) => panic!("unexpected error for {tmp:?}: {e:?}"),
1399        }
1400    }
1401}