Skip to main content

boxxkite_client/
files.rs

1//! `exec` and the file-op tool surface: `file_create`/`view`/`str_replace`/
2//! `ls`/`glob`/`grep`. Mirrors `sdk-python`'s methods of the same names,
3//! field-for-field against `control_plane.schemas`'s `Sandbox*Request`/
4//! `Sandbox*Response` models.
5
6use std::collections::HashMap;
7use std::time::Duration;
8
9use reqwest::Method;
10use serde::{Deserialize, Serialize};
11
12use crate::client::{Client, EXEC_TIMEOUT_HEADROOM};
13use crate::error::BoxxkiteError;
14
15/// Optional `exec` parameters.
16#[derive(Debug, Clone, Default)]
17pub struct ExecOptions {
18    timeout: Option<u32>,
19    description: Option<String>,
20}
21
22impl ExecOptions {
23    pub fn new() -> Self {
24        Self::default()
25    }
26
27    /// Command timeout in seconds (1-100 server-side, default 30).
28    pub fn timeout(mut self, timeout: u32) -> Self {
29        self.timeout = Some(timeout);
30        self
31    }
32
33    pub fn description(mut self, description: impl Into<String>) -> Self {
34        self.description = Some(description.into());
35        self
36    }
37}
38
39#[derive(Serialize)]
40struct ExecBody<'a> {
41    command: &'a str,
42    #[serde(skip_serializing_if = "Option::is_none")]
43    timeout: Option<u32>,
44    #[serde(skip_serializing_if = "Option::is_none")]
45    description: Option<&'a str>,
46}
47
48/// `POST /v1/sandboxes/{id}/exec`'s response.
49#[derive(Debug, Clone, Deserialize)]
50pub struct ExecResult {
51    pub exit_code: i32,
52    pub stdout: String,
53    pub stderr: String,
54}
55
56/// Optional `http_request` parameters.
57#[derive(Debug, Clone, Default)]
58pub struct HttpRequestOptions {
59    headers: Option<HashMap<String, String>>,
60    body: Option<String>,
61    timeout: Option<u32>,
62}
63
64impl HttpRequestOptions {
65    pub fn new() -> Self {
66        Self::default()
67    }
68
69    /// Request headers. A value may contain a literal `{{secret:name}}`
70    /// reference for a secret granted to this session via
71    /// [`CreateSandboxOptions::secret_names`](crate::CreateSandboxOptions::secret_names);
72    /// the sidecar substitutes the real value in-process -- this SDK never
73    /// sees it.
74    pub fn headers(mut self, headers: HashMap<String, String>) -> Self {
75        self.headers = Some(headers);
76        self
77    }
78
79    /// Request body. May contain `{{secret:name}}` references, same as
80    /// [`HttpRequestOptions::headers`].
81    pub fn body(mut self, body: impl Into<String>) -> Self {
82        self.body = Some(body.into());
83        self
84    }
85
86    /// Request timeout in seconds.
87    pub fn timeout(mut self, timeout: u32) -> Self {
88        self.timeout = Some(timeout);
89        self
90    }
91}
92
93#[derive(Serialize)]
94struct HttpRequestBody<'a> {
95    method: &'a str,
96    url: &'a str,
97    #[serde(skip_serializing_if = "Option::is_none")]
98    headers: Option<&'a HashMap<String, String>>,
99    #[serde(skip_serializing_if = "Option::is_none")]
100    body: Option<&'a str>,
101    #[serde(skip_serializing_if = "Option::is_none")]
102    timeout: Option<u32>,
103}
104
105/// `POST /v1/sandboxes/{id}/http-request`'s response.
106#[derive(Debug, Clone, Deserialize)]
107pub struct HttpRequestResult {
108    pub status_code: i32,
109    #[serde(default)]
110    pub headers: HashMap<String, String>,
111    pub body: String,
112    #[serde(default)]
113    pub truncated: bool,
114}
115
116/// A single optional `description` field shared by every file-op request --
117/// a caller-supplied annotation surfaced later in `get_log`'s audit trail.
118#[derive(Debug, Clone, Default)]
119pub struct FileOptions {
120    description: Option<String>,
121}
122
123impl FileOptions {
124    pub fn new() -> Self {
125        Self::default()
126    }
127
128    pub fn description(mut self, description: impl Into<String>) -> Self {
129        self.description = Some(description.into());
130        self
131    }
132}
133
134#[derive(Serialize)]
135struct FileCreateBody<'a> {
136    path: &'a str,
137    content: &'a str,
138    #[serde(skip_serializing_if = "Option::is_none")]
139    description: Option<&'a str>,
140}
141
142/// `POST /v1/sandboxes/{id}/files`'s response.
143#[derive(Debug, Clone, Deserialize)]
144pub struct FileCreateResult {
145    pub path: String,
146    pub size: i64,
147    pub created: bool,
148}
149
150/// Optional `view` parameters.
151#[derive(Debug, Clone, Default)]
152pub struct ViewOptions {
153    view_range: Option<[i64; 2]>,
154    description: Option<String>,
155}
156
157impl ViewOptions {
158    pub fn new() -> Self {
159        Self::default()
160    }
161
162    /// `[start_line, end_line]`, 1-indexed.
163    pub fn view_range(mut self, start_line: i64, end_line: i64) -> Self {
164        self.view_range = Some([start_line, end_line]);
165        self
166    }
167
168    pub fn description(mut self, description: impl Into<String>) -> Self {
169        self.description = Some(description.into());
170        self
171    }
172}
173
174#[derive(Serialize)]
175struct ViewBody<'a> {
176    path: &'a str,
177    #[serde(skip_serializing_if = "Option::is_none")]
178    view_range: Option<[i64; 2]>,
179    #[serde(skip_serializing_if = "Option::is_none")]
180    description: Option<&'a str>,
181}
182
183/// `POST /v1/sandboxes/{id}/files/view`'s response.
184#[derive(Debug, Clone, Deserialize)]
185pub struct ViewResult {
186    pub content: String,
187    pub lines: i64,
188    #[serde(default)]
189    pub is_directory: bool,
190    pub entries: Option<Vec<String>>,
191}
192
193/// Optional `str_replace` parameters.
194#[derive(Debug, Clone, Default)]
195pub struct StrReplaceOptions {
196    replace_all: bool,
197    description: Option<String>,
198}
199
200impl StrReplaceOptions {
201    pub fn new() -> Self {
202        Self::default()
203    }
204
205    /// Replace every occurrence of `old_str` instead of requiring exactly
206    /// one match.
207    pub fn replace_all(mut self, replace_all: bool) -> Self {
208        self.replace_all = replace_all;
209        self
210    }
211
212    pub fn description(mut self, description: impl Into<String>) -> Self {
213        self.description = Some(description.into());
214        self
215    }
216}
217
218#[derive(Serialize)]
219struct StrReplaceBody<'a> {
220    path: &'a str,
221    old_str: &'a str,
222    new_str: &'a str,
223    replace_all: bool,
224    #[serde(skip_serializing_if = "Option::is_none")]
225    description: Option<&'a str>,
226}
227
228/// `POST /v1/sandboxes/{id}/files/str-replace`'s response.
229#[derive(Debug, Clone, Deserialize)]
230pub struct StrReplaceResult {
231    pub path: String,
232    pub replaced: bool,
233    pub occurrences: i64,
234}
235
236/// Optional `ls` parameters.
237#[derive(Debug, Clone, Default)]
238pub struct LsOptions {
239    path: Option<String>,
240}
241
242impl LsOptions {
243    pub fn new() -> Self {
244        Self::default()
245    }
246
247    /// Directory to list. Defaults to `"/"` server-side when omitted.
248    pub fn path(mut self, path: impl Into<String>) -> Self {
249        self.path = Some(path.into());
250        self
251    }
252}
253
254#[derive(Serialize)]
255struct LsBody {
256    path: String,
257}
258
259/// `POST /v1/sandboxes/{id}/files/ls`'s response. Each entry's shape isn't
260/// pinned by the API contract (`list[dict]` server-side), so entries are
261/// left as raw JSON rather than an over-fitted struct.
262#[derive(Debug, Clone, Deserialize)]
263pub struct LsResult {
264    pub entries: Vec<serde_json::Value>,
265}
266
267/// Optional `glob` parameters.
268#[derive(Debug, Clone, Default)]
269pub struct GlobOptions {
270    path: Option<String>,
271}
272
273impl GlobOptions {
274    pub fn new() -> Self {
275        Self::default()
276    }
277
278    /// Directory to search under. Defaults to `"/"` server-side when omitted.
279    pub fn path(mut self, path: impl Into<String>) -> Self {
280        self.path = Some(path.into());
281        self
282    }
283}
284
285#[derive(Serialize)]
286struct GlobBody<'a> {
287    pattern: &'a str,
288    path: String,
289}
290
291/// `POST /v1/sandboxes/{id}/files/glob`'s response.
292#[derive(Debug, Clone, Deserialize)]
293pub struct GlobResult {
294    pub matches: Vec<serde_json::Value>,
295}
296
297/// Optional `grep` parameters.
298#[derive(Debug, Clone, Default)]
299pub struct GrepOptions {
300    path: Option<String>,
301    glob: Option<String>,
302    max_matches: Option<u32>,
303}
304
305impl GrepOptions {
306    pub fn new() -> Self {
307        Self::default()
308    }
309
310    /// Directory to search under. Defaults to `"/"` server-side when omitted.
311    pub fn path(mut self, path: impl Into<String>) -> Self {
312        self.path = Some(path.into());
313        self
314    }
315
316    /// Restrict which files are searched by this glob.
317    pub fn glob(mut self, glob: impl Into<String>) -> Self {
318        self.glob = Some(glob.into());
319        self
320    }
321
322    /// Maximum number of matches to return (default 500 server-side).
323    pub fn max_matches(mut self, max_matches: u32) -> Self {
324        self.max_matches = Some(max_matches);
325        self
326    }
327}
328
329#[derive(Serialize)]
330struct GrepBody<'a> {
331    pattern: &'a str,
332    path: String,
333    #[serde(skip_serializing_if = "Option::is_none")]
334    glob: Option<&'a str>,
335    #[serde(skip_serializing_if = "Option::is_none")]
336    max_matches: Option<u32>,
337}
338
339/// `POST /v1/sandboxes/{id}/files/grep`'s response.
340#[derive(Debug, Clone, Deserialize)]
341pub struct GrepResult {
342    pub matches: Vec<serde_json::Value>,
343    pub error: Option<String>,
344    #[serde(default)]
345    pub truncated: bool,
346}
347
348impl Client {
349    /// `POST /v1/sandboxes/{id}/exec` -- run a shell command inside the
350    /// session's sandbox and return its exit code, stdout, and stderr.
351    /// Commands run synchronously; there is no streaming of partial output.
352    pub async fn exec(
353        &self,
354        session_id: &str,
355        command: &str,
356        options: ExecOptions,
357    ) -> Result<ExecResult, BoxxkiteError> {
358        let body = ExecBody {
359            command,
360            timeout: options.timeout,
361            description: options.description.as_deref(),
362        };
363        let mut builder = self
364            .request(Method::POST, &format!("/v1/sandboxes/{session_id}/exec"))
365            .json(&body);
366        if let Some(timeout) = options.timeout {
367            builder = builder.timeout(Duration::from_secs(timeout as u64) + EXEC_TIMEOUT_HEADROOM);
368        }
369        self.send(builder).await
370    }
371
372    /// `POST /v1/sandboxes/{id}/http-request` -- the secrets-broker HTTP
373    /// request proxy (`docs/SECRETS-DESIGN.md`). `options.headers`/
374    /// `options.body` may contain a literal `{{secret:name}}` reference for a
375    /// secret granted to this session via
376    /// [`CreateSandboxOptions::secret_names`](crate::CreateSandboxOptions::secret_names);
377    /// the sidecar substitutes the real value in-process -- this SDK never
378    /// sees the credential.
379    pub async fn http_request(
380        &self,
381        session_id: &str,
382        method: &str,
383        url: &str,
384        options: HttpRequestOptions,
385    ) -> Result<HttpRequestResult, BoxxkiteError> {
386        let body = HttpRequestBody {
387            method,
388            url,
389            headers: options.headers.as_ref(),
390            body: options.body.as_deref(),
391            timeout: options.timeout,
392        };
393        let mut builder = self
394            .request(
395                Method::POST,
396                &format!("/v1/sandboxes/{session_id}/http-request"),
397            )
398            .json(&body);
399        if let Some(timeout) = options.timeout {
400            builder = builder.timeout(Duration::from_secs(timeout as u64) + EXEC_TIMEOUT_HEADROOM);
401        }
402        self.send(builder).await
403    }
404
405    /// `POST /v1/sandboxes/{id}/files` -- create or overwrite a file in the
406    /// session's sandbox workspace.
407    pub async fn file_create(
408        &self,
409        session_id: &str,
410        path: &str,
411        content: &str,
412        options: FileOptions,
413    ) -> Result<FileCreateResult, BoxxkiteError> {
414        let body = FileCreateBody {
415            path,
416            content,
417            description: options.description.as_deref(),
418        };
419        let builder = self
420            .request(Method::POST, &format!("/v1/sandboxes/{session_id}/files"))
421            .json(&body);
422        self.send(builder).await
423    }
424
425    /// `POST /v1/sandboxes/{id}/files/view` -- read a text file's contents
426    /// (optionally a line range), or list a directory's entries.
427    /// Binary/image files are not supported (text-only, mirroring the
428    /// sidecar's own `/view` route).
429    pub async fn view(
430        &self,
431        session_id: &str,
432        path: &str,
433        options: ViewOptions,
434    ) -> Result<ViewResult, BoxxkiteError> {
435        let body = ViewBody {
436            path,
437            view_range: options.view_range,
438            description: options.description.as_deref(),
439        };
440        let builder = self
441            .request(
442                Method::POST,
443                &format!("/v1/sandboxes/{session_id}/files/view"),
444            )
445            .json(&body);
446        self.send(builder).await
447    }
448
449    /// `POST /v1/sandboxes/{id}/files/str-replace` -- replace `old_str` with
450    /// `new_str` in a file; `old_str` must appear exactly once unless
451    /// `options.replace_all(true)` is set.
452    pub async fn str_replace(
453        &self,
454        session_id: &str,
455        path: &str,
456        old_str: &str,
457        new_str: &str,
458        options: StrReplaceOptions,
459    ) -> Result<StrReplaceResult, BoxxkiteError> {
460        let body = StrReplaceBody {
461            path,
462            old_str,
463            new_str,
464            replace_all: options.replace_all,
465            description: options.description.as_deref(),
466        };
467        let builder = self
468            .request(
469                Method::POST,
470                &format!("/v1/sandboxes/{session_id}/files/str-replace"),
471            )
472            .json(&body);
473        self.send(builder).await
474    }
475
476    /// `POST /v1/sandboxes/{id}/files/ls` -- list a directory's direct children.
477    pub async fn ls(&self, session_id: &str, options: LsOptions) -> Result<LsResult, BoxxkiteError> {
478        let body = LsBody {
479            path: options.path.unwrap_or_else(|| "/".to_string()),
480        };
481        let builder = self
482            .request(
483                Method::POST,
484                &format!("/v1/sandboxes/{session_id}/files/ls"),
485            )
486            .json(&body);
487        self.send(builder).await
488    }
489
490    /// `POST /v1/sandboxes/{id}/files/glob` -- find files by name pattern
491    /// (e.g. `"**/*.py"`).
492    pub async fn glob(
493        &self,
494        session_id: &str,
495        pattern: &str,
496        options: GlobOptions,
497    ) -> Result<GlobResult, BoxxkiteError> {
498        let body = GlobBody {
499            pattern,
500            path: options.path.unwrap_or_else(|| "/".to_string()),
501        };
502        let builder = self
503            .request(
504                Method::POST,
505                &format!("/v1/sandboxes/{session_id}/files/glob"),
506            )
507            .json(&body);
508        self.send(builder).await
509    }
510
511    /// `POST /v1/sandboxes/{id}/files/grep` -- search file contents by regex.
512    pub async fn grep(
513        &self,
514        session_id: &str,
515        pattern: &str,
516        options: GrepOptions,
517    ) -> Result<GrepResult, BoxxkiteError> {
518        let body = GrepBody {
519            pattern,
520            path: options.path.unwrap_or_else(|| "/".to_string()),
521            glob: options.glob.as_deref(),
522            max_matches: options.max_matches,
523        };
524        let builder = self
525            .request(
526                Method::POST,
527                &format!("/v1/sandboxes/{session_id}/files/grep"),
528            )
529            .json(&body);
530        self.send(builder).await
531    }
532}