Remove the in-process docker executor
The orchestrator no longer dispatches `(sh …)` via `docker exec` —
production has run host-only since the previous executor flip, and
the workspace-split plan replaces the in-process docker path with
`quire-ci eval` running inside the run container. Ripping the dead
path now decouples `runtime` from the orchestrator (no more
`DockerLifecycle` backref), unblocking the move of `runtime` and
`mirror` into `quire-core`.

What goes:
- `ci::docker` (the docker CLI wrapper module).
- `Executor` enum and the `--executor` CLI flag — there's only one
  mode now.
- `ExecutorRuntime`, `DockerLifecycle`, `Cmd::wrap_in_docker_exec`,
  and the per-run container build path in `Run::execute`.
- The `DockerUnavailable` / `DockerfileMissing` / `ImageBuildFailed`
  / `ContainerStartFailed` error variants.
- The docker-mode integration test and the docker-tag sanitization
  helper.

What stays (handled in follow-ups):
- `(ci.image …)` registration parses but the result is unused until
  stage 3 reintroduces the per-run container.
- The `runs` schema still has `image_tag` / `container_id` /
  `*_at_ms` columns; rows just never populate them now. Worth a
  cleanup migration once the new container shape is settled.

Assisted-by: Claude Opus 4.7 (1M context) via Claude Code
change uqqlwmpuqywlrwozotypytrnwvntmnty
commit f1bcfdec83883fd1b32410410f5a338117a8a20e
author Alpha Chen <alpha@kejadlen.dev>
date
parent zvmpxxls
diff --git a/quire-server/src/bin/quire/commands/ci.rs b/quire-server/src/bin/quire/commands/ci.rs
index 5382cdb..193ab4f 100644
--- a/quire-server/src/bin/quire/commands/ci.rs
+++ b/quire-server/src/bin/quire/commands/ci.rs
@@ -2,7 +2,7 @@ use std::path::PathBuf;
 
 use miette::{IntoDiagnostic, Result};
 use quire::Quire;
-use quire::ci::{Ci, CommitRef, Executor, RunMeta, Runs};
+use quire::ci::{Ci, CommitRef, RunMeta, Runs};
 
 /// Validate a repo's ci.fnl without executing any jobs.
 ///
@@ -41,7 +41,7 @@ pub async fn validate(maybe_sha: Option<&str>) -> Result<()> {
 /// default), creates a transient Run rooted at a tempdir, drives the
 /// pipeline through it, and prints each job's `(ci.sh …)` output to
 /// stdout. The tempdir is removed when the command exits.
-pub async fn run(quire: &Quire, maybe_sha: Option<&str>, executor: Executor) -> Result<()> {
+pub async fn run(quire: &Quire, maybe_sha: Option<&str>) -> Result<()> {
     let repo_path = discover_repo()?;
     let commit = resolve_commit(maybe_sha)?;
     let ci = Ci::new(repo_path.clone());
@@ -88,13 +88,7 @@ pub async fn run(quire: &Quire, maybe_sha: Option<&str>, executor: Executor) ->
     let workspace = tmp.path().join("workspace");
     quire::ci::materialize_workspace(&repo_path.join(".git"), &commit.sha, &workspace)
         .into_diagnostic()?;
-    let exec_result = run.execute(
-        pipeline,
-        secrets,
-        &repo_path.join(".git"),
-        &workspace,
-        executor,
-    );
+    let exec_result = run.execute(pipeline, secrets, &repo_path.join(".git"), &workspace);
 
     match exec_result {
         Ok(outputs) => {
diff --git a/quire-server/src/bin/quire/main.rs b/quire-server/src/bin/quire/main.rs
index 3bf1c97..25e0c83 100644
--- a/quire-server/src/bin/quire/main.rs
+++ b/quire-server/src/bin/quire/main.rs
@@ -103,30 +103,9 @@ enum CiCommands {
         /// Commit SHA to run. Defaults to the working-copy revision.
         #[arg(short, long)]
         sha: Option<String>,
-
-        /// Where to run `(sh ...)` calls. `host` runs them locally in the
-        /// materialized workspace; `docker` builds `.quire/Dockerfile` and
-        /// routes commands through `docker exec`.
-        #[arg(long, value_enum, default_value = "host")]
-        executor: CliExecutor,
     },
 }
 
-#[derive(Clone, Debug, clap::ValueEnum)]
-enum CliExecutor {
-    Host,
-    Docker,
-}
-
-impl From<CliExecutor> for quire::ci::Executor {
-    fn from(value: CliExecutor) -> Self {
-        match value {
-            CliExecutor::Host => quire::ci::Executor::Host,
-            CliExecutor::Docker => quire::ci::Executor::Docker,
-        }
-    }
-}
-
 /// Initialize Sentry if the global config provides a DSN.
 ///
 /// Returns the guard if initialized, or None if Sentry is not configured.
@@ -237,9 +216,7 @@ async fn main() -> Result<()> {
         },
         Commands::Ci { command } => match command {
             CiCommands::Validate { sha } => commands::ci::validate(sha.as_deref()).await?,
-            CiCommands::Run { sha, executor } => {
-                commands::ci::run(&quire, sha.as_deref(), executor.into()).await?
-            }
+            CiCommands::Run { sha } => commands::ci::run(&quire, sha.as_deref()).await?,
         },
     }
 
diff --git a/quire-server/src/ci/docker.rs b/quire-server/src/ci/docker.rs
deleted file mode 100644
index e23d34a..0000000
--- a/quire-server/src/ci/docker.rs
+++ /dev/null
@@ -1,198 +0,0 @@
-//! Shell-out helpers for the docker subsystem.
-
-use std::path::Path;
-
-use super::error::{Error, Result};
-
-/// Returns true iff the docker daemon is reachable. Used to gate
-/// integration tests; calls `docker info` and treats any failure
-/// (binary missing, daemon down, permissions) as unavailable.
-pub(crate) fn is_available() -> bool {
-    std::process::Command::new("docker")
-        .arg("info")
-        .stdout(std::process::Stdio::null())
-        .stderr(std::process::Stdio::null())
-        .status()
-        .map(|s| s.success())
-        .unwrap_or(false)
-}
-
-/// Build the per-run image. Runs `docker build --file <dockerfile>
-/// --tag <tag> <context>`. Failures (including a missing Dockerfile)
-/// surface as `Error::ImageBuildFailed` carrying docker's stderr.
-pub(crate) fn docker_build(dockerfile: &Path, context: &Path, tag: &str) -> Result<()> {
-    let output = std::process::Command::new("docker")
-        .arg("build")
-        .arg("--file")
-        .arg(dockerfile)
-        .arg("--tag")
-        .arg(tag)
-        .arg(context)
-        .output()
-        .map_err(|e| Error::ImageBuildFailed { source: e })?;
-    if !output.status.success() {
-        return Err(Error::ImageBuildFailed {
-            source: std::io::Error::other(String::from_utf8_lossy(&output.stderr).into_owned()),
-        });
-    }
-    Ok(())
-}
-
-/// A running container owned by a quire run. Started via [`start`];
-/// stopped via [`Drop`]. The `--rm` flag on `docker run` removes the
-/// container record once stop completes, so callers don't need to
-/// manage `docker rm` separately.
-///
-/// `Drop` swallows errors from `docker stop` because `Drop` cannot
-/// return `Result`. Failures are logged via `tracing::error!`; orphan
-/// reconciliation handles anything that survives.
-pub(crate) struct ContainerSession {
-    pub(crate) container_id: String,
-    pub(crate) container_started_at: jiff::Timestamp,
-}
-
-impl ContainerSession {
-    /// Start a long-lived container from `image_tag` with `mount_source`
-    /// bind-mounted at `mount_target` inside the container, with
-    /// `mount_target` as the working directory. Captures the container
-    /// ID. Failures surface as `Error::ContainerStartFailed`.
-    pub(crate) fn start(image_tag: &str, mount_source: &Path, mount_target: &str) -> Result<Self> {
-        let mount = format!(
-            "type=bind,src={},dst={}",
-            mount_source.to_string_lossy(),
-            mount_target,
-        );
-        let output = std::process::Command::new("docker")
-            .args(["run", "--detach", "--rm", "--mount"])
-            .arg(&mount)
-            // 15-minute ceiling. Real runs end when
-            // `ContainerSession::drop` calls `docker stop`; this is
-            // the safety net for cases where Drop never fires (orphaned
-            // process, killed parent).
-            .args(["--workdir", mount_target, image_tag, "sleep", "15m"])
-            .output()
-            .map_err(|e| Error::ContainerStartFailed { source: e })?;
-        if !output.status.success() {
-            return Err(Error::ContainerStartFailed {
-                source: std::io::Error::other(String::from_utf8_lossy(&output.stderr).into_owned()),
-            });
-        }
-        let container_id = String::from_utf8_lossy(&output.stdout).trim().to_string();
-        Ok(Self {
-            container_id,
-            container_started_at: jiff::Timestamp::now(),
-        })
-    }
-}
-
-impl Drop for ContainerSession {
-    fn drop(&mut self) {
-        let result = std::process::Command::new("docker")
-            .args(["stop", "--time", "5"])
-            .arg(&self.container_id)
-            .output();
-        match result {
-            Ok(out) if out.status.success() => {}
-            Ok(out) => tracing::error!(
-                container_id = %self.container_id,
-                stderr = %String::from_utf8_lossy(&out.stderr),
-                "docker stop returned non-zero"
-            ),
-            Err(e) => tracing::error!(
-                container_id = %self.container_id,
-                error = %e,
-                "docker stop failed"
-            ),
-        }
-    }
-}
-
-#[cfg(test)]
-mod tests {
-    use super::*;
-
-    #[test]
-    #[ignore = "requires docker"]
-    fn docker_build_succeeds_with_minimal_dockerfile() {
-        if !is_available() {
-            return;
-        }
-        let dir = tempfile::tempdir().expect("tempdir");
-        let context = dir.path();
-        let dockerfile = context.join("Dockerfile");
-        fs_err::write(&dockerfile, "FROM alpine:3.19\nRUN echo built\n").expect("write Dockerfile");
-
-        let tag = "quire-ci/test-task5:test";
-        docker_build(&dockerfile, context, tag).expect("build should succeed");
-
-        // Cleanup — best effort; ignore failures.
-        let _ = std::process::Command::new("docker")
-            .args(["image", "rm", tag])
-            .output();
-    }
-
-    #[test]
-    #[ignore = "requires docker"]
-    fn docker_build_errors_on_bad_dockerfile() {
-        if !is_available() {
-            return;
-        }
-        let dir = tempfile::tempdir().expect("tempdir");
-        let context = dir.path();
-        let dockerfile = context.join("Dockerfile");
-        fs_err::write(&dockerfile, "GARBAGE\n").expect("write");
-
-        let err = docker_build(&dockerfile, context, "quire-ci/test-task5-bad:test")
-            .expect_err("should fail");
-        assert!(matches!(err, Error::ImageBuildFailed { .. }));
-    }
-
-    #[test]
-    #[ignore = "requires docker"]
-    fn container_session_start_returns_id() {
-        if !is_available() {
-            return;
-        }
-        let dir = tempfile::tempdir().expect("tempdir");
-
-        let session = ContainerSession::start("alpine:3.19", dir.path(), "/work")
-            .expect("start should succeed");
-
-        assert!(!session.container_id.is_empty());
-        // Docker container IDs from `docker run` are 64-char SHA256 hex.
-        assert_eq!(
-            session.container_id.len(),
-            64,
-            "got: {}",
-            session.container_id
-        );
-        // session drops here; docker stop runs.
-    }
-
-    #[test]
-    #[ignore = "requires docker"]
-    fn container_session_drop_stops_container() {
-        if !is_available() {
-            return;
-        }
-        let dir = tempfile::tempdir().expect("tempdir");
-        let id = {
-            let session = ContainerSession::start("alpine:3.19", dir.path(), "/work")
-                .expect("start should succeed");
-            session.container_id.clone()
-        }; // session drops here
-
-        // Give the daemon a moment to settle the stop.
-        std::thread::sleep(std::time::Duration::from_millis(500));
-        let out = std::process::Command::new("docker")
-            .args(["ps", "-a", "--quiet", "--filter"])
-            .arg(format!("id={id}"))
-            .output()
-            .expect("docker ps");
-        assert!(
-            String::from_utf8_lossy(&out.stdout).trim().is_empty(),
-            "container should be removed after drop, but ps shows: {}",
-            String::from_utf8_lossy(&out.stdout),
-        );
-    }
-}
diff --git a/quire-server/src/ci/error.rs b/quire-server/src/ci/error.rs
index e35b4d5..e4675a1 100644
--- a/quire-server/src/ci/error.rs
+++ b/quire-server/src/ci/error.rs
@@ -34,30 +34,12 @@ pub enum Error {
         source: Box<Error>,
     },
 
-    #[error("docker is not available — install docker and ensure the daemon is running")]
-    DockerUnavailable,
-
-    #[error("missing .quire/Dockerfile")]
-    DockerfileMissing,
-
     #[error("workspace materialization failed")]
     WorkspaceMaterializationFailed {
         #[source]
         source: std::io::Error,
     },
 
-    #[error("image build failed")]
-    ImageBuildFailed {
-        #[source]
-        source: std::io::Error,
-    },
-
-    #[error("container start failed")]
-    ContainerStartFailed {
-        #[source]
-        source: std::io::Error,
-    },
-
     #[error("git error: {0}")]
     Git(String),
 
diff --git a/quire-server/src/ci/mirror.rs b/quire-server/src/ci/mirror.rs
index 3fedcc4..a71097d 100644
--- a/quire-server/src/ci/mirror.rs
+++ b/quire-server/src/ci/mirror.rs
@@ -236,7 +236,7 @@ mod tests {
 
     use crate::ci::pipeline::{Diagnostic, RustRunFn, compile};
     use crate::ci::run::RunMeta;
-    use crate::ci::runtime::{ExecutorRuntime, RuntimeHandle};
+    use crate::ci::runtime::RuntimeHandle;
     use quire_core::secret::{Error as SecretError, SecretString};
 
     /// Set up a bare git repo with one commit. Returns the tempdir,
@@ -446,7 +446,6 @@ mod tests {
             meta,
             git_dir,
             std::env::current_dir().expect("cwd"),
-            ExecutorRuntime::Host,
         ));
         let _ = RuntimeHandle(runtime.clone())
             .into_lua(runtime.lua())
diff --git a/quire-server/src/ci/mod.rs b/quire-server/src/ci/mod.rs
index 1f7a996..cf8f36b 100644
--- a/quire-server/src/ci/mod.rs
+++ b/quire-server/src/ci/mod.rs
@@ -2,7 +2,6 @@
 
 use std::collections::HashMap;
 
-pub(crate) mod docker;
 pub(crate) mod logs;
 mod mirror;
 mod pipeline;
@@ -14,7 +13,7 @@ pub(crate) mod error;
 
 pub use error::{Error, Result};
 pub use pipeline::{DefinitionError, Diagnostic, Job, Pipeline, PipelineError, StructureError};
-pub use run::{Executor, Run, RunMeta, RunState, Runs, materialize_workspace, reconcile_orphans};
+pub use run::{Run, RunMeta, RunState, Runs, materialize_workspace, reconcile_orphans};
 
 /// A resolved commit reference.
 ///
@@ -132,14 +131,7 @@ pub fn trigger(quire: &crate::Quire, event: &PushEvent) {
 
     let db_path = quire.db_path();
     for push_ref in event.updated_refs() {
-        if let Err(e) = trigger_ref(
-            &repo,
-            &db_path,
-            event.pushed_at,
-            push_ref,
-            &secrets,
-            run::Executor::Host,
-        ) {
+        if let Err(e) = trigger_ref(&repo, &db_path, event.pushed_at, push_ref, &secrets) {
             tracing::error!(
                 repo = %event.repo,
                 sha = %push_ref.new_sha, // cov-excl-line
@@ -157,7 +149,6 @@ fn trigger_ref(
     pushed_at: jiff::Timestamp,
     push_ref: &PushRef,
     secrets: &HashMap<String, quire_core::secret::SecretString>,
-    executor: run::Executor,
 ) -> error::Result<()> {
     let ci = repo.ci();
 
@@ -191,13 +182,7 @@ fn trigger_ref(
 
     let workspace = run.path().join("workspace");
     run::materialize_workspace(&repo.path(), &push_ref.new_sha, &workspace)?;
-    run.execute(
-        pipeline,
-        secrets.clone(),
-        &repo.path(),
-        &workspace,
-        executor,
-    )?;
+    run.execute(pipeline, secrets.clone(), &repo.path(), &workspace)?;
     Ok(())
 }
 
@@ -374,7 +359,6 @@ mod tests {
             pushed_at,
             &push_ref,
             &HashMap::new(),
-            run::Executor::Host,
         )
         .expect("trigger_ref should succeed");
 
@@ -408,7 +392,6 @@ mod tests {
             pushed_at,
             &push_ref,
             &HashMap::new(),
-            run::Executor::Host,
         )
         .expect("should succeed without ci.fnl");
     }
@@ -432,7 +415,6 @@ mod tests {
             pushed_at,
             &push_ref,
             &HashMap::new(),
-            run::Executor::Host,
         );
         assert!(result.is_err(), "invalid pipeline should fail");
     }
diff --git a/quire-server/src/ci/run.rs b/quire-server/src/ci/run.rs
index 00cc0f4..08c81e1 100644
--- a/quire-server/src/ci/run.rs
+++ b/quire-server/src/ci/run.rs
@@ -14,61 +14,9 @@ use mlua::IntoLua;
 
 use super::error::{Error, Result};
 use super::pipeline::{Pipeline, RunFn};
-use super::runtime::{ExecutorRuntime, Runtime, RuntimeHandle, ShOutput};
-use crate::display_chain;
+use super::runtime::{Runtime, RuntimeHandle, ShOutput};
 use quire_core::secret::SecretString;
 
-/// The execution mode for a run. Host runs `sh` directly on the host.
-/// Docker materializes a container and routes `sh` through `docker exec`.
-#[derive(Debug, Clone)]
-pub enum Executor {
-    Host,
-    Docker,
-}
-
-/// Owns the per-run container session alongside the database path
-/// so [`Drop`] can stamp `container_stopped_at` *before* the session
-/// itself drops and fires `docker stop`.
-///
-/// Field declaration order matters: `session` is declared first so it
-/// drops first after this struct's custom `Drop` body returns. The
-/// effect is: write `container_stopped_at` → drop `session` →
-/// `docker stop`.
-pub(super) struct DockerLifecycle {
-    pub(super) session: crate::ci::docker::ContainerSession,
-    db_path: PathBuf,
-    run_id: String,
-    pub(super) work_dir: String,
-}
-
-impl Drop for DockerLifecycle {
-    fn drop(&mut self) {
-        // Stamp `container_stopped_at` before ContainerSession's Drop
-        // (`docker stop`) fires. Errors are logged and swallowed —
-        // Drop cannot return Result.
-        match crate::db::open(&self.db_path) {
-            Ok(conn) => {
-                let now = Timestamp::now().as_millisecond();
-                if let Err(e) = conn.execute(
-                    "UPDATE runs SET container_stopped_at_ms = ?1 WHERE id = ?2",
-                    rusqlite::params![now, &self.run_id],
-                ) {
-                    tracing::error!(
-                        error = %display_chain(&Error::from(e)),
-                        "failed to write container_stopped_at"
-                    );
-                }
-            }
-            Err(e) => tracing::error!(
-                error = %display_chain(&e),
-                "failed to open db before container stop"
-            ),
-        }
-        // After this body returns, fields drop in declaration order:
-        // `session` drops first → ContainerSession::Drop → docker stop.
-    }
-}
-
 /// The state of a CI run.
 #[derive(Clone, Copy, Debug, PartialEq, Eq)]
 pub enum RunState {
@@ -268,33 +216,17 @@ impl Run {
         secrets: HashMap<String, SecretString>,
         git_dir: &std::path::Path,
         workspace: &std::path::Path,
-        executor: Executor,
     ) -> Result<HashMap<String, Vec<ShOutput>>> {
         let meta = self.read_meta()?;
 
-        // Transition to Active *before* building/starting the
-        // container. The docker build can take a long time and
-        // happens with the run in `active` so the database state
-        // accurately reflects "this run is in progress." It also
-        // means `container_id` is allowed (the CHECK constraint
-        // permits it in `active`).
         self.transition(RunState::Active)?;
 
-        let executor_runtime = match self.build_executor_runtime(executor, workspace) {
-            Ok(rt) => rt,
-            Err(e) => {
-                self.transition(RunState::Failed)?;
-                return Err(e);
-            }
-        };
-
         let runtime = Rc::new(Runtime::new(
             pipeline,
             secrets,
             &meta,
             git_dir,
             workspace.to_path_buf(),
-            executor_runtime,
         ));
 
         let lua = runtime.lua();
@@ -377,64 +309,6 @@ impl Run {
         Ok(outputs)
     }
 
-    /// Build the per-run container if `executor` is `Docker`, writing
-    /// build and container timestamps to the database incrementally.
-    /// Run must already be in `active` so `container_id` is permitted.
-    fn build_executor_runtime(
-        &self,
-        executor: Executor,
-        workspace: &std::path::Path,
-    ) -> Result<ExecutorRuntime> {
-        match executor {
-            Executor::Host => Ok(ExecutorRuntime::Host),
-            Executor::Docker => {
-                if !crate::ci::docker::is_available() {
-                    return Err(Error::DockerUnavailable);
-                }
-
-                // Build phase.
-                let now = Timestamp::now().as_millisecond();
-                let db = crate::db::open(&self.db_path)?;
-                db.execute(
-                    "UPDATE runs SET build_started_at_ms = ?1 WHERE id = ?2",
-                    rusqlite::params![now, &self.id],
-                )?;
-
-                let dockerfile = workspace.join(".quire/Dockerfile");
-                if !dockerfile.exists() {
-                    return Err(Error::DockerfileMissing);
-                }
-                let tag = format!("quire-ci/{}:{}", repo_segment(&self.base_dir), self.id);
-
-                crate::ci::docker::docker_build(&dockerfile, workspace, &tag)?;
-
-                let build_finished = Timestamp::now().as_millisecond();
-                db.execute(
-                    "UPDATE runs SET image_tag = ?1, build_finished_at_ms = ?2 WHERE id = ?3",
-                    rusqlite::params![&tag, build_finished, &self.id],
-                )?;
-
-                // Start phase.
-                const WORK_DIR: &str = "/work";
-                let session =
-                    crate::ci::docker::ContainerSession::start(&tag, workspace, WORK_DIR)?;
-
-                let container_started = session.container_started_at.as_millisecond();
-                db.execute(
-                    "UPDATE runs SET container_id = ?1, container_started_at_ms = ?2 WHERE id = ?3",
-                    rusqlite::params![&session.container_id, container_started, &self.id],
-                )?;
-
-                Ok(ExecutorRuntime::Docker(DockerLifecycle {
-                    session,
-                    db_path: self.db_path.clone(),
-                    run_id: self.id.clone(),
-                    work_dir: WORK_DIR.to_string(),
-                }))
-            }
-        }
-    }
-
     /// Write sh events to the database and per-sh CRI log files to
     /// disk. Written before the final state transition so logs are
     /// available for both successful and failed runs.
@@ -582,34 +456,6 @@ impl Run {
 
 /// Take the final path component of a runs base (`runs/<repo>/`) and
 /// sanitize it for use as the tag segment in `quire-ci/<segment>:<id>`.
-/// Docker reference components match `[a-z0-9]+(?:[._-][a-z0-9]+)*`,
-/// so we lowercase, replace any other character with `_`, and strip
-/// leading non-alphanumerics (e.g. tempdir names like `.tmpXyZ`).
-/// Falls back to `repo` when the result would be empty.
-fn repo_segment(base: &Path) -> String {
-    let Some(raw) = base.file_name().and_then(|s| s.to_str()) else {
-        return "repo".to_string();
-    };
-    let sanitized: String = raw
-        .to_lowercase()
-        .chars()
-        .map(|c| {
-            if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') {
-                c
-            } else {
-                '_'
-            }
-        })
-        .collect::<String>()
-        .trim_start_matches(|c: char| !c.is_ascii_alphanumeric())
-        .to_string();
-    if sanitized.is_empty() {
-        "repo".to_string()
-    } else {
-        sanitized
-    }
-}
-
 /// Materialize a working tree at `sha` into `workspace` via
 /// `git archive | tar -x`. Creates the workspace dir if needed.
 pub fn materialize_workspace(git_dir: &Path, sha: &str, workspace: &Path) -> Result<()> {
@@ -1004,7 +850,6 @@ mod tests {
                 HashMap::new(),
                 std::path::Path::new("."),
                 &workspace,
-                Executor::Host,
             )
             .expect("execute");
         let pwd = &outputs["pwd"];
@@ -1035,7 +880,6 @@ mod tests {
                 HashMap::new(),
                 std::path::Path::new("."),
                 &test_workspace(&quire),
-                Executor::Host,
             )
             .expect("execute");
 
@@ -1072,7 +916,6 @@ mod tests {
             HashMap::new(),
             std::path::Path::new("."),
             &test_workspace(&quire),
-            Executor::Host,
         )
         .expect("execute");
 
@@ -1099,7 +942,6 @@ mod tests {
                 HashMap::new(),
                 std::path::Path::new("."),
                 &test_workspace(&quire),
-                Executor::Host,
             )
             .expect_err("expected failure");
         assert!(matches!(err, Error::JobFailed { ref job, .. } if job == "a"));
@@ -1129,7 +971,6 @@ mod tests {
                 HashMap::new(),
                 std::path::Path::new("."),
                 &test_workspace(&quire),
-                Executor::Host,
             )
             .expect("execute");
 
@@ -1159,7 +1000,6 @@ mod tests {
                 HashMap::new(),
                 std::path::Path::new("."),
                 &test_workspace(&quire),
-                Executor::Host,
             )
             .expect("execute");
 
@@ -1185,7 +1025,6 @@ mod tests {
                 HashMap::new(),
                 std::path::Path::new("."),
                 &test_workspace(&quire),
-                Executor::Host,
             )
             .expect_err("expected failure");
         let Error::JobFailed { job, source } = err else {
@@ -1217,7 +1056,6 @@ mod tests {
                 HashMap::new(),
                 std::path::Path::new("."),
                 &test_workspace(&quire),
-                Executor::Host,
             )
             .expect_err("expected failure");
         let Error::JobFailed { source, .. } = err else {
@@ -1247,7 +1085,6 @@ mod tests {
                 HashMap::new(),
                 std::path::Path::new("."),
                 &test_workspace(&quire),
-                Executor::Host,
             )
             .expect_err("expected failure");
         let Error::JobFailed { source, .. } = err else {
@@ -1281,7 +1118,6 @@ mod tests {
                 HashMap::new(),
                 std::path::Path::new("."),
                 &test_workspace(&quire),
-                Executor::Host,
             )
             .expect("execute");
         let b = &outputs["b"];
@@ -1306,7 +1142,6 @@ mod tests {
             HashMap::new(),
             std::path::Path::new("."),
             &test_workspace(&quire),
-            Executor::Host,
         )
         .expect("execute");
 
@@ -1353,7 +1188,6 @@ mod tests {
             HashMap::new(),
             std::path::Path::new("."),
             &test_workspace(&quire),
-            Executor::Host,
         );
 
         let failed_dir = runs.base_dir.join(&run_id);
@@ -1389,7 +1223,6 @@ mod tests {
                 HashMap::new(),
                 std::path::Path::new("."),
                 &test_workspace(&quire),
-                Executor::Host,
             )
             .expect_err("expected failure");
         let Error::JobFailed { job, source } = err else {
@@ -1428,7 +1261,6 @@ mod tests {
             HashMap::new(),
             std::path::Path::new("."),
             &test_workspace(&quire),
-            Executor::Host,
         )
         .expect("execute should succeed");
         assert!(called.get(), "rust run-fn should have been called");
@@ -1455,7 +1287,6 @@ mod tests {
                 HashMap::new(),
                 std::path::Path::new("."),
                 &test_workspace(&quire),
-                Executor::Host,
             )
             .expect_err("expected failure");
         let Error::JobFailed { job, source } = err else {
@@ -1467,145 +1298,4 @@ mod tests {
             "expected source to surface rust error, got: {source}"
         );
     }
-
-    #[test]
-    fn repo_segment_returns_final_component() {
-        assert_eq!(repo_segment(Path::new("runs/test.git")), "test.git");
-        assert_eq!(
-            repo_segment(Path::new("/var/lib/quire/runs/repo.git")),
-            "repo.git"
-        );
-        assert_eq!(repo_segment(Path::new("")), "repo");
-    }
-
-    #[test]
-    fn repo_segment_sanitizes_for_docker_tags() {
-        assert_eq!(repo_segment(Path::new("/tmp/.tmpAbCdEf")), "tmpabcdef");
-        assert_eq!(repo_segment(Path::new("MyRepo.git")), "myrepo.git");
-        assert_eq!(
-            repo_segment(Path::new("repo with spaces")),
-            "repo_with_spaces"
-        );
-    }
-
-    #[test]
-    #[ignore = "requires docker"]
-    fn execute_docker_mode_runs_jobs_in_container() {
-        if !crate::ci::docker::is_available() {
-            return;
-        }
-
-        let dir = tempfile::tempdir().expect("tempdir");
-        let src_repo = dir.path().join("src");
-        fs_err::create_dir_all(&src_repo).expect("mkdir src");
-
-        let env_vars: [(&str, &str); 6] = [
-            ("GIT_AUTHOR_NAME", "test"),
-            ("GIT_AUTHOR_EMAIL", "test@test"),
-            ("GIT_COMMITTER_NAME", "test"),
-            ("GIT_COMMITTER_EMAIL", "test@test"),
-            ("GIT_CONFIG_GLOBAL", "/dev/null"),
-            ("GIT_CONFIG_SYSTEM", "/dev/null"),
-        ];
-        for cmd in [vec!["init", "-b", "main"]] {
-            let out = std::process::Command::new("git")
-                .args(&cmd)
-                .current_dir(&src_repo)
-                .envs(env_vars)
-                .output()
-                .expect("git");
-            assert!(out.status.success());
-        }
-        fs_err::create_dir_all(src_repo.join(".quire")).expect("mkdir .quire");
-        fs_err::write(src_repo.join(".quire/Dockerfile"), "FROM alpine:3.19\n")
-            .expect("write Dockerfile");
-        for cmd in [vec!["add", "."], vec!["commit", "-m", "initial"]] {
-            let out = std::process::Command::new("git")
-                .args(&cmd)
-                .current_dir(&src_repo)
-                .envs(env_vars)
-                .output()
-                .expect("git");
-            assert!(out.status.success());
-        }
-        let sha = String::from_utf8(
-            std::process::Command::new("git")
-                .args(["rev-parse", "HEAD"])
-                .current_dir(&src_repo)
-                .envs(env_vars)
-                .output()
-                .expect("rev-parse")
-                .stdout,
-        )
-        .unwrap()
-        .trim()
-        .to_string();
-
-        let workspace = dir.path().join("ws");
-        materialize_workspace(&src_repo.join(".git"), &sha, &workspace).expect("materialize");
-
-        let (_qd, quire) = tmp_quire();
-        let runs = test_runs(&quire);
-        let meta = RunMeta {
-            sha,
-            r#ref: "refs/heads/main".to_string(),
-            pushed_at: "2026-05-04T12:00:00Z".parse().unwrap(),
-        };
-        let run = runs.create(&meta).expect("create");
-        let run_id = run.id().to_string();
-
-        let pipeline = load(
-            r#"(local ci (require :quire.ci))
-(ci.job :probe [:quire/push] (fn [{: sh}] (sh ["uname" "-s"])))"#,
-        );
-
-        let outputs = run
-            .execute(
-                pipeline,
-                HashMap::new(),
-                &src_repo.join(".git"),
-                &workspace,
-                Executor::Docker,
-            )
-            .expect("execute");
-
-        let probe = &outputs["probe"];
-        assert_eq!(probe.len(), 1);
-        assert_eq!(
-            probe[0].stdout.trim(),
-            "Linux",
-            "expected uname -s to return Linux from inside the container, got: {:?}",
-            probe[0].stdout,
-        );
-
-        // Verify container metadata was written to the DB.
-        let db = crate::db::open(&quire.db_path()).expect("open db");
-        let image_tag: Option<String> = db
-            .query_row(
-                "SELECT image_tag FROM runs WHERE id = ?1",
-                rusqlite::params![&run_id],
-                |row| row.get(0),
-            )
-            .expect("query");
-        assert!(image_tag.is_some(), "image_tag should be set");
-
-        let container_stopped_ms: Option<i64> = db
-            .query_row(
-                "SELECT container_stopped_at_ms FROM runs WHERE id = ?1",
-                rusqlite::params![&run_id],
-                |row| row.get(0),
-            )
-            .expect("query");
-        assert!(
-            container_stopped_ms.is_some(),
-            "container_stopped_at_ms should be set"
-        );
-
-        // Cleanup the image we built.
-        if let Some(tag) = image_tag {
-            let _ = std::process::Command::new("docker")
-                .args(["image", "rm", &tag])
-                .output();
-        }
-    }
 }
diff --git a/quire-server/src/ci/runtime.rs b/quire-server/src/ci/runtime.rs
index 0833d86..98387e4 100644
--- a/quire-server/src/ci/runtime.rs
+++ b/quire-server/src/ci/runtime.rs
@@ -14,21 +14,11 @@ use jiff::Timestamp;
 use mlua::{IntoLua, Lua, LuaSerdeExt};
 
 use super::pipeline::{Job, Pipeline};
-use super::run::{DockerLifecycle, RunMeta};
+use super::run::RunMeta;
 use quire_core::secret::{SecretRegistry, SecretString, redact};
 /// Per-sh timing: (index, started_at, finished_at).
 pub(super) type ShTimings = Vec<(usize, Timestamp, Timestamp)>;
 
-/// The runtime-side carrier for the chosen [`Executor`](super::run::Executor).
-/// `Host` runs `sh` directly on the host. `Docker` owns a
-/// [`DockerLifecycle`] whose Drop tears down the per-run container;
-/// `Runtime::sh` reads the variant's payload to wrap each command in
-/// `docker exec`.
-pub(super) enum ExecutorRuntime {
-    Host,
-    Docker(DockerLifecycle),
-}
-
 /// Per-execution runtime: owns the Lua VM, holds the secrets exposed
 /// to the job, the per-job `(jobs name)` views, the current-job
 /// cursor, and the per-job captured `sh` outputs.
@@ -67,9 +57,6 @@ pub(super) struct Runtime {
     /// The materialized workspace for this run. Every `(sh …)` call
     /// runs here.
     workspace: std::path::PathBuf,
-    /// The chosen executor. `Host` is a no-op; `Docker` owns the
-    /// per-run container's lifecycle (teardown on Drop).
-    executor: ExecutorRuntime,
 }
 
 impl Runtime {
@@ -90,7 +77,6 @@ impl Runtime {
         meta: &RunMeta,
         git_dir: &std::path::Path,
         workspace: std::path::PathBuf,
-        executor: ExecutorRuntime,
     ) -> Self {
         let transitive = pipeline.transitive_inputs();
         let lua = pipeline.fennel().lua();
@@ -132,7 +118,6 @@ impl Runtime {
             sh_timings: RefCell::new(HashMap::new()),
             sh_counter: RefCell::new(HashMap::new()),
             workspace,
-            executor,
         }
     }
 
@@ -151,15 +136,6 @@ impl Runtime {
         self.pipeline.job(id)
     }
 
-    /// In docker mode, return the `(container_id, work_dir)` used to
-    /// route `(sh …)` through `docker exec`. Host mode returns `None`.
-    pub(super) fn docker_target(&self) -> Option<(&str, &str)> {
-        match &self.executor {
-            ExecutorRuntime::Host => None,
-            ExecutorRuntime::Docker(l) => Some((&l.session.container_id, &l.work_dir)),
-        }
-    }
-
     /// Mark `id` as the currently executing job. `(sh …)` invocations
     /// from this job's `run_fn` will record output under `id`, and
     /// `(jobs …)` lookups will resolve against `id`'s view.
@@ -207,38 +183,16 @@ impl Runtime {
     /// Run `cmd` with `opts` and record its output against the
     /// current job (if one is active). Non-zero exits come back in
     /// `:exit`, not as `Err`.
-    ///
-    /// In docker mode the command is wrapped in `docker exec` against
-    /// the per-run container; the bind-mounted workspace is the
-    /// container's working directory, and `opts.env` is forwarded as
-    /// `-e KEY=VAL` flags.
     pub(super) fn sh(&self, cmd: Cmd, opts: ShOpts) -> super::error::Result<ShOutput> {
         let started_at = Timestamp::now();
-        let output = match self.docker_target() {
-            None => {
-                let program = cmd.program().to_string();
-                cmd.run(opts, &self.workspace).map_err(|e| {
-                    super::error::Error::CommandSpawnFailed {
-                        program,
-                        cwd: self.workspace.clone(),
-                        source: e,
-                    }
-                })?
-            }
-            Some((container_id, work_dir)) => {
-                let wrapped = Cmd::wrap_in_docker_exec(cmd, container_id, work_dir, &opts);
-                let program = wrapped.program().to_string();
-                // env was embedded into the docker exec argv; clear it
-                // so it isn't also set on the host `docker` process.
-                wrapped
-                    .run(ShOpts::default(), &self.workspace)
-                    .map_err(|e| super::error::Error::CommandSpawnFailed {
-                        program,
-                        cwd: self.workspace.clone(),
-                        source: e,
-                    })?
+        let program = cmd.program().to_string();
+        let output = cmd.run(opts, &self.workspace).map_err(|e| {
+            super::error::Error::CommandSpawnFailed {
+                program,
+                cwd: self.workspace.clone(),
+                source: e,
             }
-        };
+        })?;
         let finished_at = Timestamp::now();
         if let Some(job) = self.current_job.borrow().as_ref() {
             let n = {
@@ -286,7 +240,6 @@ impl Runtime {
             sh_timings: RefCell::new(HashMap::new()),
             sh_counter: RefCell::new(HashMap::new()),
             workspace: std::env::current_dir().expect("cwd"),
-            executor: ExecutorRuntime::Host,
         }
     }
 }
@@ -435,50 +388,6 @@ impl Cmd {
     // Also revisit the `from_utf8_lossy` calls below — non-UTF-8 bytes
     // are silently replaced with U+FFFD and `:stdout` / `:stderr` end
     // up as mojibake with no signal that anything was lost.
-    /// Build a new `Cmd` that invokes `docker exec` against the given
-    /// container. Embeds `cwd` as `--workdir` and `opts.env` as
-    /// repeated `-e KEY=VALUE` flags, so the caller's `ShOpts` after
-    /// wrapping should be empty.
-    pub(super) fn wrap_in_docker_exec(
-        inner: Cmd,
-        container_id: &str,
-        work_dir: &str,
-        opts: &ShOpts,
-    ) -> Cmd {
-        // `--interactive` keeps stdin attached. No `--tty` so stdout
-        // and stderr stay as separate streams.
-        let mut args: Vec<String> = vec![
-            "exec".to_string(),
-            "--interactive".to_string(),
-            "--workdir".to_string(),
-            work_dir.to_string(),
-        ];
-        for (k, v) in &opts.env {
-            args.push("--env".to_string());
-            args.push(format!("{k}={v}"));
-        }
-        args.push(container_id.to_string());
-
-        match inner {
-            Cmd::Argv {
-                program,
-                args: inner_args,
-            } => {
-                args.push(program);
-                args.extend(inner_args);
-            }
-            Cmd::Shell(s) => {
-                args.push("sh".to_string());
-                args.push("-c".to_string());
-                args.push(s);
-            }
-        }
-        Cmd::Argv {
-            program: "docker".to_string(),
-            args,
-        }
-    }
-
     pub(super) fn run(self, opts: ShOpts, cwd: &std::path::Path) -> std::io::Result<ShOutput> {
         let cmd_str = format!("{self}");
         let mut command: std::process::Command = self.into();
@@ -784,61 +693,6 @@ mod tests {
         );
     }
 
-    #[test]
-    fn cmd_wrap_in_docker_exec_argv_form() {
-        let inner = Cmd::Argv {
-            program: "echo".to_string(),
-            args: vec!["hi".to_string()],
-        };
-        let mut opts = ShOpts::default();
-        opts.env.insert("FOO".to_string(), "bar".to_string());
-
-        let wrapped = Cmd::wrap_in_docker_exec(inner, "abc123", "/work", &opts);
-        let Cmd::Argv { program, args } = wrapped else {
-            panic!("expected argv form");
-        };
-        assert_eq!(program, "docker");
-        assert_eq!(
-            args,
-            vec![
-                "exec",
-                "--interactive",
-                "--workdir",
-                "/work",
-                "--env",
-                "FOO=bar",
-                "abc123",
-                "echo",
-                "hi",
-            ]
-        );
-    }
-
-    #[test]
-    fn cmd_wrap_in_docker_exec_shell_form() {
-        let inner = Cmd::Shell("echo hi | tr a-z A-Z".to_string());
-        let opts = ShOpts::default();
-
-        let wrapped = Cmd::wrap_in_docker_exec(inner, "abc123", "/work", &opts);
-        let Cmd::Argv { program, args } = wrapped else {
-            panic!("expected argv form");
-        };
-        assert_eq!(program, "docker");
-        assert_eq!(
-            args,
-            vec![
-                "exec",
-                "--interactive",
-                "--workdir",
-                "/work",
-                "abc123",
-                "sh",
-                "-c",
-                "echo hi | tr a-z A-Z",
-            ]
-        );
-    }
-
     #[test]
     fn sh_rejects_number_as_cmd() {
         let (runtime, run_fn) = rt(