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                }))
272                .collect();
273            Ok((200, "application/json".to_string(), serde_json::to_string(&serde_json::json!({
274                "api_version": scc_api::API_VERSION,
275                "scc_version": env!("CARGO_PKG_VERSION"),
276                "operations": ids,
277            }))?))
278        }
279        ("POST", p) if p.starts_with("/v1/operations/") => {
280            let id = p.trim_start_matches("/v1/operations/");
281            if scc_engine::ops::describe(id).is_none() {
282                return json_err(404, format!("unknown operation '{id}' (see GET /v1/operations)"));
283            }
284            let input: serde_json::Value = if body.trim().is_empty() {
285                serde_json::json!({})
286            } else {
287                match serde_json::from_str(body) {
288                    Ok(v) => v,
289                    Err(_) => return json_err(400, "invalid JSON body".to_string()),
290                }
291            };
292            match scc_engine::invoke(root, id, input) {
293                Ok(output) => Ok((200, "application/json".to_string(), serde_json::to_string(&serde_json::json!({
294                    "operation": id,
295                    "api_version": scc_api::API_VERSION,
296                    "scc_version": env!("CARGO_PKG_VERSION"),
297                    "output": output,
298                }))?)),
299                Err(e) => json_err(500, e.to_string()),
300            }
301        }
302        ("GET", "/healthz") => Ok((200, "text/plain".to_string(), "ok".into())),
303        ("GET", "/") | ("GET", "/components") | ("GET", "/flows") | ("GET", "/diagram")
304        | ("GET", "/search") => {
305            let store = crate::open_store(root)?;
306            if store.snapshot_status()?.is_none() {
307                return json_err(409, "not indexed".into());
308            }
309            let (vstatus, body) = crate::viewer::serve_viewer(&store, url);
310            if vstatus == 200 {
311                return html_ok(body);
312            }
313            Ok((vstatus, "text/html; charset=utf-8".to_string(), body))
314        }
315        ("GET", p) if crate::viewer::is_viewer_path(p) => {
316            let store = crate::open_store(root)?;
317            if store.snapshot_status()?.is_none() {
318                return json_err(409, "not indexed".into());
319            }
320            let (vstatus, body) = crate::viewer::serve_viewer(&store, url);
321            Ok((vstatus, "text/html; charset=utf-8".to_string(), body))
322        }
323        _ => {
324            let _ = addr;
325            json_err(404, format!("no route for {method} {path}"))
326        }
327    }
328}
329
330// trace:v1 id=impl.crates-scc-cli-src-httpd.json-arr
331fn json_arr(v: &serde_json::Value, key: &str) -> Vec<String> {
332    v.get(key)
333        .and_then(|x| x.as_array())
334        .map(|a| {
335            a.iter()
336                .filter_map(|s| s.as_str().map(|x| x.to_string()))
337                .collect()
338        })
339        .unwrap_or_default()
340}
341
342/// Runtime observation ingest: OTLP/JSON traces (`resourceSpans`) or the
343/// simple `[{source, target, count}]` shape. Aggregates into runtime_edges
344/// (OBSERVED provenance).
345// trace:v1 id=impl.crates-scc-cli-src-httpd.ingest-runtime
346pub fn ingest_runtime(store: &Store, body: &str) -> crate::Result<()> {
347    if body.contains("resourceSpans") {
348        scc_indexer::runtime::ingest_otlp_json(store, body)
349            .map_err(|e| crate::CliError::Other(e.to_string()))?;
350        return Ok(());
351    }
352    scc_indexer::runtime::ingest_simple_edges(store, body)
353        .map_err(|e| crate::CliError::Other(e.to_string()))?;
354    Ok(())
355}
356
357
358// ---------------------------------------------------------------------------
359// file watcher
360// ---------------------------------------------------------------------------
361
362/// `scc watch`: foreground watcher loop.
363// trace:v1 id=impl.crates-scc-cli-src-httpd.watch-loop
364pub fn watch_loop(root: &Path) -> crate::Result<()> {
365    watch_loop_inner(root, false)
366}
367
368/// Refresh files whose content hash no longer matches the snapshot.
369/// Hash remains authority; used when the OS watcher cannot start.
370// 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
371pub fn refresh_stale_by_hash(root: &Path) -> crate::Result<Vec<String>> {
372    let store = crate::open_store(root)?;
373    // Single notion of staleness: modified, deleted, AND added files
374    // (scan-diff lives in `stale_paths`, shared with verify/status).
375    let mut paths = crate::stale_paths(&store)?;
376    drop(store);
377    paths.sort();
378    paths.dedup();
379    if !paths.is_empty() {
380        crate::commands::cmd_index_paths(root, &paths, true)?;
381    }
382    Ok(paths)
383}
384
385// trace:v1 id=impl.crates-scc-cli-src-httpd.watch-loop-inner
386fn watch_loop_inner(root: &Path, quiet: bool) -> crate::Result<()> {
387    let (tx, rx) = mpsc::channel::<notify::Event>();
388    let mut watcher = match notify::recommended_watcher(move |res: notify::Result<notify::Event>| {
389        if let Ok(ev) = res {
390            let _ = tx.send(ev);
391        }
392    }) {
393        Ok(w) => w,
394        Err(e) => {
395            if !quiet {
396                eprintln!("watcher unavailable ({e}); falling back to content-hash sweep");
397            }
398            return hash_sweep_loop(root, quiet);
399        }
400    };
401    if let Err(e) = notify::Watcher::watch(&mut watcher, root, notify::RecursiveMode::Recursive) {
402        if !quiet {
403            eprintln!("watch {root:?} failed ({e}); falling back to content-hash sweep");
404        }
405        drop(watcher);
406        return hash_sweep_loop(root, quiet);
407    }
408
409    if !quiet {
410        println!("watching {} (ctrl-c to stop)", root.display());
411    }
412    let mut pending: std::collections::BTreeSet<String> = Default::default();
413    let mut last: std::time::Instant = std::time::Instant::now();
414    loop {
415        match rx.recv_timeout(Duration::from_millis(250)) {
416            Ok(ev) => {
417                for p in ev.paths {
418                    if let Some(rel) = crate::relative_of(root, &p) {
419                        pending.insert(rel);
420                    }
421                }
422                last = std::time::Instant::now();
423            }
424            Err(mpsc::RecvTimeoutError::Timeout) => {
425                if pending.is_empty() {
426                    continue;
427                }
428                if last.elapsed() < Duration::from_millis(400) {
429                    continue; // debounce
430                }
431                let paths: Vec<String> = std::mem::take(&mut pending).into_iter().collect();
432                let res = crate::commands::cmd_index_paths(root, &paths, true);
433                match res {
434                    Ok(()) => {}
435                    Err(e) => eprintln!("reindex error: {e}"),
436                }
437            }
438            Err(mpsc::RecvTimeoutError::Disconnected) => break,
439        }
440    }
441    Ok(())
442}
443
444// trace:exempt reason=internal-detail
445fn hash_sweep_loop(root: &Path, quiet: bool) -> crate::Result<()> {
446    if !quiet {
447        println!("hash-sweep watching {} (ctrl-c to stop)", root.display());
448    }
449    loop {
450        if let Err(e) = refresh_stale_by_hash(root) {
451            eprintln!("hash sweep error: {e}");
452        }
453        std::thread::sleep(Duration::from_secs(2));
454    }
455}
456
457#[cfg(test)]
458mod tests {
459    use super::*;
460    use crate::benchctx::{copy_fixture, locate_fixtures_dir};
461
462    #[test]
463    // trace:v1 id=test.scc.cli.remote-listen-gate verifies=REQ-SI-503JSBGP exercises=impl.crates-scc-cli-src-httpd.require-remote-opt-in
464    fn remote_listen_fails_closed_without_opt_in() {
465        assert!(require_remote_opt_in("127.0.0.1:7777").is_ok());
466        assert!(require_remote_opt_in("localhost:7777").is_ok());
467        assert!(require_remote_opt_in("[::1]:7777").is_ok());
468        // unauthenticated daemon: non-loopback refuses without opt-in
469        assert!(require_remote_opt_in("0.0.0.0:7777").is_err());
470        std::env::remove_var("SCC_ALLOW_REMOTE_LISTEN");
471        assert!(require_remote_opt_in("192.168.1.10:7777").is_err());
472    }
473
474    #[test]
475    // 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
476    fn hash_sweep_refreshes_edited_file() {
477        let fixtures = locate_fixtures_dir().expect("fixtures");
478        let src = fixtures.join("behavior-native");
479        let tmp = tempfile::TempDir::new().unwrap();
480        let root = tmp.path().join("repo");
481        copy_fixture(&src, &root);
482        crate::commands::cmd_index(&root, true).unwrap();
483        let store = crate::open_store(&root).unwrap();
484        assert!(crate::stale_paths(&store).unwrap().is_empty());
485        drop(store);
486        let app = root.join("app.py");
487        let mut text = std::fs::read_to_string(&app).unwrap();
488        text.push_str("\n# hash-sweep probe\n");
489        std::fs::write(&app, text).unwrap();
490        let stale = refresh_stale_by_hash(&root).unwrap();
491        assert!(
492            stale.iter().any(|p| p == "app.py" || p.ends_with("/app.py")),
493            "edited file must be in the hash sweep: {stale:?}"
494        );
495        let store = crate::open_store(&root).unwrap();
496        assert!(
497            crate::stale_paths(&store).unwrap().is_empty(),
498            "after sweep the snapshot must match disk"
499        );
500    }
501
502    #[test]
503    // 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
504    fn hash_sweep_indexes_newly_created_file() {
505        let fixtures = locate_fixtures_dir().expect("fixtures");
506        let src = fixtures.join("behavior-native");
507        let tmp = tempfile::TempDir::new().unwrap();
508        let root = tmp.path().join("repo");
509        copy_fixture(&src, &root);
510        crate::commands::cmd_index(&root, true).unwrap();
511        std::fs::write(root.join("fresh.py"), "def fresh():\n    return 1\n").unwrap();
512        let stale = refresh_stale_by_hash(&root).unwrap();
513        assert!(
514            stale.iter().any(|p| p == "fresh.py" || p.ends_with("/fresh.py")),
515            "new file must be in the hash sweep: {stale:?}"
516        );
517        let store = crate::open_store(&root).unwrap();
518        let files: Vec<_> = store
519            .all_files()
520            .unwrap()
521            .into_iter()
522            .map(|(p, _, _, _, _)| p)
523            .collect();
524        assert!(
525            files.iter().any(|p| p == "fresh.py" || p.ends_with("/fresh.py")),
526            "new file must be indexed: {files:?}"
527        );
528    }
529}