1use std::collections::HashMap;
2use std::collections::HashSet;
3use std::path::Path;
4use std::path::PathBuf;
5use std::sync::Arc;
6
7use axum::extract::{DefaultBodyLimit, Multipart, State};
8use axum::http::{HeaderMap, StatusCode};
9use axum::routing::{get, post};
10use axum::{Json, Router};
11use base64::Engine as _;
12use serde::Deserialize;
13use tokio::io::AsyncReadExt;
14use tokio::sync::Mutex;
15use tokio::task::JoinHandle;
16use tracing;
17
18use rap_protocol::{
19 CallbackClient, DiffContent, DisplaySegment, RapCallback, RapInvocation, RapToolResult,
20 RapToolResultContent, ThreadId, ToolDef, ToolsetManifest, send_subscription_event,
21 send_tool_result, send_user_choice,
22};
23
24type DisplayResult = Result<(String, Option<Vec<DisplaySegment>>), SandboxError>;
25
26#[derive(Default)]
31struct ToolOutput {
32 text: String,
35 display_as: Option<Vec<DisplaySegment>>,
37 content: Option<Vec<RapToolResultContent>>,
39}
40
41impl ToolOutput {
42 fn text(text: impl Into<String>) -> Self {
44 Self {
45 text: text.into(),
46 display_as: None,
47 content: None,
48 }
49 }
50}
51
52type ContentResult = Result<ToolOutput, SandboxError>;
53
54use crate::error::SandboxError;
55use crate::git;
56use crate::jj::run_jj;
57use crate::metadata::MetadataStore;
58use crate::sandbox::{CloneContext, SandboxBackend};
59use crate::types::{
60 CloneRepoArgs, CreateFileArgs, DescribeOverallChangesArgs, EditFileArgs, ExecuteCommandArgs,
61 GrepArgs, OpenSandboxDirectArgs, ReadFileArgs, RepoState, SandboxMode, SquashSandboxArgs,
62};
63
64enum OutputEvent {
66 Stdout(String),
67 Stderr(String),
68 Exit(i32),
69}
70
71type PendingTasks = Arc<Mutex<Vec<JoinHandle<()>>>>;
72
73type InFlightMap = Arc<Mutex<HashMap<rap_protocol::ToolCallId, tokio::sync::oneshot::Sender<()>>>>;
80
81#[cfg(unix)]
87fn kill_process(pid: Option<u32>) {
88 if let Some(pid) = pid {
89 use nix::sys::signal::{self, Signal};
90 use nix::unistd::Pid;
91 let _ = signal::kill(Pid::from_raw(-(pid as i32)), Signal::SIGTERM);
92 tracing::info!(pid, "sent SIGTERM to process group");
93 }
94}
95
96struct AppState<B: SandboxBackend, M: MetadataStore, C: CallbackClient> {
97 backend: B,
98 metadata: M,
99 callback_client: C,
100 pending_tasks: PendingTasks,
101 in_flight: InFlightMap,
102 pending_choices:
105 Arc<Mutex<HashMap<rap_protocol::ToolCallId, tokio::sync::oneshot::Sender<usize>>>>,
106 server_base_url: std::sync::OnceLock<String>,
108 needs_migration: bool,
110 repo_root: Option<PathBuf>,
113 http_client: reqwest::Client,
115}
116
117#[derive(Clone)]
119pub struct TaskTracker {
120 pending_tasks: PendingTasks,
121 in_flight: InFlightMap,
122}
123
124impl TaskTracker {
125 pub async fn cancel_all_in_flight(&self) {
128 let senders: Vec<_> = {
129 let mut map = self.in_flight.lock().await;
130 map.drain().map(|(_, tx)| tx).collect()
131 };
132 for tx in senders {
133 let _ = tx.send(());
134 }
135 }
136
137 pub async fn drain(&self) {
138 let tasks: Vec<JoinHandle<()>> = {
139 let mut pending = self.pending_tasks.lock().await;
140 std::mem::take(&mut *pending)
141 };
142 for handle in tasks {
143 if let Err(e) = handle.await {
144 tracing::error!("background task panicked: {e}");
145 }
146 }
147 }
148}
149
150pub fn build_router<B, M, C>(
152 backend: B,
153 metadata: M,
154 callback_client: C,
155 needs_migration: bool,
156 repo_root: Option<PathBuf>,
157) -> (Router, TaskTracker)
158where
159 B: SandboxBackend + 'static,
160 M: MetadataStore + 'static,
161 C: CallbackClient + 'static,
162{
163 let pending_tasks: PendingTasks = Arc::new(Mutex::new(Vec::new()));
164 let in_flight: InFlightMap = Arc::new(Mutex::new(HashMap::new()));
165
166 let tracker = TaskTracker {
167 pending_tasks,
168 in_flight: in_flight.clone(),
169 };
170
171 let state = Arc::new(AppState {
172 backend,
173 metadata,
174 callback_client,
175 pending_tasks: tracker.pending_tasks.clone(),
176 in_flight,
177 pending_choices: Arc::new(Mutex::new(HashMap::new())),
178 server_base_url: std::sync::OnceLock::new(),
179 needs_migration,
180 repo_root,
181 http_client: reqwest::Client::new(),
182 });
183
184 let router = Router::new()
185 .route("/.well-known/rap-toolset", get(toolset_handler::<B, M, C>))
186 .route("/invoke", post(invoke_handler::<B, M, C>))
187 .route("/close_thread", post(close_thread_handler::<B, M, C>))
188 .route(
189 "/cancel_tool_call",
190 post(cancel_tool_call_handler::<B, M, C>),
191 )
192 .route(
193 "/user_choice_response",
194 post(user_choice_response_handler::<B, M, C>),
195 )
196 .route("/migrate", post(migrate_handler::<B, M, C>))
197 .route(
198 "/migrate/import",
199 post(migrate_import_handler::<B, M, C>).layer(DefaultBodyLimit::disable()),
200 )
201 .with_state(state);
202
203 (router, tracker)
204}
205
206async fn toolset_handler<
207 B: SandboxBackend + 'static,
208 M: MetadataStore + 'static,
209 C: CallbackClient + 'static,
210>(
211 headers: HeaderMap,
212 State(state): State<Arc<AppState<B, M, C>>>,
213) -> Json<ToolsetManifest> {
214 let host = headers
215 .get("host")
216 .and_then(|v| v.to_str().ok())
217 .unwrap_or("localhost");
218 let scheme = if host.starts_with("localhost") || host.starts_with("127.0.0.1") {
219 "http"
220 } else {
221 "https"
222 };
223 let base_url = format!("{scheme}://{host}");
224 let _ = state.server_base_url.set(base_url);
225 let endpoint = format!("{scheme}://{host}/invoke");
226 Json(build_manifest(&endpoint, state.needs_migration))
227}
228
229async fn invoke_handler<
230 B: SandboxBackend + 'static,
231 M: MetadataStore + 'static,
232 C: CallbackClient + 'static,
233>(
234 State(state): State<Arc<AppState<B, M, C>>>,
235 Json(invocation): Json<RapInvocation>,
236) -> StatusCode {
237 if invocation.operation == "execute_command" {
241 let (cancel_tx, cancel_rx) = tokio::sync::oneshot::channel();
242 state
243 .in_flight
244 .lock()
245 .await
246 .insert(invocation.id.clone(), cancel_tx);
247
248 let state_clone = state.clone();
249 let handle = tokio::spawn(async move {
250 handle_execute_command_streaming(&state_clone, &invocation, cancel_rx).await;
251 });
252 state.pending_tasks.lock().await.push(handle);
253 return StatusCode::OK;
254 }
255
256 let state_clone = state.clone();
257 let handle = tokio::spawn(async move {
258 let result = match invocation.operation.as_str() {
259 "clone_repo" => handle_clone_repo(&state_clone, &invocation)
260 .await
261 .map(ToolOutput::text),
262 "open_sandbox_direct" => handle_open_sandbox_direct(&state_clone, &invocation)
263 .await
264 .map(ToolOutput::text),
265 "read_file" => handle_read_file(&state_clone, &invocation).await,
266 "edit_file" => with_content(handle_edit_file(&state_clone, &invocation).await),
267 "create_file" => with_content(handle_create_file(&state_clone, &invocation).await),
268 "grep" => with_content(handle_grep(&state_clone, &invocation).await),
269 "describe_overall_changes" => {
270 with_content(handle_describe_overall_changes(&state_clone, &invocation).await)
271 }
272 "squash_sandbox" => {
273 with_content(handle_squash_sandbox(&state_clone, &invocation).await)
274 }
275 _ => Err(SandboxError::Other(format!(
276 "unknown operation: {}",
277 invocation.operation
278 ))),
279 };
280
281 let ToolOutput {
282 text,
283 display_as,
284 content,
285 } = result.unwrap_or_else(|e| ToolOutput::text(format!("Error: {e}")));
286
287 let text = content.is_none().then_some(text);
290
291 let tool_result = RapCallback::ToolResult(RapToolResult {
292 group_id: invocation.group_id.clone(),
293 id: invocation.id.clone(),
294 call_id: invocation.call_id.clone(),
295 text,
296 content,
297 display_as,
298 subscription: None,
299 });
300
301 let body =
302 serde_json::to_string(&tool_result).expect("bug: failed to serialize tool result");
303 if let Err(e) = state_clone
304 .callback_client
305 .post_json(&invocation.callback_url, &body)
306 .await
307 {
308 tracing::error!("failed to send tool result to callback: {e}");
309 }
310 });
311
312 state.pending_tasks.lock().await.push(handle);
313
314 StatusCode::OK
315}
316
317fn with_content(result: DisplayResult) -> ContentResult {
319 result.map(|(text, display_as)| ToolOutput {
320 text,
321 display_as,
322 content: None,
323 })
324}
325
326#[derive(Debug, Deserialize)]
328struct CloseThreadRequest {
329 thread_id: ThreadId,
330}
331
332async fn close_thread_handler<
339 B: SandboxBackend + 'static,
340 M: MetadataStore + 'static,
341 C: CallbackClient + 'static,
342>(
343 State(state): State<Arc<AppState<B, M, C>>>,
344 Json(request): Json<CloseThreadRequest>,
345) -> StatusCode {
346 let thread_id = request.thread_id;
347 tracing::info!(thread_id = %thread_id, "received close_thread notification");
348
349 let state_clone = state.clone();
350 let handle = tokio::spawn(async move {
351 if let Err(e) = state_clone
352 .backend
353 .cleanup_sandbox_permanently(&thread_id)
354 .await
355 {
356 tracing::warn!(
357 thread_id = %thread_id,
358 "failed to permanently clean up sandbox: {e}"
359 );
360 }
361 });
362
363 state.pending_tasks.lock().await.push(handle);
364
365 StatusCode::OK
366}
367
368#[derive(Debug, Deserialize)]
370struct CancelToolCallRequest {
371 tool_call_id: String,
372 thread_id: ThreadId,
373}
374
375async fn cancel_tool_call_handler<
384 B: SandboxBackend + 'static,
385 M: MetadataStore + 'static,
386 C: CallbackClient + 'static,
387>(
388 State(state): State<Arc<AppState<B, M, C>>>,
389 Json(request): Json<CancelToolCallRequest>,
390) -> StatusCode {
391 tracing::info!(
392 tool_call_id = %request.tool_call_id,
393 thread_id = %request.thread_id,
394 "received cancel_tool_call notification"
395 );
396
397 let sender = state
398 .in_flight
399 .lock()
400 .await
401 .remove(rap_protocol::ToolCallId::from_ref(&request.tool_call_id));
402
403 if let Some(sender) = sender {
404 let _ = sender.send(());
406 tracing::info!(
407 tool_call_id = %request.tool_call_id,
408 "sent cancel signal to command handler"
409 );
410 } else {
411 tracing::info!(
412 tool_call_id = %request.tool_call_id,
413 "no in-flight command found for tool call (may have already completed)"
414 );
415 }
416
417 StatusCode::OK
418}
419
420#[derive(Debug, Deserialize)]
422struct UserChoiceResponse {
423 id: String,
424 selected: usize,
425}
426
427async fn user_choice_response_handler<
429 B: SandboxBackend + 'static,
430 M: MetadataStore + 'static,
431 C: CallbackClient + 'static,
432>(
433 State(state): State<Arc<AppState<B, M, C>>>,
434 Json(response): Json<UserChoiceResponse>,
435) -> StatusCode {
436 tracing::info!(
437 id = %response.id,
438 selected = response.selected,
439 "received user_choice_response"
440 );
441
442 let sender = state
443 .pending_choices
444 .lock()
445 .await
446 .remove(rap_protocol::ToolCallId::from_ref(&response.id));
447
448 if let Some(sender) = sender {
449 let _ = sender.send(response.selected);
450 }
451
452 StatusCode::OK
453}
454
455fn thread_chain(invocation: &RapInvocation) -> Vec<ThreadId> {
457 let mut chain: Vec<ThreadId> = invocation
458 .thread_ancestors
459 .as_deref()
460 .unwrap_or_default()
461 .to_vec();
462 chain.push(invocation.group_id.clone());
463 chain
464}
465
466async fn is_write_orig_granted<M: MetadataStore>(metadata: &M, chain: &[ThreadId]) -> bool {
468 for id in chain {
469 if let Ok(Some(s)) = metadata.get(id).await
470 && s.write_orig_granted
471 {
472 return true;
473 }
474 }
475 false
476}
477
478async fn granted_write_paths<M: MetadataStore>(
480 metadata: &M,
481 chain: &[ThreadId],
482) -> HashSet<String> {
483 let mut granted = HashSet::new();
484 for id in chain {
485 if let Ok(Some(s)) = metadata.get(id).await {
486 granted.extend(s.write_path_grants.iter().cloned());
487 }
488 }
489 granted
490}
491
492fn build_grant_choices(chain: &[ThreadId]) -> (Vec<String>, Vec<ThreadId>) {
496 let mut choices = Vec::new();
497 let mut ids = Vec::new();
498 for id in chain {
499 choices.push(format!("Yes ({id} and all children)"));
500 ids.push(id.clone());
501 }
502 (choices, ids)
503}
504
505async fn handle_clone_repo<B: SandboxBackend, M: MetadataStore, C: CallbackClient>(
506 state: &AppState<B, M, C>,
507 invocation: &RapInvocation,
508) -> Result<String, SandboxError> {
509 let args: CloneRepoArgs = serde_json::from_value(invocation.arguments.clone())
510 .map_err(|e| SandboxError::Other(format!("invalid arguments: {e}")))?;
511
512 if let Some(existing) = state.metadata.get(&invocation.group_id).await?
514 && !matches!(existing.mode, SandboxMode::Direct)
515 {
516 return Err(SandboxError::Other(
517 "A repository has already been initialized for this thread. \
518 Re-initializing with a different repository is not supported."
519 .to_owned(),
520 ));
521 }
522
523 let bookmark = format!("sandbox-{}", invocation.group_id);
524
525 let base_bookmark = args
530 .base_thread_id
531 .as_ref()
532 .map(|id| format!("sandbox-{id}"));
533 let ctx = CloneContext {
534 group_id: &invocation.group_id,
535 requested_path: &args.repo,
536 bookmark: &bookmark,
537 base_bookmark: base_bookmark.as_deref(),
538 };
539 let Some(init) = state.backend.detect_mode(&ctx).await? else {
540 return Err(SandboxError::Other(
541 "No sandbox mode provider claimed this repository.".to_owned(),
542 ));
543 };
544
545 let remote_uri = init.repo_root.to_string_lossy().into_owned();
547 let repo_state = RepoState {
548 group_id: invocation.group_id.clone(),
549 remote_uri: remote_uri.clone(),
550 bookmark: bookmark.clone(),
551 mode: init.mode,
552 sandbox_path: None,
553 write_orig_granted: false,
554 write_path_grants: Default::default(),
555 root_thread_id: Some(
556 invocation
557 .thread_ancestors
558 .as_ref()
559 .and_then(|a| a.first().cloned())
560 .unwrap_or_else(|| invocation.group_id.clone()),
561 ),
562 };
563 state.metadata.put(&repo_state).await?;
564 if init.precreate {
565 state.backend.create_sandbox(&repo_state).await?;
566 }
567 tracing::info!(group_id = %invocation.group_id, remote = %remote_uri, "repo cloned");
568
569 let mut msg = init.message;
572 if let Some(note) = repo_root_note(&args.repo, &init.repo_root) {
573 msg.push_str(¬e);
574 }
575 Ok(msg)
576}
577
578fn repo_root_note(requested: &str, root: &Path) -> Option<String> {
583 let requested_abs = Path::new(requested).canonicalize().ok()?;
584 if requested_abs != root && requested_abs.starts_with(root) {
585 Some(format!(
586 " Note: the requested path `{requested}` is inside a repository rooted at `{}`; \
587 the sandbox operates on the whole repository, and file paths are relative to \
588 that root.",
589 root.display()
590 ))
591 } else {
592 None
593 }
594}
595
596async fn handle_open_sandbox_direct<B: SandboxBackend, M: MetadataStore, C: CallbackClient>(
597 state: &AppState<B, M, C>,
598 invocation: &RapInvocation,
599) -> Result<String, SandboxError> {
600 let args: OpenSandboxDirectArgs = serde_json::from_value(invocation.arguments.clone())
601 .map_err(|e| SandboxError::Other(format!("invalid arguments: {e}")))?;
602
603 let remote_uri = state.backend.init_direct(&args.repo).await?;
604
605 let repo_state = RepoState {
606 group_id: invocation.group_id.clone(),
607 remote_uri: remote_uri.clone(),
608 bookmark: format!("sandbox-{}", invocation.group_id),
609 mode: SandboxMode::Direct,
610 sandbox_path: None,
611 write_orig_granted: false,
612 write_path_grants: Default::default(),
613 root_thread_id: Some(
614 invocation
615 .thread_ancestors
616 .as_ref()
617 .and_then(|a| a.first().cloned())
618 .unwrap_or_else(|| invocation.group_id.clone()),
619 ),
620 };
621
622 state.metadata.put(&repo_state).await?;
623
624 tracing::info!(group_id = %invocation.group_id, remote = %remote_uri, "opened direct sandbox");
625 let mut msg = "Repository initialized (Direct mode — file edits require approval, commands run without file write access unless write-orig is granted).".to_owned();
626 if let Some(note) = repo_root_note(&args.repo, Path::new(&remote_uri)) {
632 msg.push_str(¬e);
633 }
634 Ok(msg)
635}
636
637use crate::{DEFAULT_SANDBOX_EMAIL, DEFAULT_SANDBOX_NAME};
638
639fn append_co_author_trailer(description: &str) -> String {
641 format!("{description}\n\nCo-authored-by: {DEFAULT_SANDBOX_NAME} <{DEFAULT_SANDBOX_EMAIL}>")
642}
643
644async fn with_sandbox<B, M, C, F, Fut, T>(
651 state: &AppState<B, M, C>,
652 invocation: &RapInvocation,
653 description: Option<&str>,
654 action: F,
655 modifies: bool,
656) -> Result<(String, T), SandboxError>
657where
658 B: SandboxBackend,
659 M: MetadataStore,
660 C: CallbackClient,
661 F: FnOnce(PathBuf) -> Fut,
662 Fut: Future<Output = Result<(String, T), SandboxError>>,
663{
664 let group_id = &invocation.group_id;
665 let repo_state = state
666 .metadata
667 .get(group_id)
668 .await?
669 .ok_or_else(|| SandboxError::RepoNotFound(group_id.to_owned()))?;
670
671 let sandbox_dir = state.backend.create_sandbox(&repo_state).await?;
672
673 let description_with_trailer = description.map(append_co_author_trailer);
674 let description_ref = description_with_trailer.as_deref();
675
676 if let Some(description) = description_ref {
677 state
678 .backend
679 .describe_sandbox(&sandbox_dir, &repo_state, description)
680 .await?;
681 }
682
683 let result = action(sandbox_dir.clone()).await;
684
685 let push_warning = if modifies && result.is_ok() {
687 state
688 .backend
689 .detect_external_change(&sandbox_dir, &repo_state)
690 .await
691 } else {
692 None
693 };
694
695 if modifies {
696 state
697 .backend
698 .push_sandbox(&sandbox_dir, group_id, description_ref)
699 .await?;
700 }
701
702 if modifies {
703 push_diff_view(state, invocation, &sandbox_dir).await;
704 }
705
706 if let Err(e) = state.backend.cleanup_sandbox(&sandbox_dir).await {
707 tracing::warn!("failed to cleanup sandbox: {e}");
708 }
709
710 tracing::info!(group_id = %group_id, "sandbox operation complete");
711
712 match (push_warning, result) {
714 (Some(warning), Ok((text, display))) => Ok((format!("{warning}\n{text}"), display)),
715 (_, result) => result,
716 }
717}
718
719fn detect_cd_to_original_repo(command: &str, remote_uri: &str) -> Option<String> {
728 if !remote_uri.starts_with('/') {
730 return None;
731 }
732
733 let trimmed = command.trim();
734
735 if !trimmed.starts_with("cd ") {
737 return None;
738 }
739
740 let after_cd = trimmed[3..].trim_start();
741
742 let (path_str, _rest) = if let Some(stripped) = after_cd.strip_prefix('"') {
744 let end = stripped.find('"')?;
745 (&stripped[..end], &after_cd[2 + end..])
746 } else if let Some(stripped) = after_cd.strip_prefix('\'') {
747 let end = stripped.find('\'')?;
748 (&stripped[..end], &after_cd[2 + end..])
749 } else {
750 let end = after_cd
752 .find(|c: char| c.is_whitespace() || c == '&' || c == ';')
753 .unwrap_or(after_cd.len());
754 (&after_cd[..end], &after_cd[end..])
755 };
756
757 let path_normalized = path_str.trim_end_matches('/');
758 let uri_normalized = remote_uri.trim_end_matches('/');
759
760 if path_normalized == uri_normalized
762 || path_normalized.starts_with(&format!("{uri_normalized}/"))
763 {
764 Some(format!(
765 "Error: Do not `cd` to the absolute path `{path_str}`. \
766 Commands are already executed in a sandboxed copy of that repository. \
767 Run your commands directly in the current working directory \
768 (e.g., `cargo build` instead of `cd {remote_uri} && cargo build`). \
769 If you need to enter a subdirectory, use a relative path \
770 (e.g., `cd src && ...`)."
771 ))
772 } else {
773 None
774 }
775}
776
777fn format_exec_output(stdout: &str, stderr: &str, exit_code: i32) -> String {
779 let mut result = String::new();
780 if !stdout.is_empty() {
781 result.push_str(stdout);
782 }
783 if !stderr.is_empty() {
784 if !result.is_empty() {
785 result.push('\n');
786 }
787 result.push_str("[stderr]\n");
788 result.push_str(stderr);
789 }
790 if result.is_empty() {
791 result = format!("Command completed with exit code {exit_code}");
792 } else {
793 result.push_str(&format!("\n[exit code: {exit_code}]"));
794 }
795 result
796}
797
798async fn handle_execute_command_streaming<
800 B: SandboxBackend + 'static,
801 M: MetadataStore,
802 C: CallbackClient,
803>(
804 state: &Arc<AppState<B, M, C>>,
805 invocation: &RapInvocation,
806 cancel_rx: tokio::sync::oneshot::Receiver<()>,
807) {
808 if let Err(e) = handle_execute_command_streaming_inner(state, invocation, cancel_rx).await {
809 send_tool_result(
810 &state.callback_client,
811 invocation,
812 &format!("Error: {e}"),
813 None,
814 false,
815 )
816 .await;
817 }
818}
819
820async fn handle_execute_command_streaming_inner<
827 B: SandboxBackend + 'static,
828 M: MetadataStore,
829 C: CallbackClient,
830>(
831 state: &Arc<AppState<B, M, C>>,
832 invocation: &RapInvocation,
833 mut cancel_rx: tokio::sync::oneshot::Receiver<()>,
834) -> Result<(), SandboxError> {
835 let args: ExecuteCommandArgs = serde_json::from_value(invocation.arguments.clone())
836 .map_err(|e| SandboxError::Other(format!("invalid arguments: {e}")))?;
837
838 let repo_state = state
839 .metadata
840 .get(&invocation.group_id)
841 .await?
842 .ok_or_else(|| SandboxError::RepoNotFound(invocation.group_id.clone()))?;
843
844 let chain = thread_chain(invocation);
845
846 let needs_write_orig = args
848 .additional_permissions
849 .as_ref()
850 .is_some_and(|p| p.iter().any(|s| s == "write-orig"));
851
852 let write_paths: Vec<String> = args
854 .additional_permissions
855 .as_ref()
856 .map(|perms| {
857 perms
858 .iter()
859 .filter_map(|s| s.strip_prefix("write:").map(|p| p.to_owned()))
860 .collect()
861 })
862 .unwrap_or_default();
863
864 let needs_write_orig_approval =
866 needs_write_orig && !is_write_orig_granted(&state.metadata, &chain).await;
867 let already_granted_paths = granted_write_paths(&state.metadata, &chain).await;
868 let unapproved_paths: Vec<String> = write_paths
869 .iter()
870 .filter(|p| !already_granted_paths.contains(*p))
871 .cloned()
872 .collect();
873
874 if needs_write_orig_approval || !unapproved_paths.is_empty() {
875 let mut prompt_parts = Vec::new();
877 if needs_write_orig_approval {
878 prompt_parts.push("the original repository directory".to_owned());
879 }
880 for p in &unapproved_paths {
881 prompt_parts.push(p.to_string());
882 }
883 let prompt = format!("Allow writing to {}?", prompt_parts.join(", "));
884
885 let (mut choices, grant_ids) = build_grant_choices(&chain);
887 let yes_once_idx = choices.len();
888 let no_idx = yes_once_idx + 1;
889 choices.push("Yes once".to_owned());
890 choices.push("No".to_owned());
891
892 let base_url = state
893 .server_base_url
894 .get()
895 .cloned()
896 .unwrap_or_else(|| "http://localhost".to_owned());
897 let response_url = format!("{base_url}/user_choice_response");
898
899 let (choice_tx, choice_rx) = tokio::sync::oneshot::channel();
900 state
901 .pending_choices
902 .lock()
903 .await
904 .insert(invocation.id.clone(), choice_tx);
905
906 send_user_choice(
907 &state.callback_client,
908 invocation,
909 &prompt,
910 choices,
911 no_idx,
912 &response_url,
913 )
914 .await;
915
916 let selected = choice_rx.await.unwrap_or(no_idx);
917
918 if selected < grant_ids.len() {
919 let target_id = &grant_ids[selected];
921 let mut target = state
922 .metadata
923 .get(target_id)
924 .await?
925 .unwrap_or_else(|| repo_state.clone());
926 if needs_write_orig_approval {
927 target.write_orig_granted = true;
928 }
929 for p in &unapproved_paths {
930 target.write_path_grants.insert(p.clone());
931 }
932 if let Err(e) = state.metadata.put(&target).await {
933 tracing::warn!("failed to persist grants: {e}");
934 }
935 } else if selected == yes_once_idx {
936 } else {
938 state.in_flight.lock().await.remove(&invocation.id);
939 send_tool_result(
940 &state.callback_client,
941 invocation,
942 "Error: write permission denied by user.",
943 None,
944 false,
945 )
946 .await;
947 return Ok(());
948 }
949 }
950
951 if !needs_write_orig
955 && write_paths.is_empty()
956 && let Some(error_msg) = detect_cd_to_original_repo(&args.command, &repo_state.remote_uri)
957 {
958 state.in_flight.lock().await.remove(&invocation.id);
959 send_tool_result(&state.callback_client, invocation, &error_msg, None, false).await;
960 return Ok(());
961 }
962
963 let sandbox_dir = state.backend.create_sandbox(&repo_state).await?;
964
965 if cancel_rx.try_recv().is_ok() {
967 let _ = state
968 .backend
969 .push_sandbox(&sandbox_dir, &invocation.group_id, None)
970 .await;
971 if let Err(e) = state.backend.cleanup_sandbox(&sandbox_dir).await {
972 tracing::warn!("failed to cleanup sandbox: {e}");
973 }
974 send_tool_result(
975 &state.callback_client,
976 invocation,
977 "Command cancelled.",
978 None,
979 false,
980 )
981 .await;
982 return Ok(());
983 }
984
985 let is_direct = matches!(repo_state.mode, SandboxMode::Direct);
987 let orig_path = PathBuf::from(&repo_state.remote_uri);
988 let write_path_bufs: Vec<PathBuf> = write_paths.iter().map(PathBuf::from).collect();
989 let mut extra_writable: Vec<&Path> = if needs_write_orig {
990 vec![orig_path.as_path()]
991 } else {
992 vec![]
993 };
994 for p in &write_path_bufs {
995 extra_writable.push(p.as_path());
996 }
997 let mut spawned = match state
998 .backend
999 .spawn_command(
1000 &sandbox_dir,
1001 &["bash", "-c", &args.command],
1002 &extra_writable,
1003 !is_direct,
1004 )
1005 .await
1006 {
1007 Ok(s) => s,
1008 Err(e) => {
1009 state.in_flight.lock().await.remove(&invocation.id);
1010 let _ = state
1011 .backend
1012 .push_sandbox(&sandbox_dir, &invocation.group_id, None)
1013 .await;
1014 if let Err(ce) = state.backend.cleanup_sandbox(&sandbox_dir).await {
1015 tracing::warn!("failed to cleanup sandbox: {ce}");
1016 }
1017 return Err(e);
1018 }
1019 };
1020
1021 let child_pid = spawned.child.id();
1022
1023 let stdout = spawned
1024 .child
1025 .stdout
1026 .take()
1027 .ok_or_else(|| SandboxError::Other("failed to capture stdout".to_owned()))?;
1028 let stderr = spawned
1029 .child
1030 .stderr
1031 .take()
1032 .ok_or_else(|| SandboxError::Other("failed to capture stderr".to_owned()))?;
1033
1034 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<OutputEvent>();
1036
1037 let tx_out = tx.clone();
1039 let out_handle = tokio::spawn(async move {
1040 let mut stdout = stdout;
1041 let mut buf = vec![0u8; 8192];
1042 loop {
1043 match stdout.read(&mut buf).await {
1044 Ok(0) | Err(_) => break,
1045 Ok(n) => {
1046 let _ = tx_out.send(OutputEvent::Stdout(
1047 String::from_utf8_lossy(&buf[..n]).to_string(),
1048 ));
1049 }
1050 }
1051 }
1052 });
1053
1054 let tx_err = tx.clone();
1056 let err_handle = tokio::spawn(async move {
1057 let mut stderr = stderr;
1058 let mut buf = vec![0u8; 8192];
1059 loop {
1060 match stderr.read(&mut buf).await {
1061 Ok(0) | Err(_) => break,
1062 Ok(n) => {
1063 let _ = tx_err.send(OutputEvent::Stderr(
1064 String::from_utf8_lossy(&buf[..n]).to_string(),
1065 ));
1066 }
1067 }
1068 }
1069 });
1070
1071 tokio::spawn(async move {
1074 let status = spawned.child.wait().await;
1075 let code = status.map(|s| s.code().unwrap_or(-1)).unwrap_or(-1);
1076 let _ = out_handle.await;
1077 let _ = err_handle.await;
1078 let _ = tx.send(OutputEvent::Exit(code));
1079 });
1080
1081 let mut stdout_buf = String::new();
1083 let mut stderr_buf = String::new();
1084 let mut exit_code: Option<i32> = None;
1085 let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(5);
1086
1087 loop {
1088 tokio::select! {
1089 biased;
1090 _ = &mut cancel_rx => {
1091 tracing::info!(tool_call_id = %invocation.id, "command cancelled during phase 1");
1092 #[cfg(unix)]
1093 kill_process(child_pid);
1094 let _ = state.backend.push_sandbox(&sandbox_dir, &invocation.group_id, None).await;
1096 if let Err(e) = state.backend.cleanup_sandbox(&sandbox_dir).await {
1099 tracing::warn!("failed to cleanup sandbox: {e}");
1100 }
1101 return Ok(());
1102 }
1103 _ = tokio::time::sleep_until(deadline) => {
1104 break;
1105 }
1106 event = rx.recv() => {
1107 match event {
1108 Some(OutputEvent::Stdout(s)) => stdout_buf.push_str(&s),
1109 Some(OutputEvent::Stderr(s)) => stderr_buf.push_str(&s),
1110 Some(OutputEvent::Exit(code)) => {
1111 exit_code = Some(code);
1112 break;
1113 }
1114 None => break,
1115 }
1116 }
1117 }
1118 }
1119
1120 if let Some(code) = exit_code {
1121 state.in_flight.lock().await.remove(&invocation.id);
1123 let text = format_exec_output(&stdout_buf, &stderr_buf, code);
1124 let _ = state
1125 .backend
1126 .push_sandbox(&sandbox_dir, &invocation.group_id, None)
1127 .await;
1128 send_tool_result(&state.callback_client, invocation, &text, None, false).await;
1129 push_diff_view(state, invocation, &sandbox_dir).await;
1130 if let Err(e) = state.backend.cleanup_sandbox(&sandbox_dir).await {
1131 tracing::warn!("failed to cleanup sandbox: {e}");
1132 }
1133 return Ok(());
1134 }
1135
1136 let mut initial_text = format!(
1138 "Command is still running. Output will be streamed via subscription events. Subscription ID: {}",
1139 invocation.id
1140 );
1141 if !stdout_buf.is_empty() || !stderr_buf.is_empty() {
1142 initial_text.push_str("\nOutput so far:\n");
1143 initial_text.push_str(&stdout_buf);
1144 if !stderr_buf.is_empty() {
1145 if !stdout_buf.is_empty() {
1146 initial_text.push('\n');
1147 }
1148 initial_text.push_str("[stderr]\n");
1149 initial_text.push_str(&stderr_buf);
1150 }
1151 }
1152 send_tool_result(
1153 &state.callback_client,
1154 invocation,
1155 &initial_text,
1156 None,
1157 true,
1158 )
1159 .await;
1160
1161 let mut accumulated = String::new();
1163 let mut interval = tokio::time::interval(std::time::Duration::from_secs(10));
1164 interval.tick().await; loop {
1167 tokio::select! {
1168 biased;
1169 _ = &mut cancel_rx => {
1170 tracing::info!(tool_call_id = %invocation.id, "command cancelled during streaming");
1171 #[cfg(unix)]
1172 kill_process(child_pid);
1173
1174 if !accumulated.is_empty() {
1175 accumulated.push_str("\n[cancelled]");
1176 } else {
1177 accumulated.push_str("[cancelled]");
1178 }
1179
1180 let _ = state.backend.push_sandbox(
1181 &sandbox_dir,
1182 &invocation.group_id,
1183 None,
1184 )
1185 .await;
1186 push_diff_view(state, invocation, &sandbox_dir).await;
1187 send_subscription_event(
1188 &state.callback_client,
1189 &invocation.callback_url,
1190 invocation.group_id.clone(),
1191 invocation.id.clone(),
1192 &accumulated,
1193 true,
1194 true,
1195 )
1196 .await;
1197 if let Err(e) = state.backend.cleanup_sandbox(&sandbox_dir).await {
1198 tracing::warn!("failed to cleanup sandbox: {e}");
1199 }
1200 return Ok(());
1201 }
1202 _ = interval.tick() => {
1203 if !accumulated.is_empty() {
1204 send_subscription_event(
1205 &state.callback_client,
1206 &invocation.callback_url,
1207 invocation.group_id.clone(),
1208 invocation.id.clone(),
1209 &accumulated,
1210 true,
1211 false,
1212 )
1213 .await;
1214 accumulated.clear();
1215 }
1216 }
1217 event = rx.recv() => {
1218 match event {
1219 Some(OutputEvent::Stdout(s) | OutputEvent::Stderr(s)) => {
1220 accumulated.push_str(&s);
1221 }
1222 Some(OutputEvent::Exit(code)) => {
1223 state.in_flight.lock().await.remove(&invocation.id);
1224
1225 if accumulated.is_empty() {
1226 accumulated.push_str(&format!("[exit code: {code}]"));
1227 } else {
1228 accumulated.push_str(&format!("\n[exit code: {code}]"));
1229 }
1230
1231 let _ = state.backend.push_sandbox(
1232 &sandbox_dir,
1233 &invocation.group_id,
1234 None,
1235 )
1236 .await;
1237 push_diff_view(state, invocation, &sandbox_dir).await;
1238 send_subscription_event(
1239 &state.callback_client,
1240 &invocation.callback_url,
1241 invocation.group_id.clone(),
1242 invocation.id.clone(),
1243 &accumulated,
1244 true,
1245 true,
1246 )
1247 .await;
1248 if let Err(e) = state.backend.cleanup_sandbox(&sandbox_dir).await {
1249 tracing::warn!("failed to cleanup sandbox: {e}");
1250 }
1251 return Ok(());
1252 }
1253 None => {
1254 state.in_flight.lock().await.remove(&invocation.id);
1255
1256 let _ = state.backend.push_sandbox(
1257 &sandbox_dir,
1258 &invocation.group_id,
1259 None,
1260 )
1261 .await;
1262 push_diff_view(state, invocation, &sandbox_dir).await;
1263 if !accumulated.is_empty() {
1264 send_subscription_event(
1265 &state.callback_client,
1266 &invocation.callback_url,
1267 invocation.group_id.clone(),
1268 invocation.id.clone(),
1269 &accumulated,
1270 true,
1271 true,
1272 )
1273 .await;
1274 }
1275 if let Err(e) = state.backend.cleanup_sandbox(&sandbox_dir).await {
1276 tracing::warn!("failed to cleanup sandbox: {e}");
1277 }
1278 return Ok(());
1279 }
1280 }
1281 }
1282 }
1283 }
1284}
1285
1286fn detect_image_media_type(bytes: &[u8]) -> Option<&'static str> {
1291 if bytes.starts_with(b"\x89PNG\r\n\x1a\n") {
1292 Some("image/png")
1293 } else if bytes.starts_with(&[0xFF, 0xD8, 0xFF]) {
1294 Some("image/jpeg")
1295 } else if bytes.starts_with(b"GIF87a") || bytes.starts_with(b"GIF89a") {
1296 Some("image/gif")
1297 } else if bytes.len() >= 12 && bytes[0..4] == *b"RIFF" && bytes[8..12] == *b"WEBP" {
1298 Some("image/webp")
1299 } else {
1300 None
1301 }
1302}
1303
1304async fn handle_read_file<B: SandboxBackend, M: MetadataStore, C: CallbackClient>(
1305 state: &AppState<B, M, C>,
1306 invocation: &RapInvocation,
1307) -> ContentResult {
1308 let args: ReadFileArgs = serde_json::from_value(invocation.arguments.clone())
1309 .map_err(|e| SandboxError::Other(format!("invalid arguments: {e}")))?;
1310
1311 let (text, (display, content)) = with_sandbox(
1312 state,
1313 invocation,
1314 None,
1315 |sandbox_dir| async move {
1316 let file_path = sandbox_dir.join(&args.path);
1317 let bytes = tokio::fs::read(&file_path)
1318 .await
1319 .map_err(SandboxError::Io)?;
1320
1321 if let Some(media_type) = detect_image_media_type(&bytes) {
1326 let encoded = base64::engine::general_purpose::STANDARD.encode(&bytes);
1327 let text = format!(
1328 "Read image file \"{}\" ({media_type}, {} bytes). The image is attached.",
1329 args.path,
1330 bytes.len()
1331 );
1332 let display = vec![
1335 DisplaySegment::Image(rap_protocol::ImageContent {
1336 data: encoded.clone(),
1337 media_type: media_type.to_owned(),
1338 }),
1339 DisplaySegment::Text(format!(
1340 "Read image ({media_type}, {} bytes);",
1341 bytes.len()
1342 )),
1343 ];
1344 let content = vec![
1345 RapToolResultContent::Text { text: text.clone() },
1346 RapToolResultContent::Image {
1347 data: encoded,
1348 media_type: media_type.to_owned(),
1349 },
1350 ];
1351 return Ok((text, (Some(display), Some(content))));
1352 }
1353
1354 let content = String::from_utf8(bytes)
1355 .map_err(|e| SandboxError::Other(format!("file is not valid UTF-8: {e}")))?;
1356
1357 let lines: Vec<&str> = content.lines().collect();
1358 let total_lines = lines.len();
1359
1360 let start = args.start_line.unwrap_or(1).max(1);
1361 let end = args.end_line.unwrap_or(total_lines).min(total_lines);
1362
1363 if start > total_lines {
1364 let msg = format!("File has {total_lines} lines, but start_line is {start}");
1365 return Ok((msg.clone(), (Some(vec![DisplaySegment::Text(msg)]), None)));
1366 }
1367
1368 let selected: Vec<String> = lines[start - 1..end]
1369 .iter()
1370 .enumerate()
1371 .map(|(i, line)| format!("{:>4} {}", start + i, line))
1372 .collect();
1373
1374 let read_count = end - start + 1;
1375 let display = vec![DisplaySegment::Text(format!("Read {} lines;", read_count))];
1376
1377 let text = format!(
1378 "<file name=\"{}\" lines=\"{total_lines}\">\n{}\n</file>",
1379 args.path,
1380 selected.join("\n")
1381 );
1382
1383 Ok((text, (Some(display), None)))
1384 },
1385 false,
1386 )
1387 .await?;
1388
1389 Ok(ToolOutput {
1390 text,
1391 display_as: display,
1392 content,
1393 })
1394}
1395
1396async fn request_direct_write_approval<B: SandboxBackend, M: MetadataStore, C: CallbackClient>(
1399 state: &AppState<B, M, C>,
1400 invocation: &RapInvocation,
1401 path: &str,
1402) -> bool {
1403 let chain = thread_chain(invocation);
1404
1405 if is_write_orig_granted(&state.metadata, &chain).await {
1406 return true;
1407 }
1408
1409 let (mut choices, grant_ids) = build_grant_choices(&chain);
1410 let yes_once_idx = choices.len();
1411 let no_idx = yes_once_idx + 1;
1412 choices.push("Yes once".to_owned());
1413 choices.push("No".to_owned());
1414
1415 let base_url = state
1416 .server_base_url
1417 .get()
1418 .cloned()
1419 .unwrap_or_else(|| "http://localhost".to_owned());
1420 let response_url = format!("{base_url}/user_choice_response");
1421
1422 let (choice_tx, choice_rx) = tokio::sync::oneshot::channel();
1423 state
1424 .pending_choices
1425 .lock()
1426 .await
1427 .insert(invocation.id.clone(), choice_tx);
1428
1429 send_user_choice(
1430 &state.callback_client,
1431 invocation,
1432 &format!("Direct mode: allow writing to {path}?"),
1433 choices,
1434 no_idx,
1435 &response_url,
1436 )
1437 .await;
1438
1439 let selected = choice_rx.await.unwrap_or(no_idx);
1440
1441 if selected < grant_ids.len() {
1442 let target_id = &grant_ids[selected];
1443 if let Ok(Some(mut target)) = state.metadata.get(target_id).await {
1444 target.write_orig_granted = true;
1445 if let Err(e) = state.metadata.put(&target).await {
1446 tracing::warn!("failed to persist grants: {e}");
1447 }
1448 }
1449 true
1450 } else {
1451 selected == yes_once_idx
1452 }
1453}
1454
1455async fn handle_edit_file<B: SandboxBackend, M: MetadataStore, C: CallbackClient>(
1456 state: &AppState<B, M, C>,
1457 invocation: &RapInvocation,
1458) -> DisplayResult {
1459 let args: EditFileArgs = serde_json::from_value(invocation.arguments.clone())
1460 .map_err(|e| SandboxError::Other(format!("invalid arguments: {e}")))?;
1461
1462 let repo_state = state.metadata.get(&invocation.group_id).await?;
1464 if matches!(
1465 repo_state.as_ref().map(|s| &s.mode),
1466 Some(SandboxMode::Direct)
1467 ) && !request_direct_write_approval(state, invocation, &args.path).await
1468 {
1469 return Ok(("Error: file write denied by user.".to_owned(), None));
1470 }
1471
1472 with_sandbox(
1473 state,
1474 invocation,
1475 None,
1476 |sandbox_dir| async move {
1477 let file_path = sandbox_dir.join(&args.path);
1478 let content = tokio::fs::read_to_string(&file_path)
1479 .await
1480 .map_err(SandboxError::Io)?;
1481
1482 let matches: Vec<_> = content.match_indices(&args.old_str).collect();
1483
1484 let new_content = if matches.len() == 1 {
1485 content.replacen(&args.old_str, &args.new_str, 1)
1486 } else if matches.len() > 1 {
1487 return Err(SandboxError::Other(format!(
1488 "old_str matches {} locations in {} — must be unique",
1489 matches.len(),
1490 args.path
1491 )));
1492 } else {
1493 let content_lines: Vec<&str> = content.lines().collect();
1495 let old_lines: Vec<&str> = args.old_str.lines().collect();
1496 let old_trimmed: Vec<&str> = old_lines.iter().map(|l| l.trim()).collect();
1497 let mut ws_match: Option<usize> = None;
1498 let mut ambiguous = false;
1499 if !old_lines.is_empty() && content_lines.len() >= old_lines.len() {
1500 for i in 0..=content_lines.len() - old_lines.len() {
1501 if content_lines[i..i + old_lines.len()]
1502 .iter()
1503 .zip(&old_trimmed)
1504 .all(|(a, b)| a.trim() == *b)
1505 {
1506 if ws_match.is_some() {
1507 ambiguous = true;
1508 break;
1509 }
1510 ws_match = Some(i);
1511 }
1512 }
1513 }
1514 if ambiguous {
1515 return Err(SandboxError::Other(format!(
1516 "old_str matches multiple locations in {} (by whitespace-insensitive match) — must be unique",
1517 args.path
1518 )));
1519 }
1520 if let Some(start) = ws_match {
1521 let mut result_lines: Vec<&str> = content_lines[..start].to_vec();
1522 let new_lines: Vec<&str> = args.new_str.lines().collect();
1523 result_lines.extend(&new_lines);
1524 result_lines.extend(&content_lines[start + old_lines.len()..]);
1525 let mut result = result_lines.join("\n");
1526 if content.ends_with('\n') {
1527 result.push('\n');
1528 }
1529 result
1530 } else {
1531 return Err(SandboxError::Other(format!(
1532 "old_str not found in {}",
1533 args.path
1534 )));
1535 }
1536 };
1537 tokio::fs::write(&file_path, &new_content)
1538 .await
1539 .map_err(SandboxError::Io)?;
1540
1541 let display = build_edit_diff(&args.path, &content, &new_content);
1543
1544 Ok((format!("Replaced text in {}", args.path), Some(display)))
1545 },
1546 true,
1547 )
1548 .await
1549}
1550
1551async fn handle_create_file<B: SandboxBackend, M: MetadataStore, C: CallbackClient>(
1552 state: &AppState<B, M, C>,
1553 invocation: &RapInvocation,
1554) -> DisplayResult {
1555 let args: CreateFileArgs = serde_json::from_value(invocation.arguments.clone())
1556 .map_err(|e| SandboxError::Other(format!("invalid arguments: {e}")))?;
1557
1558 let repo_state = state.metadata.get(&invocation.group_id).await?;
1560 if matches!(
1561 repo_state.as_ref().map(|s| &s.mode),
1562 Some(SandboxMode::Direct)
1563 ) && !request_direct_write_approval(state, invocation, &args.path).await
1564 {
1565 return Ok(("Error: file write denied by user.".to_owned(), None));
1566 }
1567
1568 with_sandbox(
1569 state,
1570 invocation,
1571 None,
1572 |sandbox_dir| async move {
1573 let file_path = sandbox_dir.join(&args.path);
1574
1575 if file_path.exists() {
1577 return Err(SandboxError::Other(format!(
1578 "file already exists: {}. Use edit_file to modify existing files.",
1579 args.path
1580 )));
1581 }
1582
1583 if let Some(parent) = file_path.parent() {
1585 tokio::fs::create_dir_all(parent)
1586 .await
1587 .map_err(SandboxError::Io)?;
1588 }
1589
1590 tokio::fs::write(&file_path, &args.content)
1591 .await
1592 .map_err(SandboxError::Io)?;
1593
1594 let display = build_edit_diff(&args.path, "", &args.content);
1595
1596 Ok((format!("Created file {}", args.path), Some(display)))
1597 },
1598 true,
1599 )
1600 .await
1601}
1602
1603async fn handle_grep<B: SandboxBackend, M: MetadataStore, C: CallbackClient>(
1604 state: &AppState<B, M, C>,
1605 invocation: &RapInvocation,
1606) -> DisplayResult {
1607 let args: GrepArgs = serde_json::from_value(invocation.arguments.clone())
1608 .map_err(|e| SandboxError::Other(format!("invalid arguments: {e}")))?;
1609
1610 let query_for_display = args.query.clone();
1611
1612 if which::which("rg").is_err() {
1614 return Err(SandboxError::Other(
1615 "ripgrep (rg) is not installed or not found in PATH. \
1616 Please install it: https://github.com/BurntSushi/ripgrep#installation"
1617 .to_owned(),
1618 ));
1619 }
1620
1621 with_sandbox(
1622 state,
1623 invocation,
1624 None,
1625 |sandbox_dir| async move {
1626 let exclude_glob: Option<String>;
1627 let mut cmd_parts = vec!["rg", "--line-number"];
1628 cmd_parts.extend(["-C", "2"]);
1630 cmd_parts.extend(["--max-count", "50"]);
1632 cmd_parts.extend(["--max-columns", "200", "--max-columns-preview"]);
1634
1635 if args.case_sensitive != Some(true) {
1636 cmd_parts.push("--ignore-case");
1637 }
1638
1639 if let Some(ref pattern) = args.include_pattern {
1640 cmd_parts.push("--glob");
1641 cmd_parts.push(pattern);
1642 }
1643
1644 if let Some(ref pattern) = args.exclude_pattern {
1645 exclude_glob = Some(format!("!{pattern}"));
1646 cmd_parts.push("--glob");
1647 cmd_parts.push(
1648 exclude_glob
1649 .as_ref()
1650 .expect("bug: exclude_glob was just set"),
1651 );
1652 }
1653
1654 cmd_parts.push("--");
1655 cmd_parts.push(&args.query);
1656
1657 let rg_output = tokio::process::Command::new(cmd_parts[0])
1658 .args(&cmd_parts[1..])
1659 .current_dir(&sandbox_dir)
1660 .stdout(std::process::Stdio::piped())
1661 .stderr(std::process::Stdio::piped())
1662 .output()
1663 .await
1664 .map_err(|e| SandboxError::CommandError(format!("failed to run rg: {e}")))?;
1665
1666 let stdout = String::from_utf8_lossy(&rg_output.stdout).to_string();
1667 let stderr = String::from_utf8_lossy(&rg_output.stderr).to_string();
1668 let exit_code = rg_output.status.code().unwrap_or(-1);
1669
1670 if stdout.is_empty() && exit_code == 1 {
1671 let display = vec![DisplaySegment::Text(format!(
1672 "Searched for '{}' — no matches",
1673 query_for_display
1674 ))];
1675 return Ok(("No matches found.".to_owned(), Some(display)));
1676 }
1677
1678 let mut files = HashSet::new();
1680 let mut match_count = 0usize;
1681 for line in stdout.lines() {
1682 if let Some(colon1) = line.find(':') {
1685 let rest = &line[colon1 + 1..];
1686 if let Some(colon2) = rest.find(':')
1687 && rest[..colon2].parse::<usize>().is_ok()
1688 {
1689 files.insert(&line[..colon1]);
1690 match_count += 1;
1691 }
1692 }
1693 }
1694
1695 let display = vec![DisplaySegment::Text(format!(
1696 "Searched for '{}' — {} match(es) across {} file(s)",
1697 query_for_display,
1698 match_count,
1699 files.len()
1700 ))];
1701
1702 let mut output = stdout;
1703 if !stderr.is_empty() {
1704 output.push_str("\n[stderr]\n");
1705 output.push_str(&stderr);
1706 }
1707 Ok((output, Some(display)))
1708 },
1709 false,
1710 )
1711 .await
1712}
1713
1714async fn handle_describe_overall_changes<B: SandboxBackend, M: MetadataStore, C: CallbackClient>(
1715 state: &AppState<B, M, C>,
1716 invocation: &RapInvocation,
1717) -> DisplayResult {
1718 let args: DescribeOverallChangesArgs = serde_json::from_value(invocation.arguments.clone())
1719 .map_err(|e| SandboxError::Other(format!("invalid arguments: {e}")))?;
1720
1721 let display = Some(vec![DisplaySegment::Text(args.message.clone())]);
1722 with_sandbox(
1723 state,
1724 invocation,
1725 Some(&args.message),
1726 |_sandbox_dir| async move { Ok(("Edits described.".to_owned(), display)) },
1727 true,
1728 )
1729 .await
1730}
1731
1732async fn handle_squash_sandbox<B: SandboxBackend, M: MetadataStore, C: CallbackClient>(
1733 state: &AppState<B, M, C>,
1734 invocation: &RapInvocation,
1735) -> DisplayResult {
1736 let args: SquashSandboxArgs = serde_json::from_value(invocation.arguments.clone())
1737 .map_err(|e| SandboxError::Other(format!("invalid arguments: {e}")))?;
1738
1739 let from_bookmark = format!("sandbox-{}", args.from_thread_id);
1740
1741 let repo_state = state
1742 .metadata
1743 .get(&invocation.group_id)
1744 .await?
1745 .ok_or_else(|| SandboxError::RepoNotFound(invocation.group_id.clone()))?;
1746
1747 let result = match &repo_state.mode {
1748 SandboxMode::Direct => Err(SandboxError::Other(
1749 "squash_sandbox is not supported in Direct mode".to_owned(),
1750 )),
1751 _ => {
1752 let sandbox_dir = state.backend.create_sandbox(&repo_state).await?;
1753 state
1754 .backend
1755 .squash_sandbox(&sandbox_dir, &repo_state, &from_bookmark)
1756 .await?;
1757 push_diff_view(state, invocation, &sandbox_dir).await;
1758 if let Err(e) = state.backend.cleanup_sandbox(&sandbox_dir).await {
1759 tracing::warn!("failed to cleanup sandbox: {e}");
1760 }
1761 Ok((
1762 format!("Squashed changes from {from_bookmark}."),
1763 Some(vec![DisplaySegment::Text(format!(
1764 "Squashed from {from_bookmark}"
1765 ))]),
1766 ))
1767 }
1768 };
1769
1770 if result.is_ok() {
1772 let _ = state.metadata.delete(&args.from_thread_id).await;
1773 }
1774
1775 result
1776}
1777
1778async fn push_diff_view<B: SandboxBackend, M: MetadataStore, C: CallbackClient>(
1782 state: &AppState<B, M, C>,
1783 invocation: &RapInvocation,
1784 sandbox_dir: &Path,
1785) {
1786 let Ok(Some(repo_state)) = state.metadata.get(&invocation.group_id).await else {
1787 return;
1788 };
1789
1790 let files: Vec<serde_json::Value> = match &repo_state.mode {
1791 SandboxMode::Direct => Vec::new(),
1792 _ => {
1793 let Ok(changed) = state.backend.diff_files(sandbox_dir, &repo_state).await else {
1794 return;
1795 };
1796 changed
1797 .into_iter()
1798 .map(|c| {
1799 serde_json::json!({
1800 "path": c.path,
1801 "status": c.status,
1802 "oldContents": c.old_contents,
1803 "newContents": c.new_contents,
1804 })
1805 })
1806 .collect()
1807 }
1808 };
1809
1810 if !files.is_empty() {
1811 rap_protocol::send_view_update(
1812 &state.callback_client,
1813 &invocation.callback_url,
1814 invocation.group_id.clone(),
1815 "diff",
1816 serde_json::json!({ "files": files }),
1817 )
1818 .await;
1819 }
1820}
1821
1822fn build_edit_diff(path: &str, old_str: &str, new_str: &str) -> Vec<DisplaySegment> {
1824 use similar::TextDiff;
1825
1826 let diff = TextDiff::from_lines(old_str, new_str);
1827 let mut patch = diff
1828 .unified_diff()
1829 .context_radius(3)
1830 .header(path, path)
1831 .to_string();
1832 patch.truncate(patch.trim_end().len());
1834 vec![DisplaySegment::Diff(DiffContent {
1835 path: path.to_owned(),
1836 patch,
1837 })]
1838}
1839
1840#[derive(Debug, Deserialize)]
1843struct MigrateRequest {
1844 session_id: ThreadId,
1845 destination_url: String,
1846}
1847
1848fn git_dir_for(repo_path: &Path) -> PathBuf {
1853 if repo_path.join(".jj").is_dir() && !repo_path.join(".git").exists() {
1854 repo_path.join(".jj").join("repo").join("store").join("git")
1855 } else {
1856 repo_path.to_path_buf()
1857 }
1858}
1859
1860async fn migrate_handler<
1862 B: SandboxBackend + 'static,
1863 M: MetadataStore + 'static,
1864 C: CallbackClient + 'static,
1865>(
1866 State(state): State<Arc<AppState<B, M, C>>>,
1867 Json(request): Json<MigrateRequest>,
1868) -> (StatusCode, String) {
1869 tracing::info!(
1870 session_id = %request.session_id,
1871 destination = %request.destination_url,
1872 "received migrate request"
1873 );
1874 match migrate_inner(&state, &request).await {
1875 Ok(()) => (StatusCode::OK, "migration complete".to_owned()),
1876 Err(e) => {
1877 tracing::error!("migration failed: {e}");
1878 (
1879 StatusCode::INTERNAL_SERVER_ERROR,
1880 format!("migration failed: {e}"),
1881 )
1882 }
1883 }
1884}
1885
1886async fn migrate_inner<B: SandboxBackend, M: MetadataStore, C: CallbackClient>(
1887 state: &AppState<B, M, C>,
1888 request: &MigrateRequest,
1889) -> Result<(), SandboxError> {
1890 let all_states: Vec<RepoState> = state
1891 .metadata
1892 .list_all()
1893 .await?
1894 .into_iter()
1895 .filter(|s| s.root_thread_id.as_ref() == Some(&request.session_id))
1896 .collect();
1897 if all_states.is_empty() {
1898 return Err(SandboxError::Other(
1899 "nothing to migrate: no sandboxes found for this session".into(),
1900 ));
1901 }
1902
1903 let mut repo_uris = HashSet::new();
1905 let mut is_jj = false;
1906 for s in &all_states {
1907 match &s.mode {
1908 SandboxMode::Direct => {
1909 return Err(SandboxError::Other(
1910 "cannot migrate Direct mode sandboxes".into(),
1911 ));
1912 }
1913 SandboxMode::Custom { .. } => {
1914 return Err(SandboxError::Other(
1915 "cannot migrate custom mode sandboxes".into(),
1916 ));
1917 }
1918 SandboxMode::Jj { .. } => {
1919 is_jj = true;
1920 }
1921 SandboxMode::Git { .. } => {}
1922 }
1923 repo_uris.insert(s.remote_uri.clone());
1924 }
1925 if repo_uris.len() > 1 {
1926 return Err(SandboxError::Other(
1927 "migration across multiple repositories is not supported".into(),
1928 ));
1929 }
1930 let repo_uri = repo_uris
1931 .into_iter()
1932 .next()
1933 .expect("bug: checked non-empty above");
1934 let repo_path = PathBuf::from(&repo_uri);
1935
1936 if is_jj {
1940 for s in &all_states {
1941 state.backend.create_sandbox(s).await?;
1942 }
1943 run_jj(&repo_path, &["git", "export"]).await?;
1944 }
1945
1946 let bookmarks: Vec<&str> = all_states.iter().map(|s| s.bookmark.as_str()).collect();
1948
1949 let tmp = tempfile::NamedTempFile::new().map_err(SandboxError::Io)?;
1951 let bundle_path = tmp.path().to_string_lossy().to_string();
1952 let git_dir = git_dir_for(&repo_path);
1953 let mut args: Vec<&str> = vec!["bundle", "create", &bundle_path];
1954 args.extend(bookmarks.iter());
1955 git::run_git(&git_dir, &args).await?;
1956
1957 let bundle_bytes = tokio::fs::read(tmp.path())
1959 .await
1960 .map_err(SandboxError::Io)?;
1961
1962 let metadata_json = serde_json::to_string(&all_states)
1964 .map_err(|e| SandboxError::Other(format!("failed to serialize metadata: {e}")))?;
1965
1966 let dest = format!(
1968 "{}/migrate/import",
1969 request.destination_url.trim_end_matches('/')
1970 );
1971 tracing::info!(destination = %dest, "sending migration bundle");
1972
1973 let mut last_err = None;
1974 for attempt in 1..=5u32 {
1975 let form = reqwest::multipart::Form::new()
1976 .text("metadata", metadata_json.clone())
1977 .part(
1978 "bundle",
1979 reqwest::multipart::Part::bytes(bundle_bytes.clone()).file_name("migrate.bundle"),
1980 );
1981
1982 match state.http_client.post(&dest).multipart(form).send().await {
1983 Ok(resp) if resp.status().is_success() => {
1984 last_err = None;
1985 break;
1986 }
1987 Ok(resp) => {
1988 let status = resp.status();
1989 let body = resp.text().await.unwrap_or_default();
1990 last_err = Some(format!("destination returned {status}: {body}"));
1991 break;
1992 }
1993 Err(e) if e.is_connect() && attempt < 5 => {
1994 tracing::warn!(attempt, "connection refused, retrying in 5s");
1995 tokio::time::sleep(std::time::Duration::from_secs(5)).await;
1996 last_err = Some(format!("failed to send bundle to destination: {e}"));
1997 }
1998 Err(e) => {
1999 last_err = Some(format!("failed to send bundle to destination: {e}"));
2000 break;
2001 }
2002 }
2003 }
2004
2005 if let Some(err) = last_err {
2006 return Err(SandboxError::Other(err));
2007 }
2008
2009 tracing::info!("migration bundle sent successfully");
2010
2011 for s in &all_states {
2014 if let Err(e) = state.backend.cleanup_sandbox_permanently(&s.group_id).await {
2015 tracing::warn!(
2016 group_id = %s.group_id,
2017 "failed to clean up sandbox after migration: {e}"
2018 );
2019 }
2020 }
2021
2022 Ok(())
2023}
2024
2025async fn migrate_import_handler<
2027 B: SandboxBackend + 'static,
2028 M: MetadataStore + 'static,
2029 C: CallbackClient + 'static,
2030>(
2031 State(state): State<Arc<AppState<B, M, C>>>,
2032 mut multipart: Multipart,
2033) -> (StatusCode, String) {
2034 match migrate_import_inner(&state, &mut multipart).await {
2035 Ok(()) => (StatusCode::OK, "import complete".to_owned()),
2036 Err(e) => {
2037 tracing::error!("migration import failed: {e}");
2038 (
2039 StatusCode::INTERNAL_SERVER_ERROR,
2040 format!("import failed: {e}"),
2041 )
2042 }
2043 }
2044}
2045
2046async fn migrate_import_inner<B: SandboxBackend, M: MetadataStore, C: CallbackClient>(
2047 state: &AppState<B, M, C>,
2048 multipart: &mut Multipart,
2049) -> Result<(), SandboxError> {
2050 let mut metadata_json: Option<String> = None;
2051 let mut bundle_bytes: Option<Vec<u8>> = None;
2052
2053 while let Some(field) = multipart
2054 .next_field()
2055 .await
2056 .map_err(|e| SandboxError::Other(format!("multipart error: {e}")))?
2057 {
2058 match field.name() {
2059 Some("metadata") => {
2060 metadata_json = Some(field.text().await.map_err(|e| {
2061 SandboxError::Other(format!("failed to read metadata field: {e}"))
2062 })?);
2063 }
2064 Some("bundle") => {
2065 bundle_bytes = Some(
2066 field
2067 .bytes()
2068 .await
2069 .map_err(|e| {
2070 SandboxError::Other(format!("failed to read bundle field: {e}"))
2071 })?
2072 .to_vec(),
2073 );
2074 }
2075 _ => {}
2076 }
2077 }
2078
2079 let metadata_json =
2080 metadata_json.ok_or_else(|| SandboxError::Other("missing metadata field".into()))?;
2081 let bundle_bytes =
2082 bundle_bytes.ok_or_else(|| SandboxError::Other("missing bundle field".into()))?;
2083
2084 let states: Vec<RepoState> = serde_json::from_str(&metadata_json)
2085 .map_err(|e| SandboxError::Other(format!("invalid metadata JSON: {e}")))?;
2086
2087 if states.is_empty() {
2088 return Err(SandboxError::Other("no states in metadata".into()));
2089 }
2090
2091 let cwd = state.repo_root.clone().unwrap_or_else(|| {
2093 std::env::current_dir()
2094 .expect("bug: failed to get cwd")
2095 .parent()
2096 .expect("expected cwd to be inside .infinity")
2097 .to_path_buf()
2098 });
2099 let local_repo = cwd
2100 .canonicalize()
2101 .map_err(|e| SandboxError::Other(format!("failed to canonicalize cwd: {e}")))?;
2102
2103 let local_repo = crate::find_repo_root(&local_repo)
2106 .ok_or_else(|| SandboxError::Other("cwd is not inside a git or jj repository".into()))?;
2107
2108 let is_jj = local_repo.join(".jj").is_dir();
2109
2110 let tmp = tempfile::NamedTempFile::new().map_err(SandboxError::Io)?;
2112 tokio::fs::write(tmp.path(), &bundle_bytes)
2113 .await
2114 .map_err(SandboxError::Io)?;
2115 let bundle_path = tmp.path().to_string_lossy().to_string();
2116
2117 let git_dir = git_dir_for(&local_repo);
2119 let refspecs: Vec<String> = states
2120 .iter()
2121 .map(|s| format!("+{}:{}", s.bookmark, s.bookmark))
2122 .collect();
2123 let mut args: Vec<&str> = vec!["fetch", &bundle_path];
2124 args.extend(refspecs.iter().map(|s| s.as_str()));
2125 git::run_git(&git_dir, &args).await?;
2126
2127 if is_jj {
2129 run_jj(&local_repo, &["git", "import"]).await?;
2130 }
2131
2132 let local_uri = local_repo.to_string_lossy().to_string();
2134 for mut s in states {
2135 s.remote_uri = local_uri.clone();
2136 s.sandbox_path = None;
2137 state.metadata.put(&s).await?;
2138 }
2139
2140 tracing::info!("migration import complete");
2141 Ok(())
2142}
2143
2144fn build_manifest(endpoint: &str, needs_migration: bool) -> ToolsetManifest {
2145 ToolsetManifest {
2146 name: "sandbox-tools".to_owned(),
2147 description: Some("Sandboxed code editing and execution tools using jujutsu for filesystem versioning".to_owned()),
2148 endpoint: endpoint.to_owned(),
2149 needs_migration,
2150 tools: vec![
2151 ToolDef {
2152 name: "clone_repo".to_owned(),
2153 description: "Initialize a repository for sandboxed execution. Provide a local path to a git repo (local mode) or a git remote URI (remote mode). This must be called before execute_command.".to_owned(),
2154 input_schema: serde_json::json!({
2155 "type": "object",
2156 "properties": {
2157 "repo": {
2158 "type": "string",
2159 "description": "Local path to a git repository, or a git remote URI"
2160 },
2161 "base_thread_id": {
2162 "type": "string",
2163 "description": "Optional thread ID to base this sandbox on top of. The new sandbox will be rebased onto that thread's bookmark."
2164 }
2165 },
2166 "required": ["repo"]
2167 }),
2168 annotations: None,
2169 display_script: None,
2170 },
2171 ToolDef {
2172 name: "open_sandbox_direct".to_owned(),
2173 description: "Open a repository in Direct mode (regular clone_repo is always preferred). Direct mode operates on the original directory with no worktrees — file edits and creation require user approval, and commands run without file write access unless write-orig is granted. Use this only when clone_repo fails (e.g. the repository has no commits yet).".to_owned(),
2174 input_schema: serde_json::json!({
2175 "type": "object",
2176 "properties": {
2177 "repo": {
2178 "type": "string",
2179 "description": "Local path to a git repository"
2180 }
2181 },
2182 "required": ["repo"]
2183 }),
2184 annotations: None,
2185 display_script: None,
2186 },
2187 ToolDef {
2188 name: "execute_command".to_owned(),
2189 description: "Execute a bash command in a sandboxed copy of the repository. The sandbox is an isolated temporary directory with the repo's current state. There is no need to cd to the repo directory when running commands. /tmp is not writable inside the sandbox; use $TMPDIR or mktemp for temporary files. $TMPDIR is stable across commands but may be garbage collected if unused for extended periods. To persist a file reliably, write it to the working copy as a tracked file. If you filter or truncate command output (e.g. with grep, tail, or head), use tee to also save the full output to a tempfile so you can inspect it later without re-running the command.".to_owned(),
2190 input_schema: serde_json::json!({
2191 "type": "object",
2192 "properties": {
2193 "command": {
2194 "type": "string",
2195 "description": "The bash command to execute in the sandbox"
2196 },
2197 "additional_permissions": {
2198 "type": "array",
2199 "items": { "type": "string" },
2200 "description": "Optional additional permissions. Supported: \"write-orig\" (allow writing to the original repo directory, e.g. for git push), \"write:/path\" (allow writing to a specific path). Requires user approval."
2201 }
2202 },
2203 "required": ["command"]
2204 }),
2205 annotations: None,
2206 display_script: Some(r#""$ " + args.command"#.to_owned()),
2207 },
2208 ToolDef {
2209 name: "read_file".to_owned(),
2210 description: "Read the content of a single file with optional line range specification. Returns the file content with line numbers. Use start_line and end_line to focus on specific sections of large files. Image files (PNG, JPEG, GIF, WebP) are detected by content and returned as an image attachment instead of text; line ranges are ignored for images.".to_owned(),
2211 input_schema: serde_json::json!({
2212 "type": "object",
2213 "properties": {
2214 "path": {
2215 "type": "string",
2216 "description": "Path to the file to read, relative to the repository root"
2217 },
2218 "start_line": {
2219 "type": "number",
2220 "description": "Starting line number, 1-indexed (optional)"
2221 },
2222 "end_line": {
2223 "type": "number",
2224 "description": "Ending line number, 1-indexed, inclusive (optional)"
2225 }
2226 },
2227 "required": ["path"]
2228 }),
2229 annotations: None,
2230 display_script: Some(r#"let s = "Read " + args.path; if args.start_line != () { s += ":" + args.start_line; if args.end_line != () { s += "-" + args.end_line; } } s"#.to_owned()),
2231 },
2232 ToolDef {
2233 name: "edit_file".to_owned(),
2234 description: "Replace text in a file. The old_str must match exactly one location in the file. The new_str will replace it. Ensure old_str is unique and includes enough context to identify a single location.".to_owned(),
2235 input_schema: serde_json::json!({
2236 "type": "object",
2237 "properties": {
2238 "path": {
2239 "type": "string",
2240 "description": "Path to the file to edit, relative to the repository root"
2241 },
2242 "old_str": {
2243 "type": "string",
2244 "description": "The exact string to find in the file (must match exactly one location)"
2245 },
2246 "new_str": {
2247 "type": "string",
2248 "description": "The replacement string"
2249 }
2250 },
2251 "required": ["path", "old_str", "new_str"]
2252 }),
2253 annotations: None,
2254 display_script: Some(r#"let n = args.new_str.split("\n").len(); "Edit " + args.path + " (" + n + " lines)""#.to_owned()),
2255 },
2256 ToolDef {
2257 name: "create_file".to_owned(),
2258 description: "Create a new file with the given content. Fails if the file already exists. Parent directories are created automatically if needed.".to_owned(),
2259 input_schema: serde_json::json!({
2260 "type": "object",
2261 "properties": {
2262 "path": {
2263 "type": "string",
2264 "description": "Path for the new file, relative to the repository root"
2265 },
2266 "content": {
2267 "type": "string",
2268 "description": "The content to write to the new file"
2269 }
2270 },
2271 "required": ["path", "content"]
2272 }),
2273 annotations: None,
2274 display_script: None,
2275 },
2276 ToolDef {
2277 name: "grep".to_owned(),
2278 description: "Fast text-based regex search that finds exact pattern matches within files using ripgrep. Search results include line numbers, file paths, and 2 lines of context around each match. Results are capped at 50 matches.".to_owned(),
2279 input_schema: serde_json::json!({
2280 "type": "object",
2281 "properties": {
2282 "query": {
2283 "type": "string",
2284 "description": "The regex pattern to search for (Rust regex syntax)"
2285 },
2286 "includePattern": {
2287 "type": "string",
2288 "description": "Glob pattern for files to include (e.g. '**/*.rs')"
2289 },
2290 "excludePattern": {
2291 "type": "string",
2292 "description": "Glob pattern for files to exclude"
2293 },
2294 "caseSensitive": {
2295 "type": "boolean",
2296 "description": "Whether the search should be case sensitive (defaults to false)"
2297 }
2298 },
2299 "required": ["query"]
2300 }),
2301 annotations: None,
2302 display_script: Some(r#"let s = "Grep: " + args.query; if args.includePattern != () { s += " in " + args.includePattern; } s"#.to_owned()),
2303 },
2304 ToolDef {
2305 name: "describe_overall_changes".to_owned(),
2306 description: "Call this after finishing a coding task or subtask to describe the overall changes. Use a git-style commit message: a short one-line summary, followed by a blank line, then detailed explanations of what was changed and why. The message will be displayed to the user automatically; do not repeat it in your response.".to_owned(),
2307 input_schema: serde_json::json!({
2308 "type": "object",
2309 "properties": {
2310 "message": {
2311 "type": "string",
2312 "description": "A git-style description of the edits: a short one-line summary, followed by a blank line, then detailed explanations"
2313 }
2314 },
2315 "required": ["message"]
2316 }),
2317 annotations: None,
2318 display_script: Some(r#"let lines = args.message.split("\n"); "Describe changes: " + lines[0]"#.to_owned()),
2319 },
2320 ToolDef {
2321 name: "squash_sandbox".to_owned(),
2322 description: "Squash changes from a child thread's sandbox into the current thread's sandbox. This runs `jj squash` to merge the child's changes. Use this after a child thread completes its work to incorporate its edits.".to_owned(),
2323 input_schema: serde_json::json!({
2324 "type": "object",
2325 "properties": {
2326 "from_thread_id": {
2327 "type": "string",
2328 "description": "The thread ID of the child sandbox to squash from"
2329 }
2330 },
2331 "required": ["from_thread_id"]
2332 }),
2333 annotations: None,
2334 display_script: None,
2335 },
2336 ],
2337 }
2338}
2339
2340#[cfg(test)]
2341mod tests {
2342 use super::*;
2343
2344 #[test]
2345 fn detects_cd_to_exact_repo_path() {
2346 let uri = "/Users/foo/my-repo";
2347 assert!(detect_cd_to_original_repo("cd /Users/foo/my-repo && cargo build", uri).is_some());
2348 }
2349
2350 #[test]
2351 fn detects_cd_with_trailing_slash() {
2352 let uri = "/Users/foo/my-repo";
2353 assert!(detect_cd_to_original_repo("cd /Users/foo/my-repo/ && cargo build", uri).is_some());
2354 }
2355
2356 #[test]
2357 fn detects_cd_with_double_quotes() {
2358 let uri = "/Users/foo/my-repo";
2359 assert!(
2360 detect_cd_to_original_repo("cd \"/Users/foo/my-repo\" && cargo build", uri).is_some()
2361 );
2362 }
2363
2364 #[test]
2365 fn detects_cd_with_single_quotes() {
2366 let uri = "/Users/foo/my-repo";
2367 assert!(
2368 detect_cd_to_original_repo("cd '/Users/foo/my-repo' && cargo build", uri).is_some()
2369 );
2370 }
2371
2372 #[test]
2373 fn detects_cd_with_semicolon() {
2374 let uri = "/Users/foo/my-repo";
2375 assert!(detect_cd_to_original_repo("cd /Users/foo/my-repo; ls", uri).is_some());
2376 }
2377
2378 #[test]
2379 fn detects_cd_to_subdirectory() {
2380 let uri = "/Users/foo/my-repo";
2381 assert!(
2382 detect_cd_to_original_repo("cd /Users/foo/my-repo/src && cargo build", uri).is_some()
2383 );
2384 }
2385
2386 #[test]
2387 fn detects_cd_alone() {
2388 let uri = "/Users/foo/my-repo";
2389 assert!(detect_cd_to_original_repo("cd /Users/foo/my-repo", uri).is_some());
2390 }
2391
2392 #[test]
2393 fn allows_relative_cd() {
2394 let uri = "/Users/foo/my-repo";
2395 assert!(detect_cd_to_original_repo("cd src && cargo build", uri).is_none());
2396 }
2397
2398 #[test]
2399 fn allows_different_absolute_path() {
2400 let uri = "/Users/foo/my-repo";
2401 assert!(detect_cd_to_original_repo("cd /tmp && ls", uri).is_none());
2402 }
2403
2404 #[test]
2405 fn allows_non_cd_commands() {
2406 let uri = "/Users/foo/my-repo";
2407 assert!(detect_cd_to_original_repo("cargo build", uri).is_none());
2408 }
2409
2410 #[test]
2411 fn allows_non_cd_command_containing_path() {
2412 let uri = "/Users/foo/my-repo";
2413 assert!(detect_cd_to_original_repo("ls /Users/foo/my-repo", uri).is_none());
2414 }
2415
2416 #[test]
2417 fn ignores_s3_remote_uri() {
2418 let uri = "s3://bucket/my-repo";
2419 assert!(detect_cd_to_original_repo("cd s3://bucket/my-repo && ls", uri).is_none());
2420 }
2421
2422 #[test]
2423 fn does_not_match_prefix_of_different_repo() {
2424 let uri = "/Users/foo/my-repo";
2425 assert!(
2426 detect_cd_to_original_repo("cd /Users/foo/my-repo-other && cargo build", uri).is_none()
2427 );
2428 }
2429
2430 #[test]
2431 fn handles_uri_with_trailing_slash() {
2432 let uri = "/Users/foo/my-repo/";
2433 assert!(detect_cd_to_original_repo("cd /Users/foo/my-repo && cargo build", uri).is_some());
2434 }
2435}