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/// Byte-preserving output record. Sequence numbers remain monotonic across log rotation.
94#[derive(Debug, Clone, Serialize, Deserialize)]
95#[non_exhaustive]
96pub struct JobOutput {
97    /// Monotonic sequence number, starting at one.
98    pub sequence: u64,
99    /// Unix milliseconds at capture.
100    pub timestamp: i64,
101    /// `stdout`, `stderr`, `output` (PTY), or `stdin_error`.
102    pub source: String,
103    /// Original bytes, encoded explicitly for portable persisted JSON.
104    pub data_base64: String,
105}
106
107/// One bounded list page.
108#[derive(Debug, Clone, Serialize, Deserialize)]
109pub struct JobPage {
110    /// Job summaries in stable identity order.
111    pub items: Vec<JobInfo>,
112    /// Last identity in this page when more records remain.
113    pub next_cursor: Option<JobId>,
114}
115
116/// One bounded replay result; missing retained output is reported explicitly.
117#[derive(Debug, Clone, Serialize, Deserialize)]
118pub struct JobOutputPage {
119    /// Captured records after the requested cursor.
120    pub items: Vec<JobOutput>,
121    /// Cursor after the returned records, or the requested cursor when empty.
122    pub cursor: u64,
123    /// Earliest available sequence when requested records were pruned.
124    pub gap_before: Option<u64>,
125    /// Latest metadata, including terminal state after output has been captured.
126    pub info: JobInfo,
127}
128
129/// One capability-probed, runtime-fenced request.
130#[derive(Debug, Clone, Serialize, Deserialize)]
131pub struct JobRequest {
132    /// Expected extension version.
133    pub version: u32,
134    /// Required for every operation except Hello.
135    pub runtime_boot_id: Option<String>,
136    /// Requested bounded operation.
137    pub operation: JobOperation,
138}
139
140/// Host-only operations. None waits for a process to exit or for output to become available.
141#[derive(Debug, Clone, Serialize, Deserialize)]
142#[serde(tag = "op", rename_all = "snake_case")]
143#[non_exhaustive]
144pub enum JobOperation {
145    /// Discover support without creating a process.
146    Hello,
147    /// Admit a launch exactly once for this ID and command fingerprint.
148    Start {
149        /// Client-generated random identity and idempotency key.
150        id: JobId,
151        /// Existing guest command contract.
152        command: ExecRequest,
153        /// Close guest stdin immediately after confirmed spawn.
154        no_stdin: bool,
155        /// Optional finite pipe input (at most 64 KiB), followed by EOF.
156        input_base64: Option<String>,
157        /// Host-enforced elapsed execution limit in milliseconds.
158        timeout_ms: Option<u64>,
159    },
160    /// List active or all retained jobs.
161    List {
162        /// Include terminal records.
163        all: bool,
164        /// Maximum page size (1..=100).
165        limit: usize,
166        /// Continue after this identity.
167        cursor: Option<JobId>,
168    },
169    /// Inspect one retained job.
170    Inspect {
171        /// Owning job identity.
172        id: JobId,
173    },
174    /// Read bounded output, optionally renewing an attachment lease.
175    Read {
176        /// Owning job identity.
177        id: JobId,
178        /// Resume strictly after this output sequence.
179        after: u64,
180        /// Opaque attachment lease token.
181        attachment: Option<String>,
182    },
183    /// Acquire one attachment. Reusing the same token is idempotent.
184    Attach {
185        /// Owning job identity.
186        id: JobId,
187        /// Opaque attachment lease token.
188        attachment: String,
189        /// Observe without acquiring input ownership.
190        read_only: bool,
191    },
192    /// Release an attachment without EOF or process termination.
193    Detach {
194        /// Owning job identity.
195        id: JobId,
196        /// Lease to release.
197        attachment: String,
198    },
199    /// Keep an attachment alive without consuming output.
200    Renew {
201        /// Owning job identity.
202        id: JobId,
203        /// Lease to renew.
204        attachment: String,
205    },
206    /// Queue input for the current input-owning attachment.
207    Write {
208        /// Owning job identity.
209        id: JobId,
210        /// Opaque attachment lease token.
211        attachment: String,
212        /// Original input bytes encoded as base64.
213        data_base64: String,
214    },
215    /// Resize the terminal for the input-owning attachment.
216    Resize {
217        /// Owning job identity.
218        id: JobId,
219        /// Opaque attachment lease token.
220        attachment: String,
221        /// Nonzero terminal row count.
222        rows: u16,
223        /// Nonzero terminal column count.
224        cols: u16,
225    },
226    /// Send a Linux guest process-group signal.
227    Signal {
228        /// Owning job identity.
229        id: JobId,
230        /// Linux guest signal number.
231        signal: i32,
232    },
233    /// Close a pipe after already admitted input; invalid for PTYs.
234    Eof {
235        /// Owning job identity.
236        id: JobId,
237    },
238}
239
240/// Result of one operation. Errors retain machine-readable codes across SDKs.
241#[derive(Debug, Clone, Serialize, Deserialize)]
242#[serde(tag = "result", rename_all = "snake_case")]
243#[non_exhaustive]
244pub enum JobResponse {
245    /// Successful capability discovery.
246    Hello {
247        /// Selected managed-job protocol version.
248        version: u32,
249        /// Immutable identity of the selected runtime.
250        runtime_boot_id: String,
251    },
252    /// Admission or inspection result.
253    Info {
254        /// Current job metadata.
255        info: JobInfo,
256    },
257    /// List page.
258    Page {
259        /// Bounded list result.
260        page: JobPage,
261    },
262    /// Output page.
263    Output {
264        /// Bounded output result.
265        page: JobOutputPage,
266    },
267    /// Attachment metadata and current replay cursor.
268    Attached {
269        /// Current job metadata.
270        info: JobInfo,
271        /// Latest captured sequence at admission.
272        cursor: u64,
273    },
274    /// Control operation was admitted.
275    Ok,
276    /// Explicit refusal or failure; never a fabricated process exit.
277    Error {
278        /// Stable machine-readable code.
279        code: String,
280        /// Human-readable diagnostic.
281        message: String,
282    },
283}
284
285//--------------------------------------------------------------------------------------------------
286// Methods
287//--------------------------------------------------------------------------------------------------
288
289impl JobId {
290    /// Validate the portable opaque ID, including before it becomes a path component.
291    pub fn parse(value: impl Into<String>) -> Result<Self, String> {
292        let value = value.into();
293        if value.len() != 36
294            || !value.starts_with("job_")
295            || !value[4..]
296                .bytes()
297                .all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase())
298        {
299            return Err("job ID must be job_ followed by 32 lowercase hexadecimal digits".into());
300        }
301        Ok(Self(value))
302    }
303
304    /// Return the stable string representation.
305    pub fn as_str(&self) -> &str {
306        &self.0
307    }
308}
309
310impl JobState {
311    /// Whether the runtime may still own a live process for this record.
312    pub fn is_active(self) -> bool {
313        matches!(self, Self::Starting | Self::Running)
314    }
315}
316
317impl JobInfo {
318    /// Construct a pending admission without persisting the command's environment or input.
319    pub fn starting(
320        id: JobId,
321        runtime_boot_id: String,
322        command: &ExecRequest,
323        no_stdin: bool,
324        created_at: i64,
325    ) -> Self {
326        Self {
327            version: JOB_PROTOCOL_VERSION,
328            id,
329            runtime_boot_id,
330            command: std::iter::once(command.cmd.clone())
331                .chain(command.args.clone())
332                .collect(),
333            state: JobState::Starting,
334            tty: command.tty,
335            stdin_closed: no_stdin,
336            pid: None,
337            created_at,
338            started_at: None,
339            finished_at: None,
340            exit_code: None,
341            failure: None,
342            error: None,
343            timed_out: false,
344        }
345    }
346}
347
348impl JobOutput {
349    /// Construct one captured output record.
350    pub fn new(sequence: u64, timestamp: i64, source: String, data_base64: String) -> Self {
351        Self {
352            sequence,
353            timestamp,
354            source,
355            data_base64,
356        }
357    }
358}
359
360impl JobResponse {
361    /// Construct a structured failure without exposing request contents.
362    pub fn error(code: impl Into<String>, message: impl Into<String>) -> Self {
363        Self::Error {
364            code: code.into(),
365            message: message.into(),
366        }
367    }
368}
369
370impl JobPage {
371    /// Page sorted, filtered records by both count and encoded size.
372    pub fn bounded(
373        records: impl IntoIterator<Item = JobInfo>,
374        limit: usize,
375    ) -> Result<Self, String> {
376        let mut items: Vec<JobInfo> = Vec::new();
377        let mut bytes = 0;
378        let mut encoded = Vec::new();
379        for info in records {
380            encoded.clear();
381            ciborium::ser::into_writer(&info, &mut encoded).map_err(|error| error.to_string())?;
382            let size = encoded.len();
383            if limit == 0 || size > JOB_LIST_BYTES {
384                return Err("invalid page limit or oversized job metadata".into());
385            }
386            if !items.is_empty() && (items.len() >= limit || bytes + size > JOB_LIST_BYTES) {
387                return Ok(Self {
388                    next_cursor: items.last().map(|info| info.id.clone()),
389                    items,
390                });
391            }
392            bytes += size;
393            items.push(info);
394        }
395        Ok(Self {
396            items,
397            next_cursor: None,
398        })
399    }
400}
401
402//--------------------------------------------------------------------------------------------------
403// Trait Implementations
404//--------------------------------------------------------------------------------------------------
405
406impl TryFrom<String> for JobId {
407    type Error = String;
408    fn try_from(value: String) -> Result<Self, Self::Error> {
409        Self::parse(value)
410    }
411}
412impl From<JobId> for String {
413    fn from(value: JobId) -> Self {
414        value.0
415    }
416}
417impl AsRef<str> for JobId {
418    fn as_ref(&self) -> &str {
419        self.as_str()
420    }
421}
422impl std::fmt::Display for JobId {
423    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
424        f.write_str(&self.0)
425    }
426}
427
428//--------------------------------------------------------------------------------------------------
429// Tests
430//--------------------------------------------------------------------------------------------------
431
432#[cfg(test)]
433mod tests {
434    use super::*;
435
436    #[test]
437    fn job_ids_cannot_escape_their_storage_directory() {
438        for invalid in [
439            "../outside",
440            "job_../../../../../../../../../../..",
441            "job_ABCDEF",
442            "job_0000000000000000000000000000000/",
443        ] {
444            assert!(JobId::parse(invalid).is_err());
445            assert!(serde_json::from_value::<JobId>(serde_json::json!(invalid)).is_err());
446        }
447        assert!(JobId::parse("job_0123456789abcdef0123456789abcdef").is_ok());
448    }
449
450    #[test]
451    fn job_pages_are_bounded_by_encoded_bytes_as_well_as_count() {
452        let command: ExecRequest =
453            serde_json::from_value(serde_json::json!({"cmd": "x".repeat(16 * 1024)})).unwrap();
454        let records = (0..100).map(|n| {
455            JobInfo::starting(
456                JobId::parse(format!("job_{n:032x}")).unwrap(),
457                "boot".into(),
458                &command,
459                false,
460                0,
461            )
462        });
463        let page = JobPage::bounded(records, 100).unwrap();
464        assert!(page.items.len() < 100);
465        assert_eq!(
466            page.next_cursor.as_ref(),
467            page.items.last().map(|info| &info.id)
468        );
469        assert!(serde_json::to_vec(&page).unwrap().len() < JOB_REPLY_BYTES as usize);
470    }
471}