Skip to main content

executor_docker/
lib.rs

1// Copyright 2026 Kotelnikovekb
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://apache.org
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14use 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/// Stable, public classification for failures originating in the Docker
46/// backend. The human-readable message is diagnostic only; callers should use
47/// `code` and `phase` for policy, retry and billing decisions.
48#[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    // Command output and agent context may contain credentials or source code.
200    // Keep only bounded, value-redacted diagnostics; never copy prompt/AGENTS/
201    // feedback files into exported artifacts.
202    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
216/// Writable, ephemeral runtime directories for images that use a read-only
217/// root filesystem. The uid/gid match the standard `opencode` user used by
218/// the bundled tool images.
219fn 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    // Session titles are irrelevant for headless jobs. Disabling the hidden
294    // title agent also avoids a second concurrent request through custom
295    // OpenAI-compatible providers.
296    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(&current) {
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(&current)?;
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                            // Some official service images (for example Redis,
583                            // Postgres and MySQL) start as root and use setpriv
584                            // to drop to their service user. Dropping every
585                            // capability prevents that entrypoint transition.
586                            // The application container keeps the stricter
587                            // cap_drop=ALL profile below.
588                            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                // Manual cleanup must not interrupt a currently leased job.
1315                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}