main
Drew Owens 0.3.3 source + crate build: BOM exported from the SOLIDWORKS assembly, build log §1–11, release script 477e64f 1mo ago
//! Caller provenance — WHO asked.
//!
//! A user runs ~20 AI threads against one desktop. ab stamps the caller on every
//! relayed request TWO ways and we read both:
//!
//!   * `args.caller = {aiThread, containerName, source}` — what the CLI's
//!     `--ai-thread` flag actually produces, and what shows up in practice;
//!   * `X-Adom-Caller-Thread` / `-Container` / `-Reason` headers — the form ab
//!     uses on its own direct-API callbacks and what the SDK samples forward.
//!
//! args wins when both are present (it carries the explicit identity).
//!
//! TRUST RULE (ab's, and not optional): these values are SELF-ASSERTED, exactly
//! like `reason`. Use them for logging, UX and window labels — NEVER for
//! authorization. `if thread == "admin" { allow() }` is a hole, not a check.
//!
//! Identity is thread-local: each HTTP request is served on its own worker
//! thread, so no request can see another's caller.

use serde_json::Value;
use std::cell::RefCell;

/// What we call ourselves when we forward identity on a callback to ab.
pub const DELEGATE: &str = "solidworks";

pub const HEADERS: [&str; 3] =
    ["X-Adom-Caller-Thread", "X-Adom-Caller-Container", "X-Adom-Caller-Reason"];

#[derive(Default, Clone, Debug)]
pub struct Identity {
    pub thread: String,
    pub container: String,
    pub reason: String,
}

thread_local! {
    static CURRENT: RefCell<Option<Identity>> = const { RefCell::new(None) };
}

/// Capture the caller of the request this thread is about to serve.
pub fn set_from_request(header: impl Fn(&str) -> Option<String>, args: &Value) -> Identity {
    let mut ident = Identity {
        thread: header(HEADERS[0]).unwrap_or_default(),
        container: header(HEADERS[1]).unwrap_or_default(),
        reason: header(HEADERS[2]).unwrap_or_default(),
    };
    if let Some(caller) = args.get("caller").and_then(|c| c.as_object()) {
        if let Some(t) = caller.get("aiThread").and_then(|v| v.as_str()) {
            if !t.is_empty() {
                ident.thread = t.to_string();
            }
        }
        if let Some(c) = caller.get("containerName").and_then(|v| v.as_str()) {
            if !c.is_empty() {
                ident.container = c.to_string();
            }
        }
    }
    if ident.reason.is_empty() {
        if let Some(r) = args.get("reason").and_then(|v| v.as_str()) {
            ident.reason = r.to_string();
        }
    }
    CURRENT.with(|c| *c.borrow_mut() = Some(ident.clone()));
    ident
}

pub fn clear() {
    CURRENT.with(|c| *c.borrow_mut() = None);
}

pub fn current() -> Option<Identity> {
    CURRENT.with(|c| c.borrow().clone())
}

/// The ` caller="..."` fragment for the one-line-per-verb log, or "" when the
/// caller is anonymous (loopback callers — Hydrogen, another bridge, the local
/// CLI — send nothing, and that is fine).
pub fn log_suffix() -> String {
    match current() {
        Some(i) if !i.thread.is_empty() => format!(" caller=\"{}\"", i.thread),
        _ => String::new(),
    }
}

/// Headers to put on a callback we make WHILE serving an AI's verb: echo the
/// three we were handed, and add the Delegate naming us — so ab's Activity Log
/// can show BOTH facts, "chip-fetcher tab 3 (via solidworks)".
pub fn forward_headers() -> Vec<(String, String)> {
    let ident = match current() {
        Some(i) if !i.thread.is_empty() => i,
        _ => return Vec::new(),
    };
    vec![
        (HEADERS[0].to_string(), ident.thread),
        (HEADERS[1].to_string(), ident.container),
        (HEADERS[2].to_string(), ident.reason),
        ("X-Adom-Caller-Delegate".to_string(), DELEGATE.to_string()),
    ]
}

/// The `caller` args block for a callback the bridge makes on its OWN behalf
/// (a poll, a timer, cleanup): an explicit caller in args always beats the
/// forwarded headers, so no stale thread name can leak onto our own work.
pub fn self_caller() -> Value {
    serde_json::json!({"aiThread": "solidworks bridge (self)", "containerName": "local"})
}

/// Call an ab verb back over the loopback direct API, forwarding the caller.
///
/// ab injects `ADOM_DIRECT_API_URL` and `ADOM_BRIDGE_TOKEN` into our spawn env.
/// NEVER raises and never fails the verb it is helping: a delegated screenshot
/// that ab refuses is a partial answer, not a broken `swx_screenshot`.
/// Is ab's direct API reachable at all? False when this bridge was started by
/// hand instead of spawned by Adom Bridge, in which case every ab-delegated verb
/// fails for a reason unrelated to SOLIDWORKS — worth being able to say up front.
pub fn ab_available() -> bool {
    std::env::var("ADOM_DIRECT_API_URL").map(|v| !v.trim().is_empty()).unwrap_or(false)
}

pub fn ad_callback(command: &str, args: Value, timeout: std::time::Duration) -> Value {
    let base = std::env::var("ADOM_DIRECT_API_URL").unwrap_or_default();
    let base = base.trim_end_matches('/').to_string();
    if base.is_empty() {
        return serde_json::json!({
            "success": false,
            "error": "ADOM_DIRECT_API_URL not in env (inbound-only spawn?)",
        });
    }
    let mut args = args;
    let mut headers = forward_headers();
    if headers.is_empty() {
        // No caller to forward — this is our own initiative, so say so.
        if let Value::Object(map) = &mut args {
            map.entry("caller").or_insert_with(self_caller);
        }
    }
    if let Ok(token) = std::env::var("ADOM_BRIDGE_TOKEN") {
        if !token.is_empty() {
            // Attribution badge, not an auth gate.
            headers.push(("X-Adom-Bridge-Token".to_string(), token));
        }
    }
    let body = serde_json::json!({"command": command, "args": args}).to_string();
    match crate::httpc::post_json(&format!("{base}/command"), &headers, &body, timeout) {
        Ok(resp) => resp
            .json()
            .unwrap_or_else(|e| serde_json::json!({"success": false, "error": e})),
        Err(e) => serde_json::json!({"success": false, "error": format!("ab callback failed: {e}")}),
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::json;

    fn no_headers(_: &str) -> Option<String> {
        None
    }

    #[test]
    fn args_caller_beats_headers() {
        let ident = set_from_request(
            |h| match h {
                "X-Adom-Caller-Thread" => Some("from-header".into()),
                _ => None,
            },
            &json!({"caller": {"aiThread": "from-args", "containerName": "box"}}),
        );
        assert_eq!(ident.thread, "from-args");
        assert_eq!(ident.container, "box");
        assert_eq!(log_suffix(), " caller=\"from-args\"");
        clear();
    }

    #[test]
    fn anonymous_callers_log_and_forward_nothing() {
        set_from_request(no_headers, &json!({}));
        assert_eq!(log_suffix(), "");
        assert!(forward_headers().is_empty());
        clear();
    }

    #[test]
    fn reason_falls_back_to_the_arg_and_the_delegate_is_added() {
        set_from_request(
            |h| (h == "X-Adom-Caller-Thread").then(|| "tab-3".to_string()),
            &json!({"reason": "prototyping a verb"}),
        );
        let fwd = forward_headers();
        assert!(fwd.contains(&("X-Adom-Caller-Delegate".into(), "solidworks".into())));
        assert!(fwd.contains(&("X-Adom-Caller-Reason".into(), "prototyping a verb".into())));
        clear();
    }
}