1use crate::{
2 CLI_VERSION,
3 api::ApiClient,
4 auth::AuthManager,
5 config::{ConfigStore, ProjectEntry, WorktreeSyncState},
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 workspace::WorkspaceEngine,
14};
15use anyhow::{Context, Result, anyhow};
16use base64::{Engine as _, engine::general_purpose::STANDARD};
17use futures_util::{SinkExt, StreamExt};
18use serde_json::{Value, json};
19use std::{
20 collections::{HashMap, HashSet},
21 path::{Path, PathBuf},
22 sync::Arc,
23 time::Duration,
24};
25use tokio::sync::{Mutex, mpsc};
26use tokio_tungstenite::{
27 connect_async,
28 tungstenite::{Error as WebSocketError, Message, client::IntoClientRequest, http::HeaderValue},
29};
30use tokio_util::sync::CancellationToken;
31use url::Url;
32
33struct ActiveCall {
34 cancel: CancellationToken,
35 root: PathBuf,
36}
37
38#[derive(Debug)]
39struct ResolvedTarget {
40 project: ProjectEntry,
41 root: PathBuf,
42 worktree_slug: Option<String>,
43}
44
45type InFlight = Arc<Mutex<HashMap<String, ActiveCall>>>;
46
47pub async fn connect_forever(
48 config: &ConfigStore,
49 api: &ApiClient,
50 auth: Arc<AuthManager>,
51 device_id: String,
52 projects: Vec<ProjectEntry>,
53 json_output: bool,
54) -> Result<()> {
55 let _awake = acquire_keep_awake(json_output);
56 let engine = Arc::new(ToolEngine::new()?);
57 let workspace = Arc::new(WorkspaceEngine::new());
58 let config_path = config.path().to_path_buf();
59 let gateway = config.gateway_url();
60 let mut delay = Duration::from_secs(1);
61 let stop = CancellationToken::new();
62 let signal_stop = stop.clone();
63 tokio::spawn(async move {
64 let _ = tokio::signal::ctrl_c().await;
65 signal_stop.cancel();
66 });
67
68 loop {
69 if stop.is_cancelled() {
70 break;
71 }
72 let outcome = connect_once(
73 &gateway,
74 &device_id,
75 &projects,
76 config_path.clone(),
77 auth.clone(),
78 api.clone(),
79 engine.clone(),
80 workspace.clone(),
81 stop.clone(),
82 json_output,
83 )
84 .await;
85 workspace.kill_all().await;
87 engine.kill_all().await;
88 match outcome {
89 Ok(ConnectOutcome::Stopped) => break,
90 Ok(ConnectOutcome::Rejected(reason)) => return Err(anyhow!(reason)),
91 Ok(ConnectOutcome::Disconnected) => {
92 delay = Duration::from_secs(1);
93 emit_event(
94 json_output,
95 "close",
96 json!({ "reason": format!("Disconnected. Reconnecting in {}s.", delay.as_secs()) }),
97 );
98 tokio::select! { _ = tokio::time::sleep(delay) => {}, _ = stop.cancelled() => break }
99 }
100 Err(error) => {
101 emit_event(
102 json_output,
103 "close",
104 json!({ "reason": format!("{error}. Reconnecting in {}s.", delay.as_secs()) }),
105 );
106 tokio::select! { _ = tokio::time::sleep(delay) => {}, _ = stop.cancelled() => break }
107 delay = (delay * 2).min(Duration::from_secs(30));
108 }
109 }
110 }
111 engine.kill_all().await;
112 workspace.kill_all().await;
113 if !json_output {
114 println!("Disconnected.");
115 }
116 Ok(())
117}
118
119fn acquire_keep_awake(json_output: bool) -> Option<keepawake::KeepAwake> {
120 match keepawake::Builder::default()
121 .idle(true)
122 .display(true)
123 .reason("Exeora is serving remote tool calls")
124 .app_name("Exeora")
125 .app_reverse_domain("dev.exeora.cli")
126 .create()
127 {
128 Ok(awake) => {
129 emit_event(json_output, "awake", awake_event(true, None));
130 if !json_output {
131 println!("✓ Keeping the system and display awake while connect runs.");
132 }
133 Some(awake)
134 }
135 Err(error) => {
136 let reason = error.to_string();
137 emit_event(json_output, "awake", awake_event(false, Some(&reason)));
138 if !json_output {
139 eprintln!(
140 "warning: Could not keep the system and display awake: {reason}. Continuing to connect."
141 );
142 }
143 None
144 }
145 }
146}
147
148fn awake_event(active: bool, reason: Option<&str>) -> Value {
149 let mut fields = json!({
150 "active": active,
151 "system": active,
152 "display": active,
153 });
154 if let (Some(fields), Some(reason)) = (fields.as_object_mut(), reason) {
155 fields.insert("reason".to_owned(), json!(reason));
156 }
157 fields
158}
159
160enum ConnectOutcome {
161 Stopped,
162 Rejected(String),
163 Disconnected,
164}
165
166fn handshake_rejection(status: u16, body: Option<&[u8]>) -> String {
167 let detail = body
168 .and_then(|bytes| std::str::from_utf8(bytes).ok())
169 .map(str::trim)
170 .filter(|text| !text.is_empty())
171 .map(|text| {
172 serde_json::from_str::<Value>(text)
173 .ok()
174 .and_then(|value| {
175 let error = value.get("error")?.as_str()?;
176 let scopes = value
177 .get("requiredScopes")
178 .and_then(Value::as_array)
179 .map(|entries| {
180 entries
181 .iter()
182 .filter_map(Value::as_str)
183 .collect::<Vec<_>>()
184 .join(", ")
185 })
186 .filter(|scopes| !scopes.is_empty());
187 Some(scopes.map_or_else(
188 || error.to_owned(),
189 |scopes| format!("{error}; required scopes: {scopes}"),
190 ))
191 })
192 .unwrap_or_else(|| text.chars().take(200).collect())
193 })
194 .unwrap_or_else(|| "the gateway refused this machine".to_owned());
195
196 format!("Relay rejected the connection ({status}): {detail}")
197}
198
199#[allow(clippy::too_many_arguments)]
200async fn connect_once(
201 gateway: &str,
202 device_id: &str,
203 projects: &[ProjectEntry],
204 config_path: PathBuf,
205 auth: Arc<AuthManager>,
206 api: crate::api::ApiClient,
207 engine: Arc<ToolEngine>,
208 workspace: Arc<WorkspaceEngine>,
209 stop: CancellationToken,
210 json_output: bool,
211) -> Result<ConnectOutcome> {
212 let token = auth.access_token().await?;
213 let mut url = Url::parse(gateway)?.join(&format!("/api/relay/{device_id}"))?;
214 url.set_scheme(if url.scheme() == "https" { "wss" } else { "ws" })
215 .map_err(|_| anyhow!("invalid relay URL"))?;
216 let mut request = url.as_str().into_client_request()?;
217 request.headers_mut().insert(
218 "authorization",
219 HeaderValue::from_str(&format!("Bearer {token}"))?,
220 );
221 let (mut socket, _) = match connect_async(request).await {
222 Ok(connection) => connection,
223 Err(WebSocketError::Http(response))
224 if matches!(response.status().as_u16(), 401 | 403 | 404) =>
225 {
226 return Ok(ConnectOutcome::Rejected(handshake_rejection(
227 response.status().as_u16(),
228 response.body().as_deref(),
229 )));
230 }
231 Err(error) => return Err(error).context("Could not connect to the Exeora relay"),
232 };
233 let can_prompt = !json_output
234 && std::io::IsTerminal::is_terminal(&std::io::stdin())
235 && std::io::IsTerminal::is_terminal(&std::io::stdout());
236 socket.send(Message::Text(serde_json::to_string(&json!({
237 "type": "hello", "protocolVersion": PROTOCOL_VERSION, "deviceId": device_id,
238 "cliVersion": CLI_VERSION, "platform": platform(),
239 "projects": projects.iter().map(|project| json!({ "id": project.id, "slug": project.slug })).collect::<Vec<_>>(),
240 "capabilities": {
241 "prompt": can_prompt,
242 "tools": ToolName::ALL.iter().map(ToString::to_string).collect::<Vec<_>>(),
243 "features": ["source-control-v1", "terminal-v1"],
244 "worktreeRouting": true,
245 },
246 }))?.into())).await?;
247 emit_event(json_output, "open", json!({}));
248 if !json_output {
249 println!("✓ Connected. Waiting for tool calls.");
250 }
251
252 let (out_tx, mut out_rx) = mpsc::unbounded_channel::<Value>();
253 let (terminal_tx, mut terminal_rx) = mpsc::channel::<Value>(256);
254 let in_flight: InFlight = Arc::new(Mutex::new(HashMap::new()));
255 let mut tick = tokio::time::interval(Duration::from_millis(HEARTBEAT_INTERVAL_MS));
256 tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
257 let mut heartbeat_auto = false;
258 let mut last_ack = now_ms();
259 let mut last_presence = now_ms();
260 let mut roots_tick = tokio::time::interval(Duration::from_secs(1));
261 roots_tick.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
262 let mut known_roots = served_roots(&config_path);
263
264 loop {
265 tokio::select! {
266 _ = stop.cancelled() => {
267 let _ = socket.close(None).await;
268 cancel_all(&in_flight).await;
269 engine.kill_all().await;
270 workspace.kill_all().await;
271 return Ok(ConnectOutcome::Stopped);
272 }
273 _ = tick.tick() => {
274 let now = now_ms();
275 if heartbeat_auto && now.saturating_sub(last_ack) > HEARTBEAT_TIMEOUT_MS {
276 let _ = socket.close(None).await;
277 break;
278 }
279 let frame = if heartbeat_auto { HEARTBEAT_REQUEST.to_owned() } else { json!({ "type": "heartbeat", "at": now }).to_string() };
280 socket.send(Message::Text(frame.into())).await?;
281 if heartbeat_auto && now.saturating_sub(last_presence) >= PRESENCE_SIGNAL_INTERVAL_MS {
282 socket.send(Message::Text(json!({ "type": "presence", "at": now }).to_string().into())).await?;
283 last_presence = now;
284 }
285 }
286 Some(outgoing) = out_rx.recv() => {
287 socket.send(Message::Text(outgoing.to_string().into())).await?;
288 }
289 Some(outgoing) = terminal_rx.recv() => {
290 socket.send(Message::Text(outgoing.to_string().into())).await?;
291 }
292 incoming = socket.next() => {
293 let Some(incoming) = incoming else { break; };
294 match incoming? {
295 Message::Text(text) => {
296 let Ok(message) = serde_json::from_str::<Value>(&text) else { continue; };
297 match message.get("type").and_then(Value::as_str) {
298 Some("heartbeat.ack") => last_ack = now_ms(),
299 Some("hello.ack") => {
300 heartbeat_auto = message.get("heartbeatMode").and_then(Value::as_str) == Some("auto");
301 last_ack = now_ms();
302 if let Some(latest) = message.get("latestCliVersion").and_then(Value::as_str)
303 && is_outdated(CLI_VERSION, latest) {
304 let notice = format!("A newer Exeora CLI is available ({CLI_VERSION} → {latest}). Run `exeora upgrade`.");
305 emit_event(json_output, "notice", json!({ "message": notice }));
306 if !json_output { println!("{notice}"); }
307 }
308 }
309 Some("cancel") => {
310 if let Some(id) = message.get("requestId").and_then(Value::as_str)
311 && let Some(call) = in_flight.lock().await.get(id) {
312 call.cancel.cancel();
313 }
314 }
315 Some("approval.request") => {
316 let tx = out_tx.clone();
317 tokio::spawn(handle_approval(message, tx, can_prompt, json_output));
318 }
319 Some("approval.resolved") => {}
320 Some("shutdown") => {
321 let reason = message.get("reason").and_then(Value::as_str).unwrap_or("The gateway closed the connection.");
322 cancel_all(&in_flight).await;
323 engine.kill_all().await;
324 workspace.kill_all().await;
325 return Ok(ConnectOutcome::Rejected(reason.to_owned()));
326 }
327 Some("tool.call") => {
328 spawn_tool_call(message, config_path.clone(), engine.clone(), in_flight.clone(), out_tx.clone(), json_output).await;
329 }
330 Some("workspace.call") => {
331 spawn_workspace_call(message, config_path.clone(), api.clone(), workspace.clone(), in_flight.clone(), out_tx.clone()).await;
332 }
333 Some("terminal.open") | Some("terminal.input") | Some("terminal.resize") | Some("terminal.close") => {
334 handle_terminal_message(message, config_path.clone(), workspace.clone(), terminal_tx.clone()).await;
335 }
336 _ => {}
337 }
338 }
339 Message::Ping(data) => socket.send(Message::Pong(data)).await?,
340 Message::Close(frame) => {
341 if let Some(frame) = frame
342 && frame.code == tokio_tungstenite::tungstenite::protocol::frame::coding::CloseCode::Policy {
343 return Ok(ConnectOutcome::Rejected(frame.reason.to_string()));
344 }
345 break;
346 }
347 _ => {}
348 }
349 }
350 _ = roots_tick.tick() => {
351 reconcile_roots(&config_path, &engine, &workspace, &in_flight, &mut known_roots).await;
352 }
353 }
354 }
355 cancel_all(&in_flight).await;
356 engine.kill_all().await;
357 workspace.kill_all().await;
358 Ok(ConnectOutcome::Disconnected)
359}
360
361async fn spawn_tool_call(
362 message: Value,
363 config_path: PathBuf,
364 engine: Arc<ToolEngine>,
365 in_flight: InFlight,
366 outgoing: mpsc::UnboundedSender<Value>,
367 json_output: bool,
368) {
369 let Some(request_id) = message
370 .get("requestId")
371 .and_then(Value::as_str)
372 .map(str::to_owned)
373 else {
374 return;
375 };
376 let Some(project_id) = message.get("projectId").and_then(Value::as_str) else {
377 return;
378 };
379 let started = now_ms();
380 let send_error = |code: ErrorCode, text: &str| {
381 let _ = outgoing.send(result_frame(
382 &request_id,
383 started,
384 Err(ExeoraError::new(code, text)),
385 ));
386 };
387 if message
388 .get("expiresAt")
389 .and_then(Value::as_u64)
390 .is_some_and(|expires| now_ms() > expires)
391 {
392 send_error(
393 ErrorCode::ToolTimeout,
394 "The request expired before it was received.",
395 );
396 return;
397 }
398 let target = match resolve_target(
399 &config_path,
400 project_id,
401 message.get("worktreeId").and_then(Value::as_str),
402 message.get("worktreeSlug").and_then(Value::as_str),
403 ) {
404 Ok(target) => target,
405 Err(error) => {
406 send_error(error.code, &error.message);
407 return;
408 }
409 };
410 let ResolvedTarget {
411 project,
412 root,
413 worktree_slug,
414 } = target;
415 let Some(tool_name) = message
416 .get("tool")
417 .and_then(Value::as_str)
418 .map(str::to_owned)
419 else {
420 send_error(ErrorCode::UnknownTool, "Unsupported tool.");
421 return;
422 };
423 let Ok(tool) = tool_name.parse::<ToolName>() else {
424 send_error(ErrorCode::UnknownTool, "Unsupported tool.");
425 return;
426 };
427 let arguments = message
428 .get("arguments")
429 .cloned()
430 .unwrap_or_else(|| json!({}));
431 let remote = message
432 .get("policy")
433 .cloned()
434 .and_then(|value| serde_json::from_value::<CommandPolicy>(value).ok());
435 let (policy, problem) = effective_policy(&root, remote);
436 if let Some(problem) = problem {
437 emit_event(json_output, "error", json!({ "message": problem }));
438 }
439 let verdict = policy_allows(&policy, tool, &arguments);
440 if !verdict.allowed {
441 send_error(
442 ErrorCode::Forbidden,
443 verdict
444 .reason
445 .as_deref()
446 .unwrap_or("This project does not allow that."),
447 );
448 return;
449 }
450
451 let cancel = CancellationToken::new();
452 in_flight.lock().await.insert(
453 request_id.clone(),
454 ActiveCall {
455 cancel: cancel.clone(),
456 root: root.clone(),
457 },
458 );
459 emit_event(
460 json_output,
461 "call",
462 json!({ "tool": tool_name, "project": project.slug, "worktree": worktree_slug, "client": describe_client(message.get("client")) }),
463 );
464 if !json_output {
465 println!(
466 "→ {tool_name} ({}/{})",
467 project.slug,
468 worktree_slug.as_deref().unwrap_or("main")
469 );
470 }
471 tokio::spawn(async move {
472 let result = engine
473 .execute_for_project(&root, &project.id, tool, arguments, cancel)
474 .await;
475 in_flight.lock().await.remove(&request_id);
476 let elapsed = now_ms().saturating_sub(started);
477 let frame = result_frame(&request_id, started, result);
478 let ok = frame
479 .pointer("/result/ok")
480 .and_then(Value::as_bool)
481 .unwrap_or(false);
482 let _ = outgoing.send(frame);
483 emit_event(
484 json_output,
485 "result",
486 json!({ "tool": tool_name, "ok": ok, "durationMs": elapsed }),
487 );
488 if !json_output {
489 println!("{} {tool_name} {elapsed}ms", if ok { "✓" } else { "✗" });
490 }
491 });
492}
493
494async fn handle_approval(
495 message: Value,
496 outgoing: mpsc::UnboundedSender<Value>,
497 can_prompt: bool,
498 json_output: bool,
499) {
500 let Some(id) = message.get("id").and_then(Value::as_str).map(str::to_owned) else {
501 return;
502 };
503 if !can_prompt {
504 let _ = outgoing.send(json!({ "type": "approval.answer", "id": id, "approved": false }));
505 return;
506 }
507 let prompt = message
508 .get("prompt")
509 .and_then(Value::as_str)
510 .unwrap_or("Allow this tool call?")
511 .to_owned();
512 let approved = tokio::task::spawn_blocking(move || {
513 cliclack::confirm(prompt)
514 .initial_value(false)
515 .interact()
516 .unwrap_or(false)
517 })
518 .await
519 .unwrap_or(false);
520 emit_event(
521 json_output,
522 "approval",
523 json!({ "id": id, "approved": approved }),
524 );
525 let _ = outgoing.send(json!({ "type": "approval.answer", "id": id, "approved": approved }));
526}
527
528async fn spawn_workspace_call(
529 message: Value,
530 config_path: PathBuf,
531 api: crate::api::ApiClient,
532 workspace: Arc<WorkspaceEngine>,
533 in_flight: InFlight,
534 outgoing: mpsc::UnboundedSender<Value>,
535) {
536 let Some(request_id) = message
537 .get("requestId")
538 .and_then(Value::as_str)
539 .map(str::to_owned)
540 else {
541 return;
542 };
543 let Some(project_id) = message.get("projectId").and_then(Value::as_str) else {
544 return;
545 };
546 let started = now_ms();
547 let send_error = |error: ExeoraError| {
548 let _ = outgoing.send(workspace_result_frame(&request_id, started, Err(error)));
549 };
550 if message
551 .get("expiresAt")
552 .and_then(Value::as_u64)
553 .is_some_and(|expires| started > expires)
554 {
555 send_error(ExeoraError::new(
556 ErrorCode::ToolTimeout,
557 "The workspace request expired before it was received.",
558 ));
559 return;
560 }
561 let target = match resolve_target(
562 &config_path,
563 project_id,
564 message.get("worktreeId").and_then(Value::as_str),
565 message.get("worktreeSlug").and_then(Value::as_str),
566 ) {
567 Ok(target) => target,
568 Err(error) => {
569 send_error(error);
570 return;
571 }
572 };
573 let action = message.get("action").cloned().unwrap_or_else(|| json!({}));
574 let cancel = CancellationToken::new();
575 in_flight.lock().await.insert(
576 request_id.clone(),
577 ActiveCall {
578 cancel: cancel.clone(),
579 root: target.root.clone(),
580 },
581 );
582 let create_worktree = action.get("action").and_then(Value::as_str) == Some("worktree_create");
583 tokio::spawn(async move {
584 let result = if create_worktree {
585 create_workspace_worktree(
586 &config_path,
587 &api,
588 &target.project.id,
589 &target.root,
590 action,
591 workspace.as_ref(),
592 cancel,
593 )
594 .await
595 } else {
596 workspace.execute(&target.root, action, cancel).await
597 };
598 in_flight.lock().await.remove(&request_id);
599 let _ = outgoing.send(workspace_result_frame(&request_id, started, result));
600 });
601}
602
603async fn create_workspace_worktree(
604 config_path: &std::path::Path,
605 api: &crate::api::ApiClient,
606 project_id: &str,
607 source_root: &Path,
608 action: Value,
609 workspace: &WorkspaceEngine,
610 cancel: CancellationToken,
611) -> Result<Value, ExeoraError> {
612 let branch = action
613 .get("branch")
614 .and_then(Value::as_str)
615 .filter(|value| !value.is_empty())
616 .ok_or_else(|| {
617 ExeoraError::new(ErrorCode::InvalidArguments, "A branch name is required.")
618 })?;
619 let mut config = ConfigStore::load_from(config_path.to_path_buf()).map_err(|_| {
620 ExeoraError::new(
621 ErrorCode::InternalError,
622 "Could not reload the local Exeora configuration.",
623 )
624 })?;
625 let project = config.find_project(project_id).cloned().ok_or_else(|| {
626 ExeoraError::new(
627 ErrorCode::UnknownProject,
628 "This machine does not serve that project. Run `exeora project add` there.",
629 )
630 })?;
631 let entry = crate::worktrees::create(
632 &config,
633 &project,
634 crate::worktrees::CreateWorktree {
635 branch: branch.to_owned(),
636 from: action
637 .get("from")
638 .and_then(Value::as_str)
639 .map(str::to_owned),
640 reuse_existing_branch: action
641 .get("reuseExistingBranch")
642 .and_then(Value::as_bool)
643 .unwrap_or(false),
644 name: action
645 .get("name")
646 .and_then(Value::as_str)
647 .map(str::to_owned),
648 slug: action
649 .get("slug")
650 .and_then(Value::as_str)
651 .map(str::to_owned),
652 path: None,
653 source: Some(source_root.to_path_buf()),
654 },
655 )
656 .map_err(|error| ExeoraError::new(ErrorCode::InvalidArguments, error.to_string()))?;
657 config.upsert_worktree(entry.clone());
658 config.save().map_err(|error| {
659 ExeoraError::new(
660 ErrorCode::InternalError,
661 format!("Could not save the local worktree record: {error}"),
662 )
663 })?;
664 let mut entry = entry;
665 api.put_worktree(&entry.project_id, &entry)
666 .await
667 .map_err(|error| {
668 ExeoraError::new(
669 ErrorCode::InternalError,
670 format!("Created the Git worktree but could not register it with Exeora: {error}"),
671 )
672 })?;
673 entry.sync_state = WorktreeSyncState::Active;
674 config.upsert_worktree(entry.clone());
675 let _ = config.save();
676 let status = workspace
677 .execute(source_root, json!({ "action": "status" }), cancel)
678 .await?;
679 Ok(json!({
680 "kind": "mutation",
681 "stdout": "",
682 "stderr": "",
683 "status": status,
684 "worktree": {
685 "id": entry.id,
686 "slug": entry.slug,
687 "name": entry.name,
688 "branch": entry.branch,
689 "localPath": entry.root,
690 }
691 }))
692}
693
694async fn handle_terminal_message(
695 message: Value,
696 config_path: PathBuf,
697 workspace: Arc<WorkspaceEngine>,
698 outgoing: mpsc::Sender<Value>,
699) {
700 let Some(kind) = message.get("type").and_then(Value::as_str) else {
701 return;
702 };
703 let Some(session_id) = message
704 .get("sessionId")
705 .and_then(Value::as_str)
706 .map(str::to_owned)
707 else {
708 return;
709 };
710 let result = match kind {
711 "terminal.open" => {
712 let Some(project_id) = message.get("projectId").and_then(Value::as_str) else {
713 send_terminal_error(&outgoing, &session_id, "A terminal project is required.")
714 .await;
715 return;
716 };
717 let target = match resolve_target(
718 &config_path,
719 project_id,
720 message.get("worktreeId").and_then(Value::as_str),
721 message.get("worktreeSlug").and_then(Value::as_str),
722 ) {
723 Ok(target) => target,
724 Err(error) => {
725 send_terminal_error(&outgoing, &session_id, &error.message).await;
726 return;
727 }
728 };
729 let cols = message
730 .get("cols")
731 .and_then(Value::as_u64)
732 .and_then(|value| u16::try_from(value).ok());
733 let rows = message
734 .get("rows")
735 .and_then(Value::as_u64)
736 .and_then(|value| u16::try_from(value).ok());
737 match (cols, rows) {
738 (Some(cols), Some(rows)) => {
739 workspace
740 .terminal_open(
741 session_id.clone(),
742 &target.root,
743 cols,
744 rows,
745 outgoing.clone(),
746 )
747 .await
748 }
749 _ => Err(ExeoraError::new(
750 ErrorCode::InvalidArguments,
751 "Invalid terminal size.",
752 )),
753 }
754 }
755 "terminal.input" => match message
756 .get("data")
757 .and_then(Value::as_str)
758 .and_then(|data| STANDARD.decode(data).ok())
759 {
760 Some(data) => workspace.terminal_input(&session_id, &data).await,
761 None => Err(ExeoraError::new(
762 ErrorCode::InvalidArguments,
763 "Invalid terminal input encoding.",
764 )),
765 },
766 "terminal.resize" => {
767 let cols = message
768 .get("cols")
769 .and_then(Value::as_u64)
770 .and_then(|value| u16::try_from(value).ok());
771 let rows = message
772 .get("rows")
773 .and_then(Value::as_u64)
774 .and_then(|value| u16::try_from(value).ok());
775 match (cols, rows) {
776 (Some(cols), Some(rows)) => {
777 workspace.terminal_resize(&session_id, cols, rows).await
778 }
779 _ => Err(ExeoraError::new(
780 ErrorCode::InvalidArguments,
781 "Invalid terminal size.",
782 )),
783 }
784 }
785 "terminal.close" => {
786 workspace.terminal_close(&session_id).await;
787 Ok(())
788 }
789 _ => return,
790 };
791 if let Err(error) = result {
792 send_terminal_error(&outgoing, &session_id, &error.message).await;
793 }
794}
795
796async fn send_terminal_error(outgoing: &mpsc::Sender<Value>, session_id: &str, message: &str) {
797 let _ = outgoing
798 .send(json!({ "type": "terminal.error", "sessionId": session_id, "message": message }))
799 .await;
800}
801
802fn resolve_target(
803 config_path: &Path,
804 project_id: &str,
805 worktree_id: Option<&str>,
806 worktree_slug: Option<&str>,
807) -> Result<ResolvedTarget, ExeoraError> {
808 let config = ConfigStore::load_from(config_path.to_path_buf()).map_err(|_| {
809 ExeoraError::new(
810 ErrorCode::InternalError,
811 "Could not reload the local Exeora configuration.",
812 )
813 })?;
814 let project = config.find_project(project_id).cloned().ok_or_else(|| {
815 ExeoraError::new(
816 ErrorCode::UnknownProject,
817 "This machine does not serve that project. Run `exeora project add` there.",
818 )
819 })?;
820 let Some(worktree_id) = worktree_id else {
821 if worktree_slug.is_some() {
822 return Err(ExeoraError::new(
823 ErrorCode::WorktreeUnavailable,
824 "The worktree target is incomplete.",
825 ));
826 }
827 if !project.root.is_dir() {
828 return Err(ExeoraError::new(
829 ErrorCode::PathNotFound,
830 "The project directory is unavailable on this machine.",
831 ));
832 }
833 return Ok(ResolvedTarget {
834 root: std::fs::canonicalize(&project.root).unwrap_or_else(|_| project.root.clone()),
835 project,
836 worktree_slug: None,
837 });
838 };
839 let worktree = config
840 .data()
841 .worktrees
842 .iter()
843 .find(|entry| {
844 entry.id == worktree_id
845 && entry.project_id == project.id
846 && entry.sync_state == WorktreeSyncState::Active
847 && worktree_slug.is_none_or(|slug| slug == entry.slug)
848 })
849 .ok_or_else(|| {
850 ExeoraError::new(
851 ErrorCode::WorktreeUnavailable,
852 "That worktree is no longer connected or available on this machine.",
853 )
854 })?;
855 if !worktree.root.is_dir() {
856 return Err(ExeoraError::new(
857 ErrorCode::WorktreeUnavailable,
858 "That worktree is registered but its directory is unavailable.",
859 ));
860 }
861 Ok(ResolvedTarget {
862 project,
863 root: std::fs::canonicalize(&worktree.root).unwrap_or_else(|_| worktree.root.clone()),
864 worktree_slug: Some(worktree.slug.clone()),
865 })
866}
867
868fn workspace_result_frame(
869 request_id: &str,
870 started: u64,
871 result: Result<Value, ExeoraError>,
872) -> Value {
873 let result = match result {
874 Ok(value)
875 if serde_json::to_vec(&value).is_ok_and(|bytes| bytes.len() <= MAX_RESULT_BYTES) =>
876 {
877 json!({ "ok": true, "value": value })
878 }
879 Ok(_) => json!({
880 "ok": false,
881 "error": {
882 "code": ErrorCode::ToolFailed.as_str(),
883 "message": "Workspace result exceeded the protocol limit.",
884 }
885 }),
886 Err(error) => {
887 json!({ "ok": false, "error": { "code": error.code.as_str(), "message": error.message } })
888 }
889 };
890 json!({ "type": "workspace.result", "requestId": request_id, "durationMs": now_ms().saturating_sub(started), "result": result })
891}
892
893fn result_frame(request_id: &str, started: u64, result: Result<Value, ExeoraError>) -> Value {
894 let result = match result {
895 Ok(value)
896 if serde_json::to_vec(&value).is_ok_and(|bytes| bytes.len() <= MAX_RESULT_BYTES) =>
897 {
898 json!({ "ok": true, "value": value })
899 }
900 Ok(_) => json!({
901 "ok": false,
902 "error": {
903 "code": ErrorCode::ToolFailed.as_str(),
904 "message": format!("Tool result exceeded the {MAX_RESULT_BYTES}-byte protocol limit. Narrow the request and try again."),
905 }
906 }),
907 Err(error) => {
908 json!({ "ok": false, "error": { "code": error.code.as_str(), "message": error.message } })
909 }
910 };
911 json!({ "type": "tool.result", "requestId": request_id, "durationMs": now_ms().saturating_sub(started), "result": result })
912}
913
914async fn cancel_all(in_flight: &InFlight) {
915 let mut calls = in_flight.lock().await;
916 for call in calls.values() {
917 call.cancel.cancel();
918 }
919 calls.clear();
920}
921
922fn served_roots(config_path: &std::path::Path) -> HashSet<PathBuf> {
923 let Ok(config) = ConfigStore::load_from(config_path.to_path_buf()) else {
924 return HashSet::new();
925 };
926 let mut allowed: HashSet<PathBuf> = config
927 .data()
928 .projects
929 .iter()
930 .filter_map(|entry| std::fs::canonicalize(&entry.root).ok())
931 .collect();
932 allowed.extend(
933 config
934 .data()
935 .worktrees
936 .iter()
937 .filter(|entry| entry.sync_state == WorktreeSyncState::Active)
938 .filter_map(|entry| std::fs::canonicalize(&entry.root).ok()),
939 );
940 allowed
941}
942
943async fn reconcile_roots(
944 config_path: &std::path::Path,
945 engine: &ToolEngine,
946 workspace: &WorkspaceEngine,
947 in_flight: &InFlight,
948 known_roots: &mut HashSet<PathBuf>,
949) {
950 let allowed = served_roots(config_path);
951 let mut removed: HashSet<PathBuf> = known_roots.difference(&allowed).cloned().collect();
952 removed.extend({
953 let calls = in_flight.lock().await;
954 calls
955 .values()
956 .map(|call| std::fs::canonicalize(&call.root).unwrap_or_else(|_| call.root.clone()))
957 .filter(|root| !allowed.contains(root))
958 .collect::<Vec<_>>()
959 });
960 *known_roots = allowed;
961 if removed.is_empty() {
962 return;
963 }
964 {
965 let calls = in_flight.lock().await;
966 for call in calls.values() {
967 let root = std::fs::canonicalize(&call.root).unwrap_or_else(|_| call.root.clone());
968 if removed.contains(&root) {
969 call.cancel.cancel();
970 }
971 }
972 }
973 for root in removed {
974 engine.kill_root(&root).await;
975 workspace.kill_root(&root).await;
976 }
977}
978
979fn describe_client(value: Option<&Value>) -> Option<String> {
980 let value = value?;
981 match (
982 value.get("name").and_then(Value::as_str),
983 value.get("version").and_then(Value::as_str),
984 ) {
985 (Some(name), Some(version)) => Some(format!("{name} {version}")),
986 (Some(name), None) => Some(name.to_owned()),
987 (None, Some(version)) => Some(version.to_owned()),
988 _ => None,
989 }
990}
991
992fn emit_event(json_output: bool, event: &str, fields: Value) {
993 if !json_output {
994 return;
995 }
996 let mut value = json!({ "at": now_ms(), "event": event });
997 if let (Some(target), Some(source)) = (value.as_object_mut(), fields.as_object()) {
998 target.extend(source.clone());
999 }
1000 println!("{value}");
1001}
1002
1003fn is_outdated(current: &str, latest: &str) -> bool {
1004 match (
1005 semver::Version::parse(current),
1006 semver::Version::parse(latest),
1007 ) {
1008 (Ok(current), Ok(latest)) => current < latest,
1009 _ => false,
1010 }
1011}
1012
1013fn platform() -> &'static str {
1014 if cfg!(target_os = "windows") {
1015 "win32"
1016 } else if cfg!(target_os = "macos") {
1017 "darwin"
1018 } else {
1019 "linux"
1020 }
1021}
1022
1023#[cfg(test)]
1024mod tests {
1025 use super::{awake_event, handshake_rejection, resolve_target, result_frame};
1026 use crate::{
1027 config::{ConfigStore, ProjectEntry, WorktreeEntry, WorktreeSyncState},
1028 error::ErrorCode,
1029 protocol::MAX_RESULT_BYTES,
1030 };
1031 use serde_json::json;
1032 use std::fs;
1033 use tempfile::tempdir;
1034
1035 #[test]
1036 fn rejects_an_oversized_tool_result_before_it_reaches_the_socket() {
1037 let frame = result_frame(
1038 "req_test",
1039 0,
1040 Ok(json!({ "content": "x".repeat(MAX_RESULT_BYTES) })),
1041 );
1042 assert_eq!(frame["result"]["ok"], false);
1043 assert_eq!(frame["result"]["error"]["code"], "TOOL_FAILED");
1044 }
1045
1046 #[test]
1047 fn explains_a_rejected_relay_handshake_instead_of_hiding_it_in_retries() {
1048 let message = handshake_rejection(
1049 403,
1050 Some(br#"{"error":"insufficient_scope","requiredScopes":["executor:connect"]}"#),
1051 );
1052
1053 assert_eq!(
1054 message,
1055 "Relay rejected the connection (403): insufficient_scope; required scopes: executor:connect"
1056 );
1057 }
1058
1059 #[test]
1060 fn reports_keep_awake_state_without_breaking_json_streams() {
1061 assert_eq!(
1062 awake_event(true, None),
1063 json!({ "active": true, "system": true, "display": true })
1064 );
1065 assert_eq!(
1066 awake_event(false, Some("not supported")),
1067 json!({
1068 "active": false,
1069 "system": false,
1070 "display": false,
1071 "reason": "not supported",
1072 })
1073 );
1074 }
1075
1076 #[test]
1077 fn resolves_only_the_active_worktree_with_the_matching_stable_identity() {
1078 let directory = tempdir().unwrap();
1079 let main = directory.path().join("main");
1080 let feature = directory.path().join("feature");
1081 let pending = directory.path().join("pending");
1082 fs::create_dir_all(&main).unwrap();
1083 fs::create_dir_all(&feature).unwrap();
1084 fs::create_dir_all(&pending).unwrap();
1085 let config_path = directory.path().join("config.json");
1086 let mut config = ConfigStore::load_from(config_path.clone()).unwrap();
1087 config.upsert_project(ProjectEntry {
1088 id: "prj_1".to_owned(),
1089 slug: "project".to_owned(),
1090 name: "Project".to_owned(),
1091 root: main.clone(),
1092 });
1093 for (id, slug, root, sync_state) in [
1094 (
1095 "wtr_active",
1096 "feature",
1097 feature.clone(),
1098 WorktreeSyncState::Active,
1099 ),
1100 (
1101 "wtr_pending",
1102 "pending",
1103 pending,
1104 WorktreeSyncState::PendingUpsert,
1105 ),
1106 ] {
1107 config.upsert_worktree(WorktreeEntry {
1108 id: id.to_owned(),
1109 project_id: "prj_1".to_owned(),
1110 slug: slug.to_owned(),
1111 name: slug.to_owned(),
1112 branch: Some(slug.to_owned()),
1113 git_root: main.clone(),
1114 root,
1115 managed: true,
1116 sync_state,
1117 });
1118 }
1119 config.save().unwrap();
1120
1121 let resolved =
1122 resolve_target(&config_path, "prj_1", Some("wtr_active"), Some("feature")).unwrap();
1123 assert_eq!(resolved.root, fs::canonicalize(feature).unwrap());
1124 assert_eq!(resolved.worktree_slug.as_deref(), Some("feature"));
1125
1126 for (id, slug) in [
1127 ("wtr_active", Some("renamed")),
1128 ("wtr_pending", Some("pending")),
1129 ("wtr_missing", None),
1130 ] {
1131 let error = resolve_target(&config_path, "prj_1", Some(id), slug).unwrap_err();
1132 assert_eq!(error.code, ErrorCode::WorktreeUnavailable);
1133 }
1134 }
1135}