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
11pub struct AgentKernelBuilder {
13 base_url: String,
14 api_key: Option<String>,
15 timeout: Duration,
16}
17
18impl AgentKernelBuilder {
19 pub fn base_url(mut self, url: impl Into<String>) -> Self {
21 self.base_url = url.into();
22 self
23 }
24
25 pub fn api_key(mut self, key: impl Into<String>) -> Self {
27 self.api_key = Some(key.into());
28 self
29 }
30
31 pub fn timeout(mut self, timeout: Duration) -> Self {
33 self.timeout = timeout;
34 self
35 }
36
37 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#[derive(Clone)]
76pub struct AgentKernel {
77 base_url: String,
78 http: reqwest::Client,
79}
80
81impl AgentKernel {
82 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 pub async fn health(&self) -> Result<String> {
94 self.request::<String>(reqwest::Method::GET, "/health", None::<&()>)
95 .await
96 }
97
98 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 pub async fn list_sandboxes(&self) -> Result<Vec<SandboxInfo>> {
113 self.request(reqwest::Method::GET, "/sandboxes", None::<&()>)
114 .await
115 }
116
117 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 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 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 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 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 let _ = self.remove_sandbox(name).await;
202 result
203 }
204
205 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 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 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 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 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 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 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 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 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 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 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 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
386pub struct SandboxHandle {
390 name: String,
391 client: AgentKernel,
392}
393
394impl SandboxHandle {
395 pub fn name(&self) -> &str {
397 &self.name
398 }
399
400 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 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 pub async fn info(&self) -> Result<SandboxInfo> {
420 self.client.get_sandbox(&self.name).await
421 }
422
423 pub async fn read_file(&self, path: &str) -> Result<FileReadResponse> {
425 self.client.read_file(&self.name, path).await
426 }
427
428 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 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 pub async fn delete_file(&self, path: &str) -> Result<String> {
450 self.client.delete_file(&self.name, path).await
451 }
452}