Skip to main content

exeora_cli/
connection.rs

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        // Local work must never outlive the authenticated relay that opened it.
86        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}