main
rs 797 lines 30.8 KB
Raw
1 //! The ACP client side, and the agent it serves in-process.
2 //!
3 //! [`AgentState::connection`] starts both halves on first use:
4 //!
5 //! - `ed_acp::serve(profile)` on one end of a [`Channel::duplex`], exactly what the product's
6 //! agent runs over stdio;
7 //! - an ACP client on the other end, which forwards `session/update` notifications and
8 //! `session/request_permission` requests to the webview, serves `fs/read_text_file` and
9 //! `fs/write_text_file` inside the session's roots, and opens the page of a URL-mode
10 //! elicitation in the system browser.
11
12 use std::collections::HashMap;
13 use std::path::{Path, PathBuf};
14 use std::sync::atomic::{AtomicBool, Ordering};
15 use std::sync::{Arc, Mutex};
16
17 use agent_client_protocol::schema::v1::{
18 AuthMethodId, AuthenticateRequest, CancelNotification, ClientCapabilities,
19 CompleteElicitationNotification, ContentBlock, CreateElicitationRequest,
20 CreateElicitationResponse, ElicitationAcceptAction, ElicitationAction, ElicitationCapabilities,
21 ElicitationMode, ElicitationUrlCapabilities, ErrorCode, FileSystemCapabilities, Implementation,
22 InitializeRequest, ListSessionsRequest, LoadSessionRequest, LogoutRequest, NewSessionRequest,
23 PromptRequest, ReadTextFileRequest, ReadTextFileResponse, RequestPermissionOutcome,
24 RequestPermissionRequest, RequestPermissionResponse, SelectedPermissionOutcome,
25 SessionConfigOption, SessionConfigOptionValue, SessionId, SessionNotification,
26 SetSessionConfigOptionRequest, TextContent, WriteTextFileRequest, WriteTextFileResponse,
27 };
28 use agent_client_protocol::schema::ProtocolVersion;
29 use agent_client_protocol::{Agent, Channel, Client, ConnectionTo};
30 use ed_acp::llm::LlmClient;
31 use ed_acp::{cli, LlmConfig, ServeOptions};
32 use log::{error, info, warn};
33 use serde_json::json;
34 use tauri::{AppHandle, Emitter};
35 use tauri_plugin_opener::OpenerExt;
36 use tokio::sync::oneshot;
37
38 use crate::{
39 AgentSessionOpened, AgentSessionSummary, AgentStatus, AgentTurnEndedPayload, HostConfig, Roots,
40 AUTH_REQUIRED,
41 };
42
43 type Pending = Arc<Mutex<HashMap<String, oneshot::Sender<RequestPermissionOutcome>>>>;
44 /// The roots each open session may use, by session id.
45 type SessionRoots = Arc<Mutex<HashMap<String, Roots>>>;
46
47 /// Managed Tauri state: the live connection and the permission requests waiting on the user.
48 pub struct AgentState {
49 config: HostConfig,
50 connection: tokio::sync::Mutex<Option<Live>>,
51 permissions: Pending,
52 roots: SessionRoots,
53 }
54
55 struct Live {
56 conn: ConnectionTo<Agent>,
57 /// The ids of the auth methods the agent advertised in `initialize`.
58 auth_methods: Vec<String>,
59 /// Dropped (or fired) to hang up, which stops the agent too.
60 _hang_up: oneshot::Sender<()>,
61 /// Set when the client side has ended, so the next call reconnects.
62 ended: Arc<AtomicBool>,
63 }
64
65 /// The `cwd` to ask `session/list` for, or `None` for every stored session.
66 ///
67 /// On a desktop the agent's data directory can be shared with the command-line agent, so the
68 /// app asks only for the conversations in the folder it opened. On iOS that directory is
69 /// inside the app's container, which nothing else writes to and whose path changes with every
70 /// app update. Sessions stored under the old path would never match, and the user's earlier
71 /// conversations would drop out of the list. Loading one rebinds it to the current path.
72 fn list_filter(cwd: &Path, private_data_dir: bool) -> Option<PathBuf> {
73 (!private_data_dir).then(|| cwd.to_path_buf())
74 }
75
76 fn list_request(roots: &Roots) -> ListSessionsRequest {
77 ListSessionsRequest::new().cwd(list_filter(&roots.cwd, cfg!(target_os = "ios")))
78 }
79
80 /// What the agent will use, without starting it: the profile's own session when it has an
81 /// auth provider, else the environment and the stored `env` file.
82 pub fn status(host: &HostConfig) -> AgentStatus {
83 let profile = (host.profile)();
84 let config = match profile.auth() {
85 Some(auth) => auth.llm_config(),
86 None => Some(LlmConfig::from_env(&profile.info().env)),
87 };
88 match config {
89 Some(config) => AgentStatus {
90 available: true,
91 has_key: config.api_key.is_some(),
92 model: config.model,
93 },
94 None => AgentStatus {
95 available: true,
96 has_key: false,
97 model: profile.info().env.default_model.to_string(),
98 },
99 }
100 }
101
102 /// What `<agent> --setup` does for Onde Inference, minus the terminal: check the key with Onde
103 /// Cloud, then write it to the agent's `env` file (mode 0600). With a `model` it is written as
104 /// `<PREFIX>_MODEL` beside the key and used for the check, so the agent doesn't fall back to a
105 /// default tier the key isn't allowed to call. In the App Store sandbox that file lives in
106 /// the app's container, so a key stored by the command-line agent isn't shared.
107 pub async fn set_api_key(host: &HostConfig, key: &str, model: Option<&str>) -> Result<(), String> {
108 let info = host.info();
109 let key = key.trim();
110 if !cli::valid_onde_key(key) {
111 return Err("An Onde key looks like \"app-id:app-secret\".".to_string());
112 }
113 let provider_var = info.env.var("PROVIDER");
114 let mut vars = vec![
115 (provider_var, "onde".to_string()),
116 ("ONDE_API_KEY".to_string(), key.to_string()),
117 ];
118 if let Some(model) = model {
119 vars.push((info.env.var("MODEL"), model.to_string()));
120 }
121 let config = LlmConfig::from_lookup(&info.env, |name| {
122 vars.iter()
123 .find(|(k, _)| k == name)
124 .map(|(_, v)| v.clone())
125 .or_else(|| std::env::var(name).ok())
126 });
127 LlmClient::new(config)
128 .check_auth()
129 .await
130 .map_err(|e| format!("Onde Cloud rejected the key: {e:#}"))?;
131
132 let dir = info
133 .env
134 .config_dir()
135 .ok_or("No config directory available.")?;
136 std::fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?;
137 let path = dir.join("env");
138 let existing = std::fs::read_to_string(&path).unwrap_or_default();
139 std::fs::write(&path, cli::with_vars(&existing, &vars))
140 .map_err(|e| format!("{}: {e}", path.display()))?;
141 #[cfg(unix)]
142 {
143 use std::os::unix::fs::PermissionsExt;
144 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))
145 .map_err(|e| format!("{}: {e}", path.display()))?;
146 }
147 info!("Stored the agent's Onde key in {}", path.display());
148 Ok(())
149 }
150
151 /// Remove the agent's Onde key, and the model that came with it, from its env file. Nothing to
152 /// do when there is no file or no key in it.
153 pub fn clear_api_key(host: &HostConfig) -> Result<(), String> {
154 let info = host.info();
155 let Some(dir) = info.env.config_dir() else {
156 return Ok(());
157 };
158 let path = dir.join("env");
159 let existing = match std::fs::read_to_string(&path) {
160 Ok(existing) => existing,
161 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
162 Err(e) => return Err(format!("{}: {e}", path.display())),
163 };
164 let (kept, removed) = without_api_key(&existing, &info.env.var("MODEL"));
165 if removed {
166 std::fs::write(&path, kept).map_err(|e| format!("{}: {e}", path.display()))?;
167 info!("Removed the agent's Onde key from {}", path.display());
168 }
169 Ok(())
170 }
171
172 /// `env` file content without any `ONDE_API_KEY` or `model_var` line, and whether a key was
173 /// there.
174 fn without_api_key(existing: &str, model_var: &str) -> (String, bool) {
175 let model_prefix = format!("{model_var}=");
176 let mut removed = false;
177 let kept: Vec<&str> = existing
178 .lines()
179 .filter(|line| {
180 let line = line.trim();
181 let line = line.strip_prefix("export ").unwrap_or(line);
182 let is_key = line.starts_with("ONDE_API_KEY=");
183 removed |= is_key;
184 !is_key && !line.starts_with(&model_prefix)
185 })
186 .collect();
187 let mut out = kept.join("\n");
188 if !out.is_empty() {
189 out.push('\n');
190 }
191 (out, removed)
192 }
193
194 impl AgentState {
195 pub fn new(config: HostConfig) -> Self {
196 Self {
197 config,
198 connection: tokio::sync::Mutex::new(None),
199 permissions: Pending::default(),
200 roots: SessionRoots::default(),
201 }
202 }
203
204 /// The live connection, starting the agent and the client on first use or after the
205 /// previous connection ended.
206 async fn connection(
207 &self,
208 app: &AppHandle,
209 ) -> Result<(ConnectionTo<Agent>, Vec<String>), String> {
210 let mut slot = self.connection.lock().await;
211 if let Some(live) = slot.as_ref() {
212 if !live.ended.load(Ordering::SeqCst) {
213 return Ok((live.conn.clone(), live.auth_methods.clone()));
214 }
215 }
216 let live = self.connect(app).await?;
217 let out = (live.conn.clone(), live.auth_methods.clone());
218 *slot = Some(live);
219 Ok(out)
220 }
221
222 async fn connect(&self, app: &AppHandle) -> Result<Live, String> {
223 let (agent_end, client_end) = Channel::duplex();
224
225 let opts = ServeOptions {
226 yolo: false,
227 surface: self.config.surface,
228 };
229 let profile = (self.config.profile)();
230 let agent_name = profile.info().display_name;
231 tauri::async_runtime::spawn(async move {
232 if let Err(e) = ed_acp::serve(profile, opts, agent_end).await {
233 error!("{agent_name} agent stopped: {e}");
234 }
235 });
236
237 let (ready_tx, ready_rx) =
238 oneshot::channel::<Result<(ConnectionTo<Agent>, Vec<String>), String>>();
239 let (hang_up, hung_up) = oneshot::channel::<()>();
240 let ended = Arc::new(AtomicBool::new(false));
241
242 let client = Client
243 .builder()
244 .name(self.config.client_name)
245 .on_receive_notification(
246 {
247 let app = app.clone();
248 let event = self.config.events.session_update;
249 async move |n: SessionNotification, _cx| {
250 if let Err(e) = app.emit(event, &n) {
251 error!("Failed to emit agent session update: {e:?}");
252 }
253 Ok(())
254 }
255 },
256 agent_client_protocol::on_receive_notification!(),
257 )
258 .on_receive_notification(
259 async move |n: CompleteElicitationNotification, _cx| {
260 info!("Sign-in page completed ({})", n.elicitation_id);
261 Ok(())
262 },
263 agent_client_protocol::on_receive_notification!(),
264 )
265 .on_receive_request(
266 {
267 let app = app.clone();
268 async move |req: CreateElicitationRequest, responder, _cx| {
269 responder.respond(CreateElicitationResponse::new(open_url(&app, &req)))
270 }
271 },
272 agent_client_protocol::on_receive_request!(),
273 )
274 .on_receive_request(
275 {
276 let app = app.clone();
277 let pending = self.permissions.clone();
278 let event = self.config.events.permission_requested;
279 async move |req: RequestPermissionRequest, responder, cx| {
280 // Wait for the user off the dispatch loop so updates keep flowing.
281 let request_id = uuid::Uuid::new_v4().to_string();
282 let (answer_tx, answer_rx) = oneshot::channel();
283 pending
284 .lock()
285 .unwrap()
286 .insert(request_id.clone(), answer_tx);
287 let payload = json!({ "requestId": request_id, "request": req });
288 if let Err(e) = app.emit(event, &payload) {
289 error!("Failed to emit agent permission request: {e:?}");
290 pending.lock().unwrap().remove(&request_id);
291 }
292 cx.spawn(async move {
293 let outcome = answer_rx
294 .await
295 .unwrap_or(RequestPermissionOutcome::Cancelled);
296 responder.respond(RequestPermissionResponse::new(outcome))
297 })
298 }
299 },
300 agent_client_protocol::on_receive_request!(),
301 )
302 .on_receive_request(
303 {
304 let roots = self.roots.clone();
305 async move |req: ReadTextFileRequest, responder, _cx| {
306 let roots = roots.clone();
307 let result =
308 tokio::task::spawn_blocking(move || read_text_file(&roots, &req)).await;
309 match result {
310 Ok(Ok(r)) => responder.respond(r),
311 Ok(Err(e)) => responder.respond_with_error(e),
312 Err(e) => responder.respond_with_internal_error(e.to_string()),
313 }
314 }
315 },
316 agent_client_protocol::on_receive_request!(),
317 )
318 .on_receive_request(
319 {
320 let roots = self.roots.clone();
321 async move |req: WriteTextFileRequest, responder, _cx| {
322 let roots = roots.clone();
323 let result =
324 tokio::task::spawn_blocking(move || write_text_file(&roots, &req))
325 .await;
326 match result {
327 Ok(Ok(r)) => responder.respond(r),
328 Ok(Err(e)) => responder.respond_with_error(e),
329 Err(e) => responder.respond_with_internal_error(e.to_string()),
330 }
331 }
332 },
333 agent_client_protocol::on_receive_request!(),
334 );
335
336 let ended_flag = ended.clone();
337 let pending = self.permissions.clone();
338 let client_name = self.config.client_name;
339 let client_version = self.config.client_version;
340 tauri::async_runtime::spawn(async move {
341 let result = client
342 .connect_with(client_end, |conn: ConnectionTo<Agent>| async move {
343 let init = conn
344 .send_request(
345 InitializeRequest::new(ProtocolVersion::V1)
346 .client_capabilities(
347 ClientCapabilities::new()
348 .fs(FileSystemCapabilities::new()
349 .read_text_file(true)
350 .write_text_file(true))
351 .elicitation(
352 ElicitationCapabilities::new()
353 .url(ElicitationUrlCapabilities::new()),
354 ),
355 )
356 .client_info(Implementation::new(client_name, client_version)),
357 )
358 .block_task()
359 .await;
360 match init {
361 Ok(init) => {
362 let methods = init
363 .auth_methods
364 .iter()
365 .map(|m| m.id().to_string())
366 .collect();
367 let _ = ready_tx.send(Ok((conn, methods)));
368 // Stay connected until the state is dropped or replaced.
369 let _ = hung_up.await;
370 Ok(())
371 }
372 Err(e) => {
373 let _ = ready_tx.send(Err(format!("Agent failed to start: {e}")));
374 Err(e)
375 }
376 }
377 })
378 .await;
379 if let Err(e) = result {
380 warn!("Agent connection ended: {e}");
381 }
382 ended_flag.store(true, Ordering::SeqCst);
383 // Nobody can answer these any more.
384 pending.lock().unwrap().clear();
385 });
386
387 let (conn, auth_methods) = ready_rx
388 .await
389 .map_err(|_| "Agent failed to start.".to_string())??;
390 info!("Agent connected in-process");
391 Ok(Live {
392 conn,
393 auth_methods,
394 _hang_up: hang_up,
395 ended,
396 })
397 }
398
399 fn roots_for(&self, app: &AppHandle, cwd: Option<&Path>) -> Result<Roots, String> {
400 (self.config.roots)(app, cwd)
401 }
402
403 /// The agent's conversations in `cwd` (the host's default roots when `None`), newest
404 /// first.
405 pub async fn list_sessions(
406 &self,
407 app: &AppHandle,
408 cwd: Option<PathBuf>,
409 ) -> Result<Vec<AgentSessionSummary>, String> {
410 let roots = self.roots_for(app, cwd.as_deref())?;
411 let (conn, _) = self.connection(app).await?;
412 let listed = conn
413 .send_request(list_request(&roots))
414 .block_task()
415 .await
416 .map_err(describe)?;
417 Ok(listed
418 .sessions
419 .into_iter()
420 .map(|session| AgentSessionSummary {
421 session_id: session.session_id.to_string(),
422 title: session.title,
423 updated_at: session.updated_at,
424 })
425 .collect())
426 }
427
428 /// Open a conversation in `cwd`: the one named by `session_id`, else (unless `fresh`) the
429 /// latest in that folder, else a new one.
430 pub async fn open_session(
431 &self,
432 app: &AppHandle,
433 cwd: Option<PathBuf>,
434 fresh: bool,
435 access_token: Option<String>,
436 session_id: Option<String>,
437 ) -> Result<AgentSessionOpened, String> {
438 let roots = self.roots_for(app, cwd.as_deref())?;
439 let (conn, _) = self.connection(app).await?;
440 let mcp_servers = (self.config.mcp_servers)(app, access_token);
441 // The one the user picked, else the latest.
442 let picked = session_id.is_some();
443 let target = match session_id {
444 Some(id) => Some(SessionId::new(id)),
445 None if !fresh => conn
446 .send_request(list_request(&roots))
447 .block_task()
448 .await
449 .map_err(describe)?
450 .sessions
451 .into_iter()
452 // Newest first.
453 .next()
454 .map(|latest| latest.session_id),
455 None => None,
456 };
457 if let Some(id) = target {
458 // The agent may read files while it replays, so the roots come first.
459 self.roots
460 .lock()
461 .unwrap()
462 .insert(id.to_string(), roots.clone());
463 let load = LoadSessionRequest::new(id.clone(), roots.cwd.clone())
464 .additional_directories(roots.extra.clone())
465 .mcp_servers(mcp_servers.clone());
466 match conn.send_request(load).block_task().await {
467 Ok(loaded) => {
468 return Ok(AgentSessionOpened {
469 session_id: id.to_string(),
470 restored: true,
471 config_options: option_values(loaded.config_options),
472 });
473 }
474 Err(e) if e.code == ErrorCode::AuthRequired => {
475 self.roots.lock().unwrap().remove(&id.to_string());
476 return Err(AUTH_REQUIRED.to_string());
477 }
478 Err(e) => {
479 self.roots.lock().unwrap().remove(&id.to_string());
480 // One the user picked and can't have is an error to show.
481 if picked {
482 return Err(describe(e));
483 }
484 // The latest no longer loading shouldn't block a new one.
485 warn!("Could not load agent session {id}: {e}");
486 }
487 }
488 }
489 let new = NewSessionRequest::new(roots.cwd.clone())
490 .additional_directories(roots.extra.clone())
491 .mcp_servers(mcp_servers);
492 let session = conn
493 .send_request(new)
494 .block_task()
495 .await
496 .map_err(describe)?;
497 self.roots
498 .lock()
499 .unwrap()
500 .insert(session.session_id.to_string(), roots);
501 Ok(AgentSessionOpened {
502 session_id: session.session_id.to_string(),
503 restored: false,
504 config_options: option_values(session.config_options),
505 })
506 }
507
508 pub async fn prompt(
509 &self,
510 app: &AppHandle,
511 session_id: String,
512 text: String,
513 ) -> Result<(), String> {
514 let (conn, _) = self.connection(app).await?;
515 let request = PromptRequest::new(
516 SessionId::new(session_id.clone()),
517 vec![ContentBlock::Text(TextContent::new(text))],
518 );
519 let app = app.clone();
520 let event = self.config.events.turn_ended;
521 // Returned before the turn ends: a long turn would otherwise hold the invoke
522 // callback open for minutes.
523 tauri::async_runtime::spawn(async move {
524 let result = conn.send_request(request).block_task().await;
525 let payload = match result {
526 Ok(r) => AgentTurnEndedPayload {
527 session_id,
528 stop_reason: serde_json::to_value(r.stop_reason)
529 .ok()
530 .and_then(|v| v.as_str().map(String::from)),
531 error: None,
532 },
533 Err(e) => AgentTurnEndedPayload {
534 session_id,
535 stop_reason: None,
536 error: Some(describe(e)),
537 },
538 };
539 if let Err(e) = app.emit(event, &payload) {
540 error!("Failed to emit agent turn end: {e:?}");
541 }
542 });
543 Ok(())
544 }
545
546 pub async fn cancel(&self, session_id: String) -> Result<(), String> {
547 // ACP: a client cancelling a turn answers its open permission requests with
548 // `cancelled`.
549 for (_, answer) in self.permissions.lock().unwrap().drain() {
550 let _ = answer.send(RequestPermissionOutcome::Cancelled);
551 }
552 let slot = self.connection.lock().await;
553 let Some(live) = slot.as_ref() else {
554 return Ok(());
555 };
556 live.conn
557 .send_notification(CancelNotification::new(SessionId::new(session_id)))
558 .map_err(|e| e.to_string())
559 }
560
561 pub fn respond_permission(
562 &self,
563 request_id: &str,
564 option_id: Option<String>,
565 ) -> Result<(), String> {
566 let answer = self
567 .permissions
568 .lock()
569 .unwrap()
570 .remove(request_id)
571 .ok_or("That request is no longer waiting for an answer.")?;
572 let outcome = match option_id {
573 Some(id) => RequestPermissionOutcome::Selected(SelectedPermissionOutcome::new(id)),
574 None => RequestPermissionOutcome::Cancelled,
575 };
576 let _ = answer.send(outcome);
577 Ok(())
578 }
579
580 /// Change a config option (the model, auto-approve). `value` is a select value id as a
581 /// string, or a boolean. Returns the session's options as they are afterwards.
582 pub async fn set_config_option(
583 &self,
584 app: &AppHandle,
585 session_id: String,
586 config_id: String,
587 value: serde_json::Value,
588 ) -> Result<Vec<serde_json::Value>, String> {
589 let value = match value {
590 serde_json::Value::Bool(value) => SessionConfigOptionValue::from(value),
591 serde_json::Value::String(value) => SessionConfigOptionValue::from(value.as_str()),
592 other => return Err(format!("Not a config option value: {other}")),
593 };
594 let (conn, _) = self.connection(app).await?;
595 let response = conn
596 .send_request(SetSessionConfigOptionRequest::new(
597 SessionId::new(session_id),
598 config_id,
599 value,
600 ))
601 .block_task()
602 .await
603 .map_err(describe)?;
604 Ok(option_values(Some(response.config_options)))
605 }
606
607 /// Sign in with one of the agent's auth methods: `method_id`, else the first it
608 /// advertised. For a product with its own sign-in this opens the browser through the
609 /// client's URL elicitation and returns once the page is finished.
610 pub async fn authenticate(
611 &self,
612 app: &AppHandle,
613 method_id: Option<String>,
614 ) -> Result<(), String> {
615 let (conn, methods) = self.connection(app).await?;
616 let method = method_id
617 .or_else(|| methods.first().cloned())
618 .ok_or("The agent offers no way to sign in.")?;
619 conn.send_request(AuthenticateRequest::new(AuthMethodId::new(method)))
620 .block_task()
621 .await
622 .map_err(|e| e.to_string())?;
623 Ok(())
624 }
625
626 /// Sign out: the agent forgets its stored session.
627 pub async fn logout(&self, app: &AppHandle) -> Result<(), String> {
628 let (conn, _) = self.connection(app).await?;
629 conn.send_request(LogoutRequest::new())
630 .block_task()
631 .await
632 .map_err(|e| e.to_string())?;
633 Ok(())
634 }
635 }
636
637 /// Answer a URL-mode elicitation by opening its page in the system browser. Only `http` and
638 /// `https` are opened; the user "declines" anything else, or a browser that won't launch.
639 fn open_url(app: &AppHandle, req: &CreateElicitationRequest) -> ElicitationAction {
640 let ElicitationMode::Url(mode) = &req.mode else {
641 // Form mode was never advertised.
642 return ElicitationAction::Decline;
643 };
644 if !(mode.url.starts_with("https://") || mode.url.starts_with("http://")) {
645 warn!("Refusing to open a non-web sign-in URL");
646 return ElicitationAction::Decline;
647 }
648 match app.opener().open_url(&mode.url, None::<&str>) {
649 Ok(()) => ElicitationAction::Accept(ElicitationAcceptAction::new()),
650 Err(e) => {
651 warn!("Could not open the sign-in page: {e}");
652 ElicitationAction::Decline
653 }
654 }
655 }
656
657 fn option_values(options: Option<Vec<SessionConfigOption>>) -> Vec<serde_json::Value> {
658 options
659 .unwrap_or_default()
660 .into_iter()
661 .filter_map(|option| serde_json::to_value(option).ok())
662 .collect()
663 }
664
665 /// [`AUTH_REQUIRED`] for a missing key or session, so the page can ask for sign-in; the
666 /// agent's message otherwise.
667 fn describe(e: agent_client_protocol::Error) -> String {
668 if e.code == ErrorCode::AuthRequired {
669 AUTH_REQUIRED.to_string()
670 } else {
671 e.to_string()
672 }
673 }
674
675 fn outside(path: &Path) -> agent_client_protocol::Error {
676 agent_client_protocol::Error::invalid_params().data(format!(
677 "{} is outside the session's workspace",
678 path.display()
679 ))
680 }
681
682 fn roots_of(
683 roots: &SessionRoots,
684 session: &SessionId,
685 path: &Path,
686 ) -> agent_client_protocol::Result<Roots> {
687 roots
688 .lock()
689 .unwrap()
690 .get(&session.to_string())
691 .cloned()
692 .filter(|roots| roots.contains(path))
693 .ok_or_else(|| outside(path))
694 }
695
696 /// `fs/read_text_file`, honouring the optional 1-based `line` and `limit`.
697 fn read_text_file(
698 roots: &SessionRoots,
699 req: &ReadTextFileRequest,
700 ) -> agent_client_protocol::Result<ReadTextFileResponse> {
701 roots_of(roots, &req.session_id, &req.path)?;
702 let content = std::fs::read_to_string(&req.path).map_err(|e| {
703 if e.kind() == std::io::ErrorKind::NotFound {
704 agent_client_protocol::Error::resource_not_found(Some(req.path.display().to_string()))
705 } else {
706 agent_client_protocol::Error::internal_error().data(e.to_string())
707 }
708 })?;
709 let skip = req.line.map_or(0, |l| l.saturating_sub(1) as usize);
710 let take = req.limit.map_or(usize::MAX, |l| l as usize);
711 let content = content
712 .lines()
713 .skip(skip)
714 .take(take)
715 .collect::<Vec<_>>()
716 .join("\n");
717 Ok(ReadTextFileResponse::new(content))
718 }
719
720 /// `fs/write_text_file`: the client creates the file and its parent directories when they
721 /// don't exist.
722 fn write_text_file(
723 roots: &SessionRoots,
724 req: &WriteTextFileRequest,
725 ) -> agent_client_protocol::Result<WriteTextFileResponse> {
726 roots_of(roots, &req.session_id, &req.path)?;
727 let write = || {
728 if let Some(parent) = req.path.parent() {
729 std::fs::create_dir_all(parent)?;
730 }
731 std::fs::write(&req.path, &req.content)
732 };
733 write().map_err(|e| agent_client_protocol::Error::internal_error().data(e.to_string()))?;
734 Ok(WriteTextFileResponse::new())
735 }
736
737 #[cfg(test)]
738 mod tests {
739 use super::*;
740
741 #[test]
742 fn sessions_are_listed_by_directory_unless_the_data_dir_is_private() {
743 let cwd = Path::new("/data/agent");
744 assert_eq!(list_filter(cwd, false), Some(cwd.to_path_buf()));
745 // iOS: the container path changes with every update, so no filter.
746 assert_eq!(list_filter(cwd, true), None);
747 }
748
749 #[test]
750 fn removing_the_key_keeps_every_other_line() {
751 let (kept, removed) = without_api_key(
752 "AGENT_PROVIDER=onde\nONDE_API_KEY=app:secret\nAGENT_MODEL=x\nAGENT_SURFACE=app\n",
753 "AGENT_MODEL",
754 );
755 assert!(removed);
756 assert_eq!(kept, "AGENT_PROVIDER=onde\nAGENT_SURFACE=app\n");
757 }
758
759 #[test]
760 fn an_exported_key_goes_too_and_a_file_without_one_is_untouched() {
761 assert_eq!(
762 without_api_key("export ONDE_API_KEY=a:b\n", "AGENT_MODEL"),
763 (String::new(), true)
764 );
765 assert_eq!(
766 without_api_key("AGENT_PROVIDER=onde\n", "AGENT_MODEL"),
767 ("AGENT_PROVIDER=onde\n".to_string(), false)
768 );
769 assert_eq!(without_api_key("", "AGENT_MODEL"), (String::new(), false));
770 }
771
772 #[test]
773 fn a_similar_variable_is_not_the_key() {
774 let (kept, removed) =
775 without_api_key("ONDE_API_KEY_BACKUP=x\nAGENT_MODEL_X=y\n", "AGENT_MODEL");
776 assert!(!removed);
777 assert_eq!(kept, "ONDE_API_KEY_BACKUP=x\nAGENT_MODEL_X=y\n");
778 }
779
780 #[test]
781 fn a_session_reaches_only_its_own_roots() {
782 let roots = SessionRoots::default();
783 roots.lock().unwrap().insert(
784 "a".into(),
785 Roots {
786 cwd: PathBuf::from("/work/a"),
787 extra: Vec::new(),
788 },
789 );
790 let a = SessionId::new("a");
791 let b = SessionId::new("b");
792 assert!(roots_of(&roots, &a, Path::new("/work/a/src/main.rs")).is_ok());
793 assert!(roots_of(&roots, &a, Path::new("/work/b/src/main.rs")).is_err());
794 // An id nobody opened has no roots at all.
795 assert!(roots_of(&roots, &b, Path::new("/work/a/src/main.rs")).is_err());
796 }
797 }