Skip to main content

microsandbox_protocol/
jobs.rs

1//! Additive host-control contract for runtime-owned command jobs.
2//!
3//! This extension deliberately does not change guest exec messages or the released control enums.
4//! Clients first request `Hello`, then fence every operation with the returned runtime identity.
5
6use serde::{Deserialize, Serialize};
7
8use crate::exec::{ExecFailed, ExecRequest};
9
10//--------------------------------------------------------------------------------------------------
11// Constants
12//--------------------------------------------------------------------------------------------------
13
14/// First version of the managed-job contract and persisted metadata.
15pub const JOB_PROTOCOL_VERSION: u32 = 1;
16/// Capability-probed host-control extension name.
17pub const JOB_REQUEST: &str = "control.jobs";
18/// Terminal response to one bounded job operation.
19pub const JOB_RESPONSE: &str = "control.jobs.result";
20/// Maximum bytes accepted in one input write or returned in one output chunk.
21pub const JOB_CHUNK_BYTES: usize = 16 * 1024;
22/// Maximum simultaneously active jobs in one sandbox.
23pub const MAX_ACTIVE_JOBS: usize = 32;
24/// Maximum retained job records, including active jobs.
25pub const MAX_RETAINED_JOBS: usize = 256;
26/// Maximum encoded metadata in a listing page, independently of its item limit.
27pub const JOB_LIST_BYTES: usize = 256 * 1024;
28/// Reserved reply capacity for one job operation, including metadata and output chunks.
29pub const JOB_REPLY_BYTES: u32 = 512 * 1024;
30
31//--------------------------------------------------------------------------------------------------
32// Types
33//--------------------------------------------------------------------------------------------------
34
35/// An opaque job identity, distinct from guest PIDs and connection-local exec IDs.
36#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
37#[serde(try_from = "String", into = "String")]
38pub struct JobId(String);
39
40/// Persisted lifecycle observation. A lost runtime never implies a successful exit.
41#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
42#[serde(rename_all = "snake_case")]
43#[non_exhaustive]
44pub enum JobState {
45    /// Admission is durable; guest spawn has not yet been confirmed.
46    Starting,
47    /// Guest spawn was confirmed. Sandbox pause is orthogonal to this state.
48    Running,
49    /// The guest reported process completion.
50    Exited,
51    /// The guest rejected the command before it started.
52    Failed,
53    /// Ownership or transport was lost without a confirmed exit result.
54    Lost,
55}
56
57/// Durable job metadata. Environment values and stdin bytes are never included.
58#[derive(Debug, Clone, Serialize, Deserialize)]
59#[non_exhaustive]
60pub struct JobInfo {
61    /// Metadata format version.
62    pub version: u32,
63    /// Stable identity within the owning sandbox.
64    pub id: JobId,
65    /// Runtime generation that created this job.
66    pub runtime_boot_id: String,
67    /// Program followed by its arguments.
68    pub command: Vec<String>,
69    /// Most recent lifecycle observation.
70    pub state: JobState,
71    /// Whether output is a merged PTY stream.
72    pub tty: bool,
73    /// Whether further input has been permanently disabled.
74    pub stdin_closed: bool,
75    /// Guest PID, if spawn was acknowledged.
76    pub pid: Option<u32>,
77    /// Admission time in Unix milliseconds.
78    pub created_at: i64,
79    /// Confirmed spawn time in Unix milliseconds.
80    pub started_at: Option<i64>,
81    /// Completion or loss observation time in Unix milliseconds.
82    pub finished_at: Option<i64>,
83    /// Confirmed guest exit code, never synthesized on transport loss.
84    pub exit_code: Option<i32>,
85    /// Structured spawn failure, when supplied by the guest.
86    pub failure: Option<ExecFailed>,
87    /// Diagnostic for unconfirmed completion or host I/O failure.
88    pub error: Option<String>,
89    /// Whether the runtime requested termination after the execution deadline.
90    pub timed_out: bool,
91}
92
93/// Origin of one captured output record.
94#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
95#[serde(rename_all = "snake_case")]
96pub enum JobOutputSource {
97    /// Command stdout.
98    Stdout,
99    /// Command stderr.
100    Stderr,
101    /// Combined PTY stream.
102    Output,
103    /// Guest notice that a stdin write failed; not command output.
104    StdinError,
105}
106
107/// Byte-preserving output record. Sequence numbers remain monotonic across log rotation.
108#[derive(Debug, Clone, Serialize, Deserialize)]
109#[non_exhaustive]
110pub struct JobOutput {
111    /// Monotonic sequence number, starting at one.
112    pub sequence: u64,
113    /// Unix milliseconds at capture.
114    pub timestamp: i64,
115    /// Where the bytes came from.
116    pub source: JobOutputSource,
117    /// Original bytes, encoded explicitly for portable persisted JSON.
118    pub data_base64: String,
119}
120
121/// One bounded list page.
122#[derive(Debug, Clone, Serialize, Deserialize)]
123pub struct JobPage {
124    /// Job summaries in stable identity order.
125    pub items: Vec<JobInfo>,
126    /// Last identity in this page when more records remain.
127    pub next_cursor: Option<JobId>,
128}
129
130/// One bounded replay result; missing retained output is reported explicitly.
131#[derive(Debug, Clone, Serialize, Deserialize)]
132pub struct JobOutputPage {
133    /// Captured records after the requested cursor.
134    pub items: Vec<JobOutput>,
135    /// Cursor after the returned records, or the requested cursor when empty.
136    pub cursor: u64,
137    /// Earliest available sequence when requested records were pruned.
138    pub gap_before: Option<u64>,
139    /// Latest metadata, including terminal state after output has been captured.
140    pub info: JobInfo,
141}
142
143/// One capability-probed, runtime-fenced request.
144#[derive(Debug, Clone, Serialize, Deserialize)]
145pub struct JobRequest {
146    /// Expected extension version.
147    pub version: u32,
148    /// Required for every operation except Hello.
149    pub runtime_boot_id: Option<String>,
150    /// Requested bounded operation.
151    pub operation: JobOperation,
152}
153
154/// Host-only operations. None waits for a process to exit or for output to become available.
155#[derive(Debug, Clone, Serialize, Deserialize)]
156#[serde(tag = "op", rename_all = "snake_case")]
157#[non_exhaustive]
158pub enum JobOperation {
159    /// Discover support without creating a process.
160    Hello,
161    /// Admit a launch exactly once for this ID and command fingerprint.
162    Start {
163        /// Client-generated random identity and idempotency key.
164        id: JobId,
165        /// Existing guest command contract.
166        command: ExecRequest,
167        /// Close guest stdin immediately after confirmed spawn.
168        no_stdin: bool,
169        /// Optional finite pipe input (at most 64 KiB), followed by EOF.
170        input_base64: Option<String>,
171        /// Host-enforced elapsed execution limit in milliseconds.
172        timeout_ms: Option<u64>,
173    },
174    /// List active or all retained jobs.
175    List {
176        /// Include terminal records.
177        all: bool,
178        /// Maximum page size (1..=100).
179        limit: usize,
180        /// Continue after this identity.
181        cursor: Option<JobId>,
182    },
183    /// Inspect one retained job.
184    Inspect {
185        /// Owning job identity.
186        id: JobId,
187    },
188    /// Read bounded output, optionally renewing an attachment lease.
189    Read {
190        /// Owning job identity.
191        id: JobId,
192        /// Resume strictly after this output sequence.
193        after: u64,
194        /// Opaque attachment lease token.
195        attachment: Option<String>,
196    },
197    /// Acquire one attachment. Reusing the same token is idempotent.
198    Attach {
199        /// Owning job identity.
200        id: JobId,
201        /// Opaque attachment lease token.
202        attachment: String,
203        /// Observe without acquiring input ownership.
204        read_only: bool,
205    },
206    /// Release an attachment without EOF or process termination.
207    Detach {
208        /// Owning job identity.
209        id: JobId,
210        /// Lease to release.
211        attachment: String,
212    },
213    /// Keep an attachment alive without consuming output.
214    Renew {
215        /// Owning job identity.
216        id: JobId,
217        /// Lease to renew.
218        attachment: String,
219    },
220    /// Queue input for the current input-owning attachment.
221    Write {
222        /// Owning job identity.
223        id: JobId,
224        /// Opaque attachment lease token.
225        attachment: String,
226        /// Original input bytes encoded as base64.
227        data_base64: String,
228    },
229    /// Resize the terminal for the input-owning attachment.
230    Resize {
231        /// Owning job identity.
232        id: JobId,
233        /// Opaque attachment lease token.
234        attachment: String,
235        /// Nonzero terminal row count.
236        rows: u16,
237        /// Nonzero terminal column count.
238        cols: u16,
239    },
240    /// Send a Linux guest process-group signal.
241    Signal {
242        /// Owning job identity.
243        id: JobId,
244        /// Linux guest signal number.
245        signal: i32,
246    },
247    /// Close a pipe after already admitted input; invalid for PTYs.
248    Eof {
249        /// Owning job identity.
250        id: JobId,
251    },
252}
253
254/// Result of one operation. Errors retain machine-readable codes across SDKs.
255#[derive(Debug, Clone, Serialize, Deserialize)]
256#[serde(tag = "result", rename_all = "snake_case")]
257#[non_exhaustive]
258pub enum JobResponse {
259    /// Successful capability discovery.
260    Hello {
261        /// Selected managed-job protocol version.
262        version: u32,
263        /// Immutable identity of the selected runtime.
264        runtime_boot_id: String,
265    },
266    /// Admission or inspection result.
267    Info {
268        /// Current job metadata.
269        info: JobInfo,
270    },
271    /// List page.
272    Page {
273        /// Bounded list result.
274        page: JobPage,
275    },
276    /// Output page.
277    Output {
278        /// Bounded output result.
279        page: JobOutputPage,
280    },
281    /// Attachment metadata and current replay cursor.
282    Attached {
283        /// Current job metadata.
284        info: JobInfo,
285        /// Latest captured sequence at admission.
286        cursor: u64,
287    },
288    /// Control operation was admitted.
289    Ok,
290    /// Explicit refusal or failure; never a fabricated process exit.
291    Error {
292        /// Stable machine-readable code.
293        code: String,
294        /// Human-readable diagnostic.
295        message: String,
296    },
297}
298
299//--------------------------------------------------------------------------------------------------
300// Methods
301//--------------------------------------------------------------------------------------------------
302
303impl JobId {
304    /// Validate the portable opaque ID, including before it becomes a path component.
305    pub fn parse(value: impl Into<String>) -> Result<Self, String> {
306        let value = value.into();
307        if value.len() != 36
308            || !value.starts_with("job_")
309            || !value[4..]
310                .bytes()
311                .all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase())
312        {
313            return Err("job ID must be job_ followed by 32 lowercase hexadecimal digits".into());
314        }
315        Ok(Self(value))
316    }
317
318    /// Return the stable string representation.
319    pub fn as_str(&self) -> &str {
320        &self.0
321    }
322}
323
324impl JobState {
325    /// Whether the runtime may still own a live process for this record.
326    pub fn is_active(self) -> bool {
327        matches!(self, Self::Starting | Self::Running)
328    }
329}
330
331impl JobInfo {
332    /// Construct a pending admission without persisting the command's environment or input.
333    pub fn starting(
334        id: JobId,
335        runtime_boot_id: String,
336        command: &ExecRequest,
337        no_stdin: bool,
338        created_at: i64,
339    ) -> Self {
340        Self {
341            version: JOB_PROTOCOL_VERSION,
342            id,
343            runtime_boot_id,
344            command: std::iter::once(command.cmd.clone())
345                .chain(command.args.clone())
346                .collect(),
347            state: JobState::Starting,
348            tty: command.tty,
349            stdin_closed: no_stdin,
350            pid: None,
351            created_at,
352            started_at: None,
353            finished_at: None,
354            exit_code: None,
355            failure: None,
356            error: None,
357            timed_out: false,
358        }
359    }
360}
361
362impl JobOutputSource {
363    /// Stable textual form, identical to the serialized spelling.
364    pub fn as_str(self) -> &'static str {
365        match self {
366            Self::Stdout => "stdout",
367            Self::Stderr => "stderr",
368            Self::Output => "output",
369            Self::StdinError => "stdin_error",
370        }
371    }
372
373    /// Whether these bytes were charged against the guest's output allowance
374    /// and must be acknowledged back once consumed.
375    pub fn charged(self) -> bool {
376        !matches!(self, Self::StdinError)
377    }
378}
379
380impl std::fmt::Display for JobOutputSource {
381    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
382        f.write_str(self.as_str())
383    }
384}
385
386impl JobOutput {
387    /// Construct one captured output record.
388    pub fn new(
389        sequence: u64,
390        timestamp: i64,
391        source: JobOutputSource,
392        data_base64: String,
393    ) -> Self {
394        Self {
395            sequence,
396            timestamp,
397            source,
398            data_base64,
399        }
400    }
401}
402
403impl JobResponse {
404    /// Construct a structured failure without exposing request contents.
405    pub fn error(code: impl Into<String>, message: impl Into<String>) -> Self {
406        Self::Error {
407            code: code.into(),
408            message: message.into(),
409        }
410    }
411}
412
413impl JobPage {
414    /// Page sorted, filtered records by both count and encoded size.
415    pub fn bounded(
416        records: impl IntoIterator<Item = JobInfo>,
417        limit: usize,
418    ) -> Result<Self, String> {
419        let mut items: Vec<JobInfo> = Vec::new();
420        let mut bytes = 0;
421        let mut encoded = Vec::new();
422        for info in records {
423            encoded.clear();
424            ciborium::ser::into_writer(&info, &mut encoded).map_err(|error| error.to_string())?;
425            let size = encoded.len();
426            if limit == 0 || size > JOB_LIST_BYTES {
427                return Err("invalid page limit or oversized job metadata".into());
428            }
429            if !items.is_empty() && (items.len() >= limit || bytes + size > JOB_LIST_BYTES) {
430                return Ok(Self {
431                    next_cursor: items.last().map(|info| info.id.clone()),
432                    items,
433                });
434            }
435            bytes += size;
436            items.push(info);
437        }
438        Ok(Self {
439            items,
440            next_cursor: None,
441        })
442    }
443}
444
445//--------------------------------------------------------------------------------------------------
446// Trait Implementations
447//--------------------------------------------------------------------------------------------------
448
449impl TryFrom<String> for JobId {
450    type Error = String;
451    fn try_from(value: String) -> Result<Self, Self::Error> {
452        Self::parse(value)
453    }
454}
455impl From<JobId> for String {
456    fn from(value: JobId) -> Self {
457        value.0
458    }
459}
460impl AsRef<str> for JobId {
461    fn as_ref(&self) -> &str {
462        self.as_str()
463    }
464}
465impl std::fmt::Display for JobId {
466    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
467        f.write_str(&self.0)
468    }
469}
470
471//--------------------------------------------------------------------------------------------------
472// Tests
473//--------------------------------------------------------------------------------------------------
474
475#[cfg(test)]
476mod tests {
477    use super::*;
478
479    #[test]
480    fn job_ids_cannot_escape_their_storage_directory() {
481        for invalid in [
482            "../outside",
483            "job_../../../../../../../../../../..",
484            "job_ABCDEF",
485            "job_0000000000000000000000000000000/",
486        ] {
487            assert!(JobId::parse(invalid).is_err());
488            assert!(serde_json::from_value::<JobId>(serde_json::json!(invalid)).is_err());
489        }
490        assert!(JobId::parse("job_0123456789abcdef0123456789abcdef").is_ok());
491    }
492
493    #[test]
494    fn job_pages_are_bounded_by_encoded_bytes_as_well_as_count() {
495        let command: ExecRequest =
496            serde_json::from_value(serde_json::json!({"cmd": "x".repeat(16 * 1024)})).unwrap();
497        let records = (0..100).map(|n| {
498            JobInfo::starting(
499                JobId::parse(format!("job_{n:032x}")).unwrap(),
500                "boot".into(),
501                &command,
502                false,
503                0,
504            )
505        });
506        let page = JobPage::bounded(records, 100).unwrap();
507        assert!(page.items.len() < 100);
508        assert_eq!(
509            page.next_cursor.as_ref(),
510            page.items.last().map(|info| &info.id)
511        );
512        assert!(serde_json::to_vec(&page).unwrap().len() < JOB_REPLY_BYTES as usize);
513    }
514}