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
+}