Route sh through docker exec in docker mode
Closes the lifecycle wiring: in docker mode every (sh ...) is wrapped
as `docker exec --interactive --workdir <work_dir> --env KEY=VAL ...
<id> <argv...>` against the per-run container. Host mode is unchanged.

env is forwarded through `--env` flags so the host `docker` process
runs with an empty env — otherwise the caller's vars would leak onto
the host invocation as well as the in-container command.

Assisted-by: Claude Opus 4.7 via Claude Code
change zyquosrutmultyltnxzlsxyskqxsozqy
commit 2e54248178c1b7c79588524090d6e53f40e9e228
author Alpha Chen <alpha@kejadlen.dev>
date
parent lrtvtxsw
diff --git a/src/ci/run.rs b/src/ci/run.rs
index 72f49ac..46c6a92 100644
--- a/src/ci/run.rs
+++ b/src/ci/run.rs
@@ -36,9 +36,9 @@ pub enum Executor {
 /// effect is: write `container_stopped_at` → drop `session` →
 /// `docker stop`.
 pub(super) struct DockerLifecycle {
-    #[allow(dead_code)] // Held for its Drop; read in Task 9 via Runtime::sh.
-    session: crate::ci::docker::ContainerSession,
+    pub(super) session: crate::ci::docker::ContainerSession,
     record_path: PathBuf,
+    pub(super) work_dir: String,
 }
 
 impl Drop for DockerLifecycle {
@@ -459,9 +459,12 @@ impl Run {
                 record.build_finished_at = Some(Timestamp::now());
                 self.write_container_record(&record)?;
 
-                // Start phase.
+                // Start phase. The bind-mount target inside the
+                // container doubles as the working directory for every
+                // `(sh …)` invocation routed through `docker exec`.
+                const WORK_DIR: &str = "/work";
                 let session =
-                    crate::ci::docker::ContainerSession::start(&tag, workspace, "/work")?;
+                    crate::ci::docker::ContainerSession::start(&tag, workspace, WORK_DIR)?;
 
                 record.container_id = Some(session.container_id.clone());
                 record.container_started_at = Some(session.container_started_at);
@@ -470,6 +473,7 @@ impl Run {
                 Ok(ExecutorRuntime::Docker(DockerLifecycle {
                     session,
                     record_path: self.path().join("container.yml"),
+                    work_dir: WORK_DIR.to_string(),
                 }))
             }
         }
@@ -1704,22 +1708,32 @@ mod tests {
         let run = runs.create(&meta).expect("create");
         let run_id = run.id().to_string();
 
-        // Pipeline runs ONE job that does nothing — Task 9 will wire
-        // sh through docker exec; this test just confirms the
-        // lifecycle works.
+        // Run `uname -s` inside the container. On macOS `uname -s`
+        // returns `Darwin`; getting `Linux` back proves the command
+        // ran inside the alpine container, not on the host.
         let pipeline = load(
             r#"(local ci (require :quire.ci))
-(ci.job :probe [:quire/push] (fn [_] nil))"#,
+(ci.job :probe [:quire/push] (fn [{: sh}] (sh ["uname" "-s"])))"#,
         );
 
-        run.execute(
-            pipeline,
-            HashMap::new(),
-            &src_repo.join(".git"),
-            &workspace,
-            Executor::Docker,
-        )
-        .expect("execute");
+        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.yml was written with all fields.
         let complete = runs.base.join(RunState::Complete.dir_name()).join(&run_id);
diff --git a/src/ci/runtime.rs b/src/ci/runtime.rs
index 6e344b7..597ecae 100644
--- a/src/ci/runtime.rs
+++ b/src/ci/runtime.rs
@@ -18,12 +18,11 @@ use crate::secret::SecretString;
 
 /// 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 —
-/// the variant's payload is unread until Task 9 routes `sh` through
+/// [`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,
-    #[allow(dead_code)]
     Docker(DockerLifecycle),
 }
 
@@ -144,6 +143,15 @@ impl Runtime {
         &self.workspace
     }
 
+    /// 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.
@@ -183,8 +191,21 @@ 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) -> crate::Result<ShOutput> {
-        let output = cmd.run(opts, &self.workspace)?;
+        let output = match self.docker_target() {
+            None => cmd.run(opts, &self.workspace)?,
+            Some((container_id, work_dir)) => {
+                let wrapped = Cmd::wrap_in_docker_exec(cmd, container_id, work_dir, &opts);
+                // 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)?
+            }
+        };
         if let Some(job) = self.current_job.borrow().as_ref() {
             self.outputs
                 .borrow_mut()
@@ -351,6 +372,50 @@ 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();
@@ -605,6 +670,61 @@ 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(