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        };
135        self.request(reqwest::Method::POST, "/sandboxes", Some(&body))
136            .await
137    }
138
139    /// Get info about a sandbox.
140    pub async fn get_sandbox(&self, name: &str) -> Result<SandboxInfo> {
141        self.request(
142            reqwest::Method::GET,
143            &format!("/sandboxes/{name}"),
144            None::<&()>,
145        )
146        .await
147    }
148
149    /// Remove a sandbox.
150    pub async fn remove_sandbox(&self, name: &str) -> Result<()> {
151        let _: String = self
152            .request(
153                reqwest::Method::DELETE,
154                &format!("/sandboxes/{name}"),
155                None::<&()>,
156            )
157            .await?;
158        Ok(())
159    }
160
161    /// Run a command in an existing sandbox.
162    pub async fn exec_in_sandbox(
163        &self,
164        name: &str,
165        command: &[&str],
166        opts: Option<ExecOptions>,
167    ) -> Result<RunOutput> {
168        let opts = opts.unwrap_or_default();
169        let body = ExecRequest {
170            command: command.iter().map(|s| s.to_string()).collect(),
171            env: opts.env,
172            workdir: opts.workdir,
173            sudo: opts.sudo,
174        };
175        self.request(
176            reqwest::Method::POST,
177            &format!("/sandboxes/{name}/exec"),
178            Some(&body),
179        )
180        .await
181    }
182
183    /// Create a sandbox and return a guard that removes it on drop.
184    ///
185    /// Use `with_sandbox` for guaranteed cleanup via a closure.
186    pub async fn with_sandbox<F, Fut, T>(
187        &self,
188        name: &str,
189        opts: Option<CreateSandboxOptions>,
190        f: F,
191    ) -> Result<T>
192    where
193        F: FnOnce(SandboxHandle) -> Fut,
194        Fut: std::future::Future<Output = Result<T>>,
195    {
196        self.create_sandbox(name, opts).await?;
197        let handle = SandboxHandle {
198            name: name.to_string(),
199            client: self.clone(),
200        };
201        let result = f(handle).await;
202        // Always clean up
203        let _ = self.remove_sandbox(name).await;
204        result
205    }
206
207    /// Create a browser sandbox with Playwright/Chromium pre-installed.
208    ///
209    /// Returns a [`BrowserSession`] you can use to navigate pages, take
210    /// screenshots, and evaluate JavaScript expressions.
211    ///
212    /// # Example
213    ///
214    /// ```no_run
215    /// # async fn example() -> agentkernel_sdk::Result<()> {
216    /// let client = agentkernel_sdk::AgentKernel::builder().build()?;
217    /// let mut browser = client.browser("my-browser", None).await?;
218    /// let page = browser.goto("https://example.com").await?;
219    /// println!("{}", page.title);
220    /// browser.remove().await?;
221    /// # Ok(())
222    /// # }
223    /// ```
224    pub async fn browser(&self, name: &str, memory_mb: Option<u64>) -> Result<BrowserSession> {
225        let opts = CreateSandboxOptions {
226            image: Some("python:3.12-slim".to_string()),
227            memory_mb: Some(memory_mb.unwrap_or(2048)),
228            profile: Some(SecurityProfile::Moderate),
229            ..Default::default()
230        };
231        self.create_sandbox(name, Some(opts)).await?;
232
233        // Install Playwright + Chromium inside the sandbox.
234        self.exec_in_sandbox(name, BROWSER_SETUP_CMD, None).await?;
235
236        Ok(BrowserSession::new(name.to_string(), self.clone()))
237    }
238
239    /// Write multiple files to a sandbox in one request.
240    pub async fn write_files(
241        &self,
242        name: &str,
243        files: std::collections::HashMap<String, String>,
244    ) -> Result<BatchFileWriteResponse> {
245        let body = BatchFileWriteRequest { files };
246        self.request(
247            reqwest::Method::POST,
248            &format!("/sandboxes/{name}/files"),
249            Some(&body),
250        )
251        .await
252    }
253
254    /// Read a file from a sandbox.
255    pub async fn read_file(&self, name: &str, path: &str) -> Result<FileReadResponse> {
256        self.request(
257            reqwest::Method::GET,
258            &format!("/sandboxes/{name}/files/{path}"),
259            None::<&()>,
260        )
261        .await
262    }
263
264    /// Write a file to a sandbox.
265    pub async fn write_file(
266        &self,
267        name: &str,
268        path: &str,
269        content: &str,
270        encoding: Option<&str>,
271    ) -> Result<String> {
272        let body = FileWriteRequest {
273            content: content.to_string(),
274            encoding: encoding.map(String::from),
275        };
276        self.request(
277            reqwest::Method::PUT,
278            &format!("/sandboxes/{name}/files/{path}"),
279            Some(&body),
280        )
281        .await
282    }
283
284    /// Delete a file from a sandbox.
285    pub async fn delete_file(&self, name: &str, path: &str) -> Result<String> {
286        self.request(
287            reqwest::Method::DELETE,
288            &format!("/sandboxes/{name}/files/{path}"),
289            None::<&()>,
290        )
291        .await
292    }
293
294    /// Get audit log entries for a sandbox.
295    pub async fn get_sandbox_logs(&self, name: &str) -> Result<Vec<serde_json::Value>> {
296        self.request(
297            reqwest::Method::GET,
298            &format!("/sandboxes/{name}/logs"),
299            None::<&()>,
300        )
301        .await
302    }
303
304    /// Start a detached (background) command in a sandbox.
305    pub async fn exec_detached(
306        &self,
307        name: &str,
308        command: &[&str],
309        opts: Option<ExecOptions>,
310    ) -> Result<DetachedCommand> {
311        let opts = opts.unwrap_or_default();
312        let body = ExecRequest {
313            command: command.iter().map(|s| s.to_string()).collect(),
314            env: opts.env,
315            workdir: opts.workdir,
316            sudo: opts.sudo,
317        };
318        self.request(
319            reqwest::Method::POST,
320            &format!("/sandboxes/{name}/exec/detach"),
321            Some(&body),
322        )
323        .await
324    }
325
326    /// Get the status of a detached command.
327    pub async fn detached_status(&self, name: &str, cmd_id: &str) -> Result<DetachedCommand> {
328        self.request(
329            reqwest::Method::GET,
330            &format!("/sandboxes/{name}/exec/detached/{cmd_id}"),
331            None::<&()>,
332        )
333        .await
334    }
335
336    /// Get logs from a detached command.
337    pub async fn detached_logs(
338        &self,
339        name: &str,
340        cmd_id: &str,
341        stream: Option<&str>,
342    ) -> Result<DetachedLogsResponse> {
343        let query = match stream {
344            Some(s) => format!("?stream={s}"),
345            None => String::new(),
346        };
347        self.request(
348            reqwest::Method::GET,
349            &format!("/sandboxes/{name}/exec/detached/{cmd_id}/logs{query}"),
350            None::<&()>,
351        )
352        .await
353    }
354
355    /// Kill a detached command.
356    pub async fn detached_kill(&self, name: &str, cmd_id: &str) -> Result<String> {
357        self.request(
358            reqwest::Method::DELETE,
359            &format!("/sandboxes/{name}/exec/detached/{cmd_id}"),
360            None::<&()>,
361        )
362        .await
363    }
364
365    /// List detached commands in a sandbox.
366    pub async fn detached_list(&self, name: &str) -> Result<Vec<DetachedCommand>> {
367        self.request(
368            reqwest::Method::GET,
369            &format!("/sandboxes/{name}/exec/detached"),
370            None::<&()>,
371        )
372        .await
373    }
374
375    /// Run multiple commands in parallel.
376    pub async fn batch_run(&self, commands: Vec<BatchCommand>) -> Result<BatchRunResponse> {
377        let body = BatchRunRequest { commands };
378        self.request(reqwest::Method::POST, "/batch/run", Some(&body))
379            .await
380    }
381
382    /// Extend a sandbox's time-to-live.
383    pub async fn extend_ttl(&self, name: &str, by: &str) -> Result<ExtendTtlResponse> {
384        let body = ExtendTtlRequest { by: by.to_string() };
385        self.request(
386            reqwest::Method::POST,
387            &format!("/sandboxes/{name}/extend"),
388            Some(&body),
389        )
390        .await
391    }
392
393    /// List all snapshots.
394    pub async fn list_snapshots(&self) -> Result<Vec<SnapshotMeta>> {
395        self.request(reqwest::Method::GET, "/snapshots", None::<&()>)
396            .await
397    }
398
399    /// Take a snapshot of a sandbox.
400    pub async fn take_snapshot(&self, opts: TakeSnapshotOptions) -> Result<SnapshotMeta> {
401        self.request(reqwest::Method::POST, "/snapshots", Some(&opts))
402            .await
403    }
404
405    /// Get info about a snapshot.
406    pub async fn get_snapshot(&self, name: &str) -> Result<SnapshotMeta> {
407        self.request(
408            reqwest::Method::GET,
409            &format!("/snapshots/{name}"),
410            None::<&()>,
411        )
412        .await
413    }
414
415    /// Delete a snapshot.
416    pub async fn delete_snapshot(&self, name: &str) -> Result<()> {
417        let _: String = self
418            .request(
419                reqwest::Method::DELETE,
420                &format!("/snapshots/{name}"),
421                None::<&()>,
422            )
423            .await?;
424        Ok(())
425    }
426
427    /// Restore a sandbox from a snapshot.
428    pub async fn restore_snapshot(&self, name: &str) -> Result<SandboxInfo> {
429        self.request(
430            reqwest::Method::POST,
431            &format!("/snapshots/{name}/restore"),
432            None::<&()>,
433        )
434        .await
435    }
436
437    // -- Internal --
438
439    async fn request<T: serde::de::DeserializeOwned>(
440        &self,
441        method: reqwest::Method,
442        path: &str,
443        body: Option<&(impl serde::Serialize + ?Sized)>,
444    ) -> Result<T> {
445        let url = format!("{}{path}", self.base_url);
446        let mut req = self.http.request(method, &url);
447        if let Some(b) = body {
448            req = req.header(CONTENT_TYPE, "application/json").json(b);
449        }
450
451        let response = req.send().await?;
452        let status = response.status().as_u16();
453        let text = response.text().await?;
454
455        if status >= 400 {
456            return Err(error_from_status(status, &text));
457        }
458
459        let parsed: ApiResponse<T> = serde_json::from_str(&text)?;
460        if !parsed.success {
461            return Err(Error::Server(
462                parsed.error.unwrap_or_else(|| "Unknown error".to_string()),
463            ));
464        }
465        parsed
466            .data
467            .ok_or_else(|| Error::Server("Missing data field".to_string()))
468    }
469}
470
471/// Handle to a sandbox within a `with_sandbox` closure.
472///
473/// Owns a clone of the client (cheap — `reqwest::Client` is `Arc`-backed).
474pub struct SandboxHandle {
475    name: String,
476    client: AgentKernel,
477}
478
479impl SandboxHandle {
480    /// The sandbox name.
481    pub fn name(&self) -> &str {
482        &self.name
483    }
484
485    /// Run a command in this sandbox.
486    pub async fn run(&self, command: &[&str]) -> Result<RunOutput> {
487        self.client.exec_in_sandbox(&self.name, command, None).await
488    }
489
490    /// Run a command with options (workdir, env, sudo).
491    pub async fn run_with_options(&self, command: &[&str], opts: ExecOptions) -> Result<RunOutput> {
492        self.client
493            .exec_in_sandbox(&self.name, command, Some(opts))
494            .await
495    }
496
497    /// Get sandbox info.
498    pub async fn info(&self) -> Result<SandboxInfo> {
499        self.client.get_sandbox(&self.name).await
500    }
501
502    /// Read a file from this sandbox.
503    pub async fn read_file(&self, path: &str) -> Result<FileReadResponse> {
504        self.client.read_file(&self.name, path).await
505    }
506
507    /// Write a file to this sandbox.
508    pub async fn write_file(
509        &self,
510        path: &str,
511        content: &str,
512        encoding: Option<&str>,
513    ) -> Result<String> {
514        self.client
515            .write_file(&self.name, path, content, encoding)
516            .await
517    }
518
519    /// Write multiple files to this sandbox.
520    pub async fn write_files(
521        &self,
522        files: std::collections::HashMap<String, String>,
523    ) -> Result<BatchFileWriteResponse> {
524        self.client.write_files(&self.name, files).await
525    }
526
527    /// Delete a file from this sandbox.
528    pub async fn delete_file(&self, path: &str) -> Result<String> {
529        self.client.delete_file(&self.name, path).await
530    }
531}