1use crate::{
2 CLI_VERSION,
3 api::ApiClient,
4 auth::AuthManager,
5 config::{ConfigStore, ProjectEntry},
6 error::{ErrorCode, ExeoraError},
7 policy::{CommandPolicy, effective_policy, policy_allows},
8 protocol::{
9 HEARTBEAT_INTERVAL_MS, HEARTBEAT_REQUEST, HEARTBEAT_TIMEOUT_MS, MAX_RESULT_BYTES,
10 PRESENCE_SIGNAL_INTERVAL_MS, PROTOCOL_VERSION, ToolName, now_ms,
11 },
12 tools::ToolEngine,
13};
14use anyhow::{Context, Result, anyhow};
15use futures_util::{SinkExt, StreamExt};
16use serde_json::{Value, json};
17use std::{collections::HashMap, sync::Arc, time::Duration};
18use tokio::sync::{Mutex, mpsc};
19use tokio_tungstenite::{
20 connect_async,
21 tungstenite::{Message, client::IntoClientRequest, http::HeaderValue},
22};
23use tokio_util::sync::CancellationToken;
24use url::Url;
25
26type InFlight = Arc<Mutex<HashMap<String, CancellationToken>>>;
27
28pub async fn connect_forever(
29 config: &ConfigStore,
30 _api: &ApiClient,
31 auth: Arc<AuthManager>,
32 device_id: String,
33 projects: Vec<ProjectEntry>,
34 json_output: bool,
35) -> Result<()> {
36 let engine = Arc::new(ToolEngine::new()?);
37 let project_map: Arc<HashMap<String, ProjectEntry>> = Arc::new(
38 projects
39 .iter()
40 .cloned()
41 .map(|project| (project.id.clone(), project))
42 .collect(),
43 );
44 let gateway = config.gateway_url();
45 let mut delay = Duration::from_secs(1);
46 let stop = CancellationToken::new();
47 let signal_stop = stop.clone();
48 tokio::spawn(async move {
49 let _ = tokio::signal::ctrl_c().await;
50 signal_stop.cancel();
51 });
52
53 loop {
54 if stop.is_cancelled() {
55 break;
56 }
57 match connect_once(
58 &gateway,
59 &device_id,
60 &projects,
61 project_map.clone(),
62 auth.clone(),
63 engine.clone(),
64 stop.clone(),
65 json_output,
66 )
67 .await
68 {
69 Ok(ConnectOutcome::Stopped) => break,
70 Ok(ConnectOutcome::Rejected(reason)) => return Err(anyhow!(reason)),
71 Ok(ConnectOutcome::Disconnected) => {
72 delay = Duration::from_secs(1);
73 emit_event(
74 json_output,
75 "close",
76 json!({ "reason": format!("Disconnected. Reconnecting in {}s.", delay.as_secs()) }),
77 );
78 tokio::select! { _ = tokio::time::sleep(delay) => {}, _ = stop.cancelled() => break }
79 }
80 Err(error) => {
81 emit_event(
82 json_output,
83 "close",
84 json!({ "reason": format!("{error}. Reconnecting in {}s.", delay.as_secs()) }),
85 );
86 tokio::select! { _ = tokio::time::sleep(delay) => {}, _ = stop.cancelled() => break }
87 delay = (delay * 2).min(Duration::from_secs(30));
88 }
89 }
90 }
91 engine.kill_all().await;
92 if !json_output {
93 println!("Disconnected.");
94 }
95 Ok(())
96}
97
98enum ConnectOutcome {
99 Stopped,
100 Rejected(String),
101 Disconnected,
102}
103
104#[allow(clippy::too_many_arguments)]
105async fn connect_once(
106 gateway: &str,
107 device_id: &str,
108 projects: &[ProjectEntry],
109 project_map: Arc<HashMap<String, ProjectEntry>>,
110 auth: Arc<AuthManager>,
111 engine: Arc<ToolEngine>,
112 stop: CancellationToken,
113 json_output: bool,
114) -> Result<ConnectOutcome> {
115 let token = auth.access_token().await?;
116 let mut url = Url::parse(gateway)?.join(&format!("/api/relay/{device_id}"))?;
117 url.set_scheme(if url.scheme() == "https" { "wss" } else { "ws" })
118 .map_err(|_| anyhow!("invalid relay URL"))?;
119 let mut request = url.as_str().into_client_request()?;
120 request.headers_mut().insert(
121 "authorization",
122 HeaderValue::from_str(&format!("Bearer {token}"))?,
123 );
124 let (mut socket, _) = connect_async(request)
125 .await
126 .context("Could not connect to the Exeora relay")?;
127 let can_prompt = !json_output
128 && std::io::IsTerminal::is_terminal(&std::io::stdin())
129 && std::io::IsTerminal::is_terminal(&std::io::stdout());
130 socket.send(Message::Text(serde_json::to_string(&json!({
131 "type": "hello", "protocolVersion": PROTOCOL_VERSION, "deviceId": device_id,
132 "cliVersion": CLI_VERSION, "platform": platform(),
133 "projects": projects.iter().map(|project| json!({ "id": project.id, "slug": project.slug })).collect::<Vec<_>>(),
134 "capabilities": { "prompt": can_prompt, "tools": ToolName::ALL.iter().map(ToString::to_string).collect::<Vec<_>>() },
135 }))?.into())).await?;
136 emit_event(json_output, "open", json!({}));
137 if !json_output {
138 println!("✓ Connected. Waiting for tool calls.");
139 }
140
141 let (out_tx, mut out_rx) = mpsc::unbounded_channel::<Value>();
142 let in_flight: InFlight = Arc::new(Mutex::new(HashMap::new()));
143 let mut tick = tokio::time::interval(Duration::from_millis(HEARTBEAT_INTERVAL_MS));
144 tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
145 let mut heartbeat_auto = false;
146 let mut last_ack = now_ms();
147 let mut last_presence = now_ms();
148
149 loop {
150 tokio::select! {
151 _ = stop.cancelled() => {
152 let _ = socket.close(None).await;
153 cancel_all(&in_flight).await;
154 engine.kill_all().await;
155 return Ok(ConnectOutcome::Stopped);
156 }
157 _ = tick.tick() => {
158 let now = now_ms();
159 if heartbeat_auto && now.saturating_sub(last_ack) > HEARTBEAT_TIMEOUT_MS {
160 let _ = socket.close(None).await;
161 break;
162 }
163 let frame = if heartbeat_auto { HEARTBEAT_REQUEST.to_owned() } else { json!({ "type": "heartbeat", "at": now }).to_string() };
164 socket.send(Message::Text(frame.into())).await?;
165 if heartbeat_auto && now.saturating_sub(last_presence) >= PRESENCE_SIGNAL_INTERVAL_MS {
166 socket.send(Message::Text(json!({ "type": "presence", "at": now }).to_string().into())).await?;
167 last_presence = now;
168 }
169 }
170 Some(outgoing) = out_rx.recv() => {
171 socket.send(Message::Text(outgoing.to_string().into())).await?;
172 }
173 incoming = socket.next() => {
174 let Some(incoming) = incoming else { break; };
175 match incoming? {
176 Message::Text(text) => {
177 let Ok(message) = serde_json::from_str::<Value>(&text) else { continue; };
178 match message.get("type").and_then(Value::as_str) {
179 Some("heartbeat.ack") => last_ack = now_ms(),
180 Some("hello.ack") => {
181 heartbeat_auto = message.get("heartbeatMode").and_then(Value::as_str) == Some("auto");
182 last_ack = now_ms();
183 if let Some(latest) = message.get("latestCliVersion").and_then(Value::as_str)
184 && is_outdated(CLI_VERSION, latest) {
185 let notice = format!("A newer Exeora CLI is available ({CLI_VERSION} → {latest}). Run `exeora upgrade`.");
186 emit_event(json_output, "notice", json!({ "message": notice }));
187 if !json_output { println!("{notice}"); }
188 }
189 }
190 Some("cancel") => {
191 if let Some(id) = message.get("requestId").and_then(Value::as_str)
192 && let Some(token) = in_flight.lock().await.get(id) {
193 token.cancel();
194 }
195 }
196 Some("approval.request") => {
197 let tx = out_tx.clone();
198 tokio::spawn(handle_approval(message, tx, can_prompt, json_output));
199 }
200 Some("approval.resolved") => {}
201 Some("shutdown") => {
202 let reason = message.get("reason").and_then(Value::as_str).unwrap_or("The gateway closed the connection.");
203 cancel_all(&in_flight).await;
204 engine.kill_all().await;
205 return Ok(ConnectOutcome::Rejected(reason.to_owned()));
206 }
207 Some("tool.call") => {
208 spawn_tool_call(message, project_map.clone(), engine.clone(), in_flight.clone(), out_tx.clone(), json_output).await;
209 }
210 _ => {}
211 }
212 }
213 Message::Ping(data) => socket.send(Message::Pong(data)).await?,
214 Message::Close(frame) => {
215 if let Some(frame) = frame
216 && frame.code == tokio_tungstenite::tungstenite::protocol::frame::coding::CloseCode::Policy {
217 return Ok(ConnectOutcome::Rejected(frame.reason.to_string()));
218 }
219 break;
220 }
221 _ => {}
222 }
223 }
224 }
225 }
226 cancel_all(&in_flight).await;
227 engine.kill_all().await;
228 Ok(ConnectOutcome::Disconnected)
229}
230
231async fn spawn_tool_call(
232 message: Value,
233 projects: Arc<HashMap<String, ProjectEntry>>,
234 engine: Arc<ToolEngine>,
235 in_flight: InFlight,
236 outgoing: mpsc::UnboundedSender<Value>,
237 json_output: bool,
238) {
239 let Some(request_id) = message
240 .get("requestId")
241 .and_then(Value::as_str)
242 .map(str::to_owned)
243 else {
244 return;
245 };
246 let Some(project_id) = message.get("projectId").and_then(Value::as_str) else {
247 return;
248 };
249 let started = now_ms();
250 let send_error = |code: ErrorCode, text: &str| {
251 let _ = outgoing.send(result_frame(
252 &request_id,
253 started,
254 Err(ExeoraError::new(code, text)),
255 ));
256 };
257 if message
258 .get("expiresAt")
259 .and_then(Value::as_u64)
260 .is_some_and(|expires| now_ms() > expires)
261 {
262 send_error(
263 ErrorCode::ToolTimeout,
264 "The request expired before it was received.",
265 );
266 return;
267 }
268 let Some(project) = projects.get(project_id).cloned() else {
269 send_error(
270 ErrorCode::UnknownProject,
271 "This machine does not serve that project. Run `exeora project add` there.",
272 );
273 return;
274 };
275 let Some(tool_name) = message
276 .get("tool")
277 .and_then(Value::as_str)
278 .map(str::to_owned)
279 else {
280 send_error(ErrorCode::UnknownTool, "Unsupported tool.");
281 return;
282 };
283 let Ok(tool) = tool_name.parse::<ToolName>() else {
284 send_error(ErrorCode::UnknownTool, "Unsupported tool.");
285 return;
286 };
287 let arguments = message
288 .get("arguments")
289 .cloned()
290 .unwrap_or_else(|| json!({}));
291 let remote = message
292 .get("policy")
293 .cloned()
294 .and_then(|value| serde_json::from_value::<CommandPolicy>(value).ok());
295 let (policy, problem) = effective_policy(&project.root, remote);
296 if let Some(problem) = problem {
297 emit_event(json_output, "error", json!({ "message": problem }));
298 }
299 let verdict = policy_allows(&policy, tool, &arguments);
300 if !verdict.allowed {
301 send_error(
302 ErrorCode::Forbidden,
303 verdict
304 .reason
305 .as_deref()
306 .unwrap_or("This project does not allow that."),
307 );
308 return;
309 }
310
311 let cancel = CancellationToken::new();
312 in_flight
313 .lock()
314 .await
315 .insert(request_id.clone(), cancel.clone());
316 emit_event(
317 json_output,
318 "call",
319 json!({ "tool": tool_name, "project": project.slug, "client": describe_client(message.get("client")) }),
320 );
321 if !json_output {
322 println!("→ {tool_name} ({})", project.slug);
323 }
324 tokio::spawn(async move {
325 let result = engine.execute(&project.root, tool, arguments, cancel).await;
326 in_flight.lock().await.remove(&request_id);
327 let elapsed = now_ms().saturating_sub(started);
328 let frame = result_frame(&request_id, started, result);
329 let ok = frame
330 .pointer("/result/ok")
331 .and_then(Value::as_bool)
332 .unwrap_or(false);
333 let _ = outgoing.send(frame);
334 emit_event(
335 json_output,
336 "result",
337 json!({ "tool": tool_name, "ok": ok, "durationMs": elapsed }),
338 );
339 if !json_output {
340 println!("{} {tool_name} {elapsed}ms", if ok { "✓" } else { "✗" });
341 }
342 });
343}
344
345async fn handle_approval(
346 message: Value,
347 outgoing: mpsc::UnboundedSender<Value>,
348 can_prompt: bool,
349 json_output: bool,
350) {
351 let Some(id) = message.get("id").and_then(Value::as_str).map(str::to_owned) else {
352 return;
353 };
354 if !can_prompt {
355 let _ = outgoing.send(json!({ "type": "approval.answer", "id": id, "approved": false }));
356 return;
357 }
358 let prompt = message
359 .get("prompt")
360 .and_then(Value::as_str)
361 .unwrap_or("Allow this tool call?")
362 .to_owned();
363 let approved = tokio::task::spawn_blocking(move || {
364 cliclack::confirm(prompt)
365 .initial_value(false)
366 .interact()
367 .unwrap_or(false)
368 })
369 .await
370 .unwrap_or(false);
371 emit_event(
372 json_output,
373 "approval",
374 json!({ "id": id, "approved": approved }),
375 );
376 let _ = outgoing.send(json!({ "type": "approval.answer", "id": id, "approved": approved }));
377}
378
379fn result_frame(request_id: &str, started: u64, result: Result<Value, ExeoraError>) -> Value {
380 let result = match result {
381 Ok(value)
382 if serde_json::to_vec(&value).is_ok_and(|bytes| bytes.len() <= MAX_RESULT_BYTES) =>
383 {
384 json!({ "ok": true, "value": value })
385 }
386 Ok(_) => json!({
387 "ok": false,
388 "error": {
389 "code": ErrorCode::ToolFailed.as_str(),
390 "message": format!("Tool result exceeded the {MAX_RESULT_BYTES}-byte protocol limit. Narrow the request and try again."),
391 }
392 }),
393 Err(error) => {
394 json!({ "ok": false, "error": { "code": error.code.as_str(), "message": error.message } })
395 }
396 };
397 json!({ "type": "tool.result", "requestId": request_id, "durationMs": now_ms().saturating_sub(started), "result": result })
398}
399
400async fn cancel_all(in_flight: &InFlight) {
401 let mut calls = in_flight.lock().await;
402 for token in calls.values() {
403 token.cancel();
404 }
405 calls.clear();
406}
407
408fn describe_client(value: Option<&Value>) -> Option<String> {
409 let value = value?;
410 match (
411 value.get("name").and_then(Value::as_str),
412 value.get("version").and_then(Value::as_str),
413 ) {
414 (Some(name), Some(version)) => Some(format!("{name} {version}")),
415 (Some(name), None) => Some(name.to_owned()),
416 (None, Some(version)) => Some(version.to_owned()),
417 _ => None,
418 }
419}
420
421fn emit_event(json_output: bool, event: &str, fields: Value) {
422 if !json_output {
423 return;
424 }
425 let mut value = json!({ "at": now_ms(), "event": event });
426 if let (Some(target), Some(source)) = (value.as_object_mut(), fields.as_object()) {
427 target.extend(source.clone());
428 }
429 println!("{value}");
430}
431
432fn is_outdated(current: &str, latest: &str) -> bool {
433 match (
434 semver::Version::parse(current),
435 semver::Version::parse(latest),
436 ) {
437 (Ok(current), Ok(latest)) => current < latest,
438 _ => false,
439 }
440}
441
442fn platform() -> &'static str {
443 if cfg!(target_os = "windows") {
444 "win32"
445 } else if cfg!(target_os = "macos") {
446 "darwin"
447 } else {
448 "linux"
449 }
450}
451
452#[cfg(test)]
453mod tests {
454 use super::result_frame;
455 use crate::protocol::MAX_RESULT_BYTES;
456 use serde_json::json;
457
458 #[test]
459 fn rejects_an_oversized_tool_result_before_it_reaches_the_socket() {
460 let frame = result_frame(
461 "req_test",
462 0,
463 Ok(json!({ "content": "x".repeat(MAX_RESULT_BYTES) })),
464 );
465 assert_eq!(frame["result"]["ok"], false);
466 assert_eq!(frame["result"]["error"]["code"], "TOOL_FAILED");
467 }
468}