Skip to main content

scc_cli/
httpd.rs

1//! Local daemon (docs/DEPLOYMENT_AND_INFRA.md ยง2): loopback HTTP API per
2//! docs/openapi.yaml + filesystem watcher with debounced incremental
3//! re-indexing.
4//!
5//! Security: binds to 127.0.0.1 by default (config.security.listen). All
6//! endpoints are repository read-only except `/v1/index` and
7//! `/v1/runtime/traces` (documented mutation class).
8
9use scc_store::Store;
10use std::io::Read;
11use std::path::{Path, PathBuf};
12use std::sync::mpsc;
13use std::time::Duration;
14
15
16/// Fail closed on non-loopback binds: SCC serves index-mutating endpoints
17/// with no authentication, so LAN exposure needs explicit opt-in
18/// (`SCC_ALLOW_REMOTE_LISTEN=1`). Loopback and `localhost` always pass.
19// trace:v1 id=impl.crates-scc-cli-src-httpd.require-remote-opt-in work=WORK-SI-MMMJA4G6 satisfies=REQ-SI-503JSBGP
20fn require_remote_opt_in(addr: &str) -> crate::Result<()> {
21    let loopback = match addr.parse::<std::net::SocketAddr>() {
22        Ok(sa) => sa.ip().is_loopback(),
23        Err(_) => {
24            let host = addr.strip_prefix('[').and_then(|s| s.split(']').next());
25            let host = host.unwrap_or_else(|| addr.split(':').next().unwrap_or(addr));
26            host == "localhost" || host == "127.0.0.1" || host == "::1"
27        }
28    };
29    if loopback {
30        return Ok(());
31    }
32    if std::env::var("SCC_ALLOW_REMOTE_LISTEN").as_deref() == Ok("1") {
33        eprintln!("warning: unauthenticated SCC daemon on non-loopback {addr} (explicit opt-in)");
34        return Ok(());
35    }
36    Err(crate::CliError::Other(format!(
37        "refusing non-loopback bind {addr}: the SCC daemon has no authentication;          bind config.security.listen to 127.0.0.1 or set SCC_ALLOW_REMOTE_LISTEN=1 to opt in explicitly"
38    )))
39}
40
41// trace:v1 id=impl.crates-scc-cli-src-httpd.serve
42pub fn serve(root: &Path) -> crate::Result<()> {
43    let config = crate::load_config(root)?;
44    let addr = config.security.listen.clone();
45
46    // watcher thread
47    let watch_root = root.to_path_buf();
48    let _watcher_handle = if config.index.watch {
49        Some(std::thread::spawn(move || {
50            let _ = watch_loop_inner(&watch_root, true);
51        }))
52    } else {
53        None
54    };
55
56    // Remote listening is unauthenticated by design (loopback-only
57    // product): a non-loopback bind refuses unless the operator opts in
58    // explicitly. There is no auth token yet โ€” see docs/SECURITY.md.
59    require_remote_opt_in(&addr)?;
60
61    let server = tiny_http::Server::http(&addr)
62        .map_err(|e| crate::CliError::Other(format!("cannot bind {addr}: {e}")))?;
63    println!("scc daemon listening on http://{addr} (root {})", root.display());
64    for request in server.incoming_requests() {
65        let root = root.to_path_buf();
66        let addr = addr.clone();
67        let _ = handle_request(root, request, &addr);
68    }
69    Ok(())
70}
71
72// trace:v1 id=impl.crates-scc-cli-src-httpd.handle-request
73fn handle_request(
74    root: PathBuf,
75    mut request: tiny_http::Request,
76    addr: &str,
77) -> crate::Result<()> {
78    let url = request.url().to_string();
79    let method = request.method().clone();
80    let mut body = String::new();
81    if method == tiny_http::Method::Post {
82        let mut buf = Vec::new();
83        request.as_reader().take(8 * 1024 * 1024).read_to_end(&mut buf)?;
84        body = String::from_utf8_lossy(&buf).to_string();
85    }
86
87    let (status, ctype, payload) = route(&root, &method.to_string(), &url, &body, addr)?;
88    let response = tiny_http::Response::from_string(payload)
89        .with_status_code(status)
90        .with_header(
91            tiny_http::Header::from_bytes(&b"Content-Type"[..], ctype.as_bytes()).unwrap(),
92        );
93    let _ = request.respond(response);
94    Ok(())
95}
96// trace:v1 id=impl.scc.http work=WORK-SCC-001 satisfies=REQ-SCC-API
97
98// trace:v1 id=impl.crates-scc-cli-src-httpd.route
99fn route(
100    root: &Path,
101    method: &str,
102    url: &str,
103    body: &str,
104    addr: &str,
105) -> crate::Result<(u16, String, String)> {
106    let path = url.split('?').next().unwrap_or(url);
107    let json_err = |code: u16, msg: String| -> crate::Result<(u16, String, String)> {
108        Ok((
109            code,
110            "application/json".to_string(),
111            serde_json::to_string(&serde_json::json!({"error": msg}))?,
112        ))
113    };
114    // Viewer HTML rides the same daemon; JSON stays on /v1/*.
115    let html_ok = |body: String| -> crate::Result<(u16, String, String)> {
116        Ok((200, "text/html; charset=utf-8".to_string(), body))
117    };
118
119    match (method, path) {
120        ("GET", "/v1/system") => {
121            let store = crate::open_store(root)?;
122            if store.snapshot_status()?.is_none() {
123                return json_err(409, "not indexed; POST /v1/index first".into());
124            }
125            let output = scc_engine::invoke(root, "context.overview", serde_json::json!({}))
126                .map_err(|e| crate::CliError::Other(e.to_string()))?;
127            Ok((200, "application/json".to_string(), serde_json::to_string(&output)?))
128        }
129        ("POST", "/v1/context/task") => {
130            let req: serde_json::Value = match serde_json::from_str(body) {
131                Ok(v) => v,
132                Err(_) => return json_err(400, "invalid JSON body".to_string()),
133            };
134            let goal = req.get("goal").and_then(|g| g.as_str()).unwrap_or("");
135            if goal.is_empty() {
136                return json_err(400, "missing required field: goal".into());
137            }
138            let store = crate::open_store(root)?;
139            if store.snapshot_status()?.is_none() {
140                return json_err(409, "not indexed".into());
141            }
142            let files = json_arr(&req, "files");
143            let symbols = json_arr(&req, "symbols");
144            let budget = req.get("token_budget").and_then(|b| b.as_u64()).map(|b| b as usize);
145            // Transport parity: THE one complete task artifact via the
146            // operation registry โ€” same derivation as CLI/MCP. The friendly
147            // route is parsing + dispatch only; the engine owns the build.
148            let output = scc_engine::invoke(root, "context.task", serde_json::json!({
149                "goal": goal, "files": files, "symbols": symbols,
150                "budget": budget, "hook": false,
151            }))
152            .map_err(|e| crate::CliError::Other(e.to_string()))?;
153            Ok((200, "application/json".to_string(), serde_json::to_string(&output)?))
154        }
155        ("POST", "/v1/context/startup") => {
156            let input: serde_json::Value = serde_json::from_str(body).unwrap_or(serde_json::json!({}));
157            let budget = input.get("token_budget").and_then(|b| b.as_u64()).map(|b| b as usize);
158            let store = crate::open_store(root)?;
159            if store.snapshot_status()?.is_none() {
160                return json_err(409, "not indexed; POST /v1/index first".into());
161            }
162            // Registry derivation: context.startup builds, allocates, and
163            // records the ledger inside the engine โ€” same as CLI/MCP.
164            let output = scc_engine::invoke(root, "context.startup", serde_json::json!({"budget": budget}))
165                .map_err(|e| crate::CliError::Other(e.to_string()))?;
166            Ok((200, "application/json".to_string(), serde_json::to_string(&output)?))
167        }
168        ("GET", "/v1/atlas") => {
169            let store = crate::open_store(root)?;
170            if store.snapshot_status()?.is_none() {
171                return json_err(409, "not indexed".into());
172            }
173            let output = scc_engine::invoke(root, "context.atlas", serde_json::json!({}))
174                .map_err(|e| crate::CliError::Other(e.to_string()))?;
175            Ok((200, "application/json".to_string(), serde_json::to_string(&output)?))
176        }
177        ("GET", p) if p.starts_with("/v1/components/") => {
178            let id = p.trim_start_matches("/v1/components/");
179            let store = crate::open_store(root)?;
180            if store.snapshot_status()?.is_none() {
181                return json_err(409, "not indexed".into());
182            }
183            let output = scc_engine::invoke(root, "context.component", serde_json::json!({"id": id}))
184                .map_err(|e| crate::CliError::Other(e.to_string()))?;
185            Ok((200, "application/json".to_string(), serde_json::to_string(&output)?))
186        }
187        ("GET", p) if p.starts_with("/v1/flows/") => {
188            let id = p.trim_start_matches("/v1/flows/");
189            let store = crate::open_store(root)?;
190            if store.snapshot_status()?.is_none() {
191                return json_err(409, "not indexed".into());
192            }
193            let output = scc_engine::invoke(root, "context.flow", serde_json::json!({"id": id}))
194                .map_err(|e| crate::CliError::Other(e.to_string()))?;
195            Ok((200, "application/json".to_string(), serde_json::to_string(&output)?))
196        }
197        ("POST", "/v1/impact") => {
198            let req: serde_json::Value = match serde_json::from_str(body) {
199                Ok(v) => v,
200                Err(_) => return json_err(400, "invalid JSON body".to_string()),
201            };
202            let store = crate::open_store(root)?;
203            if store.snapshot_status()?.is_none() {
204                return json_err(409, "not indexed".into());
205            }
206            let output = scc_engine::invoke(root, "context.impact", serde_json::json!({
207                "files": json_arr(&req, "files"), "symbols": json_arr(&req, "symbols"),
208                "diff": req.get("diff").and_then(|d| d.as_str()),
209            }))
210            .map_err(|e| crate::CliError::Other(e.to_string()))?;
211            Ok((200, "application/json".to_string(), serde_json::to_string(&output)?))
212        }
213        ("POST", "/v1/verify") => {
214            let store = crate::open_store(root)?;
215            if store.snapshot_status()?.is_none() {
216                return json_err(409, "not indexed".into());
217            }
218            let output = scc_engine::invoke(root, "context.verify", serde_json::json!({}))
219                .map_err(|e| crate::CliError::Other(e.to_string()))?;
220            Ok((200, "application/json".to_string(), serde_json::to_string(&output)?))
221        }
222        ("POST", "/v1/index") => {
223            scc_engine::invoke(root, "index.full", serde_json::json!({})).map_err(|e| crate::CliError::Other(e.to_string()))?;
224            let store = crate::open_store(root)?;
225            let status = store.snapshot_status()?;
226            Ok((
227                202,
228                "application/json".to_string(),
229                serde_json::to_string(&serde_json::json!({
230                    "status": "ok",
231                    "revision": status.map(|(s, _)| s.revision).unwrap_or_default(),
232                }))?,
233            ))
234        }
235        ("GET", "/v1/index/status") => {
236            let store = crate::open_store(root)?;
237            match store.snapshot_status()? {
238                Some((snap, files)) => Ok((
239                    200,
240                    "application/json".to_string(),
241                    serde_json::to_string(&serde_json::json!({
242                        "indexed": true,
243                        "revision": snap.revision,
244                        "branch": snap.branch,
245                        "indexed_at": snap.indexed_at,
246                        "files": files,
247                    }))?,
248                )),
249                None => Ok((
250                    200,
251                    "application/json".to_string(),
252                    serde_json::to_string(&serde_json::json!({"indexed": false}))?,
253                )),
254            }
255        }
256        ("POST", "/v1/runtime/traces") => {
257            let input: serde_json::Value = serde_json::from_str(body).unwrap_or(serde_json::json!({}));
258            // Body shapes: OTLP/resourceSpans string OR {body} envelope.
259            let payload = input.get("body").and_then(|b| b.as_str()).unwrap_or(body);
260            scc_engine::invoke(root, "runtime.ingest", serde_json::json!({"body": payload})).map_err(|e| crate::CliError::Other(e.to_string()))?;
261            Ok((202, "application/json".to_string(), serde_json::to_string(&serde_json::json!({"status": "accepted"}))?))
262        }
263        ("GET", "/v1/operations") => {
264            let ids: Vec<serde_json::Value> = scc_engine::ops::OPERATIONS
265                .iter()
266                .map(|d| serde_json::json!({
267                    "id": d.id,
268                    "description": d.description,
269                    "mutation": format!("{:?}", d.mutation),
270                    "streaming": d.streaming,
271                    "stability": format!("{:?}", d.stability),
272                }))
273                .collect();
274            Ok((200, "application/json".to_string(), serde_json::to_string(&serde_json::json!({
275                "api_version": scc_api::API_VERSION,
276                "scc_version": env!("CARGO_PKG_VERSION"),
277                "operations": ids,
278            }))?))
279        }
280        ("GET", p) if p.starts_with("/v1/operations/") => {
281            let id = p.trim_start_matches("/v1/operations/");
282            match scc_engine::ops::describe(id) {
283                None => json_err(404, format!("unknown operation '{id}' (see GET /v1/operations)")),
284                Some(d) => {
285                    let schema = scc_engine::ops::input_schema(id);
286                    Ok((200, "application/json".to_string(), serde_json::to_string(&serde_json::json!({
287                        "api_version": scc_api::API_VERSION,
288                        "scc_version": env!("CARGO_PKG_VERSION"),
289                        "operation": serde_json::to_value(d)?,
290                        "input_schema": schema,
291                    }))?))
292                }
293            }
294        }
295        ("POST", p) if p.starts_with("/v1/operations/") => {
296            let id = p.trim_start_matches("/v1/operations/");
297            if scc_engine::ops::describe(id).is_none() {
298                return json_err(404, format!("unknown operation '{id}' (see GET /v1/operations)"));
299            }
300            let input: serde_json::Value = if body.trim().is_empty() {
301                serde_json::json!({})
302            } else {
303                match serde_json::from_str(body) {
304                    Ok(v) => v,
305                    Err(_) => return json_err(400, "invalid JSON body".to_string()),
306                }
307            };
308            match scc_engine::invoke(root, id, input) {
309                Ok(output) => Ok((200, "application/json".to_string(), serde_json::to_string(&serde_json::json!({
310                    "operation": id,
311                    "api_version": scc_api::API_VERSION,
312                    "scc_version": env!("CARGO_PKG_VERSION"),
313                    "output": output,
314                }))?)),
315                Err(e) => json_err(500, e.to_string()),
316            }
317        }
318        ("GET", "/healthz") => Ok((200, "text/plain".to_string(), "ok".into())),
319        ("GET", "/") | ("GET", "/components") | ("GET", "/flows") | ("GET", "/diagram")
320        | ("GET", "/search") => {
321            let store = crate::open_store(root)?;
322            if store.snapshot_status()?.is_none() {
323                return json_err(409, "not indexed".into());
324            }
325            let (vstatus, body) = crate::viewer::serve_viewer(&store, url);
326            if vstatus == 200 {
327                return html_ok(body);
328            }
329            Ok((vstatus, "text/html; charset=utf-8".to_string(), body))
330        }
331        ("GET", p) if crate::viewer::is_viewer_path(p) => {
332            let store = crate::open_store(root)?;
333            if store.snapshot_status()?.is_none() {
334                return json_err(409, "not indexed".into());
335            }
336            let (vstatus, body) = crate::viewer::serve_viewer(&store, url);
337            Ok((vstatus, "text/html; charset=utf-8".to_string(), body))
338        }
339        _ => {
340            let _ = addr;
341            json_err(404, format!("no route for {method} {path}"))
342        }
343    }
344}
345
346// trace:v1 id=impl.crates-scc-cli-src-httpd.json-arr
347fn json_arr(v: &serde_json::Value, key: &str) -> Vec<String> {
348    v.get(key)
349        .and_then(|x| x.as_array())
350        .map(|a| {
351            a.iter()
352                .filter_map(|s| s.as_str().map(|x| x.to_string()))
353                .collect()
354        })
355        .unwrap_or_default()
356}
357
358/// Runtime observation ingest: OTLP/JSON traces (`resourceSpans`) or the
359/// simple `[{source, target, count}]` shape. Aggregates into runtime_edges
360/// (OBSERVED provenance).
361// trace:v1 id=impl.crates-scc-cli-src-httpd.ingest-runtime
362pub fn ingest_runtime(store: &Store, body: &str) -> crate::Result<()> {
363    if body.contains("resourceSpans") {
364        scc_indexer::runtime::ingest_otlp_json(store, body)
365            .map_err(|e| crate::CliError::Other(e.to_string()))?;
366        return Ok(());
367    }
368    scc_indexer::runtime::ingest_simple_edges(store, body)
369        .map_err(|e| crate::CliError::Other(e.to_string()))?;
370    Ok(())
371}
372
373
374// ---------------------------------------------------------------------------
375// file watcher
376// ---------------------------------------------------------------------------
377
378/// `scc watch`: foreground watcher loop.
379// trace:v1 id=impl.crates-scc-cli-src-httpd.watch-loop
380pub fn watch_loop(root: &Path) -> crate::Result<()> {
381    watch_loop_inner(root, false)
382}
383
384/// Refresh files whose content hash no longer matches the snapshot.
385/// Hash remains authority; used when the OS watcher cannot start.
386// trace:v1 id=impl.scc.cli.hash-sweep work=WORK-phase-7-of-scc-x-ripwire-lessons-1-one-hop-type-narrowing-from-unique satisfies=REQ-implement-phase-7-of-scc-x-ripwire-lessons-1-one-hop-type-narrowing,REQ-implement-fix-pr-review-comments-without-collapsing-scc-type-script-no
387pub fn refresh_stale_by_hash(root: &Path) -> crate::Result<Vec<String>> {
388    let store = crate::open_store(root)?;
389    // Single notion of staleness: modified, deleted, AND added files
390    // (scan-diff lives in `stale_paths`, shared with verify/status).
391    let mut paths = crate::stale_paths(&store)?;
392    drop(store);
393    paths.sort();
394    paths.dedup();
395    if !paths.is_empty() {
396        crate::commands::cmd_index_paths(root, &paths, true)?;
397    }
398    Ok(paths)
399}
400
401// trace:v1 id=impl.crates-scc-cli-src-httpd.watch-loop-inner
402fn watch_loop_inner(root: &Path, quiet: bool) -> crate::Result<()> {
403    let (tx, rx) = mpsc::channel::<notify::Event>();
404    let mut watcher = match notify::recommended_watcher(move |res: notify::Result<notify::Event>| {
405        if let Ok(ev) = res {
406            let _ = tx.send(ev);
407        }
408    }) {
409        Ok(w) => w,
410        Err(e) => {
411            if !quiet {
412                eprintln!("watcher unavailable ({e}); falling back to content-hash sweep");
413            }
414            return hash_sweep_loop(root, quiet);
415        }
416    };
417    if let Err(e) = notify::Watcher::watch(&mut watcher, root, notify::RecursiveMode::Recursive) {
418        if !quiet {
419            eprintln!("watch {root:?} failed ({e}); falling back to content-hash sweep");
420        }
421        drop(watcher);
422        return hash_sweep_loop(root, quiet);
423    }
424
425    if !quiet {
426        println!("watching {} (ctrl-c to stop)", root.display());
427    }
428    let mut pending: std::collections::BTreeSet<String> = Default::default();
429    let mut last: std::time::Instant = std::time::Instant::now();
430    loop {
431        match rx.recv_timeout(Duration::from_millis(250)) {
432            Ok(ev) => {
433                for p in ev.paths {
434                    if let Some(rel) = crate::relative_of(root, &p) {
435                        pending.insert(rel);
436                    }
437                }
438                last = std::time::Instant::now();
439            }
440            Err(mpsc::RecvTimeoutError::Timeout) => {
441                if pending.is_empty() {
442                    continue;
443                }
444                if last.elapsed() < Duration::from_millis(400) {
445                    continue; // debounce
446                }
447                let paths: Vec<String> = std::mem::take(&mut pending).into_iter().collect();
448                let res = crate::commands::cmd_index_paths(root, &paths, true);
449                match res {
450                    Ok(()) => {}
451                    Err(e) => eprintln!("reindex error: {e}"),
452                }
453            }
454            Err(mpsc::RecvTimeoutError::Disconnected) => break,
455        }
456    }
457    Ok(())
458}
459
460// trace:exempt reason=internal-detail
461fn hash_sweep_loop(root: &Path, quiet: bool) -> crate::Result<()> {
462    if !quiet {
463        println!("hash-sweep watching {} (ctrl-c to stop)", root.display());
464    }
465    loop {
466        if let Err(e) = refresh_stale_by_hash(root) {
467            eprintln!("hash sweep error: {e}");
468        }
469        std::thread::sleep(Duration::from_secs(2));
470    }
471}
472
473#[cfg(test)]
474mod tests {
475    use super::*;
476    use crate::benchctx::{copy_fixture, locate_fixtures_dir};
477
478    #[test]
479    // trace:v1 id=test.scc.cli.remote-listen-gate verifies=REQ-SI-503JSBGP exercises=impl.crates-scc-cli-src-httpd.require-remote-opt-in
480    fn remote_listen_fails_closed_without_opt_in() {
481        assert!(require_remote_opt_in("127.0.0.1:7777").is_ok());
482        assert!(require_remote_opt_in("localhost:7777").is_ok());
483        assert!(require_remote_opt_in("[::1]:7777").is_ok());
484        // unauthenticated daemon: non-loopback refuses without opt-in
485        assert!(require_remote_opt_in("0.0.0.0:7777").is_err());
486        std::env::remove_var("SCC_ALLOW_REMOTE_LISTEN");
487        assert!(require_remote_opt_in("192.168.1.10:7777").is_err());
488    }
489
490    #[test]
491    // trace:v1 id=test.scc.cli.hash-sweep verifies=REQ-implement-phase-7-of-scc-x-ripwire-lessons-1-one-hop-type-narrowing,REQ-implement-fix-pr-review-comments-without-collapsing-scc-type-script-no exercises=impl.scc.cli.hash-sweep
492    fn hash_sweep_refreshes_edited_file() {
493        let fixtures = locate_fixtures_dir().expect("fixtures");
494        let src = fixtures.join("behavior-native");
495        let tmp = tempfile::TempDir::new().unwrap();
496        let root = tmp.path().join("repo");
497        copy_fixture(&src, &root);
498        crate::commands::cmd_index(&root, true).unwrap();
499        let store = crate::open_store(&root).unwrap();
500        assert!(crate::stale_paths(&store).unwrap().is_empty());
501        drop(store);
502        let app = root.join("app.py");
503        let mut text = std::fs::read_to_string(&app).unwrap();
504        text.push_str("\n# hash-sweep probe\n");
505        std::fs::write(&app, text).unwrap();
506        let stale = refresh_stale_by_hash(&root).unwrap();
507        assert!(
508            stale.iter().any(|p| p == "app.py" || p.ends_with("/app.py")),
509            "edited file must be in the hash sweep: {stale:?}"
510        );
511        let store = crate::open_store(&root).unwrap();
512        assert!(
513            crate::stale_paths(&store).unwrap().is_empty(),
514            "after sweep the snapshot must match disk"
515        );
516    }
517
518    #[test]
519    // trace:v1 id=test.scc.cli.hash-sweep-new-file verifies=REQ-implement-phase-7-of-scc-x-ripwire-lessons-1-one-hop-type-narrowing,REQ-implement-fix-pr-review-comments-without-collapsing-scc-type-script-no exercises=impl.scc.cli.hash-sweep
520    fn hash_sweep_indexes_newly_created_file() {
521        let fixtures = locate_fixtures_dir().expect("fixtures");
522        let src = fixtures.join("behavior-native");
523        let tmp = tempfile::TempDir::new().unwrap();
524        let root = tmp.path().join("repo");
525        copy_fixture(&src, &root);
526        crate::commands::cmd_index(&root, true).unwrap();
527        std::fs::write(root.join("fresh.py"), "def fresh():\n    return 1\n").unwrap();
528        let stale = refresh_stale_by_hash(&root).unwrap();
529        assert!(
530            stale.iter().any(|p| p == "fresh.py" || p.ends_with("/fresh.py")),
531            "new file must be in the hash sweep: {stale:?}"
532        );
533        let store = crate::open_store(&root).unwrap();
534        let files: Vec<_> = store
535            .all_files()
536            .unwrap()
537            .into_iter()
538            .map(|(p, _, _, _, _)| p)
539            .collect();
540        assert!(
541            files.iter().any(|p| p == "fresh.py" || p.ends_with("/fresh.py")),
542            "new file must be indexed: {files:?}"
543        );
544    }
545}