development
rs 241 lines 8.73 KB
Raw
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 }