| 1 | //! The SplitFire-only pieces a Tauri host needs beside [`ed-acp-tauri`], which carries the |
| 2 | //! app-neutral half of embedding (the in-process ACP client, sessions, permissions, the key |
| 3 | //! store). These three stay here because they are specific to SplitFire's backend and apps: |
| 4 | //! |
| 5 | //! - [`fetch_key`]: ask the SplitFire API for the signed-in user's Onde key. |
| 6 | //! - [`splitfire_mcp`]: the SplitFire API's MCP server for a session. |
| 7 | //! - [`trim`]: drop `run_command` and swap the stems guidance on iOS. |
| 8 | //! |
| 9 | //! Nothing here depends on `tauri`. Anything that needed an `AppHandle` in the old |
| 10 | //! `splitfire_agent_host` crate takes the app identifier as a `&str` instead, so the host |
| 11 | //! passes `app.config().identifier`. |
| 12 | //! |
| 13 | //! [`ed-acp-tauri`]: https://crates.io/crates/ed-acp-tauri |
| 14 | |
| 15 | pub mod trim; |
| 16 | |
| 17 | use ed_acp::agent_client_protocol::schema::v1::{HttpHeader, McpServer, McpServerHttp}; |
| 18 | use serde::Deserialize; |
| 19 | use tracing::warn; |
| 20 | |
| 21 | /// What [`fetch_key`] rejects with when the SplitFire session is missing or |
| 22 | /// expired: the user signs in again. |
| 23 | pub const SIGN_IN_REQUIRED: &str = "sign_in_required"; |
| 24 | |
| 25 | /// What [`fetch_key`] rejects with when SplitFire can't issue a key now: the |
| 26 | /// service is off, or the account isn't allowed one. |
| 27 | pub const UNAVAILABLE: &str = "unavailable"; |
| 28 | |
| 29 | /// Name of the SplitFire API's MCP server in agent sessions. |
| 30 | pub const SPLITFIRE_MCP: &str = "splitfire"; |
| 31 | |
| 32 | /// The agent key SplitFire hands a signed-in user, and the model tier it goes with. |
| 33 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 34 | pub struct AgentKey { |
| 35 | /// The Onde key, `app-id:app-secret`. Never logged. |
| 36 | pub key: String, |
| 37 | /// The tier SplitFire chose for this app. Older servers don't send it. |
| 38 | pub model: Option<String>, |
| 39 | } |
| 40 | |
| 41 | #[derive(Deserialize)] |
| 42 | struct KeyResponse { |
| 43 | key: String, |
| 44 | model: Option<String>, |
| 45 | } |
| 46 | |
| 47 | /// Ask SplitFire for the signed-in user's Onde key and the model to use with it. `url` is the |
| 48 | /// app's `/api/v1/agent/key`; `app_identifier` goes out as sent, so a debug build is told |
| 49 | /// apart from the release app and SplitFire picks its model. The key is never logged. |
| 50 | pub async fn fetch_key( |
| 51 | url: &str, |
| 52 | access_token: &str, |
| 53 | app_identifier: &str, |
| 54 | ) -> Result<AgentKey, String> { |
| 55 | let response = reqwest::Client::new() |
| 56 | .post(url) |
| 57 | .bearer_auth(access_token) |
| 58 | .header("X-App-Identifier", app_identifier) |
| 59 | .timeout(std::time::Duration::from_secs(30)) |
| 60 | .send() |
| 61 | .await |
| 62 | .map_err(|e| { |
| 63 | warn!("Asking SplitFire for an agent key failed: {e}"); |
| 64 | "Couldn't reach SplitFire. Check your connection and try again.".to_string() |
| 65 | })?; |
| 66 | let status = response.status(); |
| 67 | match status.as_u16() { |
| 68 | 200 => { |
| 69 | let body = response |
| 70 | .bytes() |
| 71 | .await |
| 72 | .map_err(|e| format!("Reading SplitFire's answer failed: {e}"))?; |
| 73 | serde_json::from_slice::<KeyResponse>(&body) |
| 74 | .map(|parsed| AgentKey { |
| 75 | key: parsed.key, |
| 76 | model: parsed.model, |
| 77 | }) |
| 78 | .map_err(|_| "SplitFire answered with something unexpected.".to_string()) |
| 79 | } |
| 80 | 401 => Err(SIGN_IN_REQUIRED.to_string()), |
| 81 | 403 | 503 => Err(UNAVAILABLE.to_string()), |
| 82 | other => Err(format!( |
| 83 | "SplitFire answered {other}. Try again in a moment." |
| 84 | )), |
| 85 | } |
| 86 | } |
| 87 | |
| 88 | /// The SplitFire API's MCP server for a signed-in user, as an ACP HTTP MCP server. `url` is the |
| 89 | /// app's `/api/v1/mcp`. ed-acp connects to it per session; its tools reach the model as |
| 90 | /// `mcp__splitfire__*`. The server answers with the tool list for the app named in |
| 91 | /// `X-App-Identifier` (the app's own identifier, debug builds as sent). Nothing without a token. |
| 92 | pub fn splitfire_mcp( |
| 93 | url: String, |
| 94 | access_token: Option<String>, |
| 95 | app_identifier: &str, |
| 96 | ) -> Vec<McpServer> { |
| 97 | let Some(token) = access_token.filter(|t| !t.is_empty()) else { |
| 98 | return Vec::new(); |
| 99 | }; |
| 100 | vec![McpServer::Http( |
| 101 | McpServerHttp::new(SPLITFIRE_MCP, url).headers(vec![ |
| 102 | HttpHeader::new("Authorization", format!("Bearer {token}")), |
| 103 | HttpHeader::new("X-App-Identifier", app_identifier.to_string()), |
| 104 | ]), |
| 105 | )] |
| 106 | } |
| 107 | |
| 108 | #[cfg(test)] |
| 109 | mod tests { |
| 110 | use super::*; |
| 111 | |
| 112 | /// Serve one canned HTTP answer on a free port. Returns the URL and a handle |
| 113 | /// that yields the request the server saw. |
| 114 | fn stub(status: &str, body: &str) -> (String, std::thread::JoinHandle<String>) { |
| 115 | use std::io::{Read, Write}; |
| 116 | let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); |
| 117 | let url = format!("http://{}/api/v1/agent/key", listener.local_addr().unwrap()); |
| 118 | let answer = format!( |
| 119 | "HTTP/1.1 {status}\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}", |
| 120 | body.len() |
| 121 | ); |
| 122 | let handle = std::thread::spawn(move || { |
| 123 | let (mut stream, _) = listener.accept().unwrap(); |
| 124 | let mut seen = vec![0u8; 4096]; |
| 125 | let n = stream.read(&mut seen).unwrap(); |
| 126 | stream.write_all(answer.as_bytes()).unwrap(); |
| 127 | String::from_utf8_lossy(&seen[..n]).to_string() |
| 128 | }); |
| 129 | (url, handle) |
| 130 | } |
| 131 | |
| 132 | fn ask(status: &str, body: &str) -> (Result<AgentKey, String>, String) { |
| 133 | let (url, server) = stub(status, body); |
| 134 | let result = tokio::runtime::Builder::new_current_thread() |
| 135 | .enable_all() |
| 136 | .build() |
| 137 | .unwrap() |
| 138 | .block_on(fetch_key( |
| 139 | &url, |
| 140 | "user-token", |
| 141 | "ai.splitfire.KaroKoweJam-Debug", |
| 142 | )); |
| 143 | (result, server.join().unwrap()) |
| 144 | } |
| 145 | |
| 146 | #[test] |
| 147 | fn the_key_comes_from_a_post_with_the_users_token() { |
| 148 | let (result, request) = ask("200 OK", r#"{"key":"101:secret","model":"onde-balanced"}"#); |
| 149 | assert_eq!( |
| 150 | result, |
| 151 | Ok(AgentKey { |
| 152 | key: "101:secret".to_string(), |
| 153 | model: Some("onde-balanced".to_string()), |
| 154 | }) |
| 155 | ); |
| 156 | assert!(request.starts_with("POST /api/v1/agent/key"), "{request}"); |
| 157 | assert!( |
| 158 | request |
| 159 | .to_lowercase() |
| 160 | .contains("authorization: bearer user-token"), |
| 161 | "{request}" |
| 162 | ); |
| 163 | // The app id goes out unchanged, so a debug build is told apart. |
| 164 | assert!( |
| 165 | request |
| 166 | .to_lowercase() |
| 167 | .contains("x-app-identifier: ai.splitfire.karokowejam-debug"), |
| 168 | "{request}" |
| 169 | ); |
| 170 | } |
| 171 | |
| 172 | #[test] |
| 173 | fn an_older_server_sends_no_model() { |
| 174 | let (result, _) = ask("200 OK", r#"{"key":"101:secret"}"#); |
| 175 | assert_eq!( |
| 176 | result, |
| 177 | Ok(AgentKey { |
| 178 | key: "101:secret".to_string(), |
| 179 | model: None, |
| 180 | }) |
| 181 | ); |
| 182 | } |
| 183 | |
| 184 | #[test] |
| 185 | fn a_missing_session_and_an_unavailable_service_have_their_own_answers() { |
| 186 | assert_eq!( |
| 187 | ask("401 Unauthorized", "{}").0, |
| 188 | Err(SIGN_IN_REQUIRED.to_string()) |
| 189 | ); |
| 190 | assert_eq!(ask("403 Forbidden", "{}").0, Err(UNAVAILABLE.to_string())); |
| 191 | assert_eq!( |
| 192 | ask("503 Service Unavailable", "{}").0, |
| 193 | Err(UNAVAILABLE.to_string()) |
| 194 | ); |
| 195 | } |
| 196 | |
| 197 | #[test] |
| 198 | fn other_failures_say_so_without_leaking_the_body() { |
| 199 | let error = ask("502 Bad Gateway", r#"{"error":"secret-detail"}"#) |
| 200 | .0 |
| 201 | .unwrap_err(); |
| 202 | assert!(error.contains("502"), "{error}"); |
| 203 | assert!(!error.contains("secret-detail"), "{error}"); |
| 204 | let error = ask("200 OK", "not json").0.unwrap_err(); |
| 205 | assert!(error.contains("unexpected"), "{error}"); |
| 206 | } |
| 207 | |
| 208 | #[test] |
| 209 | fn mcp_needs_a_non_empty_token() { |
| 210 | assert!(splitfire_mcp("https://api/x".to_string(), None, "app").is_empty()); |
| 211 | assert!(splitfire_mcp("https://api/x".to_string(), Some(String::new()), "app").is_empty()); |
| 212 | } |
| 213 | |
| 214 | #[test] |
| 215 | fn mcp_carries_the_token_and_app_identifier() { |
| 216 | let servers = splitfire_mcp( |
| 217 | "https://api.splitfire.ai/api/v1/mcp".to_string(), |
| 218 | Some("user-token".to_string()), |
| 219 | "ai.splitfire.KaroKoweJam", |
| 220 | ); |
| 221 | let McpServer::Http(http) = servers.into_iter().next().expect("one server") else { |
| 222 | panic!("expected an HTTP MCP server"); |
| 223 | }; |
| 224 | assert_eq!(http.name, SPLITFIRE_MCP); |
| 225 | assert_eq!(http.url, "https://api.splitfire.ai/api/v1/mcp"); |
| 226 | let header = |name: &str| { |
| 227 | http.headers |
| 228 | .iter() |
| 229 | .find(|h| h.name == name) |
| 230 | .map(|h| h.value.clone()) |
| 231 | }; |
| 232 | assert_eq!( |
| 233 | header("Authorization"), |
| 234 | Some("Bearer user-token".to_string()) |
| 235 | ); |
| 236 | assert_eq!( |
| 237 | header("X-App-Identifier"), |
| 238 | Some("ai.splitfire.KaroKoweJam".to_string()) |
| 239 | ); |
| 240 | } |
| 241 | } |