Skip to main content

talon_http/
config.rs

1//! Configuration for the CoPilot HTTP channel.
2//!
3//! Non-secret settings are read with [`EnvFile::get`] (process env wins over
4//! `.env`); the API key is a secret and is only ever read from the `.env`
5//! file ([`EnvFile::file_value`]).
6//!
7//! | Variable | Default | Meaning |
8//! |---|---|---|
9//! | `HTTP_API_KEY` (`.env` only) | unset | Shared secret; unset => every request but `/health` is rejected |
10//! | `COPILOT_HTTP_PORT` | `3100` | Listen port |
11//! | `COPILOT_HTTP_BIND` | `127.0.0.1` | Listen address. Loopback by default; set a private interface explicitly to expose the API |
12//! | `COPILOT_HTTP_TLS_TERMINATED` | `false` | Operator asserts a TLS-terminating proxy fronts the API (silences the wildcard-bind warning) |
13//! | `COPILOT_HTTP_MAX_CONNECTIONS` | `512` | Open connections across all peers |
14//! | `COPILOT_HTTP_MAX_CONNECTIONS_PER_IP` | `320` | Open connections per peer IP (CoPilot usually connects from one address, so keep it above `COPILOT_HTTP_MAX_STREAMS`) |
15//! | `COPILOT_HTTP_HEADER_TIMEOUT_SECS` | `15` | Time a client has to send complete request headers |
16//! | `COPILOT_JID` | `http:copilot` | Base lane JID |
17//! | `COPILOT_GROUP_FOLDER` | `copilot` | Group folder the lanes run in |
18//! | `COPILOT_HTTP_CORS_ORIGINS` | empty | Comma-separated allowed browser origins (`*` allowed explicitly) |
19//! | `COPILOT_HTTP_MAX_BODY_BYTES` | `262144` | Request body limit |
20//! | `SESSION_IDLE_HOURS` | `24` | Idle time before a per-user lane is swept |
21//! | `COPILOT_INVESTIGATION_DEDUP_MINUTES` | `10` | Repeat-trigger window (`0` disables) |
22//! | `COPILOT_HTTP_AUTH_MAX_FAILURES` | `10` | Failed auths per IP before throttling |
23//! | `COPILOT_HTTP_AUTH_WINDOW_SECS` | `300` | Throttle window |
24//! | `COPILOT_PALACE_TIMEOUT_MS` | `30000` | Palace call timeout |
25//! | `TALAHON_PALACE_HOST_EXEC` | unset | `unsafe` runs `mempalace/palace-call.sh` **on the host** when no palace runner is supplied. DANGEROUS: agents can write `mempalace-data/`, so this allows host code execution (MYTH-01) |
26//! | `COPILOT_HTTP_MAX_USER_LANES` | `256` | Cap on in-memory per-user lanes |
27//! | `COPILOT_HTTP_MAX_STREAMS` | `256` | Cap on concurrently open SSE streams |
28//! | `COPILOT_EVALS_LEGACY_GROUP_DIR` | `false` | Migration only: `GET /evals` also reads the old `groups/<folder>/evals/`, which every CoPilot lane can write (forgeable records, N-2) |
29//!
30//! Eval records are served from the investigation lane's private state
31//! directory, `<data_dir>/sessions/copilot-investigate/evals/` (the
32//! container's `/home/agent/.talon/evals/`), which no other lane can reach.
33//!
34//! `/palace/*` is disabled (503) unless the host supplies a palace runner
35//! (see [`crate::HttpDeps::with_palace`]) or the operator opts into host
36//! execution with `TALAHON_PALACE_HOST_EXEC=unsafe`.
37
38use std::fmt;
39use std::net::{IpAddr, Ipv4Addr};
40use std::path::{Path, PathBuf};
41use std::time::Duration;
42
43use chrono_tz::Tz;
44use talon_core::config::TalonConfig;
45use talon_core::env::EnvFile;
46use talon_core::paths::is_valid_group_folder;
47use talon_core::{Error, Result};
48
49use crate::files::EVALS_DIR;
50use crate::lanes::INVESTIGATE_SESSION_KEY;
51use crate::palace::{HOST_EXEC_ENV, HOST_EXEC_UNSAFE};
52
53/// Default listen port (used when `COPILOT_HTTP_PORT` is unset).
54pub const DEFAULT_PORT: u16 = 3100;
55/// Default base lane JID.
56pub const DEFAULT_BASE_JID: &str = "http:copilot";
57/// Default group folder for every CoPilot lane.
58pub const DEFAULT_GROUP_FOLDER: &str = "copilot";
59/// Default request body limit (256 KiB).
60pub const DEFAULT_MAX_BODY_BYTES: usize = 256 * 1024;
61/// Upper bound accepted for `COPILOT_HTTP_MAX_BODY_BYTES`.
62pub const MAX_BODY_BYTES_CEILING: usize = 16 * 1024 * 1024;
63/// Keys shorter than this trigger a startup warning.
64pub const MIN_RECOMMENDED_KEY_LEN: usize = 16;
65/// Default listen address: loopback only (MYTH-05).
66pub const DEFAULT_BIND: IpAddr = IpAddr::V4(Ipv4Addr::LOCALHOST);
67/// Default cap on open connections.
68pub const DEFAULT_MAX_CONNECTIONS: usize = 512;
69/// Default cap on open connections from one peer IP.
70pub const DEFAULT_MAX_CONNECTIONS_PER_IP: usize = 320;
71/// Default time a client has to send complete request headers.
72pub const DEFAULT_HEADER_READ_TIMEOUT: Duration = Duration::from_secs(15);
73/// Env flag: also serve eval records from the legacy, shared
74/// `groups/<folder>/evals/` directory (migration only; forgeable).
75pub const EVALS_LEGACY_GROUP_DIR_ENV: &str = "COPILOT_EVALS_LEGACY_GROUP_DIR";
76
77/// The shared API key. `Debug` never prints the value.
78#[derive(Clone, Default, PartialEq, Eq)]
79pub struct ApiKey(String);
80
81impl ApiKey {
82    /// Wrap a key value.
83    pub fn new(value: impl Into<String>) -> Self {
84        Self(value.into())
85    }
86
87    /// An unset key (every authenticated request is rejected).
88    pub fn unset() -> Self {
89        Self(String::new())
90    }
91
92    /// Whether a non-blank key is configured.
93    pub fn is_configured(&self) -> bool {
94        !self.0.trim().is_empty()
95    }
96
97    /// The raw key, for the constant-time comparison only. Never log it.
98    pub fn expose(&self) -> &str {
99        &self.0
100    }
101
102    /// Length of the configured key in bytes.
103    pub fn len(&self) -> usize {
104        self.0.len()
105    }
106
107    /// Whether the key is empty.
108    pub fn is_empty(&self) -> bool {
109        self.0.is_empty()
110    }
111}
112
113impl fmt::Debug for ApiKey {
114    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
115        if self.is_configured() {
116            f.write_str("ApiKey(<redacted>)")
117        } else {
118            f.write_str("ApiKey(<unset>)")
119        }
120    }
121}
122
123/// Resolved configuration for the HTTP channel.
124#[derive(Debug, Clone)]
125pub struct HttpConfig {
126    /// Listen address (default `127.0.0.1`).
127    pub bind: IpAddr,
128    /// A TLS-terminating proxy fronts the API (operator assertion; only
129    /// silences the wildcard-bind warning).
130    pub tls_terminated: bool,
131    /// Max simultaneously open connections.
132    pub max_connections: usize,
133    /// Max simultaneously open connections from one peer IP.
134    pub max_connections_per_ip: usize,
135    /// Time a client has to send complete request headers. Established
136    /// requests (SSE streams) have no idle timeout.
137    pub header_read_timeout: Duration,
138    /// Listen port (`0` picks a free port; see `HttpChannel::local_addr`).
139    pub port: u16,
140    /// Shared secret callers present via `X-Api-Key` or `Authorization: Bearer`.
141    pub api_key: ApiKey,
142    /// Base lane JID (`http:copilot`).
143    pub base_jid: String,
144    /// Group folder every lane runs in.
145    pub group_folder: String,
146    /// Project root (`mempalace/`, `siem/`, ... live below it).
147    pub project_root: PathBuf,
148    /// `<project_root>/groups`.
149    pub groups_dir: PathBuf,
150    /// Host runtime data (`<project_root>/data`): lane state directories
151    /// live in `<data_dir>/sessions/<session_key>`.
152    pub data_dir: PathBuf,
153    /// `COPILOT_EVALS_LEGACY_GROUP_DIR`: `GET /evals` additionally reads
154    /// the pre-N-2 location `groups/<folder>/evals/` (writable by every
155    /// CoPilot lane, so records found there are forgeable). Off by default.
156    pub evals_legacy_group_dir: bool,
157    /// Timezone used for `once` task timestamps and cron evaluation.
158    pub timezone: Tz,
159    /// Allowed CORS origins (exact match, or `*`). Empty => no CORS headers.
160    pub cors_origins: Vec<String>,
161    /// Request body limit in bytes.
162    pub max_body_bytes: usize,
163    /// Idle time after which a per-user lane is swept.
164    pub lane_idle: Duration,
165    /// How often the idle-lane sweep runs.
166    pub lane_sweep_interval: Duration,
167    /// Interval between SSE keepalive comments.
168    pub sse_keepalive: Duration,
169    /// Repeat triggers for the same alert inside this window return the
170    /// existing job. Zero disables de-duplication.
171    pub dedup_window: Duration,
172    /// Failed authentications per IP (inside `auth_window`) before 429s.
173    pub auth_max_failures: u32,
174    /// Auth-failure throttle window.
175    pub auth_window: Duration,
176    /// Path of `mempalace/palace-call.sh` (only used with
177    /// [`Self::palace_host_exec`]).
178    pub palace_script: PathBuf,
179    /// `TALAHON_PALACE_HOST_EXEC=unsafe`: without an explicitly supplied
180    /// palace runner, run `palace_script` directly on the host. Dangerous
181    /// (MYTH-01); off by default.
182    pub palace_host_exec: bool,
183    /// Timeout for one palace call.
184    pub palace_timeout: Duration,
185    /// Max bytes of stdout accepted from a palace call.
186    pub palace_max_output_bytes: usize,
187    /// Max concurrently running palace calls.
188    pub palace_max_concurrency: usize,
189    /// Cap on in-memory per-user lanes.
190    pub max_user_lanes: usize,
191    /// Cap on SSE requests queued on a single lane.
192    pub max_pending_per_lane: usize,
193    /// Cap on concurrently open SSE streams across all lanes.
194    pub max_streams: usize,
195    /// Seed the (paused) 15-minute alert digest task on connect.
196    pub seed_digest_task: bool,
197}
198
199impl HttpConfig {
200    /// Defaults rooted at `project_root` (API key unset). Intended for tests
201    /// and programmatic construction; production uses [`Self::from_talon_config`].
202    pub fn new(project_root: impl Into<PathBuf>) -> Self {
203        let project_root = project_root.into();
204        Self {
205            bind: DEFAULT_BIND,
206            tls_terminated: false,
207            max_connections: DEFAULT_MAX_CONNECTIONS,
208            max_connections_per_ip: DEFAULT_MAX_CONNECTIONS_PER_IP,
209            header_read_timeout: DEFAULT_HEADER_READ_TIMEOUT,
210            port: DEFAULT_PORT,
211            api_key: ApiKey::unset(),
212            base_jid: DEFAULT_BASE_JID.to_string(),
213            group_folder: DEFAULT_GROUP_FOLDER.to_string(),
214            groups_dir: project_root.join("groups"),
215            data_dir: project_root.join("data"),
216            evals_legacy_group_dir: false,
217            palace_script: project_root.join("mempalace").join("palace-call.sh"),
218            palace_host_exec: false,
219            project_root,
220            timezone: chrono_tz::UTC,
221            cors_origins: Vec::new(),
222            max_body_bytes: DEFAULT_MAX_BODY_BYTES,
223            lane_idle: Duration::from_secs(24 * 3600),
224            lane_sweep_interval: Duration::from_secs(15 * 60),
225            sse_keepalive: Duration::from_secs(15),
226            dedup_window: Duration::from_secs(10 * 60),
227            auth_max_failures: 10,
228            auth_window: Duration::from_secs(300),
229            palace_timeout: Duration::from_secs(30),
230            palace_max_output_bytes: 1024 * 1024,
231            palace_max_concurrency: 4,
232            max_user_lanes: 256,
233            max_pending_per_lane: 8,
234            max_streams: 256,
235            seed_digest_task: true,
236        }
237    }
238
239    /// Resolve from the host configuration.
240    pub fn from_talon_config(cfg: &TalonConfig) -> Result<Self> {
241        let mut http = Self::from_env(&cfg.project_root, &cfg.groups_dir, cfg.timezone, &cfg.env)?;
242        http.data_dir = cfg.data_dir.clone();
243        Ok(http)
244    }
245
246    /// Resolve from an [`EnvFile`] (see the module docs for the keys).
247    pub fn from_env(
248        project_root: &Path,
249        groups_dir: &Path,
250        timezone: Tz,
251        env: &EnvFile,
252    ) -> Result<Self> {
253        let mut cfg = Self::new(project_root);
254        cfg.groups_dir = groups_dir.to_path_buf();
255        cfg.timezone = timezone;
256
257        if let Some(port) = env.get("COPILOT_HTTP_PORT") {
258            cfg.port = port.trim().parse::<u16>().map_err(|_| {
259                Error::Config(format!("COPILOT_HTTP_PORT {port:?} is not a valid port"))
260            })?;
261        }
262        if let Some(bind) = env.get("COPILOT_HTTP_BIND") {
263            cfg.bind = bind.trim().parse::<IpAddr>().map_err(|_| {
264                Error::Config(format!("COPILOT_HTTP_BIND {bind:?} is not an IP address"))
265            })?;
266        }
267        if let Some(jid) = env.get("COPILOT_JID") {
268            let jid = jid.trim().to_string();
269            if !is_valid_base_jid(&jid) {
270                return Err(Error::Config(format!(
271                    "COPILOT_JID {jid:?} is not a valid JID"
272                )));
273            }
274            cfg.base_jid = jid;
275        }
276        if let Some(folder) = env.get("COPILOT_GROUP_FOLDER") {
277            let folder = folder.trim().to_string();
278            if !is_valid_group_folder(&folder) {
279                return Err(Error::Config(format!(
280                    "COPILOT_GROUP_FOLDER {folder:?} is not a valid group folder"
281                )));
282            }
283            cfg.group_folder = folder;
284        }
285        cfg.api_key = ApiKey::new(env.file_value("HTTP_API_KEY").unwrap_or_default().trim());
286        if let Some(origins) = env.get("COPILOT_HTTP_CORS_ORIGINS") {
287            cfg.cors_origins = parse_cors_origins(&origins)?;
288        }
289        cfg.max_body_bytes = (env.get_u64(
290            "COPILOT_HTTP_MAX_BODY_BYTES",
291            DEFAULT_MAX_BODY_BYTES as u64,
292            1024,
293        ) as usize)
294            .min(MAX_BODY_BYTES_CEILING);
295        cfg.lane_idle = Duration::from_secs(env.get_u64("SESSION_IDLE_HOURS", 24, 1) * 3600);
296        cfg.dedup_window =
297            Duration::from_secs(env.get_u64("COPILOT_INVESTIGATION_DEDUP_MINUTES", 10, 0) * 60);
298        cfg.auth_max_failures =
299            u32::try_from(env.get_u64("COPILOT_HTTP_AUTH_MAX_FAILURES", 10, 1)).unwrap_or(u32::MAX);
300        cfg.auth_window = Duration::from_secs(env.get_u64("COPILOT_HTTP_AUTH_WINDOW_SECS", 300, 1));
301        cfg.palace_timeout =
302            Duration::from_millis(env.get_u64("COPILOT_PALACE_TIMEOUT_MS", 30_000, 1_000));
303        cfg.max_user_lanes = env.get_u64("COPILOT_HTTP_MAX_USER_LANES", 256, 1) as usize;
304        cfg.max_streams = env.get_u64("COPILOT_HTTP_MAX_STREAMS", 256, 1) as usize;
305        cfg.tls_terminated = env.get_bool("COPILOT_HTTP_TLS_TERMINATED");
306        cfg.max_connections = env.get_u64(
307            "COPILOT_HTTP_MAX_CONNECTIONS",
308            DEFAULT_MAX_CONNECTIONS as u64,
309            1,
310        ) as usize;
311        cfg.max_connections_per_ip = env.get_u64(
312            "COPILOT_HTTP_MAX_CONNECTIONS_PER_IP",
313            DEFAULT_MAX_CONNECTIONS_PER_IP as u64,
314            1,
315        ) as usize;
316        cfg.header_read_timeout = Duration::from_secs(env.get_u64(
317            "COPILOT_HTTP_HEADER_TIMEOUT_SECS",
318            DEFAULT_HEADER_READ_TIMEOUT.as_secs(),
319            1,
320        ));
321        cfg.palace_host_exec = parse_host_exec(env.get(HOST_EXEC_ENV).as_deref())?;
322        cfg.evals_legacy_group_dir = env.get_bool(EVALS_LEGACY_GROUP_DIR_ENV);
323        Ok(cfg)
324    }
325
326    /// Startup warning for a listen address that exposes the API (and its
327    /// cleartext API key) on every interface without TLS in front.
328    pub fn exposure_warning(&self) -> Option<String> {
329        (self.bind.is_unspecified() && !self.tls_terminated).then(|| {
330            format!(
331                "COPILOT_HTTP_BIND={} exposes the CoPilot HTTP API on every interface without TLS: \
332                 the API key travels in cleartext. Bind a private interface (or 127.0.0.1) and/or \
333                 put a TLS-terminating proxy in front (then set COPILOT_HTTP_TLS_TERMINATED=true)",
334                self.bind
335            )
336        })
337    }
338
339    /// `<groups_dir>/<group_folder>`.
340    pub fn group_dir(&self) -> PathBuf {
341        self.groups_dir.join(&self.group_folder)
342    }
343
344    /// `<groups_dir>/<group_folder>/prompts`.
345    pub fn prompts_dir(&self) -> PathBuf {
346        self.group_dir().join("prompts")
347    }
348
349    /// `<data_dir>/sessions`: the (operator-controlled) root of every
350    /// lane's private state directory.
351    pub fn sessions_dir(&self) -> PathBuf {
352        self.data_dir.join("sessions")
353    }
354
355    /// Session key of the investigation lane, whose state directory holds
356    /// the eval records (the key [`crate::HttpState::register_base_lanes`]
357    /// registers that lane with).
358    pub fn investigation_session_key(&self) -> &'static str {
359        INVESTIGATE_SESSION_KEY
360    }
361
362    /// `<data_dir>/sessions/<investigation session key>/evals` — where the
363    /// investigation agent writes eval records (`/home/agent/.talon/evals`
364    /// inside its container) and where `GET /evals` reads them. Reads never
365    /// go through this path directly: the lane directory and `evals/` are
366    /// opened without following symlinks.
367    pub fn evals_dir(&self) -> PathBuf {
368        self.sessions_dir()
369            .join(self.investigation_session_key())
370            .join(EVALS_DIR)
371    }
372
373    /// `<groups_dir>/<group_folder>/evals` — the pre-N-2 location, shared by
374    /// every CoPilot lane; read only with [`Self::evals_legacy_group_dir`].
375    pub fn legacy_evals_dir(&self) -> PathBuf {
376        self.group_dir().join(EVALS_DIR)
377    }
378}
379
380/// `TALAHON_PALACE_HOST_EXEC`: only the exact value `unsafe` enables host
381/// execution; common "off" spellings disable it; anything else is a
382/// configuration error rather than a silent guess.
383fn parse_host_exec(raw: Option<&str>) -> Result<bool> {
384    let Some(value) = raw.map(str::trim) else {
385        return Ok(false);
386    };
387    if value == HOST_EXEC_UNSAFE {
388        return Ok(true);
389    }
390    if value.is_empty()
391        || ["0", "false", "no", "off"]
392            .iter()
393            .any(|off| off.eq_ignore_ascii_case(value))
394    {
395        return Ok(false);
396    }
397    Err(Error::Config(format!(
398        "{HOST_EXEC_ENV}={value:?} is not recognised: set it to '{HOST_EXEC_UNSAFE}' to run \
399         mempalace/palace-call.sh on the host (DANGEROUS: agent-writable palace data allows host \
400         code execution) or leave it unset"
401    )))
402}
403
404/// A base JID: 1-128 printable ASCII characters without whitespace, not
405/// ending in `:` (sub-lanes are formed by appending `:<suffix>`).
406fn is_valid_base_jid(jid: &str) -> bool {
407    !jid.is_empty()
408        && jid.len() <= 128
409        && !jid.ends_with(':')
410        && jid.bytes().all(|b| b.is_ascii_graphic())
411}
412
413/// Parse `COPILOT_HTTP_CORS_ORIGINS`: comma-separated `http(s)://host[:port]`
414/// origins or `*`. Trailing slashes are dropped.
415pub fn parse_cors_origins(raw: &str) -> Result<Vec<String>> {
416    let mut out = Vec::new();
417    for item in raw.split(',') {
418        let item = item.trim().trim_end_matches('/');
419        if item.is_empty() {
420            continue;
421        }
422        let host = item
423            .strip_prefix("https://")
424            .or_else(|| item.strip_prefix("http://"));
425        let valid = item == "*"
426            || matches!(host, Some(h) if !h.is_empty()
427                && h.len() <= 253
428                && h.bytes().all(|b| b.is_ascii_graphic() && b != b'/'));
429        if !valid {
430            return Err(Error::Config(format!(
431                "COPILOT_HTTP_CORS_ORIGINS entry {item:?} is not an origin (scheme://host[:port]) or '*'"
432            )));
433        }
434        out.push(item.to_string());
435    }
436    Ok(out)
437}
438
439#[cfg(test)]
440mod tests {
441    use super::*;
442
443    fn env(content: &str) -> EnvFile {
444        EnvFile::from_str_content(content)
445    }
446
447    #[test]
448    fn defaults_match_the_ts_release() {
449        let cfg = HttpConfig::new("/opt/talahon");
450        assert_eq!(cfg.port, 3100);
451        assert_eq!(cfg.base_jid, "http:copilot");
452        assert_eq!(cfg.group_folder, "copilot");
453        assert_eq!(
454            cfg.prompts_dir(),
455            PathBuf::from("/opt/talahon/groups/copilot/prompts")
456        );
457        assert_eq!(cfg.data_dir, PathBuf::from("/opt/talahon/data"));
458        assert_eq!(
459            cfg.evals_dir(),
460            PathBuf::from("/opt/talahon/data/sessions/copilot-investigate/evals")
461        );
462        assert_eq!(
463            cfg.legacy_evals_dir(),
464            PathBuf::from("/opt/talahon/groups/copilot/evals")
465        );
466        assert!(!cfg.evals_legacy_group_dir);
467        assert_eq!(
468            cfg.palace_script,
469            PathBuf::from("/opt/talahon/mempalace/palace-call.sh")
470        );
471        assert!(!cfg.api_key.is_configured());
472        assert_eq!(cfg.dedup_window, Duration::from_secs(600));
473        assert_eq!(cfg.sse_keepalive, Duration::from_secs(15));
474        assert!(!cfg.palace_host_exec);
475    }
476
477    #[test]
478    fn default_bind_is_loopback() {
479        let cfg = HttpConfig::new("/opt/talahon");
480        assert_eq!(cfg.bind, IpAddr::V4(Ipv4Addr::LOCALHOST));
481        assert!(cfg.exposure_warning().is_none());
482        if std::env::var("COPILOT_HTTP_BIND").is_err() {
483            let cfg = HttpConfig::from_env(
484                Path::new("/x"),
485                Path::new("/x/g"),
486                chrono_tz::UTC,
487                &env("COPILOT_HTTP_PORT=3100\n"),
488            )
489            .unwrap();
490            assert_eq!(cfg.bind.to_string(), "127.0.0.1");
491        }
492    }
493
494    #[test]
495    fn wildcard_bind_without_tls_warns() {
496        let mut cfg = HttpConfig::new("/opt/talahon");
497        for addr in ["0.0.0.0", "::"] {
498            cfg.bind = addr.parse().unwrap();
499            cfg.tls_terminated = false;
500            let w = cfg.exposure_warning().unwrap();
501            assert!(w.contains("every interface"), "{w}");
502            cfg.tls_terminated = true;
503            assert!(cfg.exposure_warning().is_none());
504        }
505        // An explicitly chosen private interface does not warn.
506        cfg.tls_terminated = false;
507        cfg.bind = "10.0.0.5".parse().unwrap();
508        assert!(cfg.exposure_warning().is_none());
509        if std::env::var("COPILOT_HTTP_BIND").is_err()
510            && std::env::var("COPILOT_HTTP_TLS_TERMINATED").is_err()
511        {
512            let cfg = HttpConfig::from_env(
513                Path::new("/x"),
514                Path::new("/x/g"),
515                chrono_tz::UTC,
516                &env("COPILOT_HTTP_BIND=0.0.0.0\nCOPILOT_HTTP_TLS_TERMINATED=true\n"),
517            )
518            .unwrap();
519            assert!(cfg.bind.is_unspecified());
520            assert!(cfg.tls_terminated);
521            assert!(cfg.exposure_warning().is_none());
522        }
523    }
524
525    #[test]
526    fn connection_limits_are_configurable() {
527        let cfg = HttpConfig::new("/opt/talahon");
528        assert_eq!(cfg.max_connections, DEFAULT_MAX_CONNECTIONS);
529        assert!(cfg.max_connections_per_ip > cfg.max_streams);
530        assert_eq!(cfg.header_read_timeout, Duration::from_secs(15));
531        if [
532            "COPILOT_HTTP_MAX_CONNECTIONS",
533            "COPILOT_HTTP_MAX_CONNECTIONS_PER_IP",
534            "COPILOT_HTTP_HEADER_TIMEOUT_SECS",
535        ]
536        .iter()
537        .any(|k| std::env::var(k).is_ok())
538        {
539            return;
540        }
541        let cfg = HttpConfig::from_env(
542            Path::new("/x"),
543            Path::new("/x/g"),
544            chrono_tz::UTC,
545            &env("COPILOT_HTTP_MAX_CONNECTIONS=64\nCOPILOT_HTTP_MAX_CONNECTIONS_PER_IP=0\nCOPILOT_HTTP_HEADER_TIMEOUT_SECS=5\n"),
546        )
547        .unwrap();
548        assert_eq!(cfg.max_connections, 64);
549        assert_eq!(cfg.max_connections_per_ip, 1);
550        assert_eq!(cfg.header_read_timeout, Duration::from_secs(5));
551    }
552
553    #[test]
554    fn palace_host_exec_requires_the_exact_unsafe_value() {
555        assert!(!parse_host_exec(None).unwrap());
556        for off in ["", "0", "false", "NO", "off"] {
557            assert!(!parse_host_exec(Some(off)).unwrap(), "{off}");
558        }
559        assert!(parse_host_exec(Some("unsafe")).unwrap());
560        assert!(parse_host_exec(Some(" unsafe ")).unwrap());
561        for bad in ["1", "true", "yes", "UNSAFE", "on"] {
562            let err = parse_host_exec(Some(bad)).unwrap_err().to_string();
563            assert!(err.contains("DANGEROUS"), "{bad}: {err}");
564        }
565        if !talon_core::env::process_var_is_set(HOST_EXEC_ENV) {
566            let from = |content: &str| {
567                HttpConfig::from_env(
568                    Path::new("/x"),
569                    Path::new("/x/g"),
570                    chrono_tz::UTC,
571                    &env(content),
572                )
573            };
574            assert!(!from("").unwrap().palace_host_exec);
575            assert!(
576                from("TALAHON_PALACE_HOST_EXEC=unsafe\n")
577                    .unwrap()
578                    .palace_host_exec
579            );
580            assert!(from("TALAHON_PALACE_HOST_EXEC=true\n").is_err());
581        }
582    }
583
584    #[test]
585    fn legacy_evals_dir_is_opt_in() {
586        if std::env::var(EVALS_LEGACY_GROUP_DIR_ENV).is_ok() {
587            return;
588        }
589        let from = |content: &str| {
590            HttpConfig::from_env(
591                Path::new("/x"),
592                Path::new("/x/g"),
593                chrono_tz::UTC,
594                &env(content),
595            )
596            .unwrap()
597        };
598        assert!(!from("").evals_legacy_group_dir);
599        assert!(!from("COPILOT_EVALS_LEGACY_GROUP_DIR=false\n").evals_legacy_group_dir);
600        assert!(from("COPILOT_EVALS_LEGACY_GROUP_DIR=true\n").evals_legacy_group_dir);
601    }
602
603    #[test]
604    fn data_dir_comes_from_the_host_config() {
605        let mut talon = TalonConfig::from_env(Path::new("/srv/talahon"), env(""));
606        talon.data_dir = PathBuf::from("/var/lib/talon-data");
607        let Ok(cfg) = HttpConfig::from_talon_config(&talon) else {
608            // A conflicting process env (e.g. COPILOT_HTTP_PORT) is not
609            // what this test is about.
610            return;
611        };
612        assert_eq!(
613            cfg.evals_dir(),
614            PathBuf::from("/var/lib/talon-data/sessions/copilot-investigate/evals")
615        );
616        assert_eq!(cfg.investigation_session_key(), INVESTIGATE_SESSION_KEY);
617    }
618
619    #[test]
620    fn api_key_comes_from_the_env_file_and_is_never_debug_printed() {
621        let cfg = HttpConfig::from_env(
622            Path::new("/x"),
623            Path::new("/x/groups"),
624            chrono_tz::UTC,
625            &env("HTTP_API_KEY=super-secret-value-123\n"),
626        )
627        .unwrap();
628        assert!(cfg.api_key.is_configured());
629        assert_eq!(cfg.api_key.expose(), "super-secret-value-123");
630        let dbg = format!("{cfg:?}");
631        assert!(!dbg.contains("super-secret"), "{dbg}");
632        assert!(dbg.contains("<redacted>"));
633    }
634
635    #[test]
636    fn blank_key_is_unset() {
637        let k = ApiKey::new("   ");
638        assert!(!k.is_configured());
639        assert_eq!(format!("{k:?}"), "ApiKey(<unset>)");
640    }
641
642    #[test]
643    fn invalid_settings_are_rejected() {
644        let bad = |content: &str| {
645            HttpConfig::from_env(
646                Path::new("/x"),
647                Path::new("/x/g"),
648                chrono_tz::UTC,
649                &env(content),
650            )
651        };
652        if std::env::var("COPILOT_GROUP_FOLDER").is_err() {
653            assert!(bad("COPILOT_GROUP_FOLDER=../etc\n").is_err());
654        }
655        if std::env::var("COPILOT_HTTP_PORT").is_err() {
656            assert!(bad("COPILOT_HTTP_PORT=99999\n").is_err());
657        }
658        if std::env::var("COPILOT_HTTP_BIND").is_err() {
659            assert!(bad("COPILOT_HTTP_BIND=not-an-ip\n").is_err());
660        }
661        if std::env::var("COPILOT_HTTP_CORS_ORIGINS").is_err() {
662            assert!(bad("COPILOT_HTTP_CORS_ORIGINS=javascript:alert(1)\n").is_err());
663        }
664    }
665
666    #[test]
667    fn numeric_settings_apply_with_bounds() {
668        if std::env::var("COPILOT_INVESTIGATION_DEDUP_MINUTES").is_ok()
669            || std::env::var("COPILOT_HTTP_MAX_BODY_BYTES").is_ok()
670        {
671            return;
672        }
673        let cfg = HttpConfig::from_env(
674            Path::new("/x"),
675            Path::new("/x/g"),
676            chrono_tz::UTC,
677            &env(
678                "COPILOT_INVESTIGATION_DEDUP_MINUTES=0\nCOPILOT_HTTP_MAX_BODY_BYTES=999999999999\n",
679            ),
680        )
681        .unwrap();
682        assert_eq!(cfg.dedup_window, Duration::ZERO);
683        assert_eq!(cfg.max_body_bytes, MAX_BODY_BYTES_CEILING);
684    }
685
686    #[test]
687    fn cors_origins_parse() {
688        assert_eq!(
689            parse_cors_origins(" https://copilot.example.com/ , http://10.0.0.5:5173,*").unwrap(),
690            vec![
691                "https://copilot.example.com".to_string(),
692                "http://10.0.0.5:5173".to_string(),
693                "*".to_string()
694            ]
695        );
696        assert!(parse_cors_origins("https://a.example/path").is_err());
697        assert!(parse_cors_origins("ftp://a").is_err());
698        assert!(parse_cors_origins("").unwrap().is_empty());
699    }
700
701    #[test]
702    fn base_jid_validation() {
703        assert!(is_valid_base_jid("http:copilot"));
704        assert!(!is_valid_base_jid("http:copilot:"));
705        assert!(!is_valid_base_jid("has space"));
706        assert!(!is_valid_base_jid(""));
707    }
708}