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
12pub struct AgentKernelBuilder {
14 base_url: String,
15 api_key: Option<String>,
16 timeout: Duration,
17}
18
19impl AgentKernelBuilder {
20 pub fn base_url(mut self, url: impl Into<String>) -> Self {
22 self.base_url = url.into();
23 self
24 }
25
26 pub fn api_key(mut self, key: impl Into<String>) -> Self {
28 self.api_key = Some(key.into());
29 self
30 }
31
32 pub fn timeout(mut self, timeout: Duration) -> Self {
34 self.timeout = timeout;
35 self
36 }
37
38 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#[derive(Clone)]
77pub struct AgentKernel {
78 base_url: String,
79 http: reqwest::Client,
80}
81
82impl AgentKernel {
83 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 pub async fn health(&self) -> Result<String> {
95 self.request::<String>(reqwest::Method::GET, "/health", None::<&()>)
96 .await
97 }
98
99 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 pub async fn list_sandboxes(&self) -> Result<Vec<SandboxInfo>> {
114 self.request(reqwest::Method::GET, "/sandboxes", None::<&()>)
115 .await
116 }
117
118 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 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 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 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 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 let _ = self.remove_sandbox(name).await;
204 result
205 }
206
207 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 self.exec_in_sandbox(name, BROWSER_SETUP_CMD, None).await?;
235
236 Ok(BrowserSession::new(name.to_string(), self.clone()))
237 }
238
239 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 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 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 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 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 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 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 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 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 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 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 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 pub async fn list_snapshots(&self) -> Result<Vec<SnapshotMeta>> {
395 self.request(reqwest::Method::GET, "/snapshots", None::<&()>)
396 .await
397 }
398
399 pub async fn take_snapshot(&self, opts: TakeSnapshotOptions) -> Result<SnapshotMeta> {
401 self.request(reqwest::Method::POST, "/snapshots", Some(&opts))
402 .await
403 }
404
405 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 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 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 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
471pub struct SandboxHandle {
475 name: String,
476 client: AgentKernel,
477}
478
479impl SandboxHandle {
480 pub fn name(&self) -> &str {
482 &self.name
483 }
484
485 pub async fn run(&self, command: &[&str]) -> Result<RunOutput> {
487 self.client.exec_in_sandbox(&self.name, command, None).await
488 }
489
490 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 pub async fn info(&self) -> Result<SandboxInfo> {
499 self.client.get_sandbox(&self.name).await
500 }
501
502 pub async fn read_file(&self, path: &str) -> Result<FileReadResponse> {
504 self.client.read_file(&self.name, path).await
505 }
506
507 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 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 pub async fn delete_file(&self, path: &str) -> Result<String> {
529 self.client.delete_file(&self.name, path).await
530 }
531}