1use 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
53pub const DEFAULT_PORT: u16 = 3100;
55pub const DEFAULT_BASE_JID: &str = "http:copilot";
57pub const DEFAULT_GROUP_FOLDER: &str = "copilot";
59pub const DEFAULT_MAX_BODY_BYTES: usize = 256 * 1024;
61pub const MAX_BODY_BYTES_CEILING: usize = 16 * 1024 * 1024;
63pub const MIN_RECOMMENDED_KEY_LEN: usize = 16;
65pub const DEFAULT_BIND: IpAddr = IpAddr::V4(Ipv4Addr::LOCALHOST);
67pub const DEFAULT_MAX_CONNECTIONS: usize = 512;
69pub const DEFAULT_MAX_CONNECTIONS_PER_IP: usize = 320;
71pub const DEFAULT_HEADER_READ_TIMEOUT: Duration = Duration::from_secs(15);
73pub const EVALS_LEGACY_GROUP_DIR_ENV: &str = "COPILOT_EVALS_LEGACY_GROUP_DIR";
76
77#[derive(Clone, Default, PartialEq, Eq)]
79pub struct ApiKey(String);
80
81impl ApiKey {
82 pub fn new(value: impl Into<String>) -> Self {
84 Self(value.into())
85 }
86
87 pub fn unset() -> Self {
89 Self(String::new())
90 }
91
92 pub fn is_configured(&self) -> bool {
94 !self.0.trim().is_empty()
95 }
96
97 pub fn expose(&self) -> &str {
99 &self.0
100 }
101
102 pub fn len(&self) -> usize {
104 self.0.len()
105 }
106
107 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#[derive(Debug, Clone)]
125pub struct HttpConfig {
126 pub bind: IpAddr,
128 pub tls_terminated: bool,
131 pub max_connections: usize,
133 pub max_connections_per_ip: usize,
135 pub header_read_timeout: Duration,
138 pub port: u16,
140 pub api_key: ApiKey,
142 pub base_jid: String,
144 pub group_folder: String,
146 pub project_root: PathBuf,
148 pub groups_dir: PathBuf,
150 pub data_dir: PathBuf,
153 pub evals_legacy_group_dir: bool,
157 pub timezone: Tz,
159 pub cors_origins: Vec<String>,
161 pub max_body_bytes: usize,
163 pub lane_idle: Duration,
165 pub lane_sweep_interval: Duration,
167 pub sse_keepalive: Duration,
169 pub dedup_window: Duration,
172 pub auth_max_failures: u32,
174 pub auth_window: Duration,
176 pub palace_script: PathBuf,
179 pub palace_host_exec: bool,
183 pub palace_timeout: Duration,
185 pub palace_max_output_bytes: usize,
187 pub palace_max_concurrency: usize,
189 pub max_user_lanes: usize,
191 pub max_pending_per_lane: usize,
193 pub max_streams: usize,
195 pub seed_digest_task: bool,
197}
198
199impl HttpConfig {
200 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 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 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 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 pub fn group_dir(&self) -> PathBuf {
341 self.groups_dir.join(&self.group_folder)
342 }
343
344 pub fn prompts_dir(&self) -> PathBuf {
346 self.group_dir().join("prompts")
347 }
348
349 pub fn sessions_dir(&self) -> PathBuf {
352 self.data_dir.join("sessions")
353 }
354
355 pub fn investigation_session_key(&self) -> &'static str {
359 INVESTIGATE_SESSION_KEY
360 }
361
362 pub fn evals_dir(&self) -> PathBuf {
368 self.sessions_dir()
369 .join(self.investigation_session_key())
370 .join(EVALS_DIR)
371 }
372
373 pub fn legacy_evals_dir(&self) -> PathBuf {
376 self.group_dir().join(EVALS_DIR)
377 }
378}
379
380fn 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
404fn 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
413pub 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/talon");
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/talon/groups/copilot/prompts")
456 );
457 assert_eq!(cfg.data_dir, PathBuf::from("/opt/talon/data"));
458 assert_eq!(
459 cfg.evals_dir(),
460 PathBuf::from("/opt/talon/data/sessions/copilot-investigate/evals")
461 );
462 assert_eq!(
463 cfg.legacy_evals_dir(),
464 PathBuf::from("/opt/talon/groups/copilot/evals")
465 );
466 assert!(!cfg.evals_legacy_group_dir);
467 assert_eq!(
468 cfg.palace_script,
469 PathBuf::from("/opt/talon/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/talon");
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/talon");
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 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/talon");
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 std::env::var(HOST_EXEC_ENV).is_err() {
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("TALON_PALACE_HOST_EXEC=unsafe\n")
577 .unwrap()
578 .palace_host_exec
579 );
580 assert!(from("TALON_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/talon"), env(""));
606 talon.data_dir = PathBuf::from("/var/lib/talon-data");
607 let Ok(cfg) = HttpConfig::from_talon_config(&talon) else {
608 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}