1use anyhow::{Context, Result, anyhow, bail};
15use bollard::container::{
16 Config, CreateContainerOptions, ListContainersOptions, LogOutput, LogsOptions,
17 NetworkingConfig, RemoveContainerOptions, StartContainerOptions, StopContainerOptions,
18};
19use bollard::exec::{CreateExecOptions, StartExecOptions, StartExecResults};
20use bollard::image::CreateImageOptions;
21use bollard::models::{EndpointSettings, HealthConfig, HostConfig};
22use bollard::network::{CreateNetworkOptions, ListNetworksOptions};
23use bollard::{API_DEFAULT_VERSION, Docker};
24use futures_util::StreamExt;
25use runner_core::executor::{DoctorCheck, DoctorReport, Executor, ExecutorCapabilities};
26use runner_core::{
27 artifacts,
28 config::{RunnerConfig, platform_from_env, validate_platform},
29 journal::Journal,
30 policy, state, workspace,
31};
32use runner_protocol::{
33 CommandSpec, FailureInfo, FailureKind, JobResult, JobSpec, LogChunk, SandboxResult,
34 ServiceSpec, WorkflowSpec, validate_feedback_file,
35};
36use std::sync::atomic::{AtomicU64, Ordering};
37use std::{
38 collections::HashMap,
39 fs,
40 path::{Component, Path, PathBuf},
41 sync::{Arc, Mutex},
42 time::Instant,
43};
44
45#[derive(Debug, thiserror::Error)]
49#[error("Docker {code} during {phase}: {message}")]
50pub struct DockerFailure {
51 pub code: &'static str,
52 pub phase: &'static str,
53 pub message: String,
54 pub retryable: bool,
55}
56
57impl DockerFailure {
58 fn image_pull(error: impl std::fmt::Display) -> Self {
59 Self {
60 code: "image_pull_failed",
61 phase: "image_pull",
62 message: sanitize_docker_error(error),
63 retryable: true,
64 }
65 }
66
67 pub fn failure_info(&self) -> FailureInfo {
68 FailureInfo {
69 kind: FailureKind::Infrastructure,
70 code: self.code.into(),
71 message: format!("{} (phase: {})", self.message, self.phase),
72 }
73 }
74}
75
76fn sanitize_docker_error(error: impl std::fmt::Display) -> String {
77 let message = error.to_string();
78 let mut value = serde_json::Value::String(message);
79 redact_diagnostic_value(&mut value);
80 value
81 .as_str()
82 .unwrap_or("Docker operation failed")
83 .chars()
84 .take(500)
85 .collect()
86}
87
88#[derive(Debug, Clone, Copy, PartialEq, Eq)]
89enum ExecutionTermination {
90 Cancelled,
91 TimedOut,
92}
93
94#[derive(Debug)]
95struct ExecResult {
96 status: i64,
97 stdout: Vec<u8>,
98 stderr: Vec<u8>,
99 truncated: bool,
100 termination: Option<ExecutionTermination>,
101}
102
103fn append_bounded(buffer: &mut Vec<u8>, data: &[u8], limit: u64) -> bool {
104 let remaining = limit.saturating_sub(buffer.len() as u64) as usize;
105 let amount = data.len().min(remaining);
106 buffer.extend_from_slice(&data[..amount]);
107 amount < data.len()
108}
109
110fn redact_diagnostic_value(value: &mut serde_json::Value) {
111 match value {
112 serde_json::Value::Object(object) => {
113 for (key, value) in object {
114 let normalized = key.to_ascii_lowercase();
115 if normalized == "command" {
116 *value = serde_json::json!(["<redacted: command may contain credentials>"]);
117 } else if normalized == "secrets" {
118 if let serde_json::Value::Array(secrets) = value {
119 for secret in secrets.iter_mut() {
120 if let Some(value) = secret.get_mut("value") {
121 *value = serde_json::Value::String("<redacted>".into());
122 }
123 }
124 }
125 redact_diagnostic_value(value);
126 } else if normalized.contains("token")
127 || normalized.contains("password")
128 || normalized.contains("secret")
129 || normalized.contains("authorization")
130 || normalized.ends_with("apikey")
131 || normalized.ends_with("api_key")
132 || normalized.ends_with("_key")
133 {
134 *value = serde_json::Value::String("<redacted>".into());
135 } else {
136 redact_diagnostic_value(value);
137 }
138 }
139 }
140 serde_json::Value::Array(values) => {
141 for value in values {
142 redact_diagnostic_value(value);
143 }
144 }
145 _ => {}
146 }
147}
148
149fn redact_diagnostic_bytes(spec: &JobSpec, bytes: &[u8]) -> Vec<u8> {
150 const MAX_DIAGNOSTIC_BYTES: usize = 64 * 1024;
151 let mut text = String::from_utf8_lossy(bytes).into_owned();
152 let mut secrets: Vec<String> = Vec::new();
153 if let runner_protocol::WorkspaceSpec::Git { token, .. } = &spec.workspace {
154 secrets.push(token.clone());
155 }
156 for secret in &spec.secrets {
157 secrets.push(secret.value.clone());
158 }
159 for name in &spec.environment_from_runner {
160 if let Ok(value) = std::env::var(name) {
161 secrets.push(value);
162 }
163 }
164 for secret in secrets {
165 if !secret.is_empty() {
166 text = text.replace(&secret, "<redacted>");
167 }
168 }
169 let mut output = text.into_bytes();
170 if output.len() > MAX_DIAGNOSTIC_BYTES {
171 output.truncate(MAX_DIAGNOSTIC_BYTES);
172 output.extend_from_slice(b"\n[diagnostic output truncated]\n");
173 }
174 output
175}
176
177fn write_failure_diagnostics(
178 workspace: &Path,
179 spec: &JobSpec,
180 phase: Option<&str>,
181 stdout: &[u8],
182 stderr: &[u8],
183) -> Result<()> {
184 let dir = workspace.join(".runner/diagnostics");
185 fs::create_dir_all(&dir)?;
186 let mut job = serde_json::to_value(spec)?;
187 redact_diagnostic_value(&mut job);
188 fs::write(dir.join("job.json"), serde_json::to_vec_pretty(&job)?)?;
189 fs::write(
190 dir.join("meta.json"),
191 serde_json::to_vec_pretty(&serde_json::json!({
192 "job_id": spec.id,
193 "attempt": spec.attempt,
194 "image": spec.image,
195 "network": spec.network.mode,
196 "phase": phase,
197 }))?,
198 )?;
199 fs::write(
203 dir.join("stdout.log"),
204 redact_diagnostic_bytes(spec, stdout),
205 )?;
206 fs::write(
207 dir.join("stderr.log"),
208 redact_diagnostic_bytes(spec, stderr),
209 )?;
210 Ok(())
211}
212use tokio::sync::mpsc::{Sender, error::TrySendError};
213use tracing::{info, warn};
214use uuid::Uuid;
215
216fn runtime_tmpfs() -> HashMap<String, String> {
220 HashMap::from([
221 ("/tmp".into(), "rw,noexec,nosuid,size=256m".into()),
222 (
223 "/home/opencode/.opencode".into(),
224 "rw,nosuid,nodev,uid=10001,gid=10001,mode=0700,size=64m".into(),
225 ),
226 (
227 "/home/opencode/.config/opencode".into(),
228 "rw,nosuid,nodev,uid=10001,gid=10001,mode=0700,size=512m".into(),
229 ),
230 (
231 "/home/opencode/.local/share/opencode".into(),
232 "rw,nosuid,nodev,uid=10001,gid=10001,mode=0700,size=1g".into(),
233 ),
234 (
235 "/home/opencode/.local/state".into(),
236 "rw,nosuid,nodev,uid=10001,gid=10001,mode=0700,size=32m".into(),
237 ),
238 (
239 "/home/opencode/.cache/npm".into(),
240 "rw,nosuid,nodev,uid=10001,gid=10001,mode=0700,size=1g".into(),
241 ),
242 (
243 "/home/opencode/.cache/opencode".into(),
244 "rw,nosuid,nodev,uid=10001,gid=10001,mode=0700,size=128m".into(),
245 ),
246 (
247 "/home/opencode/.pub-cache".into(),
248 "rw,nosuid,nodev,uid=10001,gid=10001,mode=0700,size=1g".into(),
249 ),
250 ])
251}
252
253fn command_with_opencode_retry(command: &[String]) -> Vec<String> {
254 if command.len() != 3
255 || command[0] != "bash"
256 || command[1] != "-lc"
257 || !command[2].contains("opencode run ")
258 || !command[2].contains(".ai-kodu-runner/results/opencode.json")
259 {
260 return command.to_vec();
261 }
262
263 let script = command[2].replacen(
264 "opencode run ",
265 "opencode run --print-logs --log-level INFO ",
266 1,
267 );
268 let wrapped = format!(
269 r#"set +e
270(
271{script}
272)
273runner_rc=$?
274if [ "$runner_rc" -ne 0 ] && grep -Fq 'Unexpected server error' .ai-kodu-runner/results/opencode.json 2>/dev/null; then
275 echo 'OpenCode reported an unexpected error; retrying once in 2 seconds' >&2
276 sleep 2
277 (
278{script}
279 )
280 runner_rc=$?
281fi
282exit "$runner_rc""#
283 );
284
285 vec![command[0].clone(), command[1].clone(), wrapped]
286}
287
288fn headless_opencode_environment(mut env: Vec<String>, command: &[String]) -> Vec<String> {
289 if !command.iter().any(|arg| arg.contains("opencode run ")) {
290 return env;
291 }
292
293 env.push(r#"OPENCODE_CONFIG_CONTENT={"agent":{"title":{"disable":true}}}"#.into());
297 env.push("OPENCODE_DISABLE_MODELS_FETCH=true".into());
298 env
299}
300
301fn safe_feedback_path(root: &Path, feedback_file: &str) -> Result<PathBuf> {
302 let relative = validate_feedback_file(feedback_file)?;
303 let root = root
304 .canonicalize()
305 .context("canonicalize feedback workspace")?;
306 let mut current = root.clone();
307 if let Some(parent) = relative.parent() {
308 for component in parent
309 .components()
310 .filter(|component| !matches!(component, Component::CurDir))
311 {
312 current.push(component.as_os_str());
313 match fs::symlink_metadata(¤t) {
314 Ok(metadata) if metadata.file_type().is_symlink() => {
315 bail!("feedback_file parent must not be a symlink")
316 }
317 Ok(metadata) if !metadata.is_dir() => {
318 bail!("feedback_file parent is not a directory")
319 }
320 Ok(_) => {}
321 Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
322 fs::create_dir(¤t)?;
323 }
324 Err(error) => return Err(error.into()),
325 }
326 }
327 }
328 let target = root.join(&relative);
329 if let Ok(metadata) = fs::symlink_metadata(&target)
330 && metadata.file_type().is_symlink()
331 {
332 bail!("feedback_file must not be a symlink")
333 }
334 let parent = target
335 .parent()
336 .context("feedback_file has no parent directory")?
337 .canonicalize()?;
338 if !parent.starts_with(&root) {
339 bail!("feedback_file escapes workspace")
340 }
341 Ok(target)
342}
343
344pub struct DockerExecutor {
345 config: RunnerConfig,
346 docker: Docker,
347 log_sender: Option<Sender<LogChunk>>,
348 log_sequence: AtomicU64,
349 dropped_log_chunks: AtomicU64,
350 log_context: Arc<Mutex<Option<(String, u32, String)>>>,
351}
352impl DockerExecutor {
353 pub fn new(config: RunnerConfig) -> Result<Self> {
354 Self::with_log_sender(config, None)
355 }
356 pub fn with_log_sender(
357 config: RunnerConfig,
358 log_sender: Option<Sender<LogChunk>>,
359 ) -> Result<Self> {
360 validate_platform(&config.docker.platform)?;
361 let docker =
362 Docker::connect_with_local_defaults().context("connect to Docker Engine/Desktop")?;
363 Ok(Self {
364 config,
365 docker,
366 log_sender,
367 log_sequence: AtomicU64::new(0),
368 dropped_log_chunks: AtomicU64::new(0),
369 log_context: Arc::new(Mutex::new(None)),
370 })
371 }
372 fn begin_log_stream(&self, spec: &JobSpec) {
373 self.log_sequence.store(0, Ordering::Relaxed);
374 self.dropped_log_chunks.store(0, Ordering::Relaxed);
375 if let Ok(mut context) = self.log_context.lock() {
376 *context = Some((spec.id.clone(), spec.attempt, "running".into()));
377 }
378 }
379 fn set_log_phase(&self, phase: &str) {
380 if let Ok(mut context) = self.log_context.lock()
381 && let Some((_, _, current_phase)) = context.as_mut()
382 {
383 *current_phase = phase.into();
384 }
385 }
386 fn emit_log(&self, stream: &str, bytes: &[u8]) {
387 let Some(sender) = &self.log_sender else {
388 return;
389 };
390 let Ok(context) = self.log_context.lock() else {
391 return;
392 };
393 let Some((_, attempt, phase)) = context.as_ref() else {
394 return;
395 };
396 for chunk in bytes.chunks(64 * 1024) {
397 let sequence = self.log_sequence.fetch_add(1, Ordering::Relaxed) + 1;
398 let chunk = LogChunk {
399 attempt: *attempt,
400 sequence,
401 stream: stream.to_owned(),
402 phase: phase.clone(),
403 level: Some(if stream == "stderr" { "warn" } else { "info" }.into()),
404 message: String::from_utf8_lossy(chunk).into_owned(),
405 };
406 match sender.try_send(chunk) {
407 Ok(()) | Err(TrySendError::Closed(_)) => {}
408 Err(TrySendError::Full(_)) => {
409 let dropped = self.dropped_log_chunks.fetch_add(1, Ordering::Relaxed) + 1;
410 if dropped == 1 || dropped.is_power_of_two() {
411 warn!(dropped, "job log queue is full; dropping chunks");
412 }
413 }
414 }
415 }
416 }
417 async fn connect() -> Result<Docker> {
418 Docker::connect_with_local_defaults().context("connect to Docker Engine/Desktop")
419 }
420 pub async fn doctor() -> Result<()> {
421 let d = Self::connect().await?;
422 d.ping().await.context("Docker ping")?;
423 let v = d.version().await?;
424 let test = format!("ai-kodu-runner-doctor-{}", Uuid::new_v4());
425 let id = d
426 .create_container(
427 Some(CreateContainerOptions::<String> {
428 name: test.clone(),
429 platform: Some(platform_from_env()),
430 }),
431 Config::<String> {
432 image: Some("alpine:3.20".into()),
433 cmd: Some(vec!["true".into()]),
434 ..Default::default()
435 },
436 )
437 .await?
438 .id;
439 d.remove_container(
440 &id,
441 Some(RemoveContainerOptions {
442 force: true,
443 ..Default::default()
444 }),
445 )
446 .await?;
447 println!(
448 "host_os: {}\narchitecture: {}\ndocker_api: {}\nexecutor: docker\ncapabilities: network, artifacts, streaming_logs, cancellation\ntest_container: ok",
449 std::env::consts::OS,
450 std::env::consts::ARCH,
451 v.api_version
452 .map(|x| format!("{x:?}"))
453 .unwrap_or_else(|| format!("{API_DEFAULT_VERSION:?}"))
454 );
455 Ok(())
456 }
457 async fn pull(
458 &self,
459 image: &str,
460 cancellation: &tokio_util::sync::CancellationToken,
461 deadline: tokio::time::Instant,
462 ) -> Result<Option<ExecutionTermination>> {
463 if cancellation.is_cancelled() {
464 return Ok(Some(ExecutionTermination::Cancelled));
465 }
466 if tokio::time::Instant::now() >= deadline {
467 return Ok(Some(ExecutionTermination::TimedOut));
468 }
469 if self.config.docker.pull_policy.as_deref() == Some("always") {
470 let mut s = self.docker.create_image(
471 Some(CreateImageOptions {
472 from_image: image,
473 platform: self.config.docker.platform.as_str(),
474 ..Default::default()
475 }),
476 None,
477 None,
478 );
479 loop {
480 let item = tokio::select! {
481 _ = cancellation.cancelled() => {
482 return Ok(Some(ExecutionTermination::Cancelled));
483 }
484 _ = tokio::time::sleep_until(deadline) => {
485 return Ok(Some(ExecutionTermination::TimedOut));
486 }
487 item = s.next() => item,
488 };
489 let Some(item) = item else {
490 break;
491 };
492 item.map_err(DockerFailure::image_pull)?;
493 }
494 }
495 Ok(None)
496 }
497
498 async fn start_services(
499 &self,
500 services: &[ServiceSpec],
501 network_name: &str,
502 job_id: &str,
503 resources: &runner_protocol::Resources,
504 cancellation: &tokio_util::sync::CancellationToken,
505 deadline: tokio::time::Instant,
506 ) -> Result<Vec<String>> {
507 let mut ids = Vec::new();
508 for service in services {
509 if service.name.trim().is_empty()
510 || service.name.contains('/')
511 || service.name.contains(':')
512 {
513 return Err(anyhow!("invalid service name: {}", service.name));
514 }
515 if service.image.trim().is_empty() || service.image.contains(char::is_whitespace) {
516 return Err(anyhow!("invalid service image for {}", service.name));
517 }
518 if let Err(error) = self
519 .pull(&service.image, cancellation, deadline)
520 .await
521 .and_then(|termination| {
522 termination.map_or(Ok(()), |reason| {
523 Err(anyhow!(match reason {
524 ExecutionTermination::Cancelled => "execution cancelled",
525 ExecutionTermination::TimedOut => "execution timed out",
526 }))
527 })
528 })
529 {
530 self.remove_containers(&ids).await;
531 return Err(error);
532 }
533 let id = match self
534 .docker
535 .create_container(
536 Some(CreateContainerOptions::<String> {
537 name: format!("ai-kodu-runner-service-{}-{}", service.name, Uuid::new_v4()),
538 platform: Some(self.config.docker.platform.clone()),
539 }),
540 Config {
541 image: Some(service.image.clone()),
542 cmd: (!service.command.is_empty()).then(|| service.command.clone()),
543 env: Some(
544 service
545 .environment
546 .iter()
547 .map(|(k, v)| format!("{k}={v}"))
548 .collect(),
549 ),
550 networking_config: Some(NetworkingConfig {
551 endpoints_config: HashMap::from([(
552 network_name.to_string(),
553 EndpointSettings {
554 aliases: Some(vec![
555 service
556 .alias
557 .clone()
558 .unwrap_or_else(|| service.name.clone()),
559 ]),
560 ..Default::default()
561 },
562 )]),
563 }),
564 healthcheck: service.healthcheck.as_ref().map(|h| HealthConfig {
565 test: Some(
566 std::iter::once("CMD".to_string())
567 .chain(h.command.clone())
568 .collect(),
569 ),
570 ..Default::default()
571 }),
572 labels: Some(HashMap::from([
573 ("ai-kodu-runner.managed".into(), "true".into()),
574 ("ai-kodu-runner.runner_id".into(), self.config.runner_id()),
575 ("ai-kodu-runner.job_id".into(), job_id.to_string()),
576 ("ai-kodu-runner.service".into(), service.name.clone()),
577 ])),
578 host_config: Some(HostConfig {
579 memory: Some(resources.memory_mb * 1024 * 1024),
580 nano_cpus: Some((resources.cpu * 1_000_000_000.0) as i64),
581 pids_limit: Some(resources.pids),
582 security_opt: Some(vec!["no-new-privileges:true".into()]),
589 auto_remove: Some(false),
590 ..Default::default()
591 }),
592 ..Default::default()
593 },
594 )
595 .await
596 {
597 Ok(container) => container.id,
598 Err(error) => {
599 self.remove_containers(&ids).await;
600 return Err(error.into());
601 }
602 };
603 if let Err(error) = self
604 .docker
605 .start_container(&id, None::<StartContainerOptions<String>>)
606 .await
607 {
608 let _ = self.remove_container(&id).await;
609 self.remove_containers(&ids).await;
610 return Err(error.into());
611 }
612 ids.push(id);
613 }
614 for (service, id) in services.iter().zip(&ids) {
615 if let Some(healthcheck) = &service.healthcheck {
616 let health_deadline = tokio::time::Instant::now()
617 + std::time::Duration::from_secs(healthcheck.timeout_seconds);
618 loop {
619 let result = self
620 .exec_command(
621 id,
622 &CommandSpec {
623 command: healthcheck.command.clone(),
624 working_directory: None,
625 },
626 cancellation,
627 deadline.min(health_deadline),
628 )
629 .await?;
630 if let Some(termination) = result.termination {
631 self.remove_containers(&ids).await;
632 return Err(anyhow!(match termination {
633 ExecutionTermination::Cancelled => "execution cancelled",
634 ExecutionTermination::TimedOut => "execution timed out",
635 }));
636 }
637 let status = result.status;
638 let output_stdout = result.stdout;
639 let output_stderr = result.stderr;
640 if status == 0 {
641 break;
642 }
643 if tokio::time::Instant::now() >= health_deadline {
644 self.remove_containers(&ids).await;
645 return Err(anyhow!(
646 "service {} did not become healthy: stdout: {}\nstderr: {}",
647 service.name,
648 String::from_utf8_lossy(&output_stdout),
649 String::from_utf8_lossy(&output_stderr)
650 ));
651 }
652 tokio::time::sleep(std::time::Duration::from_millis(500)).await;
653 }
654 }
655 }
656 Ok(ids)
657 }
658
659 async fn remove_container(&self, id: &str) -> Result<()> {
660 self.docker
661 .remove_container(
662 id,
663 Some(RemoveContainerOptions {
664 force: true,
665 ..Default::default()
666 }),
667 )
668 .await?;
669 Ok(())
670 }
671
672 async fn remove_containers(&self, ids: &[String]) {
673 for id in ids {
674 let _ = self.remove_container(id).await;
675 }
676 }
677}
678
679#[cfg(test)]
680#[allow(clippy::items_after_test_module)]
681mod tests {
682 use super::{
683 DockerFailure, command_with_opencode_retry, headless_opencode_environment,
684 redact_diagnostic_bytes, redact_diagnostic_value, runtime_tmpfs, safe_feedback_path,
685 };
686 use runner_protocol::JobSpec;
687
688 #[test]
689 fn docker_failure_exposes_stable_code_and_safe_failure_info() {
690 let failure = DockerFailure::image_pull("registry token=secret");
691 assert_eq!(failure.code, "image_pull_failed");
692 assert_eq!(failure.phase, "image_pull");
693 let info = failure.failure_info();
694 assert_eq!(info.kind, runner_protocol::FailureKind::Infrastructure);
695 assert_eq!(info.code, "image_pull_failed");
696 }
697
698 #[test]
699 fn runtime_tmpfs_includes_writable_opencode_cache() {
700 let mounts = runtime_tmpfs();
701 let options = mounts
702 .get("/home/opencode/.cache/opencode")
703 .expect("OpenCode cache must be writable with a read-only root filesystem");
704
705 assert!(options.contains("rw"));
706 assert!(options.contains("uid=10001"));
707 assert!(options.contains("gid=10001"));
708 assert!(options.contains("mode=0700"));
709 }
710
711 #[test]
712 fn runtime_tmpfs_includes_all_opencode_persistent_state() {
713 let mounts = runtime_tmpfs();
714 let options = mounts
715 .get("/home/opencode/.local/share/opencode")
716 .expect("OpenCode logs, snapshots, and future state must be writable");
717
718 assert!(options.contains("rw"));
719 assert!(options.contains("uid=10001"));
720 assert!(options.contains("gid=10001"));
721 assert!(options.contains("size=1g"));
722 assert!(!mounts.contains_key("/home/opencode/.local/share/opencode/log"));
723 }
724
725 #[test]
726 fn runtime_tmpfs_gives_provider_install_enough_space() {
727 let mounts = runtime_tmpfs();
728 let options = mounts
729 .get("/home/opencode/.config/opencode")
730 .expect("OpenCode config directory must be writable");
731
732 assert!(options.contains("size=512m"));
733 }
734
735 #[test]
736 fn wraps_generated_opencode_command_with_transient_server_retry() {
737 let command = vec![
738 "bash".into(),
739 "-lc".into(),
740 "opencode run task | tee .ai-kodu-runner/results/opencode.json; exit ${PIPESTATUS[0]}"
741 .into(),
742 ];
743
744 let wrapped = command_with_opencode_retry(&command);
745
746 assert_eq!(wrapped.len(), 3);
747 assert!(wrapped[2].contains("Unexpected server error"));
748 assert_eq!(
749 wrapped[2]
750 .matches("opencode run --print-logs --log-level INFO task")
751 .count(),
752 2
753 );
754 }
755
756 #[test]
757 fn leaves_non_opencode_commands_unchanged() {
758 let command = vec!["bash".into(), "-lc".into(), "flutter test".into()];
759
760 assert_eq!(command_with_opencode_retry(&command), command);
761 }
762
763 #[test]
764 fn disables_title_agent_only_for_headless_opencode() {
765 let opencode = vec!["bash".into(), "-lc".into(), "opencode run task".into()];
766 let flutter = vec!["bash".into(), "-lc".into(), "flutter test".into()];
767
768 let opencode_env = headless_opencode_environment(Vec::new(), &opencode);
769 let flutter_env = headless_opencode_environment(Vec::new(), &flutter);
770
771 assert!(
772 opencode_env
773 .iter()
774 .any(|value| value.contains(r#""title":{"disable":true}"#))
775 );
776 assert!(
777 opencode_env
778 .iter()
779 .any(|value| value == "OPENCODE_DISABLE_MODELS_FETCH=true")
780 );
781 assert!(flutter_env.is_empty());
782 }
783
784 #[test]
785 fn redacts_nested_diagnostic_credentials() {
786 let mut value = serde_json::json!({
787 "workspace": { "token": "git-token", "username": "bot" },
788 "provider": { "apiKey": "api-key" },
789 "secrets": [{ "name": "MODEL_KEY", "value": "secret-value" }],
790 "workflow": { "agent": { "command": ["curl", "--header", "secret-value"] } }
791 });
792
793 redact_diagnostic_value(&mut value);
794
795 assert_eq!(value["workspace"]["token"], "<redacted>");
796 assert_eq!(value["provider"]["apiKey"], "<redacted>");
797 assert_eq!(value["secrets"][0]["value"], "<redacted>");
798 assert_eq!(
799 value["workflow"]["agent"]["command"][0],
800 "<redacted: command may contain credentials>"
801 );
802 assert_eq!(value["workspace"]["username"], "bot");
803 }
804
805 #[test]
806 fn diagnostic_output_redacts_known_secrets_and_is_bounded() {
807 let spec = JobSpec::from_json(
808 r#"{
809 "api_version":"ai-kodu-runner.dev/v1alpha1","id":"diag","attempt":0,
810 "executor":"docker","image":"example@sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
811 "command":["true"],"working_directory":"/workspace",
812 "workspace":{"kind":"local","path":"."},
813 "resources":{"cpu":1.0,"memory_mb":128,"pids":32,"timeout_seconds":60},
814 "network":{"mode":"none"},
815 "secrets":[{"name":"MODEL_KEY","value":"super-secret"}]
816 }"#,
817 )
818 .unwrap();
819 let output = redact_diagnostic_bytes(&spec, b"before super-secret after");
820 assert_eq!(
821 String::from_utf8(output).unwrap(),
822 "before <redacted> after"
823 );
824 let output = redact_diagnostic_bytes(&spec, &vec![b'x'; 70 * 1024]);
825 assert!(output.len() < 70 * 1024);
826 assert!(String::from_utf8_lossy(&output).contains("diagnostic output truncated"));
827 }
828
829 #[test]
830 fn feedback_path_is_contained_and_creates_safe_parent() {
831 let root = tempfile::tempdir().unwrap();
832 let feedback = safe_feedback_path(root.path(), "/workspace/.runner/feedback.md").unwrap();
833
834 assert_eq!(
835 feedback,
836 root.path()
837 .canonicalize()
838 .unwrap()
839 .join(".runner/feedback.md")
840 );
841 assert!(root.path().join(".runner").is_dir());
842 }
843
844 #[cfg(unix)]
845 #[test]
846 fn feedback_path_rejects_symlink_parent() {
847 let root = tempfile::tempdir().unwrap();
848 let outside = tempfile::tempdir().unwrap();
849 std::os::unix::fs::symlink(outside.path(), root.path().join(".runner")).unwrap();
850
851 assert!(safe_feedback_path(root.path(), "/workspace/.runner/feedback.md").is_err());
852 }
853}
854
855#[async_trait::async_trait]
856impl Executor for DockerExecutor {
857 fn capabilities(&self) -> ExecutorCapabilities {
858 ExecutorCapabilities::new(["artifacts", "cancellation", "network", "streaming_logs"])
859 }
860
861 async fn doctor(&self) -> Result<DoctorReport> {
862 self.docker.ping().await.context("Docker ping")?;
863 let version = self.docker.version().await?;
864 let test_name = format!("ai-kodu-runner-doctor-{}", Uuid::new_v4());
865 let test_id = self
866 .docker
867 .create_container(
868 Some(CreateContainerOptions::<String> {
869 name: test_name,
870 platform: Some(self.config.docker.platform.clone()),
871 }),
872 Config::<String> {
873 image: Some("alpine:3.20".into()),
874 cmd: Some(vec!["true".into()]),
875 ..Default::default()
876 },
877 )
878 .await?
879 .id;
880 self.docker
881 .remove_container(
882 &test_id,
883 Some(RemoveContainerOptions {
884 force: true,
885 ..Default::default()
886 }),
887 )
888 .await?;
889 Ok(DoctorReport {
890 executor: "docker".into(),
891 healthy: true,
892 capabilities: self.capabilities(),
893 checks: vec![
894 DoctorCheck {
895 name: "docker_ping".into(),
896 healthy: true,
897 message: "Docker Engine responded to ping".into(),
898 },
899 DoctorCheck {
900 name: "docker_version".into(),
901 healthy: true,
902 message: version
903 .version
904 .unwrap_or_else(|| "Docker version unavailable".into()),
905 },
906 DoctorCheck {
907 name: "test_container".into(),
908 healthy: true,
909 message: "Docker can create and remove a test container".into(),
910 },
911 ],
912 })
913 }
914
915 async fn run(
916 &self,
917 spec: JobSpec,
918 cancel: Option<tokio_util::sync::CancellationToken>,
919 ) -> Result<JobResult> {
920 let cancellation = cancel.unwrap_or_default();
921 self.begin_log_stream(&spec);
922 self.set_log_phase(if spec.workflow.is_some() {
923 "prepare"
924 } else {
925 "command"
926 });
927 info!(
928 job_id = %spec.id,
929 image = %spec.image,
930 network = %spec.network.mode,
931 platform = %self.config.docker.platform,
932 "job execution started"
933 );
934 let resources = policy::validate(&spec, &self.config, false)?;
935 let deadline =
936 tokio::time::Instant::now() + std::time::Duration::from_secs(resources.timeout_seconds);
937 if let Some(workflow) = spec.workflow.clone() {
938 return self
939 .run_workflow(spec, workflow, resources, cancellation, deadline)
940 .await;
941 }
942 let journal = Journal::open(&self.config.work_dir.join("runner.db"))?;
943 journal.transition(&spec.id, spec.attempt, state::State::Received)?;
944 journal.transition(&spec.id, spec.attempt, state::State::Preparing)?;
945 let prepared =
946 workspace::prepare(&spec.workspace, &self.config, &cancellation, deadline).await?;
947 if let Some(termination) = self.pull(&spec.image, &cancellation, deadline).await? {
948 return Err(anyhow!(match termination {
949 ExecutionTermination::Cancelled => "execution cancelled",
950 ExecutionTermination::TimedOut => "execution timed out",
951 }));
952 }
953 let network_name = format!("ai-kodu-runner-net-{}", Uuid::new_v4());
954 let network = if spec.network.mode == "bridge" {
955 Some(
956 self.docker
957 .create_network(CreateNetworkOptions {
958 name: network_name.clone(),
959 check_duplicate: true,
960 driver: "bridge".into(),
961 internal: false,
962 attachable: false,
963 labels: HashMap::from([
964 ("ai-kodu-runner.managed".into(), "true".into()),
965 ("ai-kodu-runner.runner_id".into(), self.config.runner_id()),
966 ("ai-kodu-runner.job_id".into(), spec.id.clone()),
967 ]),
968 ..Default::default()
969 })
970 .await?
971 .id,
972 )
973 } else {
974 None
975 };
976 let name = format!("ai-kodu-runner-job-{}", Uuid::new_v4());
977 let env = headless_opencode_environment(
978 policy::environment_for_job(&spec, &self.config)?,
979 &spec.command,
980 );
981 let labels = HashMap::from([
982 ("ai-kodu-runner.managed".into(), "true".into()),
983 ("ai-kodu-runner.runner_id".into(), self.config.runner_id()),
984 ("ai-kodu-runner.job_id".into(), spec.id.clone()),
985 ("ai-kodu-runner.attempt".into(), spec.attempt.to_string()),
986 (
987 "ai-kodu-runner.expires_at".into(),
988 (chrono::Utc::now() + chrono::Duration::seconds(resources.timeout_seconds as i64))
989 .to_rfc3339(),
990 ),
991 ]);
992 let host = HostConfig {
993 binds: Some(vec![format!(
994 "{}:/workspace",
995 prepared.dir.path().display()
996 )]),
997 network_mode: if network.is_some() {
998 Some(network_name.clone())
999 } else {
1000 Some("none".into())
1001 },
1002 memory: Some(resources.memory_mb * 1024 * 1024),
1003 nano_cpus: Some((resources.cpu * 1_000_000_000.0) as i64),
1004 pids_limit: Some(resources.pids),
1005 cap_drop: Some(vec!["ALL".into()]),
1006 security_opt: Some(vec!["no-new-privileges:true".into()]),
1007 readonly_rootfs: Some(!spec.writable_rootfs),
1008 tmpfs: Some(runtime_tmpfs()),
1009 auto_remove: Some(false),
1010 ..Default::default()
1011 };
1012 let id = match self
1013 .docker
1014 .create_container(
1015 Some(CreateContainerOptions {
1016 name,
1017 platform: Some(self.config.docker.platform.clone()),
1018 }),
1019 Config {
1020 image: Some(spec.image.clone()),
1021 cmd: Some(command_with_opencode_retry(&spec.command)),
1022 working_dir: Some(spec.working_directory.clone()),
1023 env: Some(env),
1024 host_config: Some(host),
1025 labels: Some(labels),
1026 tty: Some(false),
1027 open_stdin: Some(false),
1028 ..Default::default()
1029 },
1030 )
1031 .await
1032 {
1033 Ok(container) => container.id,
1034 Err(error) => {
1035 if let Some(network) = &network {
1036 let _ = self.docker.remove_network(network).await;
1037 }
1038 return Err(error.into());
1039 }
1040 };
1041 journal.transition(&spec.id, spec.attempt, state::State::Running)?;
1042 let started = Instant::now();
1043 if let Err(error) = self
1044 .docker
1045 .start_container(&id, None::<StartContainerOptions<String>>)
1046 .await
1047 {
1048 let _ = self
1049 .docker
1050 .remove_container(
1051 &id,
1052 Some(RemoveContainerOptions {
1053 force: true,
1054 ..Default::default()
1055 }),
1056 )
1057 .await;
1058 if let Some(network) = &network {
1059 let _ = self.docker.remove_network(network).await;
1060 }
1061 return Err(error.into());
1062 }
1063 let mut logs = self.docker.logs(
1064 &id,
1065 Some(LogsOptions::<String> {
1066 follow: true,
1067 stdout: true,
1068 stderr: true,
1069 since: 0,
1070 until: 0,
1071 timestamps: false,
1072 tail: "all".into(),
1073 }),
1074 );
1075 let mut log_bytes = 0u64;
1076 let mut truncated = false;
1077 let mut stdout = Vec::new();
1078 let mut stderr = Vec::new();
1079 let mut cancellation_requested = false;
1080 let mut timeout_requested = false;
1081 loop {
1082 let item = tokio::select! {
1083 _ = cancellation.cancelled() => {
1084 cancellation_requested = true;
1085 let _ = self
1086 .docker
1087 .stop_container(
1088 &id,
1089 Some(StopContainerOptions { t: 5 }),
1090 )
1091 .await;
1092 break;
1093 }
1094 _ = tokio::time::sleep_until(deadline) => {
1095 timeout_requested = true;
1096 let _ = self
1097 .docker
1098 .stop_container(
1099 &id,
1100 Some(StopContainerOptions { t: 5 }),
1101 )
1102 .await;
1103 break;
1104 }
1105 item = logs.next() => item,
1106 };
1107 let Some(item) = item else {
1108 break;
1109 };
1110 match item? {
1111 LogOutput::StdOut { message } => {
1112 if log_bytes < self.config.limits.max_log_bytes {
1113 let remaining = self.config.limits.max_log_bytes - log_bytes;
1114 let n = message.len().min(remaining as usize);
1115 print!("{}", String::from_utf8_lossy(&message[..n]));
1116 stdout.extend_from_slice(&message[..n]);
1117 self.emit_log("stdout", &message[..n]);
1118 log_bytes += n as u64;
1119 if n < message.len() {
1120 truncated = true;
1121 }
1122 } else {
1123 truncated = true;
1124 }
1125 }
1126 LogOutput::StdErr { message } => {
1127 if log_bytes < self.config.limits.max_log_bytes {
1128 let remaining = self.config.limits.max_log_bytes - log_bytes;
1129 let n = message.len().min(remaining as usize);
1130 eprint!("{}", String::from_utf8_lossy(&message[..n]));
1131 stderr.extend_from_slice(&message[..n]);
1132 self.emit_log("stderr", &message[..n]);
1133 log_bytes += n as u64;
1134 if n < message.len() {
1135 truncated = true;
1136 }
1137 } else {
1138 truncated = true;
1139 }
1140 }
1141 _ => {}
1142 }
1143 }
1144 let status = match self.docker.inspect_container(&id, None).await {
1145 Ok(container) => container
1146 .state
1147 .and_then(|state| state.exit_code)
1148 .unwrap_or(-1),
1149 Err(e) => {
1150 let _ = self
1151 .docker
1152 .remove_container(
1153 &id,
1154 Some(RemoveContainerOptions {
1155 force: true,
1156 ..Default::default()
1157 }),
1158 )
1159 .await;
1160 if let Some(n) = &network {
1161 let _ = self.docker.remove_network(n).await;
1162 }
1163 return Err(anyhow!("Docker container inspect failed for {id}: {e}"));
1164 }
1165 };
1166 journal.transition(&spec.id, spec.attempt, state::State::Collecting)?;
1167 let cancelled = cancellation_requested || cancellation.is_cancelled();
1168 let timed_out =
1169 !cancelled && (timeout_requested || tokio::time::Instant::now() >= deadline);
1170 let mut final_exit_code = status;
1171 let final_status = if cancelled {
1172 "cancelled"
1173 } else if timed_out {
1174 "timed_out"
1175 } else if status == 0 {
1176 "completed"
1177 } else {
1178 "failed"
1179 };
1180 let final_status = if final_status == "completed" {
1181 match workspace::publish_git(
1182 &spec.workspace,
1183 prepared.dir.path(),
1184 &cancellation,
1185 deadline,
1186 )
1187 .await
1188 {
1189 Ok(()) => final_status,
1190 Err(error) => {
1191 warn!(job_id=%spec.id, error=%error, "git publish failed");
1192 stderr.extend_from_slice(format!("git publish failed: {error}\n").as_bytes());
1193 final_exit_code = 1;
1194 "failed"
1195 }
1196 }
1197 } else {
1198 final_status
1199 };
1200 let mut artifact_patterns = spec.artifacts.clone();
1201 if final_status != "completed" {
1202 write_failure_diagnostics(
1203 prepared.dir.path(),
1204 &spec,
1205 Some("command"),
1206 &stdout,
1207 &stderr,
1208 )?;
1209 artifact_patterns.push(".runner/diagnostics/**".into());
1210 }
1211 let export_dir = (!artifact_patterns.is_empty())
1212 .then(|| artifacts::destination(&self.config.work_dir, &spec.id, spec.attempt));
1213 let files = match &export_dir {
1214 Some(dir) => artifacts::export(
1215 prepared.dir.path(),
1216 &artifact_patterns,
1217 dir,
1218 self.config.limits.max_artifact_bytes,
1219 self.config.limits.max_artifact_files,
1220 )?
1221 .unwrap_or_default(),
1222 None => Vec::new(),
1223 };
1224 journal.transition(&spec.id, spec.attempt, state::State::from_str(final_status))?;
1225 journal.transition(&spec.id, spec.attempt, state::State::Destroying)?;
1226 let image_id = self
1227 .docker
1228 .inspect_container(&id, None)
1229 .await
1230 .ok()
1231 .and_then(|x| x.image);
1232 let _ = self
1233 .docker
1234 .remove_container(
1235 &id,
1236 Some(RemoveContainerOptions {
1237 force: true,
1238 ..Default::default()
1239 }),
1240 )
1241 .await;
1242 if let Some(n) = network {
1243 let _ = self.docker.remove_network(&n).await;
1244 }
1245 journal.transition(&spec.id, spec.attempt, state::State::Destroyed)?;
1246 info!(job_id=%spec.id, status=%final_status, exit_code=final_exit_code,"job finished");
1247 Ok(JobResult {
1248 job_id: spec.id,
1249 attempt: spec.attempt,
1250 status: final_status.into(),
1251 exit_code: (!cancelled && !timed_out).then_some(final_exit_code),
1252 started_at: chrono::Utc::now().to_rfc3339(),
1253 finished_at: chrono::Utc::now().to_rfc3339(),
1254 duration_ms: started.elapsed().as_millis(),
1255 log_truncated: truncated || self.dropped_log_chunks.load(Ordering::Relaxed) > 0,
1256 stdout: String::from_utf8_lossy(&stdout).into_owned(),
1257 stderr: String::from_utf8_lossy(&stderr).into_owned(),
1258 error_summary: runner_protocol::error_summary(
1259 final_status,
1260 &String::from_utf8_lossy(&stdout),
1261 &String::from_utf8_lossy(&stderr),
1262 ),
1263 failure: (final_status != "completed").then(|| FailureInfo {
1264 kind: if final_status == "cancelled" {
1265 FailureKind::Cancellation
1266 } else if final_status == "timed_out" {
1267 FailureKind::Timeout
1268 } else {
1269 FailureKind::Execution
1270 },
1271 code: match final_status {
1272 "cancelled" => "cancelled",
1273 "timed_out" => "timeout",
1274 _ => "command_failed",
1275 }
1276 .into(),
1277 message: match final_status {
1278 "cancelled" => "execution cancelled",
1279 "timed_out" => "execution timed out",
1280 _ => "Docker command failed",
1281 }
1282 .into(),
1283 }),
1284 failed_phase: (final_status != "completed").then(|| "command".into()),
1285 artifacts: files,
1286 artifact_dir: export_dir.map(|path| path.to_string_lossy().into_owned()),
1287 sandbox: SandboxResult {
1288 executor: "docker".into(),
1289 container_id: id,
1290 image_id,
1291 },
1292 })
1293 }
1294 async fn cleanup(&self) -> Result<()> {
1295 let filters = HashMap::from([(
1296 "label".to_string(),
1297 vec![
1298 "ai-kodu-runner.managed=true".to_string(),
1299 format!("ai-kodu-runner.runner_id={}", self.config.runner_id()),
1300 ],
1301 )]);
1302 let items = self
1303 .docker
1304 .list_containers(Some(ListContainersOptions {
1305 all: true,
1306 filters,
1307 ..Default::default()
1308 }))
1309 .await?;
1310 let mut cleanup_error = None;
1311 let mut active_resources = false;
1312 for container in items {
1313 if container.state.as_deref() == Some("running") {
1314 active_resources = true;
1316 continue;
1317 }
1318 if let Some(id) = container.id
1319 && let Err(error) = self
1320 .docker
1321 .remove_container(
1322 &id,
1323 Some(RemoveContainerOptions {
1324 force: true,
1325 ..Default::default()
1326 }),
1327 )
1328 .await
1329 {
1330 cleanup_error.get_or_insert(error.to_string());
1331 }
1332 }
1333 let network_filters = HashMap::from([(
1334 "label".to_string(),
1335 vec![
1336 "ai-kodu-runner.managed=true".to_string(),
1337 format!("ai-kodu-runner.runner_id={}", self.config.runner_id()),
1338 ],
1339 )]);
1340 let networks = self
1341 .docker
1342 .list_networks(Some(ListNetworksOptions {
1343 filters: network_filters,
1344 }))
1345 .await?;
1346 for network in networks {
1347 if network
1348 .containers
1349 .as_ref()
1350 .is_some_and(|containers| !containers.is_empty())
1351 {
1352 active_resources = true;
1353 continue;
1354 }
1355 if let Some(id) = network.id
1356 && let Err(error) = self.docker.remove_network(&id).await
1357 {
1358 cleanup_error.get_or_insert(error.to_string());
1359 }
1360 }
1361 let journal = Journal::open(&self.config.work_dir.join("runner.db"))?;
1362 if let Some(error) = cleanup_error {
1363 bail!("cleanup incomplete: {error}")
1364 }
1365 if !active_resources {
1366 for (id, attempt) in journal.unfinished()? {
1367 journal.transition(&id, attempt, state::State::Destroying)?;
1368 journal.transition(&id, attempt, state::State::Destroyed)?;
1369 }
1370 }
1371 Ok(())
1372 }
1373}
1374
1375impl DockerExecutor {
1376 async fn exec_command(
1377 &self,
1378 container_id: &str,
1379 command: &CommandSpec,
1380 cancellation: &tokio_util::sync::CancellationToken,
1381 deadline: tokio::time::Instant,
1382 ) -> Result<ExecResult> {
1383 let exec = tokio::select! {
1384 _ = cancellation.cancelled() => {
1385 let _ = self.stop_container(container_id).await;
1386 return Ok(ExecResult {
1387 status: -1,
1388 stdout: Vec::new(),
1389 stderr: Vec::new(),
1390 truncated: false,
1391 termination: Some(ExecutionTermination::Cancelled),
1392 });
1393 }
1394 _ = tokio::time::sleep_until(deadline) => {
1395 let _ = self.stop_container(container_id).await;
1396 return Ok(ExecResult {
1397 status: -1,
1398 stdout: Vec::new(),
1399 stderr: Vec::new(),
1400 truncated: false,
1401 termination: Some(ExecutionTermination::TimedOut),
1402 });
1403 }
1404 result = self.docker.create_exec(
1405 container_id,
1406 CreateExecOptions::<String> {
1407 attach_stdout: Some(true),
1408 attach_stderr: Some(true),
1409 cmd: Some(command.command.clone()),
1410 working_dir: command.working_directory.clone(),
1411 ..Default::default()
1412 },
1413 ) => result?,
1414 };
1415 let mut stdout = Vec::new();
1416 let mut stderr = Vec::new();
1417 let mut truncated = false;
1418 let start = tokio::select! {
1419 _ = cancellation.cancelled() => {
1420 let _ = self.stop_container(container_id).await;
1421 return Ok(ExecResult {
1422 status: -1,
1423 stdout,
1424 stderr,
1425 truncated,
1426 termination: Some(ExecutionTermination::Cancelled),
1427 });
1428 }
1429 _ = tokio::time::sleep_until(deadline) => {
1430 let _ = self.stop_container(container_id).await;
1431 return Ok(ExecResult {
1432 status: -1,
1433 stdout,
1434 stderr,
1435 truncated,
1436 termination: Some(ExecutionTermination::TimedOut),
1437 });
1438 }
1439 result = self.docker.start_exec(
1440 &exec.id,
1441 Some(StartExecOptions {
1442 detach: false,
1443 tty: false,
1444 output_capacity: None,
1445 }),
1446 ) => result?,
1447 };
1448 match start {
1449 StartExecResults::Attached { mut output, .. } => loop {
1450 let item = tokio::select! {
1451 _ = cancellation.cancelled() => {
1452 let _ = self.stop_container(container_id).await;
1453 return Ok(ExecResult {
1454 status: -1,
1455 stdout,
1456 stderr,
1457 truncated,
1458 termination: Some(ExecutionTermination::Cancelled),
1459 });
1460 }
1461 _ = tokio::time::sleep_until(deadline) => {
1462 let _ = self.stop_container(container_id).await;
1463 return Ok(ExecResult {
1464 status: -1,
1465 stdout,
1466 stderr,
1467 truncated,
1468 termination: Some(ExecutionTermination::TimedOut),
1469 });
1470 }
1471 item = output.next() => item,
1472 };
1473 let Some(item) = item else {
1474 break;
1475 };
1476 match item? {
1477 LogOutput::StdOut { message } => {
1478 print!("{}", String::from_utf8_lossy(&message));
1479 self.emit_log("stdout", &message);
1480 truncated |=
1481 append_bounded(&mut stdout, &message, self.config.limits.max_log_bytes);
1482 }
1483 LogOutput::StdErr { message } => {
1484 eprint!("{}", String::from_utf8_lossy(&message));
1485 self.emit_log("stderr", &message);
1486 truncated |=
1487 append_bounded(&mut stderr, &message, self.config.limits.max_log_bytes);
1488 }
1489 _ => {}
1490 }
1491 },
1492 StartExecResults::Detached => {
1493 return Err(anyhow!("workflow exec unexpectedly detached"));
1494 }
1495 }
1496 let status = self
1497 .docker
1498 .inspect_exec(&exec.id)
1499 .await?
1500 .exit_code
1501 .unwrap_or(-1);
1502 Ok(ExecResult {
1503 status,
1504 stdout,
1505 stderr,
1506 truncated,
1507 termination: None,
1508 })
1509 }
1510
1511 async fn stop_container(&self, id: &str) -> Result<()> {
1512 self.docker
1513 .stop_container(id, Some(StopContainerOptions { t: 5 }))
1514 .await?;
1515 Ok(())
1516 }
1517
1518 async fn run_workflow(
1519 &self,
1520 spec: JobSpec,
1521 workflow: WorkflowSpec,
1522 resources: runner_protocol::Resources,
1523 cancellation: tokio_util::sync::CancellationToken,
1524 deadline: tokio::time::Instant,
1525 ) -> Result<JobResult> {
1526 let journal = Journal::open(&self.config.work_dir.join("runner.db"))?;
1527 journal.transition(&spec.id, spec.attempt, state::State::Received)?;
1528 journal.transition(&spec.id, spec.attempt, state::State::Preparing)?;
1529 let prepared =
1530 workspace::prepare(&spec.workspace, &self.config, &cancellation, deadline).await?;
1531 if let Some(termination) = self.pull(&spec.image, &cancellation, deadline).await? {
1532 return Err(anyhow!(match termination {
1533 ExecutionTermination::Cancelled => "execution cancelled",
1534 ExecutionTermination::TimedOut => "execution timed out",
1535 }));
1536 }
1537 let network_name = format!("ai-kodu-runner-net-{}", Uuid::new_v4());
1538 let network = if spec.network.mode == "bridge" {
1539 Some(
1540 self.docker
1541 .create_network(CreateNetworkOptions {
1542 name: network_name.clone(),
1543 check_duplicate: true,
1544 driver: "bridge".into(),
1545 internal: false,
1546 attachable: false,
1547 labels: HashMap::from([
1548 ("ai-kodu-runner.managed".into(), "true".into()),
1549 ("ai-kodu-runner.runner_id".into(), self.config.runner_id()),
1550 ("ai-kodu-runner.job_id".into(), spec.id.clone()),
1551 ]),
1552 ..Default::default()
1553 })
1554 .await?
1555 .id,
1556 )
1557 } else {
1558 None
1559 };
1560 let env = policy::environment_for_job(&spec, &self.config)?;
1561 let service_ids = if let Some(network_name) = network.as_deref() {
1562 match self
1563 .start_services(
1564 &workflow.services,
1565 network_name,
1566 &spec.id,
1567 &resources,
1568 &cancellation,
1569 deadline,
1570 )
1571 .await
1572 {
1573 Ok(ids) => ids,
1574 Err(error) => {
1575 if let Some(n) = &network {
1576 let _ = self.docker.remove_network(n).await;
1577 }
1578 return Err(error);
1579 }
1580 }
1581 } else if workflow.services.is_empty() {
1582 Vec::new()
1583 } else {
1584 if let Some(n) = &network {
1585 let _ = self.docker.remove_network(n).await;
1586 }
1587 return Err(anyhow!("workflow services require bridge networking"));
1588 };
1589 let host = HostConfig {
1590 binds: Some(vec![format!(
1591 "{}:/workspace",
1592 prepared.dir.path().display()
1593 )]),
1594 network_mode: if network.is_some() {
1595 Some(network_name)
1596 } else {
1597 Some("none".into())
1598 },
1599 memory: Some(resources.memory_mb * 1024 * 1024),
1600 nano_cpus: Some((resources.cpu * 1_000_000_000.0) as i64),
1601 pids_limit: Some(resources.pids),
1602 cap_drop: Some(vec!["ALL".into()]),
1603 security_opt: Some(vec!["no-new-privileges:true".into()]),
1604 readonly_rootfs: Some(!spec.writable_rootfs),
1605 tmpfs: Some(runtime_tmpfs()),
1606 auto_remove: Some(false),
1607 ..Default::default()
1608 };
1609 let id = match self
1610 .docker
1611 .create_container(
1612 Some(CreateContainerOptions::<String> {
1613 name: format!("ai-kodu-runner-job-{}", Uuid::new_v4()),
1614 platform: Some(self.config.docker.platform.clone()),
1615 }),
1616 Config::<String> {
1617 image: Some(spec.image.clone()),
1618 cmd: Some(vec!["sleep".into(), "infinity".into()]),
1619 env: Some(env),
1620 host_config: Some(host),
1621 labels: Some(HashMap::from([
1622 ("ai-kodu-runner.managed".into(), "true".into()),
1623 ("ai-kodu-runner.runner_id".into(), self.config.runner_id()),
1624 ("ai-kodu-runner.job_id".into(), spec.id.clone()),
1625 ("ai-kodu-runner.attempt".into(), spec.attempt.to_string()),
1626 ])),
1627 ..Default::default()
1628 },
1629 )
1630 .await
1631 {
1632 Ok(container) => container.id,
1633 Err(error) => {
1634 self.remove_containers(&service_ids).await;
1635 if let Some(network) = &network {
1636 let _ = self.docker.remove_network(network).await;
1637 }
1638 return Err(error.into());
1639 }
1640 };
1641 if let Err(error) = self
1642 .docker
1643 .start_container(&id, None::<StartContainerOptions<String>>)
1644 .await
1645 {
1646 let _ = self.remove_container(&id).await;
1647 self.remove_containers(&service_ids).await;
1648 if let Some(network) = &network {
1649 let _ = self.docker.remove_network(network).await;
1650 }
1651 return Err(error.into());
1652 }
1653 let feedback = safe_feedback_path(prepared.dir.path(), &workflow.feedback_file)?;
1654 std::fs::write(&feedback, "No verifier feedback yet.\n")?;
1655 journal.transition(&spec.id, spec.attempt, state::State::Running)?;
1656 let started = Instant::now();
1657 let mut final_status = "failed";
1658 let mut setup_ok = true;
1659 let mut failed_phase: Option<String> = None;
1660 let mut stdout = Vec::new();
1661 let mut stderr = Vec::new();
1662 let mut truncated = false;
1663 let mut termination = None;
1664 for setup in &workflow.setup {
1665 if cancellation.is_cancelled() || tokio::time::Instant::now() >= deadline {
1666 termination = Some(if cancellation.is_cancelled() {
1667 ExecutionTermination::Cancelled
1668 } else {
1669 ExecutionTermination::TimedOut
1670 });
1671 failed_phase = Some("setup".into());
1672 break;
1673 }
1674 self.set_log_phase("setup");
1675 let result = self
1676 .exec_command(&id, setup, &cancellation, deadline)
1677 .await?;
1678 truncated |= result.truncated;
1679 truncated |= append_bounded(
1680 &mut stdout,
1681 &result.stdout,
1682 self.config.limits.max_log_bytes,
1683 );
1684 truncated |= append_bounded(
1685 &mut stderr,
1686 &result.stderr,
1687 self.config.limits.max_log_bytes,
1688 );
1689 if let Some(reason) = result.termination {
1690 termination = Some(reason);
1691 failed_phase = Some("setup".into());
1692 break;
1693 }
1694 if result.status != 0 {
1695 setup_ok = false;
1696 failed_phase = Some("setup".into());
1697 }
1698 }
1699 if termination.is_none() && !setup_ok {
1700 std::fs::write(&feedback, "Workflow setup command failed.\n")?;
1701 }
1702 if termination.is_none()
1703 && !cancellation.is_cancelled()
1704 && setup_ok
1705 && let Some(initialize) = &workflow.initialize
1706 {
1707 self.set_log_phase("initialize");
1708 info!(job_id=%spec.id, "workflow agent context initialization started");
1709 let result = self
1710 .exec_command(&id, initialize, &cancellation, deadline)
1711 .await?;
1712 truncated |= result.truncated;
1713 truncated |= append_bounded(
1714 &mut stdout,
1715 &result.stdout,
1716 self.config.limits.max_log_bytes,
1717 );
1718 truncated |= append_bounded(
1719 &mut stderr,
1720 &result.stderr,
1721 self.config.limits.max_log_bytes,
1722 );
1723 if let Some(reason) = result.termination {
1724 termination = Some(reason);
1725 failed_phase = Some("initialize".into());
1726 } else if result.status != 0 {
1727 setup_ok = false;
1728 failed_phase = Some("initialize".into());
1729 std::fs::write(
1730 &feedback,
1731 format!(
1732 "Agent context initialization failed with exit code {}.\n{}",
1733 result.status,
1734 String::from_utf8_lossy(&result.stdout)
1735 ),
1736 )?;
1737 }
1738 }
1739 for iteration in 1..=workflow.max_iterations {
1740 if !setup_ok || cancellation.is_cancelled() || termination.is_some() {
1741 break;
1742 }
1743 info!(job_id=%spec.id, iteration, "workflow agent started");
1744 self.set_log_phase("agent");
1745 let agent = self
1746 .exec_command(&id, &workflow.agent, &cancellation, deadline)
1747 .await?;
1748 truncated |= agent.truncated;
1749 truncated |=
1750 append_bounded(&mut stdout, &agent.stdout, self.config.limits.max_log_bytes);
1751 truncated |=
1752 append_bounded(&mut stderr, &agent.stderr, self.config.limits.max_log_bytes);
1753 if let Some(reason) = agent.termination {
1754 termination = Some(reason);
1755 failed_phase = Some("agent".into());
1756 break;
1757 }
1758 let mut all_passed = agent.status == 0;
1759 if agent.status != 0 {
1760 failed_phase = Some("agent".into());
1761 }
1762 let mut report = format!("Iteration {iteration} agent exit code: {}\n", agent.status);
1763 for verifier in &workflow.verifiers {
1764 self.set_log_phase("verifier");
1765 let command = CommandSpec {
1766 command: verifier.command.clone(),
1767 working_directory: verifier.working_directory.clone(),
1768 };
1769 let result = self
1770 .exec_command(&id, &command, &cancellation, deadline)
1771 .await?;
1772 truncated |= result.truncated;
1773 truncated |= append_bounded(
1774 &mut stdout,
1775 &result.stdout,
1776 self.config.limits.max_log_bytes,
1777 );
1778 truncated |= append_bounded(
1779 &mut stderr,
1780 &result.stderr,
1781 self.config.limits.max_log_bytes,
1782 );
1783 if let Some(reason) = result.termination {
1784 termination = Some(reason);
1785 failed_phase = Some("verifier".into());
1786 break;
1787 }
1788 report.push_str(&format!(
1789 "\nVerifier {} exit code: {}\nstdout:\n{}\nstderr:\n{}\n",
1790 verifier.name,
1791 result.status,
1792 String::from_utf8_lossy(&result.stdout),
1793 String::from_utf8_lossy(&result.stderr)
1794 ));
1795 if verifier.required && result.status != 0 {
1796 all_passed = false;
1797 failed_phase = Some("verifier".into());
1798 }
1799 }
1800 for verifier in &self.config.security.mandatory_verifiers {
1801 if termination.is_some() {
1802 break;
1803 }
1804 self.set_log_phase("verifier");
1805 let result = self
1806 .exec_command(&id, verifier, &cancellation, deadline)
1807 .await?;
1808 truncated |= result.truncated;
1809 truncated |= append_bounded(
1810 &mut stdout,
1811 &result.stdout,
1812 self.config.limits.max_log_bytes,
1813 );
1814 truncated |= append_bounded(
1815 &mut stderr,
1816 &result.stderr,
1817 self.config.limits.max_log_bytes,
1818 );
1819 if let Some(reason) = result.termination {
1820 termination = Some(reason);
1821 failed_phase = Some("verifier".into());
1822 break;
1823 }
1824 report.push_str(&format!(
1825 "\nMandatory verifier {:?} exit code: {}\nstdout:\n{}\nstderr:\n{}\n",
1826 verifier.command,
1827 result.status,
1828 String::from_utf8_lossy(&result.stdout),
1829 String::from_utf8_lossy(&result.stderr)
1830 ));
1831 if result.status != 0 {
1832 all_passed = false;
1833 failed_phase = Some("verifier".into());
1834 }
1835 }
1836 if termination.is_none()
1837 && all_passed
1838 && let Some(publish) = &workflow.publish
1839 {
1840 self.set_log_phase("publish");
1841 let result = self
1842 .exec_command(&id, publish, &cancellation, deadline)
1843 .await?;
1844 truncated |= result.truncated;
1845 truncated |= append_bounded(
1846 &mut stdout,
1847 &result.stdout,
1848 self.config.limits.max_log_bytes,
1849 );
1850 truncated |= append_bounded(
1851 &mut stderr,
1852 &result.stderr,
1853 self.config.limits.max_log_bytes,
1854 );
1855 if let Some(reason) = result.termination {
1856 termination = Some(reason);
1857 failed_phase = Some("publish".into());
1858 } else {
1859 all_passed = result.status == 0;
1860 }
1861 if !all_passed && termination.is_none() {
1862 failed_phase = Some("publish".into());
1863 }
1864 }
1865 if termination.is_none() && all_passed {
1866 final_status = "completed";
1867 break;
1868 }
1869 std::fs::write(&feedback, report)?;
1870 if iteration == workflow.max_iterations {
1871 break;
1872 }
1873 info!(job_id=%spec.id, next_iteration=iteration + 1, "verifier feedback written");
1874 }
1875 if termination.is_none() && cancellation.is_cancelled() {
1876 termination = Some(ExecutionTermination::Cancelled);
1877 }
1878 if termination.is_none() && tokio::time::Instant::now() >= deadline {
1879 termination = Some(ExecutionTermination::TimedOut);
1880 }
1881 if let Some(reason) = termination {
1882 final_status = match reason {
1883 ExecutionTermination::Cancelled => "cancelled",
1884 ExecutionTermination::TimedOut => "timed_out",
1885 };
1886 failed_phase = Some(final_status.into());
1887 let _ = self
1888 .docker
1889 .stop_container(&id, Some(StopContainerOptions { t: 5 }))
1890 .await;
1891 }
1892 journal.transition(&spec.id, spec.attempt, state::State::Collecting)?;
1893 let mut artifact_patterns = spec.artifacts.clone();
1894 if final_status != "completed" {
1895 write_failure_diagnostics(
1896 prepared.dir.path(),
1897 &spec,
1898 failed_phase.as_deref(),
1899 &stdout,
1900 &stderr,
1901 )?;
1902 artifact_patterns.push(".runner/diagnostics/**".into());
1903 }
1904 let export_dir = (!artifact_patterns.is_empty())
1905 .then(|| artifacts::destination(&self.config.work_dir, &spec.id, spec.attempt));
1906 let files = match &export_dir {
1907 Some(dir) => artifacts::export(
1908 prepared.dir.path(),
1909 &artifact_patterns,
1910 dir,
1911 self.config.limits.max_artifact_bytes,
1912 self.config.limits.max_artifact_files,
1913 )?
1914 .unwrap_or_default(),
1915 None => Vec::new(),
1916 };
1917 journal.transition(&spec.id, spec.attempt, state::State::from_str(final_status))?;
1918 journal.transition(&spec.id, spec.attempt, state::State::Destroying)?;
1919 let image_id = self
1920 .docker
1921 .inspect_container(&id, None)
1922 .await
1923 .ok()
1924 .and_then(|x| x.image);
1925 let _ = self
1926 .docker
1927 .remove_container(
1928 &id,
1929 Some(RemoveContainerOptions {
1930 force: true,
1931 ..Default::default()
1932 }),
1933 )
1934 .await;
1935 self.remove_containers(&service_ids).await;
1936 if let Some(n) = network {
1937 let _ = self.docker.remove_network(&n).await;
1938 }
1939 journal.transition(&spec.id, spec.attempt, state::State::Destroyed)?;
1940 Ok(JobResult {
1941 job_id: spec.id,
1942 attempt: spec.attempt,
1943 status: final_status.into(),
1944 exit_code: (final_status != "cancelled" && final_status != "timed_out")
1945 .then_some(if final_status == "completed" { 0 } else { 1 }),
1946 started_at: chrono::Utc::now().to_rfc3339(),
1947 finished_at: chrono::Utc::now().to_rfc3339(),
1948 duration_ms: started.elapsed().as_millis(),
1949 log_truncated: truncated || self.dropped_log_chunks.load(Ordering::Relaxed) > 0,
1950 stdout: String::from_utf8_lossy(&stdout).into_owned(),
1951 stderr: String::from_utf8_lossy(&stderr).into_owned(),
1952 error_summary: runner_protocol::error_summary(
1953 final_status,
1954 &String::from_utf8_lossy(&stdout),
1955 &String::from_utf8_lossy(&stderr),
1956 ),
1957 failure: (final_status != "completed").then(|| FailureInfo {
1958 kind: match final_status {
1959 "cancelled" => FailureKind::Cancellation,
1960 "timed_out" => FailureKind::Timeout,
1961 _ => FailureKind::Execution,
1962 },
1963 code: match final_status {
1964 "cancelled" => "cancelled",
1965 "timed_out" => "timeout",
1966 _ => "workflow_failed",
1967 }
1968 .into(),
1969 message: match final_status {
1970 "cancelled" => "execution cancelled",
1971 "timed_out" => "execution timed out",
1972 _ => "workflow failed",
1973 }
1974 .into(),
1975 }),
1976 failed_phase: (final_status != "completed")
1977 .then(|| failed_phase.unwrap_or_else(|| "workflow".into())),
1978 artifacts: files,
1979 artifact_dir: export_dir.map(|path| path.to_string_lossy().into_owned()),
1980 sandbox: SandboxResult {
1981 executor: "docker".into(),
1982 container_id: id,
1983 image_id,
1984 },
1985 })
1986 }
1987}