Skip to main content

agentkernel_sdk/
client.rs

1use reqwest::header::{AUTHORIZATION, CONTENT_TYPE, HeaderMap, HeaderValue, USER_AGENT};
2use std::time::Duration;
3
4use crate::browser::{BROWSER_SETUP_CMD, BrowserSession};
5use crate::error::{Error, Result, error_from_status};
6use crate::types::*;
7
8const SDK_VERSION: &str = env!("CARGO_PKG_VERSION");
9const DEFAULT_BASE_URL: &str = "http://localhost:18888";
10const DEFAULT_TIMEOUT_SECS: u64 = 30;
11
12/// Builder for constructing an [`AgentKernel`] client.
13pub struct AgentKernelBuilder {
14    base_url: String,
15    api_key: Option<String>,
16    timeout: Duration,
17}
18
19impl AgentKernelBuilder {
20    /// Set the base URL.
21    pub fn base_url(mut self, url: impl Into<String>) -> Self {
22        self.base_url = url.into();
23        self
24    }
25
26    /// Set the API key for Bearer authentication.
27    pub fn api_key(mut self, key: impl Into<String>) -> Self {
28        self.api_key = Some(key.into());
29        self
30    }
31
32    /// Set the request timeout.
33    pub fn timeout(mut self, timeout: Duration) -> Self {
34        self.timeout = timeout;
35        self
36    }
37
38    /// Build the client.
39    pub fn build(self) -> Result<AgentKernel> {
40        let mut headers = HeaderMap::new();
41        headers.insert(
42            USER_AGENT,
43            HeaderValue::from_str(&format!("agentkernel-rust-sdk/{SDK_VERSION}")).unwrap(),
44        );
45        if let Some(ref key) = self.api_key {
46            headers.insert(
47                AUTHORIZATION,
48                HeaderValue::from_str(&format!("Bearer {key}"))
49                    .map_err(|e| Error::Auth(e.to_string()))?,
50            );
51        }
52
53        let http = reqwest::Client::builder()
54            .default_headers(headers)
55            .timeout(self.timeout)
56            .build()?;
57
58        Ok(AgentKernel {
59            base_url: self.base_url.trim_end_matches('/').to_string(),
60            http,
61        })
62    }
63}
64
65/// Client for the agentkernel HTTP API.
66///
67/// # Example
68/// ```no_run
69/// # async fn example() -> agentkernel_sdk::Result<()> {
70/// let client = agentkernel_sdk::AgentKernel::builder().build()?;
71/// let output = client.run(&["echo", "hello"], None).await?;
72/// println!("{}", output.output);
73/// # Ok(())
74/// # }
75/// ```
76#[derive(Clone)]
77pub struct AgentKernel {
78    base_url: String,
79    http: reqwest::Client,
80}
81
82impl AgentKernel {
83    /// Create a new builder with defaults resolved from env vars.
84    pub fn builder() -> AgentKernelBuilder {
85        AgentKernelBuilder {
86            base_url: std::env::var("AGENTKERNEL_BASE_URL")
87                .unwrap_or_else(|_| DEFAULT_BASE_URL.to_string()),
88            api_key: std::env::var("AGENTKERNEL_API_KEY").ok(),
89            timeout: Duration::from_secs(DEFAULT_TIMEOUT_SECS),
90        }
91    }
92
93    /// Health check. Returns `"ok"`.
94    pub async fn health(&self) -> Result<String> {
95        self.request::<String>(reqwest::Method::GET, "/health", None::<&()>)
96            .await
97    }
98
99    /// Run a command in a temporary sandbox.
100    pub async fn run(&self, command: &[&str], opts: Option<RunOptions>) -> Result<RunOutput> {
101        let opts = opts.unwrap_or_default();
102        let body = RunRequest {
103            command: command.iter().map(|s| s.to_string()).collect(),
104            image: opts.image,
105            profile: opts.profile,
106            fast: opts.fast.unwrap_or(true),
107        };
108        self.request(reqwest::Method::POST, "/run", Some(&body))
109            .await
110    }
111
112    /// List all sandboxes.
113    pub async fn list_sandboxes(&self) -> Result<Vec<SandboxInfo>> {
114        self.request(reqwest::Method::GET, "/sandboxes", None::<&()>)
115            .await
116    }
117
118    /// Create a new sandbox with optional configuration.
119    pub async fn create_sandbox(
120        &self,
121        name: &str,
122        opts: Option<CreateSandboxOptions>,
123    ) -> Result<SandboxInfo> {
124        let opts = opts.unwrap_or_default();
125        let body = CreateRequest {
126            name: name.to_string(),
127            image: opts.image,
128            vcpus: opts.vcpus,
129            memory_mb: opts.memory_mb,
130            profile: opts.profile,
131            source_url: opts.source_url,
132            source_ref: opts.source_ref,
133            volumes: opts.volumes,
134            secrets: opts.secrets,
135            secret_files: opts.secret_files,
136        };
137        self.request(reqwest::Method::POST, "/sandboxes", Some(&body))
138            .await
139    }
140
141    /// Get info about a sandbox.
142    pub async fn get_sandbox(&self, name: &str) -> Result<SandboxInfo> {
143        self.request(
144            reqwest::Method::GET,
145            &format!("/sandboxes/{name}"),
146            None::<&()>,
147        )
148        .await
149    }
150
151    /// Remove a sandbox.
152    pub async fn remove_sandbox(&self, name: &str) -> Result<()> {
153        let _: String = self
154            .request(
155                reqwest::Method::DELETE,
156                &format!("/sandboxes/{name}"),
157                None::<&()>,
158            )
159            .await?;
160        Ok(())
161    }
162
163    /// Run a command in an existing sandbox.
164    pub async fn exec_in_sandbox(
165        &self,
166        name: &str,
167        command: &[&str],
168        opts: Option<ExecOptions>,
169    ) -> Result<RunOutput> {
170        let opts = opts.unwrap_or_default();
171        let body = ExecRequest {
172            command: command.iter().map(|s| s.to_string()).collect(),
173            env: opts.env,
174            workdir: opts.workdir,
175            sudo: opts.sudo,
176        };
177        self.request(
178            reqwest::Method::POST,
179            &format!("/sandboxes/{name}/exec"),
180            Some(&body),
181        )
182        .await
183    }
184
185    /// Create a sandbox and return a guard that removes it on drop.
186    ///
187    /// Use `with_sandbox` for guaranteed cleanup via a closure.
188    pub async fn with_sandbox<F, Fut, T>(
189        &self,
190        name: &str,
191        opts: Option<CreateSandboxOptions>,
192        f: F,
193    ) -> Result<T>
194    where
195        F: FnOnce(SandboxHandle) -> Fut,
196        Fut: std::future::Future<Output = Result<T>>,
197    {
198        self.create_sandbox(name, opts).await?;
199        let handle = SandboxHandle {
200            name: name.to_string(),
201            client: self.clone(),
202        };
203        let result = f(handle).await;
204        // Always clean up
205        let _ = self.remove_sandbox(name).await;
206        result
207    }
208
209    /// Create a browser sandbox with Playwright/Chromium pre-installed.
210    ///
211    /// Returns a [`BrowserSession`] you can use to navigate pages, take
212    /// screenshots, and evaluate JavaScript expressions.
213    ///
214    /// # Example
215    ///
216    /// ```no_run
217    /// # async fn example() -> agentkernel_sdk::Result<()> {
218    /// let client = agentkernel_sdk::AgentKernel::builder().build()?;
219    /// let mut browser = client.browser("my-browser", None).await?;
220    /// let page = browser.goto("https://example.com").await?;
221    /// println!("{}", page.title);
222    /// browser.remove().await?;
223    /// # Ok(())
224    /// # }
225    /// ```
226    pub async fn browser(&self, name: &str, memory_mb: Option<u64>) -> Result<BrowserSession> {
227        let opts = CreateSandboxOptions {
228            image: Some("python:3.12-slim".to_string()),
229            memory_mb: Some(memory_mb.unwrap_or(2048)),
230            profile: Some(SecurityProfile::Moderate),
231            ..Default::default()
232        };
233        self.create_sandbox(name, Some(opts)).await?;
234
235        // Install Playwright + Chromium inside the sandbox.
236        self.exec_in_sandbox(name, BROWSER_SETUP_CMD, None).await?;
237
238        Ok(BrowserSession::new(name.to_string(), self.clone()))
239    }
240
241    /// Write multiple files to a sandbox in one request.
242    pub async fn write_files(
243        &self,
244        name: &str,
245        files: std::collections::HashMap<String, String>,
246    ) -> Result<BatchFileWriteResponse> {
247        let body = BatchFileWriteRequest { files };
248        self.request(
249            reqwest::Method::POST,
250            &format!("/sandboxes/{name}/files"),
251            Some(&body),
252        )
253        .await
254    }
255
256    /// Read a file from a sandbox.
257    pub async fn read_file(&self, name: &str, path: &str) -> Result<FileReadResponse> {
258        self.request(
259            reqwest::Method::GET,
260            &format!("/sandboxes/{name}/files/{path}"),
261            None::<&()>,
262        )
263        .await
264    }
265
266    /// Write a file to a sandbox.
267    pub async fn write_file(
268        &self,
269        name: &str,
270        path: &str,
271        content: &str,
272        encoding: Option<&str>,
273    ) -> Result<String> {
274        let body = FileWriteRequest {
275            content: content.to_string(),
276            encoding: encoding.map(String::from),
277        };
278        self.request(
279            reqwest::Method::PUT,
280            &format!("/sandboxes/{name}/files/{path}"),
281            Some(&body),
282        )
283        .await
284    }
285
286    /// Delete a file from a sandbox.
287    pub async fn delete_file(&self, name: &str, path: &str) -> Result<String> {
288        self.request(
289            reqwest::Method::DELETE,
290            &format!("/sandboxes/{name}/files/{path}"),
291            None::<&()>,
292        )
293        .await
294    }
295
296    /// Get audit log entries for a sandbox.
297    pub async fn get_sandbox_logs(&self, name: &str) -> Result<Vec<serde_json::Value>> {
298        self.request(
299            reqwest::Method::GET,
300            &format!("/sandboxes/{name}/logs"),
301            None::<&()>,
302        )
303        .await
304    }
305
306    /// Start a detached (background) command in a sandbox.
307    pub async fn exec_detached(
308        &self,
309        name: &str,
310        command: &[&str],
311        opts: Option<ExecOptions>,
312    ) -> Result<DetachedCommand> {
313        let opts = opts.unwrap_or_default();
314        let body = ExecRequest {
315            command: command.iter().map(|s| s.to_string()).collect(),
316            env: opts.env,
317            workdir: opts.workdir,
318            sudo: opts.sudo,
319        };
320        self.request(
321            reqwest::Method::POST,
322            &format!("/sandboxes/{name}/exec/detach"),
323            Some(&body),
324        )
325        .await
326    }
327
328    /// Get the status of a detached command.
329    pub async fn detached_status(&self, name: &str, cmd_id: &str) -> Result<DetachedCommand> {
330        self.request(
331            reqwest::Method::GET,
332            &format!("/sandboxes/{name}/exec/detached/{cmd_id}"),
333            None::<&()>,
334        )
335        .await
336    }
337
338    /// Get logs from a detached command.
339    pub async fn detached_logs(
340        &self,
341        name: &str,
342        cmd_id: &str,
343        stream: Option<&str>,
344    ) -> Result<DetachedLogsResponse> {
345        let query = match stream {
346            Some(s) => format!("?stream={s}"),
347            None => String::new(),
348        };
349        self.request(
350            reqwest::Method::GET,
351            &format!("/sandboxes/{name}/exec/detached/{cmd_id}/logs{query}"),
352            None::<&()>,
353        )
354        .await
355    }
356
357    /// Kill a detached command.
358    pub async fn detached_kill(&self, name: &str, cmd_id: &str) -> Result<String> {
359        self.request(
360            reqwest::Method::DELETE,
361            &format!("/sandboxes/{name}/exec/detached/{cmd_id}"),
362            None::<&()>,
363        )
364        .await
365    }
366
367    /// List detached commands in a sandbox.
368    pub async fn detached_list(&self, name: &str) -> Result<Vec<DetachedCommand>> {
369        self.request(
370            reqwest::Method::GET,
371            &format!("/sandboxes/{name}/exec/detached"),
372            None::<&()>,
373        )
374        .await
375    }
376
377    /// Run multiple commands in parallel.
378    pub async fn batch_run(&self, commands: Vec<BatchCommand>) -> Result<BatchRunResponse> {
379        let body = BatchRunRequest { commands };
380        self.request(reqwest::Method::POST, "/batch/run", Some(&body))
381            .await
382    }
383
384    /// Extend a sandbox's time-to-live.
385    pub async fn extend_ttl(&self, name: &str, by: &str) -> Result<ExtendTtlResponse> {
386        let body = ExtendTtlRequest { by: by.to_string() };
387        self.request(
388            reqwest::Method::POST,
389            &format!("/sandboxes/{name}/extend"),
390            Some(&body),
391        )
392        .await
393    }
394
395    /// List all snapshots.
396    pub async fn list_snapshots(&self) -> Result<Vec<SnapshotMeta>> {
397        self.request(reqwest::Method::GET, "/snapshots", None::<&()>)
398            .await
399    }
400
401    /// Take a snapshot of a sandbox.
402    pub async fn take_snapshot(&self, opts: TakeSnapshotOptions) -> Result<SnapshotMeta> {
403        self.request(reqwest::Method::POST, "/snapshots", Some(&opts))
404            .await
405    }
406
407    /// Get info about a snapshot.
408    pub async fn get_snapshot(&self, name: &str) -> Result<SnapshotMeta> {
409        self.request(
410            reqwest::Method::GET,
411            &format!("/snapshots/{name}"),
412            None::<&()>,
413        )
414        .await
415    }
416
417    /// Delete a snapshot.
418    pub async fn delete_snapshot(&self, name: &str) -> Result<()> {
419        let _: String = self
420            .request(
421                reqwest::Method::DELETE,
422                &format!("/snapshots/{name}"),
423                None::<&()>,
424            )
425            .await?;
426        Ok(())
427    }
428
429    /// Restore a sandbox from a snapshot.
430    pub async fn restore_snapshot(&self, name: &str) -> Result<SandboxInfo> {
431        self.request(
432            reqwest::Method::POST,
433            &format!("/snapshots/{name}/restore"),
434            None::<&()>,
435        )
436        .await
437    }
438
439    // -- Internal --
440
441    async fn request<T: serde::de::DeserializeOwned>(
442        &self,
443        method: reqwest::Method,
444        path: &str,
445        body: Option<&(impl serde::Serialize + ?Sized)>,
446    ) -> Result<T> {
447        let url = format!("{}{path}", self.base_url);
448        let mut req = self.http.request(method, &url);
449        if let Some(b) = body {
450            req = req.header(CONTENT_TYPE, "application/json").json(b);
451        }
452
453        let response = req.send().await?;
454        let status = response.status().as_u16();
455        let text = response.text().await?;
456
457        if status >= 400 {
458            return Err(error_from_status(status, &text));
459        }
460
461        let parsed: ApiResponse<T> = serde_json::from_str(&text)?;
462        if !parsed.success {
463            return Err(Error::Server(
464                parsed.error.unwrap_or_else(|| "Unknown error".to_string()),
465            ));
466        }
467        parsed
468            .data
469            .ok_or_else(|| Error::Server("Missing data field".to_string()))
470    }
471}
472
473/// Handle to a sandbox within a `with_sandbox` closure.
474///
475/// Owns a clone of the client (cheap — `reqwest::Client` is `Arc`-backed).
476pub struct SandboxHandle {
477    name: String,
478    client: AgentKernel,
479}
480
481impl SandboxHandle {
482    /// The sandbox name.
483    pub fn name(&self) -> &str {
484        &self.name
485    }
486
487    /// Run a command in this sandbox.
488    pub async fn run(&self, command: &[&str]) -> Result<RunOutput> {
489        self.client.exec_in_sandbox(&self.name, command, None).await
490    }
491
492    /// Run a command with options (workdir, env, sudo).
493    pub async fn run_with_options(&self, command: &[&str], opts: ExecOptions) -> Result<RunOutput> {
494        self.client
495            .exec_in_sandbox(&self.name, command, Some(opts))
496            .await
497    }
498
499    /// Get sandbox info.
500    pub async fn info(&self) -> Result<SandboxInfo> {
501        self.client.get_sandbox(&self.name).await
502    }
503
504    /// Read a file from this sandbox.
505    pub async fn read_file(&self, path: &str) -> Result<FileReadResponse> {
506        self.client.read_file(&self.name, path).await
507    }
508
509    /// Write a file to this sandbox.
510    pub async fn write_file(
511        &self,
512        path: &str,
513        content: &str,
514        encoding: Option<&str>,
515    ) -> Result<String> {
516        self.client
517            .write_file(&self.name, path, content, encoding)
518            .await
519    }
520
521    /// Write multiple files to this sandbox.
522    pub async fn write_files(
523        &self,
524        files: std::collections::HashMap<String, String>,
525    ) -> Result<BatchFileWriteResponse> {
526        self.client.write_files(&self.name, files).await
527    }
528
529    /// Delete a file from this sandbox.
530    pub async fn delete_file(&self, path: &str) -> Result<String> {
531        self.client.delete_file(&self.name, path).await
532    }
533}