Skip to main content

talon_http/
channel.rs

1//! [`HttpChannel`]: the CoPilot HTTP API as a [`Channel`].
2
3use std::net::SocketAddr;
4use std::sync::Arc;
5use std::time::Duration;
6
7use async_trait::async_trait;
8use talon_core::audit::SharedAuditSink;
9use talon_core::channel::{Channel, ChannelHost};
10use talon_core::{Error, Result};
11use tokio::net::TcpListener;
12use tokio::sync::Mutex as AsyncMutex;
13use tokio::task::JoinHandle;
14use tokio_util::sync::CancellationToken;
15
16use crate::config::{HttpConfig, MIN_RECOMMENDED_KEY_LEN};
17use crate::lanes::SendOutcome;
18use crate::metrics::Metrics;
19use crate::palace::PalaceRunner;
20use crate::server::{HttpDeps, HttpState, router};
21use crate::store::HttpStore;
22
23/// How long open connections may drain after shutdown starts.
24const SHUTDOWN_GRACE: Duration = Duration::from_secs(5);
25/// Extra time `disconnect` gives the server task beyond [`SHUTDOWN_GRACE`]
26/// before aborting it.
27const SHUTDOWN_SLACK: Duration = Duration::from_secs(2);
28/// HTTP/2 keep-alive ping interval (dead peers are dropped).
29const HTTP2_KEEP_ALIVE: Duration = Duration::from_secs(30);
30
31/// Connection limits for [`talon_net::serve`] (MYTH-06): header-read
32/// timeout and connection caps. Established requests — SSE streams in
33/// particular — have no idle timeout.
34fn server_limits(cfg: &HttpConfig) -> talon_net::ServerLimits {
35    talon_net::ServerLimits {
36        max_connections: cfg.max_connections.max(1),
37        max_connections_per_ip: cfg.max_connections_per_ip.max(1),
38        header_read_timeout: cfg.header_read_timeout,
39        http2_keep_alive_interval: HTTP2_KEEP_ALIVE,
40        shutdown_grace: SHUTDOWN_GRACE,
41    }
42}
43
44struct Running {
45    cancel: CancellationToken,
46    server: JoinHandle<()>,
47    sweeper: JoinHandle<()>,
48    addr: SocketAddr,
49}
50
51/// The CoPilot HTTP channel. Owns `http:copilot` and every `http:copilot:*`
52/// sub-lane.
53pub struct HttpChannel {
54    state: HttpState,
55    running: AsyncMutex<Option<Running>>,
56    connected: std::sync::atomic::AtomicBool,
57    local_addr: std::sync::Mutex<Option<SocketAddr>>,
58}
59
60impl std::fmt::Debug for HttpChannel {
61    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
62        f.debug_struct("HttpChannel")
63            .field("state", &self.state)
64            .field("connected", &self.is_connected())
65            .finish_non_exhaustive()
66    }
67}
68
69impl HttpChannel {
70    /// Production constructor: `db` is the shared `talon_db::Db`.
71    ///
72    /// The `/palace/*` endpoints are disabled (503) — see [`HttpDeps::new`];
73    /// use [`Self::with_palace`] to supply a (containerized) palace runner.
74    pub fn new(
75        config: HttpConfig,
76        host: Arc<dyn ChannelHost>,
77        db: talon_db::Db,
78        audit: SharedAuditSink,
79    ) -> Self {
80        let store: Arc<dyn HttpStore> = Arc::new(db);
81        let deps = HttpDeps::new(&config, host, store, audit);
82        Self::from_state(HttpState::new(config, deps))
83    }
84
85    /// Like [`Self::new`], serving `/palace/*` through `palace` (normally a
86    /// [`crate::CommandPalaceRunner`] running the palace in a hardened
87    /// container).
88    pub fn with_palace(
89        config: HttpConfig,
90        host: Arc<dyn ChannelHost>,
91        db: talon_db::Db,
92        audit: SharedAuditSink,
93        palace: Arc<dyn PalaceRunner>,
94    ) -> Self {
95        let store: Arc<dyn HttpStore> = Arc::new(db);
96        let deps = HttpDeps::new(&config, host, store, audit).with_palace(palace);
97        Self::from_state(HttpState::new(config, deps))
98    }
99
100    /// Build around an existing state (custom dependencies, tests).
101    pub fn from_state(state: HttpState) -> Self {
102        Self {
103            state,
104            running: AsyncMutex::new(None),
105            connected: std::sync::atomic::AtomicBool::new(false),
106            local_addr: std::sync::Mutex::new(None),
107        }
108    }
109
110    /// The shared state (e.g. to build a [`router`] for tests).
111    pub fn state(&self) -> &HttpState {
112        &self.state
113    }
114
115    /// The bound address once connected (useful with port `0`).
116    pub fn local_addr(&self) -> Option<SocketAddr> {
117        *self.local_addr.lock().unwrap_or_else(|e| e.into_inner())
118    }
119
120    fn warn_about_key(&self) {
121        let key = &self.state.config().api_key;
122        if !key.is_configured() {
123            tracing::error!(
124                "HTTP_API_KEY is not set in .env: the CoPilot HTTP API will reject EVERY request \
125                 except GET /health with 401 until a key is configured"
126            );
127        } else if key.len() < MIN_RECOMMENDED_KEY_LEN {
128            tracing::warn!(
129                min_len = MIN_RECOMMENDED_KEY_LEN,
130                "HTTP_API_KEY is short; use a long random key (e.g. `openssl rand -hex 32`)"
131            );
132        }
133        if let Some(warning) = self.state.config().exposure_warning() {
134            tracing::warn!("{warning}");
135        }
136        if self.state.config().evals_legacy_group_dir {
137            tracing::warn!(
138                "COPILOT_EVALS_LEGACY_GROUP_DIR is enabled: GET /evals also serves records from \
139                 the shared group folder, which every CoPilot lane can write (forgeable). Disable \
140                 it once old evals are no longer needed"
141            );
142        }
143    }
144}
145
146async fn sweep_loop(state: HttpState, cancel: CancellationToken) {
147    let period = state
148        .config()
149        .lane_sweep_interval
150        .max(Duration::from_secs(1));
151    let mut tick = tokio::time::interval(period);
152    tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
153    tick.tick().await;
154    loop {
155        tokio::select! {
156            _ = cancel.cancelled() => break,
157            _ = tick.tick() => {
158                let swept = state.sweep_idle_lanes().await;
159                if !swept.is_empty() {
160                    tracing::info!(count = swept.len(), "HTTP channel: idle lane sweep");
161                }
162            }
163        }
164    }
165}
166
167#[async_trait]
168impl Channel for HttpChannel {
169    fn name(&self) -> &str {
170        "http"
171    }
172
173    async fn connect(&self) -> Result<()> {
174        let mut running = self.running.lock().await;
175        if running.is_some() {
176            return Ok(());
177        }
178        self.warn_about_key();
179
180        let cfg = self.state.config();
181        let bind = SocketAddr::new(cfg.bind, cfg.port);
182        let listener = TcpListener::bind(bind)
183            .await
184            .map_err(|e| Error::Channel(format!("HTTP channel: cannot bind {bind}: {e}")))?;
185        let addr = listener.local_addr()?;
186
187        self.state.register_base_lanes().await;
188        if cfg.seed_digest_task {
189            match self.state.seed_digest_task().await {
190                Ok(true) => tracing::info!(
191                    task_id = crate::investigate::DIGEST_TASK_ID,
192                    "Seeded CoPilot alert digest scheduled task (paused)"
193                ),
194                Ok(false) => {}
195                Err(e) => tracing::warn!(err = %e, "failed to seed the alert digest task"),
196            }
197        }
198
199        let cancel = CancellationToken::new();
200        let app = router(self.state.clone());
201        // talon_net::serve inserts ConnectInfo<SocketAddr> per request, so
202        // per-IP auth throttling and audit actors keep working.
203        let server = tokio::spawn(talon_net::serve(
204            listener,
205            app,
206            server_limits(cfg),
207            cancel.clone(),
208        ));
209        let sweeper = tokio::spawn(sweep_loop(self.state.clone(), cancel.clone()));
210
211        tracing::info!(%addr, palace = self.state.0.palace.mode(), "HTTP channel listening");
212        tracing::info!(
213            "HTTP channel endpoints: POST /message, POST /investigate, GET /investigations[/{{id}}], \
214             GET /evals/{{alert_id}}, GET /templates, POST /palace/lesson, GET /palace/search, \
215             POST /palace/forget, GET /status, GET /metrics, POST /session/reset, GET /health"
216        );
217        *self.local_addr.lock().unwrap_or_else(|e| e.into_inner()) = Some(addr);
218        *running = Some(Running {
219            cancel,
220            server,
221            sweeper,
222            addr,
223        });
224        self.connected
225            .store(true, std::sync::atomic::Ordering::SeqCst);
226        Ok(())
227    }
228
229    async fn send_message(&self, jid: &str, text: &str) -> Result<()> {
230        let s = &self.state.0;
231        match s.streams.send_text(jid, text) {
232            SendOutcome::Delivered => {
233                tracing::info!(
234                    jid,
235                    length = text.len(),
236                    "HTTP channel: wrote SSE text chunk"
237                );
238            }
239            SendOutcome::NoWriter => {
240                // Expected on the investigation lane: its results reach
241                // CoPilot through the MCP write-back tools. Never fall back
242                // to another lane's writer.
243                tracing::debug!(
244                    jid,
245                    length = text.len(),
246                    "HTTP channel: no SSE writer for lane, dropping chunk"
247                );
248            }
249            SendOutcome::Overflow => {
250                Metrics::inc(&s.metrics.sse_dropped_chunks);
251                tracing::warn!(
252                    jid,
253                    length = text.len(),
254                    "HTTP channel: SSE buffer full, dropping chunk"
255                );
256            }
257            SendOutcome::ClientGone => {
258                tracing::info!(jid, "HTTP channel: SSE client disconnected, dropping chunk");
259            }
260        }
261        Ok(())
262    }
263
264    fn is_connected(&self) -> bool {
265        self.connected.load(std::sync::atomic::Ordering::SeqCst)
266    }
267
268    fn owns_jid(&self, jid: &str) -> bool {
269        self.state.lanes().owns(jid)
270    }
271
272    async fn disconnect(&self) -> Result<()> {
273        let taken = self.running.lock().await.take();
274        self.connected
275            .store(false, std::sync::atomic::Ordering::SeqCst);
276        let Some(mut r) = taken else {
277            return Ok(());
278        };
279        r.cancel.cancel();
280        // Open SSE streams would otherwise hold graceful shutdown forever.
281        self.state.0.streams.close_all();
282        if tokio::time::timeout(SHUTDOWN_GRACE + SHUTDOWN_SLACK, &mut r.server)
283            .await
284            .is_err()
285        {
286            tracing::warn!("HTTP channel: graceful shutdown timed out; aborting");
287            r.server.abort();
288        }
289        r.sweeper.abort();
290        *self.local_addr.lock().unwrap_or_else(|e| e.into_inner()) = None;
291        tracing::info!(addr = %r.addr, "HTTP channel stopped");
292        Ok(())
293    }
294
295    async fn set_typing(&self, jid: &str, is_typing: bool) -> Result<()> {
296        let streams = &self.state.0.streams;
297        if is_typing {
298            // Agent is starting a turn: promote the next writer on THIS lane.
299            if streams.turn_started(jid) {
300                tracing::info!(
301                    jid,
302                    remaining = streams.pending_len(jid),
303                    "HTTP channel: dequeued SSE writer"
304                );
305            } else {
306                tracing::debug!(jid, "HTTP channel: turn start without a new SSE writer");
307            }
308        } else if streams.turn_ended(jid) {
309            // Container exited. on_turn_complete normally closed the stream
310            // already; this is the safety net.
311            tracing::warn!(
312                jid,
313                "HTTP channel: container stopped with an active writer; closed via safety net"
314            );
315        }
316        Ok(())
317    }
318
319    async fn on_turn_complete(&self, jid: &str) -> Result<()> {
320        if self.state.0.streams.turn_complete(jid) {
321            tracing::info!(
322                jid,
323                reason = "turn-complete",
324                "HTTP channel: SSE stream closed"
325            );
326        }
327        Ok(())
328    }
329}