Skip to main content

assay_runner_core/
run.rs

1use crate::RunnerSpikeArchive;
2use assay_runner_schema::{CgroupCorrelationStatus, KernelLayerStatus};
3use serde::{Deserialize, Serialize};
4use serde_json::json;
5use std::collections::BTreeMap;
6use std::process::{Command, ExitStatus};
7use std::time::{Duration, Instant};
8use thiserror::Error;
9use uuid::Uuid;
10
11pub const RUN_EVENT_SCHEMA: &str = "assay.runner.event.v0";
12
13#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
14pub struct RunSpec {
15    pub run_id: String,
16    pub platform: String,
17    pub agent_shim: String,
18    pub command: Vec<String>,
19    pub env: BTreeMap<String, String>,
20}
21
22#[derive(Debug, Clone, PartialEq, Eq, Error)]
23pub enum RunSpecError {
24    #[error("command must not be empty")]
25    EmptyCommand,
26    #[error("run_id must not be empty")]
27    EmptyRunId,
28    #[error("run_id must not contain ':'")]
29    // Colons are used as prefix separators in policy_decisions values such as
30    // "allow:filesystem.read_file"; banning them keeps future join keys simple.
31    RunIdContainsColon,
32    #[error("run_id may only contain ASCII letters, digits, '_' and '-'")]
33    RunIdContainsUnsafeCharacter,
34    #[error("unsupported agent shim {0:?}")]
35    UnsupportedAgentShim(String),
36}
37
38#[derive(Debug, Error)]
39pub enum RunExecutionError {
40    #[error(transparent)]
41    Spec(#[from] RunSpecError),
42    #[error("failed to spawn command {command:?}: {source}")]
43    Spawn {
44        command: String,
45        source: std::io::Error,
46    },
47    #[error("failed to wait for command {command:?}: {source}")]
48    Wait {
49        command: String,
50        source: std::io::Error,
51    },
52    #[error("failed to serialize runner event: {0}")]
53    EventSerialization(#[from] serde_json::Error),
54}
55
56#[derive(Debug, Clone, PartialEq, Eq)]
57pub struct RunOutcome {
58    pub archive: RunnerSpikeArchive,
59    pub exit_code: Option<i32>,
60    pub signal: Option<i32>,
61    pub success: bool,
62}
63
64impl RunSpec {
65    pub fn new(command: Vec<String>) -> Self {
66        Self {
67            run_id: generate_run_id(),
68            platform: std::env::consts::OS.to_string(),
69            agent_shim: "none".to_string(),
70            command,
71            env: BTreeMap::new(),
72        }
73    }
74
75    pub fn with_run_id(mut self, run_id: impl Into<String>) -> Self {
76        self.run_id = run_id.into();
77        self
78    }
79
80    pub fn with_platform(mut self, platform: impl Into<String>) -> Self {
81        self.platform = platform.into();
82        self
83    }
84
85    pub fn with_agent_shim(mut self, agent_shim: impl Into<String>) -> Self {
86        self.agent_shim = agent_shim.into();
87        self
88    }
89
90    pub fn with_env(mut self, key: impl Into<String>, value: impl Into<String>) -> Self {
91        self.env.insert(key.into(), value.into());
92        self
93    }
94
95    pub fn validate(&self) -> Result<(), RunSpecError> {
96        if self.command.is_empty() {
97            return Err(RunSpecError::EmptyCommand);
98        }
99        if self.run_id.is_empty() {
100            return Err(RunSpecError::EmptyRunId);
101        }
102        if self.run_id.contains(':') {
103            return Err(RunSpecError::RunIdContainsColon);
104        }
105        if !is_safe_run_id(&self.run_id) {
106            return Err(RunSpecError::RunIdContainsUnsafeCharacter);
107        }
108        if !matches!(
109            self.agent_shim.as_str(),
110            "none" | "openai-agents" | "gemini-google-genai"
111        ) {
112            return Err(RunSpecError::UnsupportedAgentShim(self.agent_shim.clone()));
113        }
114        Ok(())
115    }
116
117    pub fn skeleton_archive(&self) -> Result<RunnerSpikeArchive, RunSpecError> {
118        self.validate()?;
119        let mut archive = RunnerSpikeArchive::empty(self.run_id.clone(), self.platform.clone());
120        archive.observation_health = archive.observation_health.with_agent_shim(&self.agent_shim);
121        archive.observation_health.kernel_layer = KernelLayerStatus::Absent;
122        archive.observation_health = archive
123            .observation_health
124            .with_cgroup_correlation(CgroupCorrelationStatus::Partial);
125        archive.observation_health.notes.push(
126            "contract_only_mode: kernel and cgroup capture not wired in this revision".to_string(),
127        );
128        Ok(archive)
129    }
130
131    pub fn run_contract_only(&self) -> Result<RunOutcome, RunExecutionError> {
132        self.validate()?;
133        let mut archive = self.skeleton_archive()?;
134        let clock = Instant::now();
135
136        self.append_run_started(&mut archive, 0, Duration::ZERO)?;
137
138        let mut child = Command::new(&self.command[0])
139            .args(&self.command[1..])
140            .envs(&self.env)
141            .spawn()
142            .map_err(|source| RunExecutionError::Spawn {
143                command: self.command[0].clone(),
144                source,
145            })?;
146        let status = child.wait().map_err(|source| RunExecutionError::Wait {
147            command: self.command[0].clone(),
148            source,
149        })?;
150
151        let exit_code = status.code();
152        let signal = exit_signal(&status);
153        let success = status.success();
154        self.append_run_finished(&mut archive, 1, &status, clock.elapsed())?;
155
156        Ok(RunOutcome {
157            archive,
158            exit_code,
159            signal,
160            success,
161        })
162    }
163
164    pub fn append_run_started(
165        &self,
166        archive: &mut RunnerSpikeArchive,
167        seq: u64,
168        window_elapsed: Duration,
169    ) -> Result<(), RunExecutionError> {
170        append_event(
171            archive,
172            json!({
173                "schema": RUN_EVENT_SCHEMA,
174                "run_id": &self.run_id,
175                "seq": seq,
176                "type": "run_started",
177                "agent_shim": &self.agent_shim,
178                "command": &self.command,
179                "env_keys": self.env.keys().collect::<Vec<_>>(),
180                "window_elapsed_ms": window_elapsed.as_millis() as u64
181            }),
182        )?;
183        Ok(())
184    }
185
186    pub fn append_run_finished(
187        &self,
188        archive: &mut RunnerSpikeArchive,
189        seq: u64,
190        status: &ExitStatus,
191        window_elapsed: Duration,
192    ) -> Result<(), RunExecutionError> {
193        append_event(
194            archive,
195            json!({
196                "schema": RUN_EVENT_SCHEMA,
197                "run_id": &self.run_id,
198                "seq": seq,
199                "type": "run_finished",
200                "exit_code": status.code(),
201                "signal": exit_signal(status),
202                "success": status.success(),
203                "window_elapsed_ms": window_elapsed.as_millis() as u64
204            }),
205        )?;
206        Ok(())
207    }
208}
209
210fn generate_run_id() -> String {
211    format!("run_{}", Uuid::new_v4().simple())
212}
213
214pub(crate) fn is_safe_run_id(run_id: &str) -> bool {
215    run_id
216        .bytes()
217        .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'-')
218}
219
220fn append_event(
221    archive: &mut RunnerSpikeArchive,
222    event: serde_json::Value,
223) -> Result<(), serde_json::Error> {
224    serde_json::to_writer(&mut archive.events_ndjson, &event)?;
225    archive.events_ndjson.push(b'\n');
226    Ok(())
227}
228
229#[cfg(unix)]
230fn exit_signal(status: &std::process::ExitStatus) -> Option<i32> {
231    use std::os::unix::process::ExitStatusExt;
232    status.signal()
233}
234
235#[cfg(not(unix))]
236fn exit_signal(_status: &std::process::ExitStatus) -> Option<i32> {
237    None
238}
239
240#[cfg(test)]
241mod tests {
242    use super::*;
243    use assay_runner_schema::SdkLayerStatus;
244
245    #[test]
246    fn generated_run_id_is_stream_safe() {
247        let spec = RunSpec::new(vec!["true".to_string()]);
248
249        assert!(spec.run_id.starts_with("run_"));
250        assert!(!spec.run_id.contains(':'));
251        assert_eq!(spec.platform, std::env::consts::OS);
252    }
253
254    #[test]
255    fn run_spec_rejects_empty_command() {
256        let spec = RunSpec::new(Vec::new());
257
258        assert_eq!(spec.validate(), Err(RunSpecError::EmptyCommand));
259    }
260
261    #[test]
262    fn run_spec_rejects_colon_run_id() {
263        let spec = RunSpec::new(vec!["true".to_string()]).with_run_id("bad:run");
264
265        assert_eq!(spec.validate(), Err(RunSpecError::RunIdContainsColon));
266    }
267
268    #[test]
269    fn run_spec_rejects_path_like_run_id() {
270        let spec = RunSpec::new(vec!["true".to_string()]).with_run_id("../bad");
271
272        assert_eq!(
273            spec.validate(),
274            Err(RunSpecError::RunIdContainsUnsafeCharacter)
275        );
276    }
277
278    #[test]
279    fn run_spec_rejects_unknown_agent_shim() {
280        let spec = RunSpec::new(vec!["true".to_string()]).with_agent_shim("typo-shim");
281
282        assert_eq!(
283            spec.validate(),
284            Err(RunSpecError::UnsupportedAgentShim("typo-shim".to_string()))
285        );
286    }
287
288    #[test]
289    fn none_shim_skeleton_archive_has_absent_sdk_layer() {
290        let archive = RunSpec::new(vec!["true".to_string()])
291            .with_run_id("run_001")
292            .with_platform("linux")
293            .with_agent_shim("none")
294            .skeleton_archive()
295            .unwrap();
296
297        assert_eq!(archive.run_id, "run_001");
298        assert_eq!(archive.observation_health.sdk_layer, SdkLayerStatus::Absent);
299    }
300
301    #[test]
302    fn sdk_shim_skeleton_archive_stays_absent_until_events_are_consumed() {
303        let archive = RunSpec::new(vec!["true".to_string()])
304            .with_run_id("run_001")
305            .with_platform("linux")
306            .with_agent_shim("openai-agents")
307            .skeleton_archive()
308            .unwrap();
309
310        assert_eq!(archive.observation_health.sdk_layer, SdkLayerStatus::Absent);
311    }
312
313    #[test]
314    fn gemini_shim_is_accepted_in_allowlist() {
315        // The Gemini Python google-genai second-runtime fixture (selected by
316        // #1305 via #1306) carries its own bundle metadata via the
317        // `gemini-google-genai` shim identifier. Reusing `openai-agents` would
318        // mislead any downstream tool that reads `agent_shim` from the bundle.
319        // Keep the allowlist explicit; do not relax validation more broadly.
320        //
321        // This test asserts only the allowlist acceptance plus the skeleton
322        // archive's default SDK-layer state (Absent). SDK-layer transition to
323        // SelfReported on event application is covered by the SDK-capture
324        // tests in src/sdk.rs and src/health.rs, not here.
325        let archive = RunSpec::new(vec!["true".to_string()])
326            .with_run_id("run_001")
327            .with_platform("linux")
328            .with_agent_shim("gemini-google-genai")
329            .skeleton_archive()
330            .unwrap();
331
332        assert_eq!(archive.observation_health.sdk_layer, SdkLayerStatus::Absent);
333    }
334
335    #[test]
336    fn contract_only_run_records_lifecycle_events_and_exit_code() {
337        let outcome = RunSpec::new(vec!["true".to_string()])
338            .with_run_id("run_001")
339            .with_platform("linux")
340            .run_contract_only()
341            .unwrap();
342        let events = String::from_utf8(outcome.archive.events_ndjson).unwrap();
343
344        assert_eq!(outcome.exit_code, Some(0));
345        assert!(outcome.success);
346        assert!(events.contains("\"type\":\"run_started\""));
347        assert!(events.contains("\"type\":\"run_finished\""));
348        assert!(outcome.archive.kernel_layer_ndjson.is_empty());
349        assert!(outcome.archive.policy_layer_ndjson.is_empty());
350        assert!(outcome.archive.sdk_layer_ndjson.is_empty());
351    }
352
353    #[test]
354    fn contract_only_bundle_does_not_claim_kernel_observation() {
355        let outcome = RunSpec::new(vec!["true".to_string()])
356            .with_run_id("run_001")
357            .with_platform("linux")
358            .run_contract_only()
359            .unwrap();
360
361        assert_eq!(
362            outcome.archive.observation_health.kernel_layer,
363            KernelLayerStatus::Absent
364        );
365        assert_eq!(
366            outcome.archive.observation_health.cgroup_correlation,
367            CgroupCorrelationStatus::Partial
368        );
369        assert!(outcome
370            .archive
371            .observation_health
372            .notes
373            .iter()
374            .any(|note| note.contains("contract_only_mode")));
375    }
376}