1use std::collections::BTreeMap;
13use std::time::Duration;
14
15use async_trait::async_trait;
16use base64::engine::general_purpose::STANDARD as BASE64;
17use base64::Engine as _;
18use futures::stream::BoxStream;
19use futures::StreamExt;
20use serde::Deserialize;
21use serde_json::json;
22
23use crate::error::{ErrorData, Result};
24use crate::traits::{CommandOutput, JobError, JobExit, JobPoll, JobStart, RunCommandRequest};
25use alien_error::{AlienError, Context, ContextError, IntoAlienError};
26
27pub use alien_core::sandbox_process::AGENT_PORT;
28
29const RUN_COMMAND: &str = "sandbox.runCommand";
31
32const JOB_START: &str = "sandbox.jobStart";
33const JOB_POLL: &str = "sandbox.jobPoll";
34const JOB_CANCEL: &str = "sandbox.jobCancel";
35
36#[cfg(not(test))]
44const AGENT_RESPONSE_TIMEOUT: Duration = Duration::from_secs(60);
45#[cfg(test)]
47const AGENT_RESPONSE_TIMEOUT: Duration = Duration::from_millis(200);
48
49#[async_trait]
53pub trait AgentTransport: Send + Sync + std::fmt::Debug {
54 async fn request(
56 &self,
57 session_id: &str,
58 method: reqwest::Method,
59 path: &str,
60 ) -> Result<reqwest::RequestBuilder>;
61
62 fn provider(&self) -> &'static str;
64}
65
66#[derive(Debug, Deserialize)]
68#[serde(rename_all = "camelCase", tag = "t")]
69enum AgentFrame {
70 Stdout {
71 seq: u64,
72 data: String,
73 },
74 Stderr {
75 seq: u64,
76 data: String,
77 },
78 Exit {
79 code: i32,
80 #[serde(default)]
81 truncated: bool,
82 },
83 Error {
84 code: String,
85 message: String,
86 },
87}
88
89#[derive(Debug, Deserialize)]
90#[serde(rename_all = "camelCase")]
91struct ReadFileResponse {
92 contents_base64: String,
93}
94
95#[derive(Debug, Deserialize)]
96#[serde(rename_all = "camelCase")]
97struct JobStartResponse {
98 job_id: String,
99}
100
101#[derive(Debug, Deserialize)]
102#[serde(rename_all = "camelCase")]
103struct JobPollResponse {
104 running: bool,
105 #[serde(default)]
106 frames: Vec<AgentFrame>,
107 #[serde(default)]
108 exit_code: Option<i32>,
109 #[serde(default)]
110 truncated: Option<bool>,
111 #[serde(default)]
112 error: Option<JobErrorResponse>,
113}
114
115#[derive(Debug, Deserialize)]
116#[serde(rename_all = "camelCase")]
117struct JobErrorResponse {
118 code: String,
119 message: String,
120}
121
122pub async fn run_command<T: AgentTransport + ?Sized>(
124 transport: &T,
125 session_id: &str,
126 request: RunCommandRequest,
127) -> Result<BoxStream<'static, Result<CommandOutput>>> {
128 let body = exec_body(&request)?;
129
130 let response = send(
131 transport
132 .request(session_id, reqwest::Method::POST, "/v1/exec")
133 .await?
134 .json(&body),
135 RUN_COMMAND,
136 )
137 .await?;
138
139 Ok(frame_stream(response, transport.provider()))
140}
141
142pub async fn start_job<T: AgentTransport + ?Sized>(
144 transport: &T,
145 session_id: &str,
146 request: RunCommandRequest,
147) -> Result<JobStart> {
148 let body = exec_body(&request)?;
149
150 let response = send(
151 transport
152 .request(session_id, reqwest::Method::POST, "/v1/jobs/start")
153 .await?
154 .json(&body),
155 JOB_START,
156 )
157 .await?;
158
159 let started: JobStartResponse = response
160 .json()
161 .await
162 .into_alien_error()
163 .context(ErrorData::UnexpectedResponseFormat {
164 provider: transport.provider().to_string(),
165 binding_name: JOB_START.to_string(),
166 field: "jobId".to_string(),
167 response_json: "the agent returned a body this provider cannot parse".to_string(),
168 })
169 .context(unanswered(
172 JOB_START,
173 "the job started and its id could not be read",
174 ))?;
175
176 Ok(JobStart {
177 job_id: started.job_id,
178 })
179}
180
181pub async fn poll_job<T: AgentTransport + ?Sized>(
183 transport: &T,
184 session_id: &str,
185 job_id: &str,
186 since_seq: Option<u64>,
187) -> Result<JobPoll> {
188 let response = send(
189 transport
190 .request(session_id, reqwest::Method::POST, "/v1/jobs/poll")
191 .await?
192 .json(&json!({ "jobId": job_id, "sinceSeq": since_seq })),
193 JOB_POLL,
194 )
195 .await?;
196
197 let JobPollResponse {
198 running,
199 frames,
200 exit_code,
201 truncated,
202 error,
203 } = response
204 .json()
205 .await
206 .into_alien_error()
207 .context(unanswered(JOB_POLL, "the poll's body could not be read"))?;
208
209 Ok(JobPoll {
210 running,
211 frames: frames
212 .into_iter()
213 .map(|frame| frame.into_output(transport.provider()))
214 .collect::<Result<Vec<_>>>()?,
215 exit: exit_code.map(|code| JobExit {
216 code,
217 truncated: truncated.unwrap_or(false),
218 }),
219 error: error.map(|error| JobError {
220 code: error.code,
221 message: error.message,
222 }),
223 })
224}
225
226pub async fn cancel_job<T: AgentTransport + ?Sized>(
228 transport: &T,
229 session_id: &str,
230 job_id: &str,
231) -> Result<()> {
232 send(
233 transport
234 .request(session_id, reqwest::Method::POST, "/v1/jobs/cancel")
235 .await?
236 .json(&json!({ "jobId": job_id })),
237 JOB_CANCEL,
238 )
239 .await?;
240
241 Ok(())
242}
243
244fn exec_body(request: &RunCommandRequest) -> Result<serde_json::Value> {
246 if deadline_millis(request.deadline) == 0 {
249 return Err(AlienError::new(ErrorData::SandboxCommandFailed {
250 failure: "invalidRequest".to_string(),
251 reason: "a command must carry a non-zero deadline".to_string(),
252 }));
253 }
254
255 Ok(json!({
256 "command": request.command,
257 "deadlineMs": deadline_millis(request.deadline),
258 "workingDirectory": request.working_directory,
259 "env": request.env,
260 }))
261}
262
263pub async fn read_file<T: AgentTransport + ?Sized>(
265 transport: &T,
266 session_id: &str,
267 path: &str,
268) -> Result<Vec<u8>> {
269 let response = send(
270 transport
271 .request(session_id, reqwest::Method::GET, "/v1/files")
272 .await?
273 .query(&[("path", path)]),
274 "sandbox.readFile",
275 )
276 .await?;
277
278 let body: ReadFileResponse =
279 response
280 .json()
281 .await
282 .into_alien_error()
283 .context(ErrorData::UnexpectedResponseFormat {
284 provider: transport.provider().to_string(),
285 binding_name: "sandbox.readFile".to_string(),
286 field: "body".to_string(),
287 response_json: "the agent returned a body this provider cannot parse".to_string(),
288 })?;
289
290 decode(
291 &body.contents_base64,
292 transport.provider(),
293 "sandbox.readFile",
294 "contentsBase64",
295 )
296}
297
298pub async fn write_files<T: AgentTransport + ?Sized>(
300 transport: &T,
301 session_id: &str,
302 files: BTreeMap<String, Vec<u8>>,
303) -> Result<()> {
304 for (path, contents) in files {
305 send(
306 transport
307 .request(session_id, reqwest::Method::PUT, "/v1/files")
308 .await?
309 .json(&json!({
310 "path": path,
311 "contentsBase64": BASE64.encode(contents),
312 })),
313 "sandbox.writeFiles",
314 )
315 .await?;
316 }
317
318 Ok(())
319}
320
321pub async fn mkdir<T: AgentTransport + ?Sized>(
323 transport: &T,
324 session_id: &str,
325 path: &str,
326) -> Result<()> {
327 send(
328 transport
329 .request(session_id, reqwest::Method::POST, "/v1/mkdir")
330 .await?
331 .json(&json!({ "path": path })),
332 "sandbox.mkdir",
333 )
334 .await?;
335
336 Ok(())
337}
338
339fn deadline_millis(deadline: Duration) -> u64 {
344 u64::try_from(deadline.as_millis()).unwrap_or(u64::MAX)
345}
346
347fn unanswered(operation: &str, reason: &str) -> ErrorData {
354 if operation == RUN_COMMAND || operation == JOB_START {
357 return ErrorData::SandboxOutcomeUnknown {
358 operation: operation.to_string(),
359 reason: reason.to_string(),
360 };
361 }
362 ErrorData::SandboxUnreachable {
363 operation: operation.to_string(),
364 reason: reason.to_string(),
365 }
366}
367
368pub async fn send(request: reqwest::RequestBuilder, operation: &str) -> Result<reqwest::Response> {
374 let response = match tokio::time::timeout(AGENT_RESPONSE_TIMEOUT, request.send()).await {
375 Ok(sent) => sent
376 .into_alien_error()
377 .context(unanswered(operation, "the request never reached the agent"))?,
378 Err(_) => {
379 return Err(AlienError::new(unanswered(
380 operation,
381 &format!(
382 "the agent did not answer within {}s",
383 AGENT_RESPONSE_TIMEOUT.as_secs()
384 ),
385 )));
386 }
387 };
388
389 if response.status().is_success() {
390 return Ok(response);
391 }
392
393 let status = response.status();
394 let body = match response.text().await {
398 Ok(body) => body,
399 Err(error) => {
400 return Err(error).into_alien_error().context(unanswered(
401 operation,
402 &format!("{operation} returned {status} and its body could not be read"),
403 ))
404 }
405 };
406
407 if status.is_server_error() {
413 return Err(AlienError::new(unanswered(
414 operation,
415 &format!("the sandbox host returned {status}: {body}"),
416 )));
417 }
418
419 Err(AlienError::new(ErrorData::SandboxCommandFailed {
420 failure: "agentRefused".to_string(),
423 reason: format!("{operation} returned {status}: {body}"),
424 }))
425}
426
427fn frame_stream(
429 response: reqwest::Response,
430 provider: &'static str,
431) -> BoxStream<'static, Result<CommandOutput>> {
432 struct State {
433 bytes: BoxStream<'static, reqwest::Result<bytes::Bytes>>,
434 buffer: Vec<u8>,
435 finished: bool,
436 saw_terminal: bool,
437 provider: &'static str,
438 }
439
440 let state = State {
441 bytes: response.bytes_stream().boxed(),
442 buffer: Vec::new(),
443 finished: false,
444 saw_terminal: false,
445 provider,
446 };
447
448 futures::stream::unfold(state, |mut state| async move {
449 loop {
450 if let Some(index) = state.buffer.iter().position(|byte| *byte == b'\n') {
451 let line: Vec<u8> = state.buffer.drain(..=index).collect();
452 let line = &line[..line.len() - 1];
453 if line.is_empty() {
454 continue;
455 }
456
457 let frame = match serde_json::from_slice::<AgentFrame>(line) {
458 Ok(frame) => frame,
459 Err(error) => {
460 state.finished = true;
461 state.buffer.clear();
462 let failure = malformed(&error.to_string(), state.provider)
465 .context(unanswered(RUN_COMMAND, "an output frame did not parse"));
466 return Some((Err(failure), state));
467 }
468 };
469
470 if matches!(frame, AgentFrame::Exit { .. } | AgentFrame::Error { .. }) {
471 state.saw_terminal = true;
472 }
473
474 let output = frame.into_output(state.provider);
475 if output.is_err() {
480 state.finished = true;
481 state.buffer.clear();
482 }
483 return Some((output, state));
484 }
485
486 if state.finished {
487 return None;
488 }
489
490 match state.bytes.next().await {
491 Some(Ok(chunk)) => state.buffer.extend_from_slice(&chunk),
492 Some(Err(error)) => {
496 state.finished = true;
497 return Some((
498 Err(AlienError::new(unanswered(
499 RUN_COMMAND,
500 &format!("the output stream failed: {error}"),
501 ))),
502 state,
503 ));
504 }
505 None => {
506 state.finished = true;
507 if !state.saw_terminal {
508 return Some((
509 Err(AlienError::new(unanswered(
510 RUN_COMMAND,
511 "the output stream ended without a terminal frame",
512 ))),
513 state,
514 ));
515 }
516 return None;
517 }
518 }
519 }
520 })
521 .boxed()
522}
523
524impl AgentFrame {
525 fn into_output(self, provider: &'static str) -> Result<CommandOutput> {
526 match self {
527 Self::Stdout { seq, data } => Ok(CommandOutput::Stdout {
531 seq,
532 data: decode(&data, provider, RUN_COMMAND, "data")
533 .context(unanswered(RUN_COMMAND, "an output frame did not decode"))?,
534 }),
535 Self::Stderr { seq, data } => Ok(CommandOutput::Stderr {
536 seq,
537 data: decode(&data, provider, RUN_COMMAND, "data")
538 .context(unanswered(RUN_COMMAND, "an output frame did not decode"))?,
539 }),
540 Self::Exit { code, truncated } => Ok(CommandOutput::Exit { code, truncated }),
541 Self::Error { code, message } => {
544 Err(AlienError::new(ErrorData::SandboxCommandFailed {
545 failure: code,
546 reason: message,
547 }))
548 }
549 }
550 }
551}
552
553fn decode(data: &str, provider: &'static str, binding_name: &str, field: &str) -> Result<Vec<u8>> {
557 BASE64
558 .decode(data)
559 .into_alien_error()
560 .context(ErrorData::UnexpectedResponseFormat {
561 provider: provider.to_string(),
562 binding_name: binding_name.to_string(),
563 field: field.to_string(),
564 response_json: format!("{field} was not valid base64"),
565 })
566}
567
568fn malformed(reason: &str, provider: &'static str) -> AlienError<ErrorData> {
569 AlienError::new(ErrorData::UnexpectedResponseFormat {
570 provider: provider.to_string(),
571 binding_name: RUN_COMMAND.to_string(),
572 field: "frame".to_string(),
573 response_json: format!("an output frame did not parse: {reason}"),
574 })
575}
576
577#[cfg(test)]
578mod tests {
579 use super::*;
580 use crate::traits::CommandOutput;
581 use axum::extract::State;
582 use axum::http::StatusCode;
583 use axum::response::IntoResponse;
584 use axum::routing::post;
585 use axum::{Json, Router};
586 use std::net::SocketAddr;
587 use std::sync::{Arc, Mutex};
588
589 async fn serve_frames(chunks: Vec<&'static str>) -> String {
590 let handler = move || {
591 let chunks = chunks.clone();
592 async move {
593 let stream = futures::stream::iter(
594 chunks
595 .into_iter()
596 .map(|chunk| Ok::<_, std::io::Error>(bytes::Bytes::from(chunk))),
597 );
598 axum::body::Body::from_stream(stream).into_response()
599 }
600 };
601
602 let router = Router::new().route("/v1/exec", post(handler));
603 let listener = tokio::net::TcpListener::bind::<SocketAddr>("127.0.0.1:0".parse().unwrap())
604 .await
605 .expect("bind");
606 let address = listener.local_addr().expect("address");
607 tokio::spawn(async move {
608 axum::serve(listener, router).await.expect("serve");
609 });
610
611 format!("http://{address}")
612 }
613
614 async fn frames_from(chunks: Vec<&'static str>) -> Vec<Result<CommandOutput>> {
615 let base = serve_frames(chunks).await;
616 let response = reqwest::Client::new()
617 .post(format!("{base}/v1/exec"))
618 .send()
619 .await
620 .expect("responds");
621
622 frame_stream(response, "test-sandbox")
623 .collect::<Vec<_>>()
624 .await
625 }
626
627 async fn send_status(status: StatusCode, body: &'static str) -> AlienError<ErrorData> {
629 let handler = move || async move { (status, body).into_response() };
630 let router = Router::new().route("/v1/exec", post(handler));
631 let listener = tokio::net::TcpListener::bind::<SocketAddr>("127.0.0.1:0".parse().unwrap())
632 .await
633 .expect("bind");
634 let address = listener.local_addr().expect("address");
635 tokio::spawn(async move { axum::serve(listener, router).await.expect("serve") });
636
637 send(
638 reqwest::Client::new().post(format!("http://{address}/v1/exec")),
639 RUN_COMMAND,
640 )
641 .await
642 .expect_err("a non-success must be an error")
643 }
644
645 #[tokio::test]
649 async fn a_bodyless_server_error_is_not_reported_as_the_agent_refusing() {
650 let error = send_status(StatusCode::BAD_GATEWAY, "").await;
651 let rendered = error.to_string();
652
653 assert!(
654 !rendered.contains("agentRefused"),
655 "a proxy 502 is not the agent refusing: {rendered}"
656 );
657 assert_eq!(
658 error.code, "SANDBOX_OUTCOME_UNKNOWN",
659 "a proxy can synthesize a 502 after the agent accepted the request, so the caller has \
660 to be told the outcome is unknown rather than that it is safe to repeat: {rendered}"
661 );
662 }
663
664 #[tokio::test]
668 async fn a_server_error_is_unknown_however_much_body_it_carries() {
669 let error = send_status(StatusCode::INTERNAL_SERVER_ERROR, "spawn failed: ENOMEM").await;
670 let rendered = error.to_string();
671
672 assert!(
673 rendered.contains("spawn failed: ENOMEM"),
674 "the cause has to survive: {rendered}"
675 );
676 assert_eq!(error.code, "SANDBOX_OUTCOME_UNKNOWN", "got: {rendered}");
677 }
678
679 #[tokio::test]
682 async fn a_gateway_timeout_with_an_error_page_does_not_read_as_the_agent_refusing() {
683 let error = send_status(
684 StatusCode::GATEWAY_TIMEOUT,
685 "<html><body>504 Gateway Time-out</body></html>",
686 )
687 .await;
688
689 assert_eq!(error.code, "SANDBOX_OUTCOME_UNKNOWN", "got: {error}");
690 assert!(
691 !error.retryable,
692 "the command may still be running behind the gateway: {error}"
693 );
694 }
695
696 #[tokio::test]
698 async fn a_client_error_is_the_agent_answering() {
699 let error = send_status(StatusCode::BAD_REQUEST, "the command was empty").await;
700
701 assert_ne!(
702 error.code, "SANDBOX_OUTCOME_UNKNOWN",
703 "a refusal before dispatch establishes that nothing ran: {error}"
704 );
705 }
706
707 #[tokio::test]
710 async fn an_agent_that_never_answers_is_refused_within_the_bound() {
711 let handler = || async {
712 tokio::time::sleep(AGENT_RESPONSE_TIMEOUT * 20).await;
713 "late".into_response()
714 };
715 let router = Router::new().route("/v1/exec", post(handler));
716 let listener = tokio::net::TcpListener::bind::<SocketAddr>("127.0.0.1:0".parse().unwrap())
717 .await
718 .expect("bind");
719 let address = listener.local_addr().expect("address");
720 tokio::spawn(async move { axum::serve(listener, router).await.expect("serve") });
721
722 let started = std::time::Instant::now();
723 let error = send(
724 reqwest::Client::new().post(format!("http://{address}/v1/exec")),
725 RUN_COMMAND,
726 )
727 .await
728 .expect_err("a stalled agent must be refused, not waited on");
729
730 assert!(
733 started.elapsed() < AGENT_RESPONSE_TIMEOUT * 10,
734 "refused at the bound, not at the agent's leisure: {:?}",
735 started.elapsed()
736 );
737 assert!(
738 error.to_string().contains("did not answer"),
739 "the refusal says the agent stalled: {error}"
740 );
741 }
742
743 #[tokio::test]
746 async fn a_slow_body_after_prompt_headers_is_not_cut_off() {
747 let handler = || async {
748 let frames = async_stream_frames(vec![
749 "{\"t\":\"stdout\",\"seq\":0,\"data\":\"aGk=\"}\n",
750 "{\"t\":\"exit\",\"code\":0,\"truncated\":false}\n",
751 ]);
752 axum::body::Body::from_stream(frames).into_response()
753 };
754 let router = Router::new().route("/v1/exec", post(handler));
755 let listener = tokio::net::TcpListener::bind::<SocketAddr>("127.0.0.1:0".parse().unwrap())
756 .await
757 .expect("bind");
758 let address = listener.local_addr().expect("address");
759 tokio::spawn(async move { axum::serve(listener, router).await.expect("serve") });
760
761 let response = send(
762 reqwest::Client::new().post(format!("http://{address}/v1/exec")),
763 RUN_COMMAND,
764 )
765 .await
766 .expect("headers arrive at once");
767 let outputs = frame_stream(response, "test-sandbox")
768 .collect::<Vec<_>>()
769 .await;
770
771 assert_eq!(outputs.len(), 2, "every frame arrived: {outputs:?}");
772 assert_eq!(
773 outputs[1].as_ref().expect("exit"),
774 &CommandOutput::Exit {
775 code: 0,
776 truncated: false
777 }
778 );
779 }
780
781 fn async_stream_frames(
784 chunks: Vec<&'static str>,
785 ) -> impl futures::Stream<Item = std::result::Result<&'static str, std::io::Error>> {
786 futures::stream::iter(chunks).then(|chunk| async move {
787 tokio::time::sleep(AGENT_RESPONSE_TIMEOUT * 2).await;
788 Ok(chunk)
789 })
790 }
791
792 async fn stalled_agent() -> String {
801 let handler = || async {
802 tokio::time::sleep(AGENT_RESPONSE_TIMEOUT * 20).await;
803 "late".into_response()
804 };
805 let router = Router::new().fallback(handler);
806 let listener = tokio::net::TcpListener::bind::<SocketAddr>("127.0.0.1:0".parse().unwrap())
807 .await
808 .expect("bind");
809 let address = listener.local_addr().expect("address");
810 tokio::spawn(async move { axum::serve(listener, router).await.expect("serve") });
811
812 format!("http://{address}")
813 }
814
815 #[tokio::test]
818 async fn a_dropped_run_command_connection_is_not_retryable() {
819 let listener = tokio::net::TcpListener::bind::<SocketAddr>("127.0.0.1:0".parse().unwrap())
820 .await
821 .expect("bind");
822 let address = listener.local_addr().expect("address");
823 tokio::spawn(async move {
825 loop {
826 if let Ok((socket, _)) = listener.accept().await {
827 drop(socket);
828 }
829 }
830 });
831
832 let error = send(
833 reqwest::Client::new().post(format!("http://{address}/v1/exec")),
834 RUN_COMMAND,
835 )
836 .await
837 .expect_err("a dropped connection is a refusal, not a response");
838
839 assert_eq!(error.code, "SANDBOX_OUTCOME_UNKNOWN", "got: {error}");
840 assert!(
841 !error.retryable,
842 "a command that may have started must not be retried: {error}"
843 );
844 assert!(
845 error.to_string().contains("may have taken effect"),
846 "the refusal says the outcome is unknown: {error}"
847 );
848 }
849
850 #[tokio::test]
853 async fn a_stalled_run_command_is_not_retryable() {
854 let base = stalled_agent().await;
855
856 let error = send(
857 reqwest::Client::new().post(format!("{base}/v1/exec")),
858 RUN_COMMAND,
859 )
860 .await
861 .expect_err("a stalled agent must be refused, not waited on");
862
863 assert_eq!(error.code, "SANDBOX_OUTCOME_UNKNOWN", "got: {error}");
864 assert!(
865 !error.retryable,
866 "a command with an unknown outcome must not be retried: {error}"
867 );
868 assert!(
869 error.to_string().contains("did not answer")
870 && error.to_string().contains("may have taken effect"),
871 "the refusal says the agent stalled and the outcome is unknown: {error}"
872 );
873 }
874
875 #[tokio::test]
877 async fn a_stalled_file_operation_stays_retryable() {
878 let base = stalled_agent().await;
879
880 let error = send(
881 reqwest::Client::new().get(format!("{base}/v1/files")),
882 "sandbox.readFile",
883 )
884 .await
885 .expect_err("a stalled agent must be refused, not waited on");
886
887 assert_eq!(error.code, "SANDBOX_UNREACHABLE", "got: {error}");
888 assert!(
889 error.retryable,
890 "a stalled file read is safe to repeat: {error}"
891 );
892 assert!(
893 error.to_string().contains("did not answer"),
894 "the refusal says the agent stalled: {error}"
895 );
896 }
897
898 #[tokio::test]
899 async fn frames_decode_in_order_with_a_real_exit_code() {
900 let outputs = frames_from(vec![
901 "{\"t\":\"stdout\",\"seq\":0,\"data\":\"aGk=\"}\n",
902 "{\"t\":\"stderr\",\"seq\":1,\"data\":\"b29wcw==\"}\n",
903 "{\"t\":\"exit\",\"code\":7,\"truncated\":false}\n",
904 ])
905 .await;
906
907 assert_eq!(outputs.len(), 3);
908 assert_eq!(
909 outputs[0].as_ref().expect("stdout"),
910 &CommandOutput::Stdout {
911 seq: 0,
912 data: b"hi".to_vec()
913 }
914 );
915 assert_eq!(
916 outputs[1].as_ref().expect("stderr"),
917 &CommandOutput::Stderr {
918 seq: 1,
919 data: b"oops".to_vec()
920 }
921 );
922 assert_eq!(
923 outputs[2].as_ref().expect("exit"),
924 &CommandOutput::Exit {
925 code: 7,
926 truncated: false
927 }
928 );
929 }
930
931 #[tokio::test]
935 async fn a_frame_split_across_chunks_is_reassembled() {
936 let outputs = frames_from(vec![
937 "{\"t\":\"stdo",
938 "ut\",\"seq\":0,\"data\":\"aGk=\"}\n{\"t\":\"ex",
939 "it\",\"code\":0,\"truncated\":false}\n",
940 ])
941 .await;
942
943 assert_eq!(
944 outputs.len(),
945 2,
946 "a split frame must not become two frames or an error"
947 );
948 assert_eq!(
949 outputs[0].as_ref().expect("stdout"),
950 &CommandOutput::Stdout {
951 seq: 0,
952 data: b"hi".to_vec()
953 }
954 );
955 assert_eq!(
956 outputs[1].as_ref().expect("exit"),
957 &CommandOutput::Exit {
958 code: 0,
959 truncated: false
960 }
961 );
962 }
963
964 #[tokio::test]
968 async fn a_frame_that_arrives_but_does_not_decode_leaves_the_outcome_unknown() {
969 let outputs = frames_from(vec![
970 "{\"t\":\"stdout\",\"seq\":0,\"data\":\"!!not base64!!\"}\n",
971 ])
972 .await;
973
974 let error = outputs[0]
975 .as_ref()
976 .expect_err("a payload that does not decode is not output");
977 assert_eq!(error.code, "SANDBOX_OUTCOME_UNKNOWN", "got: {error}");
978 assert!(
979 !error.retryable,
980 "the command ran; a repeat would run it twice: {error}"
981 );
982 assert!(
983 error.to_string().contains("base64"),
984 "the decode failure must stay in the chain: {error}"
985 );
986 }
987
988 #[tokio::test]
991 async fn a_frame_that_does_not_parse_leaves_the_outcome_unknown() {
992 let outputs = frames_from(vec!["{not json at all}\n"]).await;
993
994 let error = outputs[0]
995 .as_ref()
996 .expect_err("a malformed frame is not output");
997 assert_eq!(error.code, "SANDBOX_OUTCOME_UNKNOWN", "got: {error}");
998 assert!(!error.retryable, "the command ran: {error}");
999 assert!(
1000 error.to_string().contains("did not parse"),
1001 "the parse failure must stay in the chain: {error}"
1002 );
1003 }
1004
1005 #[tokio::test]
1010 async fn an_unestablished_outcome_ends_the_stream_mid_chunk() {
1011 let outputs = frames_from(vec![
1012 "{not json at all}\n{\"t\":\"exit\",\"code\":0,\"truncated\":false}\n",
1013 ])
1014 .await;
1015
1016 assert_eq!(
1017 outputs.len(),
1018 1,
1019 "the exit frame must not follow the failure"
1020 );
1021 let error = outputs[0]
1022 .as_ref()
1023 .expect_err("a malformed frame is not output");
1024 assert_eq!(error.code, "SANDBOX_OUTCOME_UNKNOWN", "got: {error}");
1025 }
1026
1027 #[tokio::test]
1029 async fn a_decode_failure_ends_the_stream_mid_chunk() {
1030 let outputs = frames_from(vec![
1031 "{\"t\":\"stdout\",\"seq\":0,\"data\":\"!!\"}\n{\"t\":\"exit\",\"code\":0,\"truncated\":false}\n",
1032 ])
1033 .await;
1034
1035 assert_eq!(
1036 outputs.len(),
1037 1,
1038 "the exit frame must not follow the failure"
1039 );
1040 assert_eq!(
1041 outputs[0]
1042 .as_ref()
1043 .expect_err("a bad payload is not output")
1044 .code,
1045 "SANDBOX_OUTCOME_UNKNOWN"
1046 );
1047 }
1048
1049 #[tokio::test]
1053 async fn a_stream_without_a_terminal_frame_is_an_unknown_outcome() {
1054 let outputs = frames_from(vec!["{\"t\":\"stdout\",\"seq\":0,\"data\":\"aGk=\"}\n"]).await;
1055
1056 assert_eq!(outputs.len(), 2);
1057 outputs[0].as_ref().expect("the stdout frame still arrives");
1058 let error = outputs[1]
1059 .as_ref()
1060 .expect_err("a truncated stream must not read as success");
1061 assert!(
1062 error.to_string().contains("without a terminal frame"),
1063 "the failure must name the cause: {error}"
1064 );
1065 assert_eq!(error.code, "SANDBOX_OUTCOME_UNKNOWN", "got: {error}");
1066 assert!(
1067 !error.retryable,
1068 "a command that started and whose end was lost must not be retried: {error}"
1069 );
1070 }
1071
1072 #[tokio::test]
1073 async fn an_error_frame_surfaces_as_an_error_not_a_silent_end() {
1074 let outputs = frames_from(vec![
1075 "{\"t\":\"error\",\"code\":\"deadlineExceeded\",\"message\":\"exceeded its 300ms deadline\"}\n",
1076 ])
1077 .await;
1078
1079 assert_eq!(outputs.len(), 1);
1080 let error = outputs[0]
1081 .as_ref()
1082 .expect_err("an error frame is a failure");
1083 assert!(error.to_string().contains("deadlineExceeded"), "{error}");
1084 }
1085
1086 #[derive(Debug)]
1089 struct TestTransport(String);
1090
1091 #[async_trait]
1092 impl AgentTransport for TestTransport {
1093 async fn request(
1094 &self,
1095 _session_id: &str,
1096 method: reqwest::Method,
1097 path: &str,
1098 ) -> Result<reqwest::RequestBuilder> {
1099 Ok(reqwest::Client::new().request(method, format!("{}{path}", self.0)))
1100 }
1101
1102 fn provider(&self) -> &'static str {
1103 "test-sandbox"
1104 }
1105 }
1106
1107 async fn serve(router: Router) -> TestTransport {
1108 let listener = tokio::net::TcpListener::bind::<SocketAddr>("127.0.0.1:0".parse().unwrap())
1109 .await
1110 .expect("bind");
1111 let address = listener.local_addr().expect("address");
1112 tokio::spawn(async move { axum::serve(listener, router).await.expect("serve") });
1113 TestTransport(format!("http://{address}"))
1114 }
1115
1116 type Cursors = Arc<Mutex<Vec<Option<u64>>>>;
1119
1120 async fn job_agent(cursors: Cursors) -> TestTransport {
1122 async fn poll(
1123 State(cursors): State<Cursors>,
1124 Json(body): Json<serde_json::Value>,
1125 ) -> impl IntoResponse {
1126 let since = body.get("sinceSeq").and_then(serde_json::Value::as_u64);
1127 cursors.lock().expect("cursors").push(since);
1128 match since {
1129 None => Json(json!({
1130 "running": true,
1131 "frames": [{ "t": "stdout", "seq": 0, "data": "aGk=" }],
1132 })),
1133 Some(_) => Json(json!({
1134 "running": false,
1135 "frames": [],
1136 "exitCode": 7,
1137 "truncated": false,
1138 })),
1139 }
1140 }
1141
1142 serve(
1143 Router::new()
1144 .route(
1145 "/v1/jobs/start",
1146 post(|| async { Json(json!({"jobId": "j1"})) }),
1147 )
1148 .route("/v1/jobs/poll", post(poll))
1149 .route("/v1/jobs/cancel", post(|| async { Json(json!({})) }))
1150 .with_state(cursors),
1151 )
1152 .await
1153 }
1154
1155 #[tokio::test]
1158 async fn a_job_starts_polls_from_its_cursor_and_cancels() {
1159 let cursors: Cursors = Arc::new(Mutex::new(Vec::new()));
1160 let transport = job_agent(Arc::clone(&cursors)).await;
1161
1162 let started = start_job(&transport, "s1", command(Duration::from_secs(600)))
1163 .await
1164 .expect("the job starts");
1165 assert_eq!(started.job_id, "j1");
1166
1167 let first = poll_job(&transport, "s1", &started.job_id, None)
1168 .await
1169 .expect("the first poll answers");
1170 assert!(first.running, "the job is still running: {first:?}");
1171 assert_eq!(
1172 first.frames,
1173 vec![CommandOutput::Stdout {
1174 seq: 0,
1175 data: b"hi".to_vec()
1176 }],
1177 "the agent's base64 frame is decoded"
1178 );
1179 assert!(first.exit.is_none() && first.error.is_none());
1180
1181 let second = poll_job(&transport, "s1", &started.job_id, Some(0))
1182 .await
1183 .expect("the second poll answers");
1184 assert!(!second.running);
1185 assert!(second.frames.is_empty(), "nothing follows the last frame");
1186 assert_eq!(
1187 second.exit,
1188 Some(crate::traits::JobExit {
1189 code: 7,
1190 truncated: false
1191 }),
1192 "the ending is the envelope's, not a frame's"
1193 );
1194
1195 cancel_job(&transport, "s1", &started.job_id)
1196 .await
1197 .expect("the cancel is accepted");
1198
1199 assert_eq!(
1200 *cursors.lock().expect("cursors"),
1201 vec![None, Some(0)],
1202 "the cursor a caller passes has to reach the agent, or every poll replays the whole \
1203 output and a caller sees each frame twice"
1204 );
1205 }
1206
1207 #[tokio::test]
1209 async fn a_server_error_on_a_start_leaves_the_outcome_unknown() {
1210 let transport = serve(Router::new().route(
1211 "/v1/jobs/start",
1212 post(|| async { (StatusCode::INTERNAL_SERVER_ERROR, "spawn failed").into_response() }),
1213 ))
1214 .await;
1215
1216 let error = start_job(&transport, "s1", command(Duration::from_secs(600)))
1217 .await
1218 .expect_err("a 5xx is not a job that started");
1219
1220 assert_eq!(error.code, "SANDBOX_OUTCOME_UNKNOWN", "got: {error}");
1221 assert!(
1222 !error.retryable,
1223 "the agent may have taken the command, and a repeat would run it twice: {error}"
1224 );
1225 assert!(
1226 error.to_string().contains("sandbox.jobStart"),
1227 "the operation reaches callers and telemetry, so a failed start has to be tellable \
1228 apart from a failed streaming command: {error}"
1229 );
1230 }
1231
1232 #[tokio::test]
1235 async fn a_server_error_on_a_poll_stays_retryable() {
1236 let transport = serve(Router::new().route(
1237 "/v1/jobs/poll",
1238 post(|| async { (StatusCode::SERVICE_UNAVAILABLE, "").into_response() }),
1239 ))
1240 .await;
1241
1242 let error = poll_job(&transport, "s1", "j1", Some(4))
1243 .await
1244 .expect_err("a 5xx is not a poll that answered");
1245
1246 assert_eq!(error.code, "SANDBOX_UNREACHABLE", "got: {error}");
1247 assert!(error.retryable, "the job is untouched: {error}");
1248 }
1249
1250 #[tokio::test]
1253 async fn a_poll_body_that_ends_early_is_retryable() {
1254 let transport = serve(Router::new().route(
1255 "/v1/jobs/poll",
1256 post(|| async {
1257 axum::body::Body::from_stream(futures::stream::iter(vec![
1258 Ok::<_, std::io::Error>(bytes::Bytes::from_static(b"{\"running\":tr")),
1259 Err(std::io::Error::other("the connection went")),
1260 ]))
1261 .into_response()
1262 }),
1263 ))
1264 .await;
1265
1266 let error = poll_job(&transport, "s1", "j1", None)
1267 .await
1268 .expect_err("a truncated body is not a poll");
1269
1270 assert_eq!(error.code, "SANDBOX_UNREACHABLE", "got: {error}");
1271 assert!(error.retryable, "polling again costs nothing: {error}");
1272 }
1273
1274 #[tokio::test]
1277 async fn a_refused_cancel_is_the_agent_answering() {
1278 let transport = serve(Router::new().route(
1279 "/v1/jobs/cancel",
1280 post(|| async { (StatusCode::NOT_FOUND, "JOB_NOT_FOUND").into_response() }),
1281 ))
1282 .await;
1283
1284 let error = cancel_job(&transport, "s1", "j1")
1285 .await
1286 .expect_err("a 404 is not a cancel that landed");
1287
1288 assert_eq!(error.code, "SANDBOX_COMMAND_FAILED", "got: {error}");
1289 assert!(
1290 error.to_string().contains("JOB_NOT_FOUND"),
1291 "the agent's own reason has to survive: {error}"
1292 );
1293 }
1294
1295 fn command(deadline: Duration) -> RunCommandRequest {
1296 RunCommandRequest {
1297 command: vec!["/bin/sleep".to_string(), "600".to_string()],
1298 working_directory: None,
1299 env: BTreeMap::new(),
1300 deadline,
1301 }
1302 }
1303}