car-server-core 0.35.0

Transport-neutral library for the CAR daemon JSON-RPC dispatcher (used by car-server and tokhn-daemon)
//! Linked-device tools for Parslee Core.
//!
//! This is intentionally a read-only capability surface. It lets the flagship
//! assistant see which user devices are linked and what they advertise, without
//! becoming a generic phone sensor or remote-exec API.

use std::sync::Arc;

use async_trait::async_trait;
use car_engine::ToolExecutor;
use serde_json::{json, Value};

#[async_trait]
pub trait DeviceProvider: Send + Sync {
    async fn devices(&self) -> Result<Value, String>;
    async fn notify_device(
        &self,
        device_id: Option<String>,
        title: String,
        body: String,
    ) -> Result<Value, String>;
}

pub struct DeviceTools {
    provider: Arc<dyn DeviceProvider>,
}

impl DeviceTools {
    pub fn new(provider: Arc<dyn DeviceProvider>) -> Self {
        Self { provider }
    }

    pub fn tool_defs() -> Vec<Value> {
        vec![
            json!({
                "name": "linked_devices",
                "description": "List the user's linked CAR host devices and their advertised consumer capabilities, such as iPhone chat, approvals, notifications, and push-to-talk. Read-only; does not access contacts, location, photos, microphone, or other private phone data.",
                "parameters": {
                    "type": "object",
                    "properties": {},
                    "additionalProperties": false
                }
            }),
            json!({
                "name": "notify_linked_device",
                "description": "Send a short CAR notification to a linked consumer device that advertises notifications.deliver, such as the user's iPhone. This only delivers title/body notification text through the CAR host event channel; it does not access contacts, location, photos, microphone, files, or arbitrary phone actions.",
                "tier": "full_access",
                "parameters": {
                    "type": "object",
                    "properties": {
                        "device_id": {
                            "type": "string",
                            "description": "Optional linked device id from linked_devices. If omitted, CAR chooses the first online device that can deliver notifications."
                        },
                        "title": {
                            "type": "string",
                            "description": "Short notification title."
                        },
                        "body": {
                            "type": "string",
                            "description": "Notification body shown on the linked device."
                        }
                    },
                    "required": ["title", "body"],
                    "additionalProperties": false
                }
            }),
        ]
    }
}

#[async_trait]
impl ToolExecutor for DeviceTools {
    async fn execute(&self, tool: &str, params: &Value) -> Result<Value, String> {
        match tool {
            "linked_devices" => self.provider.devices().await,
            "notify_linked_device" => self.notify_linked_device(params).await,
            _ => Err(format!("unknown tool: {tool}")),
        }
    }
}

impl DeviceTools {
    async fn notify_linked_device(&self, params: &Value) -> Result<Value, String> {
        let title = required_string(params, "title")?;
        let body = required_string(params, "body")?;
        let device_id = params
            .get("device_id")
            .and_then(Value::as_str)
            .map(str::to_string);
        let devices = self.provider.devices().await?;
        let chosen = choose_notification_device(&devices, device_id.as_deref())?;
        let chosen_id = chosen
            .get("id")
            .and_then(Value::as_str)
            .map(str::to_string)
            .or(device_id);
        self.provider
            .notify_device(chosen_id.clone(), title.clone(), body.clone())
            .await?;
        Ok(json!({
            "delivered": true,
            "device_id": chosen_id,
            "title": title,
            "body": body
        }))
    }
}

fn required_string(params: &Value, key: &str) -> Result<String, String> {
    let value = params
        .get(key)
        .and_then(Value::as_str)
        .map(str::trim)
        .filter(|s| !s.is_empty())
        .ok_or_else(|| format!("{key} is required"))?;
    Ok(value.to_string())
}

fn choose_notification_device<'a>(
    devices: &'a Value,
    requested_id: Option<&str>,
) -> Result<&'a Value, String> {
    let devices = devices
        .as_array()
        .ok_or_else(|| "linked_devices returned a non-array response".to_string())?;
    if let Some(id) = requested_id {
        let device = devices
            .iter()
            .find(|device| device.get("id").and_then(Value::as_str) == Some(id))
            .ok_or_else(|| format!("linked device '{id}' was not found"))?;
        if !device_can_notify(device) {
            return Err(format!(
                "linked device '{id}' does not advertise notifications.deliver"
            ));
        }
        return Ok(device);
    }
    devices
        .iter()
        .find(|device| device_can_notify(device))
        .ok_or_else(|| "no linked device advertises notifications.deliver".to_string())
}

fn device_can_notify(device: &Value) -> bool {
    let is_online = device
        .get("status")
        .and_then(Value::as_str)
        .map(|status| status == "online")
        .unwrap_or(true);
    let can_deliver = device
        .get("capabilities")
        .and_then(Value::as_array)
        .map(|caps| {
            caps.iter()
                .any(|cap| cap.as_str() == Some("notifications.deliver"))
        })
        .unwrap_or(false);
    is_online && can_deliver
}

#[cfg(test)]
mod tests {
    use super::*;
    use tokio::sync::Mutex;

    struct StaticProvider {
        devices: Value,
        notifications: Mutex<Vec<Value>>,
    }

    impl StaticProvider {
        fn new(devices: Value) -> Self {
            Self {
                devices,
                notifications: Mutex::new(Vec::new()),
            }
        }
    }

    #[async_trait]
    impl DeviceProvider for StaticProvider {
        async fn devices(&self) -> Result<Value, String> {
            Ok(self.devices.clone())
        }

        async fn notify_device(
            &self,
            device_id: Option<String>,
            title: String,
            body: String,
        ) -> Result<Value, String> {
            let event = json!({
                "device_id": device_id,
                "title": title,
                "body": body
            });
            self.notifications.lock().await.push(event.clone());
            Ok(event)
        }
    }

    #[tokio::test]
    async fn linked_devices_returns_provider_snapshot() {
        let tools = DeviceTools::new(Arc::new(StaticProvider::new(json!([
            {"name": "iPhone", "platform": "ios", "status": "online"}
        ]))));
        let out = tools.execute("linked_devices", &json!({})).await.unwrap();
        assert_eq!(out[0]["platform"], "ios");
    }

    #[test]
    fn linked_devices_def_is_read_only_phone_context() {
        let defs = DeviceTools::tool_defs();
        let desc = defs[0]["description"].as_str().unwrap();
        assert!(desc.contains("Read-only"));
        assert!(desc.contains("does not access contacts"));
    }

    #[tokio::test]
    async fn notify_linked_device_requires_notification_capability() {
        let tools = DeviceTools::new(Arc::new(StaticProvider::new(json!([
            {
                "id": "phone-1",
                "name": "Mia's iPhone",
                "platform": "ios",
                "status": "online",
                "capabilities": ["notifications.deliver"]
            }
        ]))));
        let out = tools
            .execute(
                "notify_linked_device",
                &json!({"title": "CAR", "body": "Done"}),
            )
            .await
            .unwrap();
        assert_eq!(out["delivered"], true);
        assert_eq!(out["device_id"], "phone-1");
    }

    #[tokio::test]
    async fn notify_linked_device_rejects_devices_without_notification_capability() {
        let tools = DeviceTools::new(Arc::new(StaticProvider::new(json!([
            {
                "id": "phone-1",
                "name": "Mia's iPhone",
                "platform": "ios",
                "status": "online",
                "capabilities": ["assistant.chat"]
            }
        ]))));
        let err = tools
            .execute(
                "notify_linked_device",
                &json!({"device_id": "phone-1", "title": "CAR", "body": "Done"}),
            )
            .await
            .unwrap_err();
        assert!(err.contains("does not advertise notifications.deliver"));
    }

    #[test]
    fn notify_linked_device_def_describes_limited_phone_access_and_tier() {
        let defs = DeviceTools::tool_defs();
        let notify = defs
            .iter()
            .find(|def| def["name"].as_str() == Some("notify_linked_device"))
            .unwrap();
        assert_eq!(notify["tier"], "full_access");
        let desc = notify["description"].as_str().unwrap();
        assert!(desc.contains("title/body notification text"));
        assert!(desc.contains("does not access contacts"));
    }
}