Skip to main content

agentkernel_sdk/
client.rs

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