Skip to main content

leviath_cli/commands/serve/
types.rs

1//! Shared types: ServerEvent, AppState, request/response structs, error types.
2
3use std::collections::HashMap;
4use std::path::PathBuf;
5use std::sync::Arc;
6
7use clap::Args;
8use serde::{Deserialize, Serialize};
9use tokio::sync::broadcast;
10
11use crate::config::Config;
12
13// ─── CLI ─────────────────────────────────────────────────────────────────────
14
15#[derive(Args)]
16pub struct ServeArgs {
17    /// Port to listen on
18    #[arg(short, long, default_value = "3000")]
19    pub port: u16,
20
21    /// Host to bind to
22    #[arg(short = 'H', long, default_value = "127.0.0.1")]
23    pub host: String,
24
25    /// Allow browser requests from this origin (e.g. `http://localhost:5173`).
26    ///
27    /// Defaults to **none**: the API is for programmatic clients, which are not
28    /// subject to CORS at all, so a browser-facing default of `*` gave nothing
29    /// to the normal case and widened the surface for the unusual one. A
30    /// dashboard served from another origin sets this explicitly.
31    ///
32    /// `*` is still accepted and still means "any origin". It is now a decision
33    /// someone typed rather than what you get by not thinking about it.
34    #[arg(long)]
35    pub cors: Option<String>,
36
37    /// API token clients must present (`Authorization: Bearer <token>`, or
38    /// `?token=` for WebSockets). Overrides the LEVIATH_API_TOKEN env var; the
39    /// server refuses to start if neither is set.
40    ///
41    /// Prefer the environment variable: an argument is visible in `ps` to every
42    /// local user for the lifetime of the process.
43    #[arg(long)]
44    pub token: Option<String>,
45
46    /// Enable the MCP administration endpoints (`POST`/`DELETE
47    /// /api/mcp/servers`).
48    ///
49    /// **Off by default, because they are remote code execution by
50    /// construction.** Adding an MCP server writes a `command` and `args` into
51    /// `~/.leviath/config.toml`, and Leviath then spawns exactly that - so any
52    /// token holder could run an arbitrary process, persistently, for every
53    /// future run. The rest of the API can only run agents the user already
54    /// installed; this one adds new executables to the machine.
55    #[arg(long)]
56    pub allow_admin: bool,
57
58    /// Restrict agent working directories to this root.
59    ///
60    /// Without it, `POST /api/agents` accepts any `workdir` - including `/` -
61    /// so a token holder can point a tool-executing agent at the whole
62    /// filesystem. Set this to the directory the API is meant to work in.
63    #[arg(long)]
64    pub workdir_root: Option<PathBuf>,
65
66    /// Refuse `"yolo": true` on spawn requests, so an API caller cannot waive
67    /// every approval prompt for an agent running on the host.
68    #[arg(long)]
69    pub no_remote_yolo: bool,
70}
71
72// ─── Shared state ────────────────────────────────────────────────────────────
73
74/// Events broadcast to WebSocket subscribers.
75#[derive(Debug, Clone, Serialize)]
76#[serde(tag = "type", rename_all = "snake_case")]
77pub enum ServerEvent {
78    AgentStatus {
79        agent_id: String,
80        run_id: String,
81        status: String,
82        stage: String,
83        iteration: usize,
84        #[serde(default)]
85        tool_calls: usize,
86        accepts_messages: bool,
87    },
88    ContextUpdate {
89        agent_id: String,
90        run_id: String,
91        total_tokens: usize,
92        max_tokens: usize,
93    },
94    Log {
95        agent_id: String,
96        run_id: String,
97        line: String,
98    },
99    InteractionNeeded {
100        agent_id: String,
101        run_id: String,
102        request: serde_json::Value,
103    },
104    AgentSpawned {
105        agent_id: String,
106        run_id: String,
107        parent_id: Option<String>,
108        blueprint: String,
109    },
110    AgentCompleted {
111        agent_id: String,
112        run_id: String,
113        status: String,
114        result: Option<String>,
115    },
116    Tokens {
117        agent_id: String,
118        run_id: String,
119        prompt_tokens: usize,
120        completion_tokens: usize,
121        #[serde(default)]
122        cached_tokens: usize,
123        #[serde(default)]
124        cache_write_tokens: usize,
125    },
126}
127
128#[derive(Clone)]
129pub struct AppState {
130    pub(super) config: Arc<Config>,
131    pub(super) event_tx: broadcast::Sender<ServerEvent>,
132    /// Client for the shared-world daemon's control socket. Agent actions
133    /// (spawn/cancel/message/interactions) go through this; read endpoints still
134    /// observe the runs dir the daemon persists to.
135    pub(super) control: leviath_runtime::control_socket::ControlClient,
136    /// Paths + seams for the MCP management endpoints.
137    pub(super) mcp: super::mcp::McpAdmin,
138    /// The spawn-request restrictions from [`ServeArgs`], resolved once at
139    /// startup so every handler reads the same decision.
140    pub(super) limits: Arc<ServeLimits>,
141}
142
143/// What this server refuses regardless of who is asking.
144///
145/// A valid API token proves the caller is allowed to *use* the server; it does
146/// not mean they should be able to reconfigure the machine or point an agent at
147/// the filesystem root. These are the operator's answers to that, fixed at
148/// startup rather than negotiable per request.
149///
150/// `--allow-admin` is deliberately absent: it decides whether a route is
151/// *mounted at all*, so it is consumed once at router construction rather than
152/// carried here for a handler to consult. An unmounted route 404s; a mounted one
153/// guarded by a field is one refactor away from being reachable.
154#[derive(Debug, Clone, Default)]
155pub(super) struct ServeLimits {
156    /// `--workdir-root`: the directory agent workdirs must sit under.
157    pub(super) workdir_root: Option<PathBuf>,
158    /// `--no-remote-yolo`: whether a spawn request may set `"yolo": true`.
159    pub(super) no_remote_yolo: bool,
160    /// `[security] allow_local_network`: whether a completion webhook may point
161    /// at loopback, private or link-local addresses.
162    pub(super) allow_local_network: bool,
163}
164
165impl ServeLimits {
166    /// Check a requested agent workdir against `--workdir-root`.
167    ///
168    /// Uses the same symlink-aware containment the file tools use, so a symlink
169    /// under the root cannot be used to point an agent outside it.
170    /// Check a requested completion webhook against the same SSRF policy every
171    /// model-supplied URL goes through.
172    ///
173    /// The URL arrives in a `POST /api/agents` body, is persisted, and is POSTed
174    /// to when the run finishes - from inside the trust boundary, and with
175    /// retries. Unchecked, `"callback_url": "http://169.254.169.254/…"` made the
176    /// daemon a repeatable request primitive against the cloud metadata service
177    /// and anything else on the local network, on behalf of a caller that
178    /// `--workdir-root` and `--no-remote-yolo` exist to keep at arm's length.
179    ///
180    /// `allow_local_network` mirrors the config setting: an operator who
181    /// deliberately points webhooks at a service on the same host can, and
182    /// everyone else cannot.
183    pub(super) fn check_callback_url(&self, url: &str) -> Result<(), String> {
184        let parsed = url
185            .parse::<url::Url>()
186            .map_err(|e| format!("callback_url is not a URL: {e}"))?;
187        leviath_core::check_url(&parsed, self.allow_local_network)
188            .map_err(|e| format!("callback_url is not allowed: {e}"))
189    }
190
191    pub(super) fn check_workdir(&self, workdir: &std::path::Path) -> Result<(), String> {
192        let Some(root) = &self.workdir_root else {
193            return Ok(());
194        };
195        match leviath_core::resolves_within(workdir, root) {
196            true => Ok(()),
197            false => Err(format!(
198                "workdir '{}' is outside the configured --workdir-root '{}'",
199                workdir.display(),
200                root.display()
201            )),
202        }
203    }
204}
205
206// ─── Error response ─────────────────────────────────────────────────────────
207
208#[derive(Debug, Serialize)]
209pub(super) struct ErrorResponse {
210    pub(super) error: String,
211}
212
213/// Build a `(status, JSON error)` response tuple.
214pub(super) fn err(
215    code: axum::http::StatusCode,
216    message: String,
217) -> (axum::http::StatusCode, axum::response::Json<ErrorResponse>) {
218    (code, axum::response::Json(ErrorResponse { error: message }))
219}
220
221// ─── Blueprint types ────────────────────────────────────────────────────────
222
223#[derive(Debug, Serialize)]
224pub(super) struct BlueprintInfo {
225    pub(super) name: String,
226    pub(super) version: String,
227    pub(super) description: String,
228    pub(super) path: String,
229    pub(super) stages: Vec<String>,
230}
231
232#[derive(Deserialize)]
233pub(super) struct CreateBlueprintReq {
234    pub(super) name: String,
235    pub(super) manifest: String,
236}
237
238#[derive(Deserialize)]
239pub(super) struct UpdateBlueprintReq {
240    pub(super) manifest: String,
241}
242
243#[derive(Deserialize)]
244pub(super) struct ValidateBlueprintReq {
245    pub(super) manifest: String,
246}
247
248#[derive(Serialize, Deserialize)]
249pub(super) struct ValidateResponse {
250    pub(super) valid: bool,
251    #[serde(skip_serializing_if = "Option::is_none")]
252    pub(super) errors: Option<Vec<String>>,
253}
254
255// ─── Agent types ────────────────────────────────────────────────────────────
256
257#[derive(Default, Deserialize)]
258pub(super) struct SpawnAgentReq {
259    pub(super) blueprint: String,
260    pub(super) task: String,
261    pub(super) model: Option<String>,
262    /// Override the blueprint's max sub-agent tree depth.
263    pub(super) max_depth: Option<usize>,
264    /// Approve every tool call for this run.
265    #[serde(default)]
266    pub(super) yolo: bool,
267    /// Tools to allow outright for this run.
268    #[serde(default)]
269    pub(super) allow: Vec<String>,
270    /// Refuse this run's `seed = { command = ... }` regions, which would
271    /// otherwise execute at spawn before any approval prompt.
272    #[serde(default)]
273    pub(super) no_seed_commands: bool,
274    pub(super) workdir: Option<String>,
275    /// Literal seed content for named caller-input regions, keyed by region name.
276    #[serde(default)]
277    pub(super) regions: HashMap<String, String>,
278    #[serde(default)]
279    pub(super) metadata: HashMap<String, String>,
280    pub(super) callback_url: Option<String>,
281    /// Optional shared secret; when set, completion webhooks carry an
282    /// `X-Leviath-Signature: sha256=<hex>` HMAC of the body keyed on this secret.
283    pub(super) callback_secret: Option<String>,
284}
285
286#[derive(Serialize, Debug)]
287pub(super) struct SpawnAgentResp {
288    pub(super) agent_id: String,
289    pub(super) run_id: String,
290}
291
292#[derive(Deserialize)]
293pub(super) struct ListAgentsQuery {
294    pub(super) status: Option<String>,
295}
296
297#[derive(Serialize)]
298pub(super) struct AgentResultResp {
299    pub(super) run_id: String,
300    pub(super) status: String,
301    pub(super) output: String,
302    pub(super) error: Option<String>,
303    pub(super) prompt_tokens: usize,
304    pub(super) completion_tokens: usize,
305}
306
307#[derive(Deserialize)]
308pub(super) struct LogsQuery {
309    pub(super) tail: Option<u64>,
310}
311
312// ─── Tree types ─────────────────────────────────────────────────────────────
313
314#[derive(Serialize)]
315pub(super) struct AgentTreeNode {
316    pub(super) run_id: String,
317    pub(super) agent_name: String,
318    pub(super) status: String,
319    pub(super) stage: String,
320    pub(super) iteration: usize,
321    pub(super) prompt_tokens: usize,
322    pub(super) completion_tokens: usize,
323    pub(super) children: Vec<AgentTreeNode>,
324}
325
326#[derive(Debug, Serialize)]
327pub(super) struct TreeStatusNode {
328    pub(super) run_id: String,
329    pub(super) agent_name: String,
330    pub(super) status: String,
331    pub(super) stage: String,
332    pub(super) prompt_tokens: usize,
333    pub(super) completion_tokens: usize,
334    pub(super) subtree_prompt_tokens: usize,
335    pub(super) subtree_completion_tokens: usize,
336    pub(super) children: Vec<TreeStatusNode>,
337}
338
339// ─── Interaction types ──────────────────────────────────────────────────────
340
341#[derive(Deserialize)]
342pub(super) struct SubmitInteractionReq {
343    pub(super) request_id: String,
344    pub(super) value: Option<String>,
345    pub(super) choice_index: Option<usize>,
346    pub(super) approved: Option<bool>,
347    pub(super) scope: Option<String>,
348}
349
350#[derive(Deserialize)]
351pub(super) struct SendMessageReq {
352    pub(super) message: String,
353    #[serde(default)]
354    pub(super) target_region: Option<String>,
355}
356
357// ─── Config types ───────────────────────────────────────────────────────────
358
359#[derive(Serialize, Deserialize)]
360pub(super) struct RedactedConfig {
361    pub(super) default_provider: String,
362    pub(super) has_anthropic_key: bool,
363    pub(super) has_openai_key: bool,
364    pub(super) has_google_key: bool,
365    pub(super) has_openrouter_key: bool,
366    pub(super) ollama_base_url: Option<String>,
367    pub(super) agent_paths: Vec<PathBuf>,
368    pub(super) mcp_server_count: usize,
369}
370
371/// Body of `PUT /api/config` (admin-only). Every field is optional; a present
372/// field is written, an absent one is left untouched. Mirrors what `lev setup`
373/// writes, so a newcomer can configure providers entirely from the browser.
374#[derive(Debug, Default, Deserialize)]
375pub(super) struct WriteConfigReq {
376    pub(super) default_provider: Option<String>,
377    pub(super) default_model: Option<String>,
378    pub(super) anthropic_key: Option<String>,
379    pub(super) openai_key: Option<String>,
380    pub(super) google_key: Option<String>,
381    pub(super) openrouter_key: Option<String>,
382    pub(super) ollama_base_url: Option<String>,
383}
384
385/// Body of `POST /api/config/validate` — a format-only key check (no network,
386/// no persistence), mirroring the `lev setup` wizard's inline validation.
387#[derive(Debug, Deserialize)]
388pub(super) struct ValidateKeyReq {
389    pub(super) provider: String,
390    pub(super) key: String,
391}
392
393#[derive(Debug, Serialize, Deserialize)]
394pub(super) struct ValidateKeyResp {
395    pub(super) valid: bool,
396    #[serde(skip_serializing_if = "Option::is_none")]
397    pub(super) message: Option<String>,
398}
399
400#[derive(Serialize)]
401pub(super) struct ModelEntry {
402    pub(super) id: String,
403    pub(super) provider: String,
404    pub(super) display_name: Option<String>,
405    pub(super) max_context_tokens: usize,
406    pub(super) max_output_tokens: usize,
407    pub(super) supports_tools: bool,
408}
409
410#[cfg(test)]
411mod tests {
412    use super::*;
413
414    #[test]
415    fn server_event_agent_status_serialization() {
416        let event = ServerEvent::AgentStatus {
417            agent_id: "coder".to_string(),
418            run_id: "run-123".to_string(),
419            status: "running".to_string(),
420            stage: "implement".to_string(),
421            iteration: 5,
422            tool_calls: 12,
423            accepts_messages: true,
424        };
425        let json = serde_json::to_string(&event).unwrap();
426        assert!(json.contains("\"type\":\"agent_status\""));
427        assert!(json.contains("\"agent_id\":\"coder\""));
428        assert!(json.contains("\"iteration\":5"));
429        assert!(json.contains("\"tool_calls\":12"));
430    }
431
432    #[test]
433    fn server_event_tokens_serialization() {
434        let event = ServerEvent::Tokens {
435            agent_id: "coder".to_string(),
436            run_id: "run-123".to_string(),
437            prompt_tokens: 5000,
438            completion_tokens: 1200,
439            cached_tokens: 200,
440            cache_write_tokens: 100,
441        };
442        let json = serde_json::to_string(&event).unwrap();
443        assert!(json.contains("\"type\":\"tokens\""));
444        assert!(json.contains("\"prompt_tokens\":5000"));
445        assert!(json.contains("\"cached_tokens\":200"));
446        assert!(json.contains("\"cache_write_tokens\":100"));
447    }
448
449    #[test]
450    fn server_event_agent_spawned_serialization() {
451        let event = ServerEvent::AgentSpawned {
452            agent_id: "coder".to_string(),
453            run_id: "run-456".to_string(),
454            parent_id: Some("run-123".to_string()),
455            blueprint: "coder".to_string(),
456        };
457        let json = serde_json::to_string(&event).unwrap();
458        assert!(json.contains("\"type\":\"agent_spawned\""));
459        assert!(json.contains("\"parent_id\":\"run-123\""));
460    }
461
462    #[test]
463    fn server_event_agent_completed_serialization() {
464        let event = ServerEvent::AgentCompleted {
465            agent_id: "coder".to_string(),
466            run_id: "run-123".to_string(),
467            status: "complete".to_string(),
468            result: Some("success".to_string()),
469        };
470        let json = serde_json::to_string(&event).unwrap();
471        assert!(json.contains("\"type\":\"agent_completed\""));
472    }
473
474    #[test]
475    fn server_event_context_update_serialization() {
476        let event = ServerEvent::ContextUpdate {
477            agent_id: "coder".to_string(),
478            run_id: "run-123".to_string(),
479            total_tokens: 10000,
480            max_tokens: 200000,
481        };
482        let json = serde_json::to_string(&event).unwrap();
483        assert!(json.contains("\"type\":\"context_update\""));
484        assert!(json.contains("\"total_tokens\":10000"));
485    }
486
487    #[test]
488    fn server_event_interaction_needed_serialization() {
489        let event = ServerEvent::InteractionNeeded {
490            agent_id: "coder".to_string(),
491            run_id: "run-123".to_string(),
492            request: serde_json::json!({"prompt": "approve?"}),
493        };
494        let json = serde_json::to_string(&event).unwrap();
495        assert!(json.contains("\"type\":\"interaction_needed\""));
496    }
497
498    #[test]
499    fn server_event_log_serialization() {
500        let event = ServerEvent::Log {
501            agent_id: "coder".to_string(),
502            run_id: "run-123".to_string(),
503            line: "doing work".to_string(),
504        };
505        let json = serde_json::to_string(&event).unwrap();
506        assert!(json.contains("\"type\":\"log\""));
507        assert!(json.contains("\"line\":\"doing work\""));
508    }
509
510    #[test]
511    fn validate_response_serde_roundtrip() {
512        let resp = ValidateResponse {
513            valid: true,
514            errors: None,
515        };
516        let json = serde_json::to_string(&resp).unwrap();
517        let parsed: ValidateResponse = serde_json::from_str(&json).unwrap();
518        assert!(parsed.valid);
519        assert!(parsed.errors.is_none());
520    }
521
522    #[test]
523    fn validate_response_with_errors_roundtrip() {
524        let resp = ValidateResponse {
525            valid: false,
526            errors: Some(vec!["bad field".to_string()]),
527        };
528        let json = serde_json::to_string(&resp).unwrap();
529        let parsed: ValidateResponse = serde_json::from_str(&json).unwrap();
530        assert!(!parsed.valid);
531        assert_eq!(parsed.errors.unwrap().len(), 1);
532    }
533
534    #[test]
535    fn redacted_config_serde_roundtrip() {
536        let config = RedactedConfig {
537            default_provider: "anthropic".to_string(),
538            has_anthropic_key: true,
539            has_openai_key: false,
540            has_google_key: false,
541            has_openrouter_key: false,
542            ollama_base_url: None,
543            agent_paths: vec![],
544            mcp_server_count: 0,
545        };
546        let json = serde_json::to_string(&config).unwrap();
547        let parsed: RedactedConfig = serde_json::from_str(&json).unwrap();
548        assert_eq!(parsed.default_provider, "anthropic");
549        assert!(parsed.has_anthropic_key);
550        assert!(!parsed.has_openai_key);
551    }
552
553    #[test]
554    fn error_response_serialization() {
555        let err = ErrorResponse {
556            error: "not found".to_string(),
557        };
558        let json = serde_json::to_string(&err).unwrap();
559        assert!(json.contains("\"error\":\"not found\""));
560    }
561}