Skip to main content

alien_bindings/providers/sandbox/
agent_protocol.rs

1//! Speaking the sandbox agent protocol, once, for every backend that ships an agent.
2//!
3//! AWS and Kubernetes both talk to the same agent over HTTP and differ in exactly one thing:
4//! how a request is authorized. AWS mints an endpoint token scoped to one MicroVM and an
5//! explicit port set; Kubernetes claims a pod and presents a capability scoped to that session. So
6//! the transport is the trait and the protocol is written once over it.
7//!
8//! The decoding is the reason this is shared rather than copied. A body that ends without a
9//! terminal frame is a **transport failure**, not a command that finished, and a stream that
10//! quietly stopped would report a truncated response as a successful command.
11
12use 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
29/// Named once: `send` treats it as the one operation a 5xx must not be retried for.
30const 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/// How long a request to the agent may take to answer with its headers.
37///
38/// Bounds reaching the agent, not what it then streams: a command's own deadline governs its
39/// output. Wrapped around `send()` alone rather than set as the request's timeout, which reqwest
40/// runs until the whole body has arrived and so would cut off any command outliving it. A caller
41/// cancelling a command waits for this request to settle before it can close the stream, so an
42/// unbounded one would leave that cancel unable to complete.
43#[cfg(not(test))]
44const AGENT_RESPONSE_TIMEOUT: Duration = Duration::from_secs(60);
45/// Short in tests so a stalled agent is exercised in milliseconds rather than waited out.
46#[cfg(test)]
47const AGENT_RESPONSE_TIMEOUT: Duration = Duration::from_millis(200);
48
49/// How a backend turns a session id into an authorized request.
50///
51/// The only thing AWS and Kubernetes disagree on.
52#[async_trait]
53pub trait AgentTransport: Send + Sync + std::fmt::Debug {
54    /// Builds a request to `path` on the session's agent, carrying whatever authorizes it.
55    async fn request(
56        &self,
57        session_id: &str,
58        method: reqwest::Method,
59        path: &str,
60    ) -> Result<reqwest::RequestBuilder>;
61
62    /// Name used in errors, so a failure says which backend refused.
63    fn provider(&self) -> &'static str;
64}
65
66/// A frame as the agent writes it.
67#[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
122/// Runs a command, streaming frames as the agent produces them.
123pub 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
142/// Starts a command as a job the agent owns until it is polled to its end or cancelled.
143pub 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        // The job is running and nothing can now poll or cancel it, so its end is unestablished
170        // rather than merely unreadable.
171        .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
181/// Reads a job's output after `since_seq`, and its ending once it has one.
182pub 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
226/// Cancels a job, stopping the command it runs.
227pub 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
244/// The body `/v1/exec` and `/v1/jobs/start` both take.
245fn exec_body(request: &RunCommandRequest) -> Result<serde_json::Value> {
246    // Checked after conversion, not on the Duration: a sub-millisecond deadline is non-zero here
247    // and floors to `deadlineMs: 0`, which the agent then refuses as invalid.
248    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
263/// Reads a file out of the sandbox.
264pub 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
298/// Writes files into the sandbox, one request per path.
299pub 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
321/// Creates a directory inside the sandbox.
322pub 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
339/// Milliseconds, saturated rather than wrapped.
340///
341/// A deadline long enough to overflow `u64` milliseconds is not a deadline anyone meant, and
342/// wrapping it would turn "effectively forever" into "immediately".
343fn deadline_millis(deadline: Duration) -> u64 {
344    u64::try_from(deadline.as_millis()).unwrap_or(u64::MAX)
345}
346
347/// What a request whose outcome is unknown becomes: no answer before its headers, or a body
348/// that failed or ended short of its last frame.
349///
350/// For a file operation every one of those is safe to repeat. For `run_command` the agent may
351/// have started the command — and past the headers it certainly did — so a repeat could run it
352/// twice, and the refusal must not carry the retry signal.
353fn unanswered(operation: &str, reason: &str) -> ErrorData {
354    // Both take a command the agent may already be running, so an unanswered one leaves the
355    // outcome unestablished. Every other operation is idempotent and safe to send again.
356    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
368/// Sends a request and turns a non-success into a typed error carrying the agent's own reason.
369///
370/// A transport failure here is marked retryable, which holds for the file operations but not for
371/// `run_command` — that request may have already started the command. Nothing retries on this
372/// path today; whoever adds a retry layer has to treat `run_command` as the exception.
373pub 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    // Read the body first: the agent puts the actual cause there, and a bare status turns a
395    // specific refusal into a guess. A read that *fails* is not an empty body — collapsing the
396    // two would let a dropped connection claim the request never arrived.
397    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    // A 5xx never proves the agent answered, with or without a body: the cloud's proxy emits them
408    // too, and a 504 means the request reached the guest and no answer came back in time. A body
409    // is no evidence either way — a proxy's error page is a body. So a 5xx is the unanswered case,
410    // which for the idempotent file operations is still safe to repeat and for `run_command` is
411    // not. A 4xx is the agent refusing before it does anything, which is an answer.
412    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        // The cause, not the operation: `reason` already names the operation, and a caller
421        // branching on `failure` gets an agent error code from every other construction site.
422        failure: "agentRefused".to_string(),
423        reason: format!("{operation} returned {status}: {body}"),
424    }))
425}
426
427/// Turns the agent's NDJSON body into output frames.
428fn 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                        // Classified like the decode arms in `into_output`, and for the same
463                        // reason: the frame arrived, so the command ran.
464                        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                // A failure here ends the stream. Yielding whatever the buffer still holds would
476                // let a later exit frame answer the question this item just reported as
477                // unanswerable, and which of the two a caller believes would depend only on
478                // whether it stopped at the first error.
479                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                // The command has started — frames were arriving — and its end is now unknown.
493                // That is the strongest case for not inviting a retry, so both a failing body and
494                // one that ends short of its terminal frame refuse the same way a lost answer does.
495                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            // A frame that arrived is proof the command ran, so a payload that will not decode
528            // leaves the outcome unestablished rather than merely malformed — reading it as a
529            // format problem would let a caller repeat a command that already executed.
530            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            // An error frame is the command's outcome, so it surfaces as an error rather than
542            // as a stream that simply stopped.
543            Self::Error { code, message } => {
544                Err(AlienError::new(ErrorData::SandboxCommandFailed {
545                    failure: code,
546                    reason: message,
547                }))
548            }
549        }
550    }
551}
552
553/// `binding_name` and `field` are the caller's, not this function's: `read_file` decodes through
554/// here too, and a corrupt file read reported as a runCommand output frame sends the reader to
555/// the wrong place.
556fn 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    /// Serves `status` with `body` on /v1/exec and returns what `send` made of it.
628    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    /// AWS answers 502 with an empty body while a MicroVM's snapshot is still restoring, so the
646    /// request never reached the agent and the command never ran. Calling that `agentRefused`
647    /// sends a reader into the agent for a fault that was never there.
648    #[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    /// A body is not evidence of who sent it: a proxy's error page is a body too, and a 504 in
665    /// particular means the request reached the guest. The cause still has to survive for whoever
666    /// reads the failure.
667    #[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    /// The scenario the rule above exists for: a gateway timing out on a command that is still
680    /// running, and answering with its own HTML.
681    #[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    /// A 4xx is the agent refusing before it does anything, which is an answer.
697    #[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    /// A cancel waits for this request before it can close the stream, so a request that never
708    /// answers would leave the cancel unable to complete. Headers that never come are refused.
709    #[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        // The agent would answer at 20x the bound; refusing well before that is the property.
731        // The margin is wide because setup shares a runtime with the rest of the suite.
732        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    /// The bound is on reaching the agent, not on the command: output that arrives slowly, long
744    /// after the headers, is the ordinary shape of a command that runs for a while.
745    #[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    /// Frames emitted one at a time, each after a pause longer than the response bound, so the
782    /// body as a whole takes several bounds to finish.
783    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    /// An agent that accepts any request and never sends its headers.
793    /// The agent may have started the command before its headers stalled, so a retry could run
794    /// A file operation is idempotent, so a stalled one is safe to repeat and says so.
795    /// An agent that accepts any request and never sends its headers.
796    /// A connection that drops before any headers is the same unknown as a stall: the agent may
797    /// The agent may have started the command before its headers stalled, so a retry could run
798    /// A file operation is idempotent, so a stalled one is safe to repeat and says so.
799    /// An agent that accepts any request and never sends its headers.
800    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    /// A connection that drops before any headers is the same unknown as a stall: the agent may
816    /// have taken the command before the socket went. It must not read as safe to repeat.
817    #[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        // Accept and close at once: the request may have been read, no response ever comes.
824        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    /// The agent may have started the command before its headers stalled, so a retry could run
851    /// it twice: the refusal must not invite one.
852    #[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    /// A file operation is idempotent, so a stalled one is safe to repeat and says so.
876    #[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    /// The protocol says a frame is never split across chunks; TCP makes no such promise. This
932    /// is the case a naive per-chunk parser gets wrong, and it fails as a parse error on
933    /// perfectly valid output.
934    #[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    /// A frame that arrived is proof the command ran, so a payload that will not decode leaves the
965    /// outcome unestablished. Reported as a format problem it would read as safe to repeat, and the
966    /// repeat would be a second execution.
967    #[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    /// A line that does not parse arrives the same way a decodable one does, so it carries the same
989    /// proof that the command ran.
990    #[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    /// One chunk can hold both a frame that will not parse and the exit frame after it. The stream
1006    /// has to stop at the first: yielding the exit as well would answer the question the failure
1007    /// just reported as unanswerable, and a caller that reads to the end would believe the wrong
1008    /// one of the two.
1009    #[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    /// The same rule for a payload that arrives whole and will not decode.
1028    #[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    /// A body that stops early looks exactly like a command that produced less output — the
1050    /// difference is only visible in the missing terminal frame. The command had started, so
1051    /// its end is unknown, and a retry could run it twice: the refusal must not invite one.
1052    #[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    /// A transport that authorizes nothing, so the tests exercise the protocol rather than a
1087    /// backend's credentials.
1088    #[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    /// What the agent was asked for, so a test can assert the cursor it received rather than only
1117    /// the frames it chose to send back.
1118    type Cursors = Arc<Mutex<Vec<Option<u64>>>>;
1119
1120    /// An agent running one job: it reports a frame while running, then its exit.
1121    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    /// The whole job round trip: a start that names the job, a first poll that reads from the
1156    /// beginning, a second that asks only for what is new, and a cancel the agent accepts.
1157    #[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    /// A start the agent may have taken is the one job call that must not invite a retry.
1208    #[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    /// A poll changes nothing about the job, so a failed one is worth repeating — the opposite of
1233    /// the rule above, and the reason the two carry different operation names.
1234    #[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    /// A body that stops half way is indistinguishable from a poll that never answered, and the
1251    /// job is still there to be polled again.
1252    #[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    /// A cancel the agent refuses — an unknown job, say — is an answer, so it reaches the caller
1275    /// as the agent's refusal rather than as a sandbox that could not be reached.
1276    #[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}