1pub mod actions;
3use crate::client::http_fetch::FetchClient;
4use crate::client::llm_budget::call_llm_for_client;
5use crate::llm::actions::client_trait::{Client, ClientActionResult};
6use crate::logging::emit::Log;
7use crate::protocol::{ConnectContext, Event};
8use crate::state::client_handles::{ClientCommand, ClientSendOutcome};
9use crate::state::{AccessLogOwner, ClientStatus};
10pub use actions::JmapClientProtocol;
11use anyhow::{bail, ensure, Context, Result};
12use serde_json::{json, Map, Value};
13use std::net::SocketAddr;
14use std::time::Duration;
15use tokio::sync::mpsc;
16
17pub const DEFAULT_SESSION_PATH: &str = "/.well-known/jmap";
18const TIMEOUT: Duration = Duration::from_secs(30);
19const MAX_BODY: usize = 4 * 1024 * 1024;
20const MAX_FOLLOWUP_DEPTH: usize = 8;
22
23enum Auth {
24 None,
25 Basic(String, String),
26 Bearer(String),
27}
28
29struct Conn {
30 fetch: FetchClient,
31 auth: Auth,
32 api_url: String,
33 primary: Map<String, Value>,
34 state: String,
35}
36
37fn origin(url: &str) -> Option<String> {
38 let (scheme, rest) = url.split_once("://")?;
39 Some(format!("{scheme}://{}", rest.split('/').next()?))
40}
41
42impl Conn {
43 fn authorize(
44 &self,
45 req: crate::client::http_fetch::FetchRequest,
46 ) -> crate::client::http_fetch::FetchRequest {
47 match &self.auth {
48 Auth::None => req,
49 Auth::Basic(u, p) => req.basic_auth(u, Some(p)),
50 Auth::Bearer(t) => req.header("authorization", format!("Bearer {t}")),
51 }
52 }
53
54 async fn send(&self, v: &Value) -> Result<Value> {
55 let (using, calls) = actions::prepare(v, &self.primary)?;
56 let body = json!({"using": using, "methodCalls": calls});
57 let resp = self
58 .authorize(self.fetch.post(&self.api_url))
59 .header("content-type", "application/json")
60 .header("accept", "application/json")
61 .body(serde_json::to_vec(&body)?)
62 .send()
63 .await?;
64 let status = resp.status().as_u16();
65 let bytes = resp.bytes().await?;
66 let parsed: Value = serde_json::from_slice(&bytes).unwrap_or(Value::Null);
67 if status != 200 {
68 return Ok(json!({"status": status, "problem": parsed}));
69 }
70 let session_state = parsed["sessionState"].as_str().unwrap_or_default();
71 Ok(json!({
72 "status": status,
73 "method_responses": parsed["methodResponses"],
74 "session_state": session_state,
75 "session_changed": session_state != self.state,
76 }))
77 }
78}
79
80pub async fn connect(ctx: ConnectContext) -> Result<SocketAddr> {
81 let p = ctx.startup_params.as_ref();
82 let get = |name| {
83 p.map(|p| p.get_optional_string(name))
84 .transpose()
85 .map(Option::flatten)
86 };
87 let auth = match (get("username")?, get("password")?, get("api_token")?) {
88 (Some(u), Some(pw), None) => Auth::Basic(u, pw),
89 (None, None, Some(t)) => Auth::Bearer(t),
90 (None, None, None) => Auth::None,
91 _ => bail!("give username and password, or api_token"),
92 };
93 let tls = p
94 .map(|p| p.get_optional_bool("tls"))
95 .transpose()?
96 .flatten()
97 .unwrap_or(true);
98 let session_path = get("session_path")?.unwrap_or_else(|| DEFAULT_SESSION_PATH.into());
99 ensure!(
100 session_path.starts_with('/') && !session_path.contains("..") && session_path.len() <= 256,
101 "session_path is an absolute path"
102 );
103 let remote: SocketAddr = tokio::net::lookup_host(&ctx.remote_addr)
104 .await?
105 .next()
106 .context("the address does not resolve")?;
107 let base = format!(
108 "{}://{}",
109 if tls { "https" } else { "http" },
110 ctx.remote_addr
111 );
112 crate::client::http_fetch::check_url(&base)?;
113 let first = origin(&base).context("a base URL")?;
114 let same_origin = first.clone();
115 let mut builder =
116 reqwest::Client::builder()
117 .timeout(TIMEOUT)
118 .redirect(reqwest::redirect::Policy::custom(move |attempt| {
119 if attempt.previous().len() >= 5
120 || origin(attempt.url().as_str()).as_deref() != Some(same_origin.as_str())
121 {
122 attempt.stop()
123 } else {
124 attempt.follow()
125 }
126 }));
127 if let Some(ca) = get("ca_cert_path")? {
128 let pem = tokio::fs::read(&ca)
129 .await
130 .with_context(|| format!("reading {ca}"))?;
131 let mut roots = rustls::RootCertStore::empty();
134 for cert in rustls_pemfile::certs(&mut pem.as_slice()) {
135 roots.add(cert.with_context(|| format!("{ca} holds no PEM certificate"))?)?;
136 }
137 ensure!(!roots.is_empty(), "{ca} holds no PEM certificate");
138 let _ = rustls::crypto::ring::default_provider().install_default();
139 let tls = rustls::ClientConfig::builder()
140 .with_root_certificates(roots)
141 .with_no_client_auth();
142 builder = builder.use_preconfigured_tls(tls);
143 }
144 let fetch = FetchClient::from_reqwest(
145 crate::llm::ollama_client::configured_for_endpoint(builder, &base).build()?,
146 )
147 .with_max_body(MAX_BODY)
148 .with_user_agent("netget-jmap");
149 let mut conn = Conn {
150 fetch,
151 auth,
152 api_url: String::new(),
153 primary: Map::new(),
154 state: String::new(),
155 };
156 let resp = conn
157 .authorize(conn.fetch.get(&format!("{base}{session_path}")))
158 .header("accept", "application/json")
159 .send()
160 .await?;
161 let status = resp.status().as_u16();
162 ensure!(status == 200, "the session resource answered HTTP {status}");
163 let session: Value =
164 serde_json::from_slice(&resp.bytes().await?).context("the session is not JSON")?;
165 let api_url = session["apiUrl"]
166 .as_str()
167 .context("the session has no apiUrl")?;
168 ensure!(
170 origin(api_url).as_deref() == Some(first.as_str()),
171 "the session's apiUrl {api_url} is not on {first}"
172 );
173 conn.api_url = api_url.to_owned();
174 conn.primary = session["primaryAccounts"]
175 .as_object()
176 .cloned()
177 .unwrap_or_default();
178 conn.state = session["state"].as_str().unwrap_or_default().to_owned();
179 let accounts: Vec<Value> = session["accounts"]
180 .as_object()
181 .map(|m| {
182 m.iter()
183 .take(64)
184 .map(|(id, a)| json!({"id": id, "name": a["name"], "capabilities": a["accountCapabilities"].as_object().map(|c| c.keys().cloned().collect::<Vec<_>>()).unwrap_or_default()}))
185 .collect()
186 })
187 .unwrap_or_default();
188 let capabilities: Vec<String> = session["capabilities"]
189 .as_object()
190 .map(|c| c.keys().take(64).cloned().collect())
191 .unwrap_or_default();
192 let info = json!({"username": session["username"], "accounts": accounts, "primary_accounts": conn.primary, "capabilities": capabilities, "state": conn.state});
193 Log::new(Some(&ctx.status_tx)).info(format!(
194 "JMAP session at {} for {}",
195 conn.api_url, session["username"]
196 ));
197 ctx.state
198 .update_client_status(ctx.client_id, ClientStatus::Connected)
199 .await;
200 let external =
201 crate::client::command_support::register_command_channel(&ctx.state, ctx.client_id).await;
202 let (internal_tx, internal_rx) = mpsc::channel::<Value>(16);
203 let (event_tx, mut event_rx) = mpsc::channel::<Event>(16);
204 event_tx.try_send(Event::new(&actions::CONNECTED_EVENT, info))?;
205 let events_ctx = ctx.clone();
206 let dispatcher = tokio::spawn(async move {
207 let protocol = JmapClientProtocol;
208 while let Some(event) = event_rx.recv().await {
209 let instruction = events_ctx
210 .state
211 .get_instruction_for_client(events_ctx.client_id)
212 .await
213 .unwrap_or_default();
214 let memory = events_ctx
215 .state
216 .get_memory_for_client(events_ctx.client_id)
217 .await
218 .unwrap_or_default();
219 match call_llm_for_client(
220 &events_ctx.llm_client,
221 &events_ctx.state,
222 events_ctx.client_id.to_string(),
223 &instruction,
224 &memory,
225 Some(&event),
226 &protocol,
227 &events_ctx.status_tx,
228 )
229 .await
230 {
231 Ok(result) => {
232 if let Some(m) = result.memory_updates {
233 events_ctx
234 .state
235 .set_memory_for_client(events_ctx.client_id, m)
236 .await;
237 }
238 for action in result.actions {
239 if internal_tx.send(action).await.is_err() {
240 return;
241 }
242 }
243 }
244 Err(e) => {
245 Log::new(Some(&events_ctx.status_tx)).warn(format!("JMAP client handler: {e}"))
246 }
247 }
248 }
249 });
250 ctx.state
251 .register_client_task(ctx.client_id, dispatcher)
252 .await;
253 let session_ctx = ctx.clone();
254 let task = tokio::spawn(async move {
255 run(&session_ctx, &conn, external, internal_rx, &event_tx).await;
256 session_ctx
257 .state
258 .update_client_status(session_ctx.client_id, ClientStatus::Disconnected)
259 .await;
260 session_ctx
261 .state
262 .remove_client_handle(session_ctx.client_id)
263 .await;
264 let _ = session_ctx.status_tx.send("__UPDATE_UI__".into());
265 });
266 ctx.state.register_client_task(ctx.client_id, task).await;
267 Ok(SocketAddr::new(remote.ip(), 0))
268}
269
270async fn run(
271 ctx: &ConnectContext,
272 conn: &Conn,
273 mut external: mpsc::Receiver<ClientCommand>,
274 mut internal: mpsc::Receiver<Value>,
275 events: &mpsc::Sender<Event>,
276) {
277 let mut depth = 0usize;
278 loop {
279 let (action, command) = tokio::select! {
280 c = external.recv() => match c { Some(c) => (c.action.clone(), Some(c)), None => return },
281 a = internal.recv() => match a { Some(a) => (a, None), None => return },
282 };
283 let outcome = match JmapClientProtocol.execute_action(action.clone()) {
284 Err(e) => ClientSendOutcome::Rejected {
285 error: e.to_string(),
286 },
287 Ok(ClientActionResult::Disconnect) => {
288 if let Some(c) = command {
289 crate::client::command_support::reply(c, Ok(ClientSendOutcome::Disconnected));
290 }
291 return;
292 }
293 Ok(_) => match conn.send(&action).await {
294 Ok(response) => {
295 depth = if command.is_some() { 0 } else { depth + 1 };
296 if depth <= MAX_FOLLOWUP_DEPTH {
297 events
298 .send(Event::new(&actions::RESPONSE_EVENT, response))
299 .await
300 .ok();
301 } else {
302 Log::new(Some(&ctx.status_tx)).warn(format!(
303 "JMAP client: follow-up depth {MAX_FOLLOWUP_DEPTH} reached; the response is not raised"
304 ));
305 }
306 ClientSendOutcome::Sent { bytes_sent: 0 }
307 }
308 Err(e) => ClientSendOutcome::Rejected {
309 error: format!("{e:#}"),
310 },
311 },
312 };
313 if let Some(c) = command {
314 ctx.state
315 .record_access_log(
316 AccessLogOwner::Client(ctx.client_id.as_u32()),
317 "JMAP",
318 None,
319 "injected_action",
320 json!({"type": action["type"]}),
321 vec![serde_json::to_value(&outcome).unwrap_or(Value::Null)],
322 )
323 .await;
324 crate::client::command_support::reply(c, Ok(outcome));
325 }
326 }
327}