Skip to main content

sandbox_core/
server.rs

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/// The outcome of a tool operation dispatched by [`invoke_handler`].
27///
28/// A tool provides either plain `text` or structured `content` (e.g. images);
29/// `display_as` is an optional set of UI-facing display segments.
30#[derive(Default)]
31struct ToolOutput {
32    /// Model-facing plain text. Used as the tool result when `content` is
33    /// `None`; ignored (superseded) when `content` is present.
34    text: String,
35    /// Optional display segments for human-facing UIs.
36    display_as: Option<Vec<DisplaySegment>>,
37    /// Optional structured content (text and images) for multimodal results.
38    content: Option<Vec<RapToolResultContent>>,
39}
40
41impl ToolOutput {
42    /// A plain-text result with no display or structured content.
43    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
64/// Events produced by the stdout/stderr readers and process exit waiter.
65enum OutputEvent {
66    Stdout(String),
67    Stderr(String),
68    Exit(i32),
69}
70
71type PendingTasks = Arc<Mutex<Vec<JoinHandle<()>>>>;
72
73/// In-flight cancellable commands, keyed by tool_call_id.
74///
75/// The sender is created in `invoke_handler` (synchronously, before the task
76/// is spawned) and the receiver is passed into the command handler.  The
77/// `cancel_tool_call_handler` removes the sender and sends `()` to signal
78/// cancellation; the handler receives it and sends SIGTERM to the process.
79type InFlightMap = Arc<Mutex<HashMap<rap_protocol::ToolCallId, tokio::sync::oneshot::Sender<()>>>>;
80
81/// Send SIGTERM to a process group by PID.
82///
83/// The spawned command is expected to have set its PGID to its own PID
84/// (via the `exec` sub-entrypoint of the sandbox-local binary), so
85/// sending the signal to the negative PID targets the entire group.
86#[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 user choice responses, keyed by tool call ID.
103    /// The sender delivers the user's selected index.
104    pending_choices:
105        Arc<Mutex<HashMap<rap_protocol::ToolCallId, tokio::sync::oneshot::Sender<usize>>>>,
106    /// Server base URL, set from the first request's Host header.
107    server_base_url: std::sync::OnceLock<String>,
108    /// Whether this server advertises migration support.
109    needs_migration: bool,
110    /// Optional repo root for migration import. When set, the import handler
111    /// uses this path instead of `std::env::current_dir()`.
112    repo_root: Option<PathBuf>,
113    /// Reusable HTTP client for outbound requests (e.g. migration).
114    http_client: reqwest::Client,
115}
116
117/// Shared handle to pending background tasks and in-flight commands.
118#[derive(Clone)]
119pub struct TaskTracker {
120    pending_tasks: PendingTasks,
121    in_flight: InFlightMap,
122}
123
124impl TaskTracker {
125    /// Cancel all in-flight commands by sending the cancel signal,
126    /// which triggers SIGTERM to child processes.
127    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
150/// Build the axum Router for the sandbox RAP server.
151pub 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    // For execute_command, register the cancellation channel synchronously
238    // (before spawning the task) so that cancel_tool_call always finds an
239    // entry — even if the cancel arrives before the command starts.
240    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        // A tool result carries either `text` or `content`; when structured
288        // content is present it supersedes the plain text.
289        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
317/// Lift a [`DisplayResult`] into a [`ContentResult`] with no structured content.
318fn with_content(result: DisplayResult) -> ContentResult {
319    result.map(|(text, display_as)| ToolOutput {
320        text,
321        display_as,
322        content: None,
323    })
324}
325
326/// Request payload for the `/close_thread` RAP protocol endpoint.
327#[derive(Debug, Deserialize)]
328struct CloseThreadRequest {
329    thread_id: ThreadId,
330}
331
332/// Best-effort notification endpoint for thread closure.
333///
334/// When the leader closes a thread it POSTs `{"thread_id": "..."}` to every
335/// tool server. The server uses this to clean up thread-specific resources
336/// (e.g. cached sandboxes). The response is always 200 OK — the leader does
337/// not retry on failure, and tool servers are free to ignore this entirely.
338async 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/// Request payload for the `/cancel_tool_call` RAP protocol endpoint.
369#[derive(Debug, Deserialize)]
370struct CancelToolCallRequest {
371    tool_call_id: String,
372    thread_id: ThreadId,
373}
374
375/// Best-effort notification endpoint for tool call cancellation.
376///
377/// When the runtime interrupts a tool call (e.g. because of a new user
378/// message), it POSTs `{"thread_id":"…","tool_call_id":"…"}` to every
379/// tool server. The server uses this to abort in-flight operations — for
380/// example, killing a long-running `execute_command` process. The
381/// response is always 200 OK regardless of whether anything was actually
382/// cancelled.
383async 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        // Signal the command handler to SIGTERM the process and clean up.
405        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/// Request payload for the `/user_choice_response` endpoint.
421#[derive(Debug, Deserialize)]
422struct UserChoiceResponse {
423    id: String,
424    selected: usize,
425}
426
427/// Endpoint that receives the user's choice selection from the runtime.
428async 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
455/// Build the full thread chain: ancestors (if any) + current group_id.
456fn 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
466/// Check if write-orig is granted on any thread in the chain.
467async 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
478/// Return the set of paths already granted across the thread chain.
479async 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
492/// Build per-thread-level "Yes" choices from root to current thread.
493/// Returns `(choices, thread_ids)` where `choices[i]` is the label and
494/// `thread_ids[i]` is the group_id to persist the grant on.
495fn 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    // Reject re-initialization unless upgrading from Direct mode.
513    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    // The backend sets up the repository (resolving the root locally, or
526    // mirroring a remote URL), then consults its mode providers in order.
527    // The built-in jj and git providers are registered last, with git acting
528    // as the fallback for any repository not claimed earlier.
529    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    // The provider resolved the repo root itself; it is authoritative.
546    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    // If the requested path was nested inside the repository (rather than
570    // being the repo root itself), note the detected root in the response.
571    let mut msg = init.message;
572    if let Some(note) = repo_root_note(&args.repo, &init.repo_root) {
573        msg.push_str(&note);
574    }
575    Ok(msg)
576}
577
578/// If `requested` is a local path nested inside the resolved repository
579/// `root`, return a note for the tool response pointing at the root.
580/// Returns `None` when the requested path is the root itself, or when it
581/// is not a resolvable local path (e.g. a git remote URI).
582fn 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    // `args.repo` is the path as requested by the caller, while `remote_uri`
627    // is the backend-resolved repository root (canonicalized, walked up to
628    // the nearest `.jj`/`.git`). For a directory with no VCS at all, the
629    // backend falls back to the requested path itself, so Direct mode still
630    // works on non-VCS directories and no note is emitted.
631    if let Some(note) = repo_root_note(&args.repo, Path::new(&remote_uri)) {
632        msg.push_str(&note);
633    }
634    Ok(msg)
635}
636
637use crate::{DEFAULT_SANDBOX_EMAIL, DEFAULT_SANDBOX_NAME};
638
639/// Append a `Co-authored-by` trailer for the default sandbox identity.
640fn append_co_author_trailer(description: &str) -> String {
641    format!("{description}\n\nCo-authored-by: {DEFAULT_SANDBOX_NAME} <{DEFAULT_SANDBOX_EMAIL}>")
642}
643
644/// Run an action inside a sandbox: create → action → push → cleanup.
645///
646/// The closure receives the sandbox directory path and returns `(text, extra)`,
647/// where `text` is the full result sent to the model and `extra` carries any
648/// additional payload (typically the optional `display_as` segments, but
649/// handlers with structured content use a wider tuple).
650async 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    // Detect external changes (e.g. bookmark moves) before push overwrites them.
686    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    // Prepend push warning to the result text if present.
713    match (push_warning, result) {
714        (Some(warning), Ok((text, display))) => Ok((format!("{warning}\n{text}"), display)),
715        (_, result) => result,
716    }
717}
718
719// ── Streaming execute_command handler ──
720
721/// Check if a command tries to `cd` into the original repo directory.
722///
723/// The agent sometimes emits commands like
724///   `cd /Users/foo/my-repo && cargo build`
725/// which escapes the sandbox and can hang on file locks. This function
726/// detects such patterns and returns a user-friendly error message.
727fn detect_cd_to_original_repo(command: &str, remote_uri: &str) -> Option<String> {
728    // Only relevant for local paths (remote URIs like s3:// won't match).
729    if !remote_uri.starts_with('/') {
730        return None;
731    }
732
733    let trimmed = command.trim();
734
735    // Must start with `cd `.
736    if !trimmed.starts_with("cd ") {
737        return None;
738    }
739
740    let after_cd = trimmed[3..].trim_start();
741
742    // Strip optional quotes around the path.
743    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        // Unquoted: take until whitespace, `&&`, or `;`.
751        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    // Exact match or the cd target is a subdirectory of the original repo.
761    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
777/// Format command output in the same style as the original non-streaming handler.
778fn 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
798/// Top-level streaming handler for execute_command. Manages its own callbacks.
799async 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
820/// Inner implementation: creates sandbox, spawns process, streams output.
821///
822/// Cancellation is handled via `cancel_rx`: when the oneshot fires the handler
823/// sends SIGTERM to the child process and returns a cancellation result.
824/// Because the oneshot sender is registered in `invoke_handler` *before* the
825/// task is spawned, there is no window where a cancel can be lost.
826async 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    // Check if write-orig permission is requested
847    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    // Collect write:/path permissions
853    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    // Check grants across the full ancestor chain
865    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        // Build a single batch prompt for all unapproved permissions
876        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        // Build per-thread-level choices
886        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            // Persist grant on the chosen thread
920            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            // Yes once — no persistence
937        } 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    // Reject commands that `cd` to the original repo directory — this escapes
952    // the sandbox and can hang on file locks (e.g. cargo build competing for
953    // the same target directory).
954    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    // Check for early cancellation before spawning the process.
966    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    // Spawn the process
986    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    // Channel for output events from reader tasks
1035    let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<OutputEvent>();
1036
1037    // Spawn stdout reader
1038    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    // Spawn stderr reader
1055    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    // Spawn exit waiter — waits for process exit AND for readers to finish,
1072    // so by the time we receive Exit all stdout/stderr data is already in the channel.
1073    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    // ── Phase 1: Wait up to 5 seconds for process completion ──
1082    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 text = format_cancel_output(&stdout_buf, &stderr_buf);
1095                  let _ = state.backend.push_sandbox(&sandbox_dir, &invocation.group_id, None).await;
1096                // TODO: determine if sending this would be in-spec
1097                // send_subscription_event(&state.callback_client, invocation, &text, true).await;
1098                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        // Process finished within 5 seconds — return a normal tool_result.
1122        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    // ── Phase 2: Process still running — send initial result, start streaming ──
1137    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    // ── Fixed-interval subscription event loop ──
1162    let mut accumulated = String::new();
1163    let mut interval = tokio::time::interval(std::time::Duration::from_secs(10));
1164    interval.tick().await; // consume first immediate tick
1165
1166    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
1286/// Detect an image's MIME type from its leading magic bytes. Only formats
1287/// commonly accepted by multimodal models are recognized. Content-based
1288/// detection (rather than by file extension) means a mislabeled or
1289/// extension-less file is still classified correctly.
1290fn 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            // Detect images by content (not extension) so mislabeled or
1322            // extension-less files are still classified correctly. Image
1323            // files are returned as structured content so multimodal models
1324            // can see them.
1325            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                // Image segment first for clients that can render it, with a
1333                // text summary fallback for those that can't.
1334                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
1396/// Request user approval for a Direct-mode file write.
1397/// Returns `true` if the user approved, `false` otherwise.
1398async 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    // In Direct mode, request user approval before writing
1463    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                // Fallback: try whitespace-insensitive line matching
1494                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            // Build a unified diff for display (use full file contents so line numbers are correct)
1542            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    // In Direct mode, request user approval before writing
1559    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            // Fail if the file already exists
1576            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            // Create parent directories if needed
1584            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    // Verify that ripgrep is installed before creating a sandbox.
1613    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            // Context lines
1629            cmd_parts.extend(["-C", "2"]);
1630            // Max count to avoid huge output
1631            cmd_parts.extend(["--max-count", "50"]);
1632            // Limit line length to avoid destroying context with minified files
1633            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            // Build summary: count matching files and match lines
1679            let mut files = HashSet::new();
1680            let mut match_count = 0usize;
1681            for line in stdout.lines() {
1682                // Match lines have the format "file:line:content" (colon after line number)
1683                // Context lines have "file-line-content" (dash after line number)
1684                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    // Remove the child's metadata so migration doesn't try to bundle a deleted bookmark.
1771    if result.is_ok() {
1772        let _ = state.metadata.delete(&args.from_thread_id).await;
1773    }
1774
1775    result
1776}
1777
1778/// Compute the overall diff for a sandbox and send a `view_update` callback.
1779/// Sends old/new file contents for each changed file so the frontend can render
1780/// expandable diffs.
1781async 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
1822/// Build a pretty-printed unified diff for display in the CLI.
1823fn 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    // Trim trailing whitespace but keep the structure intact
1833    patch.truncate(patch.trim_end().len());
1834    vec![DisplaySegment::Diff(DiffContent {
1835        path: path.to_owned(),
1836        patch,
1837    })]
1838}
1839
1840// ── Migration handlers ──
1841
1842#[derive(Debug, Deserialize)]
1843struct MigrateRequest {
1844    session_id: ThreadId,
1845    destination_url: String,
1846}
1847
1848/// Resolve the git directory for a repo path.
1849/// For colocated jj repos (.jj + .git both exist): use the repo path itself.
1850/// For non-colocated jj repos (.jj only): use `.jj/repo/store/git`.
1851/// For plain git repos: use the repo path itself.
1852fn 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
1860/// Source-side migration handler: bundles sandbox state and sends to destination.
1861async 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    // Validate modes and collect unique repo URIs
1904    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    // For jj repos, ensure all sandbox workspaces are loaded so their
1937    // bookmarks exist before exporting. Without this, empty sandboxes
1938    // (no changes yet) won't have their bookmark in git after export.
1939    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    // Collect bookmark names
1947    let bookmarks: Vec<&str> = all_states.iter().map(|s| s.bookmark.as_str()).collect();
1948
1949    // Create git bundle
1950    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    // Read bundle bytes
1958    let bundle_bytes = tokio::fs::read(tmp.path())
1959        .await
1960        .map_err(SandboxError::Io)?;
1961
1962    // Serialize metadata
1963    let metadata_json = serde_json::to_string(&all_states)
1964        .map_err(|e| SandboxError::Other(format!("failed to serialize metadata: {e}")))?;
1965
1966    // POST multipart to destination with retries (destination server may still be starting)
1967    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    // Clean up cached sandbox worktrees/workspaces on the source side —
2012    // they are no longer needed after a successful migration.
2013    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
2025/// Destination-side import handler: receives bundle + metadata and applies them.
2026async 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    // Discover local repo from repo_root or cwd
2092    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    // Walk up to the repository root so a cwd nested inside the repo
2104    // (including subdirectories of jj repos) is detected correctly.
2105    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    // Write bundle to temp file
2111    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    // Fetch from bundle into the local repo's git dir, mapping each bookmark to a local ref
2118    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    // For jj repos, import the git refs
2128    if is_jj {
2129        run_jj(&local_repo, &["git", "import"]).await?;
2130    }
2131
2132    // Write metadata with updated remote_uri
2133    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}