use anyhow::Result;
use cv_core::Harness;
use serde_json::{json, Value};
use std::io::Read;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use tiny_http::{Header, Method, Request, Response, Server};
use super::serve_tasks;
pub(crate) struct ServeOpts {
pub host: String,
pub port: u16,
pub web: Option<PathBuf>,
pub token: Option<String>,
pub insecure_expose: bool,
pub who: Option<String>,
pub open: bool,
}
struct Ctx {
bind_host: String,
web_root: Option<PathBuf>,
token: Option<String>,
default_who: Option<String>,
}
pub(crate) fn cmd_serve(opts: ServeOpts) -> Result<()> {
if std::env::var_os("CVD_TOKEN").is_some() && std::env::var_os("CV_SERVE_TOKEN").is_none() {
return crate::util::usage(
"$CVD_TOKEN was renamed $CV_SERVE_TOKEN in 0.15.0 with `cvd serve` → `cv serve` \
(or pass --token)",
);
}
let token = opts
.token
.or_else(|| std::env::var("CV_SERVE_TOKEN").ok())
.filter(|t| !t.is_empty());
let host = opts.host.as_str();
if !is_loopback_host(host) {
if token.is_none() && !opts.insecure_expose {
anyhow::bail!(
"refusing to bind non-loopback host {host:?} without auth: the API serves your full \
transcript corpus (which can contain secrets) to anyone who can reach the port.\n\
Set a token (--token or $CV_SERVE_TOKEN), or pass --insecure-expose to serve it open."
);
}
eprintln!("cv serve: WARNING: binding non-loopback host {host:?} — the API is reachable from the network.");
if token.is_none() {
eprintln!(
"cv serve: WARNING: --insecure-expose without a token: anyone who can reach {host}:{} can read \
every transcript and act in the inbox as web:<who>.",
opts.port
);
}
}
run(&opts.host, opts.port, opts.web, token, opts.who, opts.open)
}
fn run(
host: &str,
port: u16,
web_root: Option<PathBuf>,
token: Option<String>,
who: Option<String>,
open: bool,
) -> Result<()> {
let server = Server::http((host, port)).map_err(|e| anyhow::anyhow!("failed to bind {host}:{port}: {e}"))?;
let port = server.server_addr().to_ip().map_or(port, |a| a.port());
let web_root = web_root.and_then(|d| match d.canonicalize() {
Ok(c) => Some(c),
Err(e) => {
eprintln!("cv serve: --web {} is unreadable ({e}); serving API only", d.display());
None
}
});
match &web_root {
Some(dir) => eprintln!(
"cv serve on http://{host}:{port} — dashboard at /, inbox at /inbox, API at /api/* (web root {})",
dir.display()
),
None => eprintln!("cv serve on http://{host}:{port} — inbox at /, API at /api/* (no --web)"),
}
let display_host = if is_loopback_host(host) || host == "0.0.0.0" || host == "::" {
"127.0.0.1"
} else {
host
};
let inbox = match &who {
Some(w) => format!("http://{display_host}:{port}/inbox?who={}", urlencoding::encode(w)),
None => format!("http://{display_host}:{port}/inbox"),
};
eprintln!("inbox: {inbox}");
if open {
let opener = if cfg!(target_os = "macos") { "open" } else { "xdg-open" };
if let Err(e) = std::process::Command::new(opener).arg(&inbox).spawn() {
eprintln!("could not open a browser ({e}); open {inbox} yourself");
}
}
let server = Arc::new(server);
let ctx = Arc::new(Ctx {
bind_host: host.to_string(),
web_root,
token,
default_who: who,
});
let mut workers = Vec::new();
for _ in 0..4 {
let server = Arc::clone(&server);
let ctx = Arc::clone(&ctx);
workers.push(std::thread::spawn(move || {
for request in server.incoming_requests() {
let outcome = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| handle(request, &ctx)));
if outcome.is_err() {
eprintln!("cv serve: request handler panicked; worker continuing");
}
}
}));
}
for w in workers {
let _ = w.join();
}
Ok(())
}
fn handle(mut request: Request, ctx: &Ctx) {
if !host_allowed(header_value(&request, "Host").as_deref(), &ctx.bind_host) {
let _ = request.respond(json_response(403, &json!({"error": "forbidden host"}), None, &[]));
return;
}
let raw_origin = header_value(&request, "Origin");
let origin = allowed_origin(raw_origin.as_deref(), &ctx.bind_host);
let method = request.method().clone();
let raw_url = request.url().to_string();
if method == Method::Options {
let _ = request.respond(no_content(origin.as_deref()));
return;
}
let (path, query) = match raw_url.split_once('?') {
Some((p, q)) => (p, q),
None => (raw_url.as_str(), ""),
};
let segments: Vec<String> = path
.trim_end_matches('/')
.split('/')
.filter(|s| !s.is_empty())
.map(cv_core::api::decode)
.collect();
let parts: Vec<&str> = segments.iter().map(String::as_str).collect();
let is_api = parts.first() == Some(&"api");
let post = method == Method::Post;
if !(method == Method::Get || (post && matches!(parts.as_slice(), ["api", "task", _, _]))) {
let _ = request.respond(json_response(
405,
&json!({"error": "method not allowed"}),
origin.as_deref(),
&[],
));
return;
}
if !is_api {
let page = matches!(parts.as_slice(), ["inbox"] | ["inbox.html"])
|| (ctx.web_root.is_none() && matches!(parts.as_slice(), [] | ["index.html"]));
if page {
let _ = request.respond(text_response(
200,
"text/html; charset=utf-8",
serve_tasks::PAGE.as_bytes().to_vec(),
));
return;
}
if let Some(root) = &ctx.web_root {
let _ = request.respond(static_file(root, &segments));
return;
}
}
if let Some(token) = &ctx.token {
let authed = header_value(&request, "Authorization")
.and_then(|v| v.strip_prefix("Bearer ").map(str::to_string))
.is_some_and(|t| t == *token);
if !authed {
let _ = request.respond(json_response(
401,
&json!({"error": "missing or invalid bearer token"}),
origin.as_deref(),
&[],
));
return;
}
}
let mut body = Value::Null;
if post {
let json_ct = header_value(&request, "Content-Type")
.is_some_and(|ct| ct.to_ascii_lowercase().starts_with("application/json"));
if !json_ct || (raw_origin.is_some() && origin.is_none()) {
let _ = request.respond(json_response(
403,
&json!({"error": "a write must be application/json from a local origin"}),
origin.as_deref(),
&[],
));
return;
}
let mut buf = String::new();
if request.as_reader().take(1 << 20).read_to_string(&mut buf).is_err() {
let _ = request.respond(json_response(
400,
&json!({"error": "unreadable body"}),
origin.as_deref(),
&[],
));
return;
}
if !buf.trim().is_empty() {
match serde_json::from_str::<Value>(&buf) {
Ok(v) => body = v,
Err(e) => {
let _ = request.respond(json_response(
400,
&json!({"error": format!("body is not JSON: {e}")}),
origin.as_deref(),
&[],
));
return;
}
}
}
}
let q = cv_core::api::Query::parse(query);
match serve_tasks::route(post, &parts, &q, &body, ctx.default_who.as_deref()) {
Some(serve_tasks::Answer::Json(status, v)) => {
let _ = request.respond(json_response(
status,
&v,
origin.as_deref(),
&[("Cache-Control", "no-store".into())],
));
}
Some(serve_tasks::Answer::Ndjson(lines)) => {
let mut resp = text_response(200, "application/x-ndjson; charset=utf-8", lines.into_bytes());
for h in cors_headers(origin.as_deref()) {
resp.add_header(h);
}
resp.add_header(header("Cache-Control", "no-store"));
let _ = request.respond(resp);
}
None if post => {
let _ = request.respond(json_response(
404,
&json!({"error": "no such route"}),
origin.as_deref(),
&[],
));
}
None => {
let (status, body, extra) = route(&parts, query);
let _ = request.respond(json_response(status, &body, origin.as_deref(), &extra));
}
}
}
fn header_value(request: &Request, name: &'static str) -> Option<String> {
request
.headers()
.iter()
.find(|h| h.field.equiv(name))
.map(|h| h.value.as_str().to_string())
}
fn host_name(authority: &str) -> &str {
match authority.strip_prefix('[') {
Some(rest) => rest.split(']').next().unwrap_or(rest),
None => authority.rsplit_once(':').map(|(name, _)| name).unwrap_or(authority),
}
}
fn is_local_name(name: &str, bind_host: &str) -> bool {
name == "127.0.0.1" || name == "::1" || name.eq_ignore_ascii_case("localhost") || name == bind_host
}
pub(crate) fn is_loopback_host(host: &str) -> bool {
host.eq_ignore_ascii_case("localhost")
|| host
.trim_matches(['[', ']'])
.parse::<std::net::IpAddr>()
.map(|ip| ip.is_loopback())
.unwrap_or(false)
}
fn host_allowed(host_header: Option<&str>, bind_host: &str) -> bool {
if !is_loopback_host(bind_host) {
return true;
}
host_header.is_some_and(|h| is_local_name(host_name(h), bind_host))
}
fn allowed_origin(origin: Option<&str>, bind_host: &str) -> Option<String> {
let o = origin?;
if o == "tauri://localhost" || o == "http://tauri.localhost" {
return Some(o.to_string());
}
let authority = o.strip_prefix("http://").or_else(|| o.strip_prefix("https://"))?;
is_local_name(host_name(authority), bind_host).then(|| o.to_string())
}
type Reply = (u16, Value, Vec<(&'static str, String)>);
fn route(parts: &[&str], query: &str) -> Reply {
if let ["api", "search"] = parts {
return search(query);
}
if let ["api", "stats"] = parts {
let (status, body) = stats(query);
return (status, body, Vec::new());
}
let (status, body) = cv_core::api::route_json(parts, query);
(status, body, Vec::new())
}
const SEARCH_LIMIT: usize = 20;
fn stats(query: &str) -> (u16, Value) {
let params = Query::parse(query);
let q = match params.get("q") {
Some(s) => match cv_core::SessionQuery::parse(&s) {
Ok(q) => Some(q),
Err(e) => return err(400, &e.replace("`cv query`", "`cv schema`")),
},
None => None,
};
let mut refs = cv_core::sessions();
if let Some(q) = &q {
cv_core::query::filter_refs(&mut refs, q, &crate::cmd::query::text_sets(q));
}
ok(cv_core::stats::CorpusStats::compute(&refs).to_json())
}
fn search(query: &str) -> Reply {
let params = Query::parse(query);
let Some(q) = params.get("q") else {
let (s, v) = err(400, "missing q parameter");
return (s, v, Vec::new());
};
let want = match params.get("harness") {
Some(h) => match Harness::parse(&h) {
Some(h) => Some(h),
None => {
let (s, v) = err(400, &format!("unknown harness {h:?}"));
return (s, v, Vec::new());
}
},
None => None,
};
let limit = params
.get("limit")
.and_then(|l| l.parse::<usize>().ok())
.unwrap_or(SEARCH_LIMIT);
let cwd = params.get("cwd");
let semantic = params
.get("semantic")
.is_some_and(|v| v == "1" || v.eq_ignore_ascii_case("true"));
let fetch = limit.saturating_mul(4);
let (hits, source, mut note) = if semantic {
match cv_search::semantic_search(None, &q, fetch) {
Ok(hits) => (hits, "semantic", None),
Err(e) => {
let (s, v) = err(
500,
&format!("semantic search failed (run `cv index --semantic` first?): {e:#}"),
);
return (s, v, Vec::new());
}
}
} else if cv_search::default_tantivy_dir().exists() {
match cv_search::text_search(None, &q, fetch) {
Ok(hits) => (hits, "index", None),
Err(e) => (
Vec::new(),
"live",
Some(format!("tantivy index unavailable ({e:#}); scanned live")),
),
}
} else {
(
Vec::new(),
"live",
Some("no index yet — scanned live; run `cv index` for instant, full-transcript search".into()),
)
};
let mut rows: Vec<Value> = if source == "live" {
match cv_core::scan::live_search(&q, want, limit) {
Ok(found) => {
if found.truncated {
note = Some(format!(
"{} (stopped at {limit} hits; raise limit for more)",
note.unwrap_or_default()
));
}
found
.hits
.iter()
.map(|h| cv_core::rows::live_search_row(&h.session, &h.title, &h.snippet))
.collect()
}
Err(e) => {
let (s, v) = err(500, &format!("live scan failed: {e:#}"));
return (s, v, Vec::new());
}
}
} else {
hits.iter()
.filter(|h| want.is_none_or(|w| h.harness == w.as_str()))
.map(|h| {
cv_core::rows::SearchRow {
id: &h.id,
harness: &h.harness,
cwd: h.cwd.as_deref(),
title: h.title.as_deref(),
created_at: h.created_at,
updated_at: h.updated_at,
score: Some(h.score),
snippet: &h.snippet,
agent_id: h.agent_id.as_deref(),
parent_id: h.parent_id.as_deref(),
workflow: h.workflow.as_deref(),
}
.to_json()
})
.collect()
};
if let Some(needle) = &cwd {
rows.retain(|r| r["cwd"].as_str().is_some_and(|c| c.contains(needle.as_str())));
}
rows.truncate(limit);
if source == "index" && rows.is_empty() {
note = index_miss_note();
}
let mut headers = vec![("X-Cv-Search-Source", source.to_string())];
if let Some(n) = note {
headers.push(("X-Cv-Search-Note", header_safe(&n)));
}
(200, json!(rows), headers)
}
fn index_miss_note() -> Option<String> {
let mut parts: Vec<String> = Vec::new();
if let Some(days) = index_days_behind() {
parts.push(format!(
"the index is ~{days} day(s) behind the newest session — run `cv index`"
));
}
if !cv_search::fts::has_subagent_docs(&cv_search::default_tantivy_dir()) {
parts.push(
"sub-agent transcripts aren't searched yet — rebuild with `cv index --subagents` to \
include the Agent/Workflow lanes"
.into(),
);
}
(!parts.is_empty()).then(|| parts.join("; "))
}
fn index_days_behind() -> Option<u64> {
const DAY_NS: i64 = 86_400 * 1_000_000_000;
let indexed = cv_search::fts::newest_indexed_mtime(&cv_search::default_tantivy_dir())?;
let newest_on_disk = cv_core::discover_all()
.iter()
.map(|r| cv_core::offsets::file_sig(&r.path).0)
.max()?;
let days = newest_on_disk.saturating_sub(indexed) / DAY_NS;
(days >= 1).then_some(days as u64)
}
fn header_safe(s: &str) -> String {
s.chars()
.map(|c| if (' '..='~').contains(&c) { c } else { ' ' })
.collect::<String>()
.trim()
.to_string()
}
use cv_core::api::Query;
fn ok(value: Value) -> (u16, Value) {
(200, value)
}
fn static_file(root: &Path, segments: &[String]) -> Response<std::io::Cursor<Vec<u8>>> {
let index = root.join("index.html");
let resolved = if segments.is_empty() {
index.clone()
} else {
if segments.iter().any(|s| s == ".." || s.contains('\0')) {
return text_response(403, "text/plain; charset=utf-8", b"forbidden".to_vec());
}
let mut p = root.to_path_buf();
for s in segments {
p.push(s);
}
p
};
let is_asset = resolved
.extension()
.and_then(|e| e.to_str())
.is_some_and(|e| !e.eq_ignore_ascii_case("html"));
let serve_path = match resolved.canonicalize() {
Ok(c) if c.starts_with(root) && c.is_file() => c,
_ if is_asset => return text_response(404, "text/plain; charset=utf-8", b"not found".to_vec()),
_ => match index.canonicalize() {
Ok(c) if c.starts_with(root) && c.is_file() => c,
_ => return text_response(404, "text/plain; charset=utf-8", b"not found".to_vec()),
},
};
match std::fs::read(&serve_path) {
Ok(bytes) => text_response(200, content_type_for(&serve_path), bytes),
Err(_) => text_response(404, "text/plain; charset=utf-8", b"not found".to_vec()),
}
}
fn content_type_for(path: &Path) -> &'static str {
match path
.extension()
.and_then(|e| e.to_str())
.map(str::to_ascii_lowercase)
.as_deref()
{
Some("html") => "text/html; charset=utf-8",
Some("js") | Some("mjs") => "text/javascript; charset=utf-8",
Some("css") => "text/css; charset=utf-8",
Some("json") => "application/json; charset=utf-8",
Some("wasm") => "application/wasm",
Some("svg") => "image/svg+xml",
Some("png") => "image/png",
Some("jpg") | Some("jpeg") => "image/jpeg",
Some("gif") => "image/gif",
Some("ico") => "image/x-icon",
Some("woff2") => "font/woff2",
Some("woff") => "font/woff",
Some("map") => "application/json; charset=utf-8",
_ => "application/octet-stream",
}
}
fn text_response(status: u16, content_type: &str, body: Vec<u8>) -> Response<std::io::Cursor<Vec<u8>>> {
let mut resp = Response::from_data(body).with_status_code(status);
resp.add_header(header("Content-Type", content_type));
resp
}
fn err(status: u16, msg: &str) -> (u16, Value) {
(status, json!({ "error": msg }))
}
fn json_response(
status: u16,
body: &Value,
origin: Option<&str>,
extra: &[(&'static str, String)],
) -> Response<std::io::Cursor<Vec<u8>>> {
let data = serde_json::to_vec(body).unwrap_or_else(|_| b"{\"error\":\"serialize failed\"}".to_vec());
let mut resp = Response::from_data(data).with_status_code(status);
for h in cors_headers(origin) {
resp.add_header(h);
}
for (field, value) in extra {
resp.add_header(header(field, value));
}
resp.add_header(header("Content-Type", "application/json"));
resp
}
fn no_content(origin: Option<&str>) -> Response<std::io::Empty> {
let mut resp = Response::empty(204);
for h in cors_headers(origin) {
resp.add_header(h);
}
resp
}
const EXPOSED_HEADERS: &str = "X-Cv-Search-Source, X-Cv-Search-Note";
fn cors_headers(origin: Option<&str>) -> Vec<Header> {
let mut headers = vec![header("Vary", "Origin")];
if let Some(o) = origin {
headers.push(header("Access-Control-Allow-Origin", o));
headers.push(header("Access-Control-Allow-Methods", "GET, POST, OPTIONS"));
headers.push(header("Access-Control-Allow-Headers", "Authorization, Content-Type"));
headers.push(header("Access-Control-Expose-Headers", EXPOSED_HEADERS));
}
headers
}
fn header(field: &str, value: &str) -> Header {
Header::from_bytes(field.as_bytes(), value.as_bytes())
.unwrap_or_else(|_| Header::from_bytes(&b"X-Invalid"[..], &b"1"[..]).unwrap())
}