Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| The runner no longer runs g1t's own agents. Cloud and self-hosted runners now do what a forge's runner does: Actions jobs, required checks, merge checks and merge-queue test runs, deployments, dependency bumps and backups. Everything that started Claude Code in a sandbox on an issue or pull request is gone, with its prompts, model access, mentions, delegation, plans, reviews, revisions and the workspace agents' computers. | 1 | //! Running what g1t hands the runner: a workflow job, or g1t's own work (a |
| 2 | //! merge check or a state of the merge queue), with the same harness g1t's | |
| 3 | //! sandboxes run (this program, in another mode). | |
| Cache hits are counted, not only their bytes | 4 | //! |
| 5 | //! - **In Docker** (the default): one container per job, removed when it | |
| 6 | //! ends, from the job's `container:` image or the runner's `--image`. A | |
| Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r9 | 7 | //! Linux build of the harness is mounted into it read-only; that and the |
| 8 | //! job's own work volume, removed after, are its only mounts. It runs | |
| 9 | //! with every capability dropped but the few a build needs, no new | |
| 10 | //! privileges, a process, memory and CPU limit, and a network of its | |
| 11 | //! own. With nested-container isolation (sandbox.rs), all of this is on | |
| 12 | //! the runner's own Docker Engine, inside the runner's container, and a | |
| 13 | //! job that uses Docker gets an engine of its own (engine.rs). | |
| 14 | //! - **Directly** (`--no-docker`, deprecated): the harness runs as a child | |
| 15 | //! process, in a fresh folder under the runner's work folder that is | |
| 16 | //! removed after. Unsandboxed: the job's log says so, and a public | |
| 17 | //! repository's work is refused. | |
| Cache hits are counted, not only their bytes | 18 | //! |
| 19 | //! The runner's own credential never reaches either: a job only ever has | |
| Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r9 | 20 | //! its own short-lived token, as it would in g1t's sandbox. Credentials are |
| 21 | //! passed by name (`-e NAME`), never on the command line. | |
| Cache hits are counted, not only their bytes | 22 | |
| 23 | use std::collections::BTreeMap; | |
| 24 | use std::path::{Path, PathBuf}; | |
| 25 | use std::process::{Child, Command, Stdio}; | |
| 26 | use std::time::{Duration, Instant}; | |
| 27 | ||
| 28 | use anyhow::{Context, Result, bail}; | |
| 29 | ||
| 30 | use super::api::Assignment; | |
| 31 | use super::config::Config; | |
| Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r9 | 32 | use super::sandbox::{self, Isolation}; |
| Cache hits are counted, not only their bytes | 33 | use super::{log, update}; |
| 34 | ||
| 35 | /// The image workflow jobs run in when they name none: Debian with Node, | |
| 36 | /// git and Python, as g1t's sandbox has. | |
| 37 | pub const DEFAULT_IMAGE: &str = "node:24-bookworm"; | |
| Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r9 | 38 | /// The most processes in a job's container, unless the runner says. |
| 39 | pub const DEFAULT_PIDS_LIMIT: u32 = 4096; | |
| 40 | ||
| 41 | /// The capabilities a job's container keeps; Docker's others, and | |
| 42 | /// everything else, are dropped. A workflow job runs as its image's user, | |
| 43 | /// root for most CI images, and these are what installing packages and | |
| 44 | /// building need: | |
| 45 | /// | |
| 46 | /// - `CHOWN`, `FOWNER`, `FSETID`, `DAC_OVERRIDE`: a package manager (apt, | |
| 47 | /// npm as root) writing files it then hands to another owner, keeping | |
| 48 | /// setgid bits, and root writing files it does not own. | |
| 49 | /// - `SETUID`, `SETGID`: apt and others drop to their own user (`_apt`) to | |
| 50 | /// download; with no new privileges, nothing can gain a user back. | |
| 51 | /// - `KILL`: a step stopping processes another user started. | |
| 52 | /// - `NET_BIND_SERVICE`: a test server on port 80 or 443. | |
| 53 | /// | |
| 54 | /// Not kept, among others: `NET_RAW` (raw sockets, `ping`), `MKNOD`, | |
| 55 | /// `SYS_CHROOT`, `SETFCAP`, `AUDIT_WRITE`, and every `SYS_*` and `NET_ADMIN`. | |
| 56 | pub const JOB_CAPS: &[&str] = &["CHOWN", "DAC_OVERRIDE", "FOWNER", "FSETID", "SETUID", "SETGID", "KILL", "NET_BIND_SERVICE"]; | |
| Cache hits are counted, not only their bytes | 57 | |
| The runner no longer runs g1t's own agents. Cloud and self-hosted runners now do what a forge's runner does: Actions jobs, required checks, merge checks and merge-queue test runs, deployments, dependency bumps and backups. Everything that started Claude Code in a sandbox on an issue or pull request is gone, with its prompts, model access, mentions, delegation, plans, reviews, revisions and the workspace agents' computers. | 58 | /// The work kind g1t's own tasks come as: a merge check or a state of the |
| 59 | /// merge queue, with the environment g1t's sandbox would have had. Older | |
| 60 | /// g1t sent agents' runs as this kind too; those are refused. | |
| 61 | pub const TASK_KIND: &str = "agent"; | |
| 62 | ||
| 63 | /// The modes of g1t's own tasks this runner runs (main.rs). | |
| 64 | pub const TASK_MODES: &[&str] = &["queue", "mergecheck", "checks"]; | |
| Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r9 | 65 | |
| Cache hits are counted, not only their bytes | 66 | /// Work in progress. |
| 67 | pub struct Running { | |
| 68 | pub id: String, | |
| 69 | pub kind: String, | |
| 70 | pub name: String, | |
| Merge the runner sandbox: g1t-runner sandbox up makes a person's own computer a self-hosted runner that nothing a job runs can get out of. Linux jobs run in a Linux VM of the runner's own (a WSL 2 distribution with no host mounts, interop or Docker socket on Windows; vfkit on macOS; Sysbox, or QEMU and KVM, on Linux), each in a fresh container under Sysbox inside it; on Windows 11 Pro and up, Windows jobs each run in a Windows Sandbox discarded after the job. Runners report their isolation, and the Runners page shows it. | 71 | pub repo: String, |
| Cache hits are counted, not only their bytes | 72 | child: Child, |
| 73 | container: Option<String>, | |
| Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r9 | 74 | /// The job's network and work volume, removed after. |
| 75 | network: Option<String>, | |
| 76 | volume: Option<String>, | |
| 77 | /// The job's own Docker Engine, when it may have one. | |
| 78 | engine: Option<JobEngine>, | |
| Cache hits are counted, not only their bytes | 79 | folder: Option<PathBuf>, |
| Merge the runner sandbox: g1t-runner sandbox up makes a person's own computer a self-hosted runner that nothing a job runs can get out of. Linux jobs run in a Linux VM of the runner's own (a WSL 2 distribution with no host mounts, interop or Docker socket on Windows; vfkit on macOS; Sysbox, or QEMU and KVM, on Linux), each in a fresh container under Sysbox inside it; on Windows 11 Pro and up, Windows jobs each run in a Windows Sandbox discarded after the job. Runners report their isolation, and the Runners page shows it. | 80 | /// The job's own VM, for a job native to this OS (native.rs); `child` |
| 81 | /// is then `wsb exec`, whose output carries the job's exit code. | |
| 82 | native: Option<WsbJob>, | |
| 83 | /// How it ended, once read: a native job's code is read once from | |
| 84 | /// `wsb exec`'s output. | |
| 85 | exit: Option<i32>, | |
| Cache hits are counted, not only their bytes | 86 | /// Past its time limit and a grace for g1t to say so: stopped here. |
| 87 | deadline: Instant, | |
| 88 | } | |
| 89 | ||
| Merge the runner sandbox: g1t-runner sandbox up makes a person's own computer a self-hosted runner that nothing a job runs can get out of. Linux jobs run in a Linux VM of the runner's own (a WSL 2 distribution with no host mounts, interop or Docker socket on Windows; vfkit on macOS; Sysbox, or QEMU and KVM, on Linux), each in a fresh container under Sysbox inside it; on Windows 11 Pro and up, Windows jobs each run in a Windows Sandbox discarded after the job. Runners report their isolation, and the Runners page shows it. | 90 | use super::native::{self, WsbJob}; |
| 91 | ||
| Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r9 | 92 | #[cfg(unix)] |
| 93 | use super::engine::JobEngine; | |
| 94 | ||
| 95 | /// No job has an engine of its own off Unix. | |
| 96 | #[cfg(not(unix))] | |
| 97 | pub struct JobEngine; | |
| 98 | ||
| 99 | #[cfg(not(unix))] | |
| 100 | impl JobEngine { | |
| 101 | fn remove(&self) {} | |
| 102 | fn job_args(&self) -> Vec<String> { | |
| 103 | Vec::new() | |
| 104 | } | |
| 105 | } | |
| 106 | ||
| 107 | /// `docker`, at the runner's own engine when it has one. | |
| 108 | fn docker() -> Command { | |
| 109 | #[cfg(unix)] | |
| 110 | return super::engine::docker(); | |
| 111 | #[cfg(not(unix))] | |
| 112 | Command::new("docker") | |
| 113 | } | |
| 114 | ||
| 115 | fn quietly(command: &mut Command) { | |
| 116 | let _ = command.stdout(Stdio::null()).stderr(Stdio::null()).status(); | |
| 117 | } | |
| 118 | ||
| Cache hits are counted, not only their bytes | 119 | impl Running { |
| 120 | pub fn overdue(&self) -> bool { | |
| 121 | Instant::now() > self.deadline | |
| 122 | } | |
| 123 | ||
| 124 | /// Its exit code once it has ended. | |
| 125 | pub fn ended(&mut self) -> Option<i32> { | |
| Merge the runner sandbox: g1t-runner sandbox up makes a person's own computer a self-hosted runner that nothing a job runs can get out of. Linux jobs run in a Linux VM of the runner's own (a WSL 2 distribution with no host mounts, interop or Docker socket on Windows; vfkit on macOS; Sysbox, or QEMU and KVM, on Linux), each in a fresh container under Sysbox inside it; on Windows 11 Pro and up, Windows jobs each run in a Windows Sandbox discarded after the job. Runners report their isolation, and the Runners page shows it. | 126 | if self.exit.is_some() { |
| 127 | return self.exit; | |
| 128 | } | |
| 129 | self.exit = match self.child.try_wait() { | |
| 130 | Ok(Some(_)) if self.native.is_some() => { | |
| 131 | use std::io::Read; | |
| 132 | let mut text = String::new(); | |
| 133 | if let Some(out) = self.child.stdout.as_mut() { | |
| 134 | let _ = out.read_to_string(&mut text); | |
| 135 | } | |
| 136 | Some(native::parse_exit_code(&text).unwrap_or(1)) | |
| 137 | } | |
| Cache hits are counted, not only their bytes | 138 | Ok(Some(status)) => Some(status.code().unwrap_or(1)), |
| 139 | Ok(None) => None, | |
| 140 | Err(_) => Some(1), | |
| Merge the runner sandbox: g1t-runner sandbox up makes a person's own computer a self-hosted runner that nothing a job runs can get out of. Linux jobs run in a Linux VM of the runner's own (a WSL 2 distribution with no host mounts, interop or Docker socket on Windows; vfkit on macOS; Sysbox, or QEMU and KVM, on Linux), each in a fresh container under Sysbox inside it; on Windows 11 Pro and up, Windows jobs each run in a Windows Sandbox discarded after the job. Runners report their isolation, and the Runners page shows it. | 141 | }; |
| 142 | self.exit | |
| Cache hits are counted, not only their bytes | 143 | } |
| 144 | ||
| 145 | /// Stops it: the container, then the process and everything it started. | |
| 146 | pub fn stop(&mut self) { | |
| 147 | if let Some(container) = &self.container { | |
| Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r9 | 148 | quietly(docker().args(["kill", container])); |
| 149 | } | |
| Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' into claude/cool-newton-7nm8r9 | 150 | #[cfg(unix)] |
| Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r9 | 151 | if let Some(engine) = &self.engine { |
| 152 | quietly(docker().args(["kill", &engine.container])); | |
| Cache hits are counted, not only their bytes | 153 | } |
| Merge the runner sandbox: g1t-runner sandbox up makes a person's own computer a self-hosted runner that nothing a job runs can get out of. Linux jobs run in a Linux VM of the runner's own (a WSL 2 distribution with no host mounts, interop or Docker socket on Windows; vfkit on macOS; Sysbox, or QEMU and KVM, on Linux), each in a fresh container under Sysbox inside it; on Windows 11 Pro and up, Windows jobs each run in a Windows Sandbox discarded after the job. Runners report their isolation, and the Runners page shows it. | 154 | if let Some(job) = &self.native { |
| 155 | job.stop(); | |
| 156 | } | |
| Cache hits are counted, not only their bytes | 157 | let pid = self.child.id().to_string(); |
| 158 | if cfg!(windows) { | |
| Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r9 | 159 | quietly(Command::new("taskkill").args(["/T", "/F", "/PID", &pid])); |
| Cache hits are counted, not only their bytes | 160 | } else { |
| 161 | // Its own process group (see `directly`): the steps' processes too. | |
| Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r9 | 162 | quietly(Command::new("kill").args(["-TERM", &format!("-{pid}")])); |
| Cache hits are counted, not only their bytes | 163 | } |
| 164 | let _ = self.child.kill(); | |
| 165 | let _ = self.child.wait(); | |
| 166 | } | |
| 167 | ||
| Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r9 | 168 | /// Removes what it left: its container, its engine and that engine's |
| 169 | /// data, its network, its work volume, its folder. | |
| Cache hits are counted, not only their bytes | 170 | pub fn clean(&mut self) { |
| 171 | if let Some(container) = &self.container { | |
| Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r9 | 172 | quietly(docker().args(["rm", "-f", "-v", container])); |
| 173 | } | |
| 174 | if let Some(engine) = self.engine.take() { | |
| 175 | engine.remove(); | |
| Cache hits are counted, not only their bytes | 176 | } |
| Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r9 | 177 | if let Some(network) = &self.network { |
| 178 | quietly(docker().args(["network", "rm", network])); | |
| 179 | } | |
| 180 | if let Some(volume) = &self.volume { | |
| 181 | quietly(docker().args(["volume", "rm", "-f", volume])); | |
| 182 | } | |
| Cache hits are counted, not only their bytes | 183 | if let Some(folder) = &self.folder { |
| 184 | let _ = std::fs::remove_dir_all(folder); | |
| 185 | } | |
| Merge the runner sandbox: g1t-runner sandbox up makes a person's own computer a self-hosted runner that nothing a job runs can get out of. Linux jobs run in a Linux VM of the runner's own (a WSL 2 distribution with no host mounts, interop or Docker socket on Windows; vfkit on macOS; Sysbox, or QEMU and KVM, on Linux), each in a fresh container under Sysbox inside it; on Windows 11 Pro and up, Windows jobs each run in a Windows Sandbox discarded after the job. Runners report their isolation, and the Runners page shows it. | 186 | if let Some(job) = self.native.take() { |
| 187 | job.clean(); | |
| 188 | } | |
| Cache hits are counted, not only their bytes | 189 | } |
| 190 | } | |
| 191 | ||
| 192 | /// A name Docker takes, from a job's id. | |
| 193 | fn container_name(id: &str) -> String { | |
| 194 | let clean: String = id.chars().map(|c| if c.is_ascii_alphanumeric() || c == '_' || c == '-' { c } else { '-' }).collect(); | |
| 195 | format!("g1t-{clean}") | |
| 196 | } | |
| 197 | ||
| 198 | /// The environment the harness gets for this work. | |
| 199 | fn harness_env(config: &Config, work: &Assignment) -> Result<BTreeMap<String, String>> { | |
| 200 | let mut env = BTreeMap::new(); | |
| 201 | match work.kind.as_str() { | |
| 202 | "workflow" => { | |
| 203 | let Some(token) = &work.token else { bail!("the job came without its token") }; | |
| 204 | env.insert("MODE".into(), "actions".into()); | |
| 205 | env.insert("G1T_API".into(), config.api.clone()); | |
| 206 | env.insert("ACTIONS_JOB".into(), work.id.clone()); | |
| 207 | env.insert("ACTIONS_TOKEN".into(), token.clone()); | |
| Merge main into rm-agents: mirroring removal, open signups, free-tier limits, the g1t CLI and runner fixes, with the agent removal kept. The CLI's table of operations is regenerated from the API without the agent, customize and AI credit operations; no CLI command used them. | 208 | // That token is where this computer can see it (a Windows job's |
| 209 | // script in the job's folder, a container's environment): the | |
| 210 | // harness spends it on fetching the job and works on with a new | |
| 211 | // one only it has. | |
| 212 | env.insert(crate::actions::ROTATE_TOKEN.into(), "1".into()); | |
| Cache hits are counted, not only their bytes | 213 | } |
| The runner no longer runs g1t's own agents. Cloud and self-hosted runners now do what a forge's runner does: Actions jobs, required checks, merge checks and merge-queue test runs, deployments, dependency bumps and backups. Everything that started Claude Code in a sandbox on an issue or pull request is gone, with its prompts, model access, mentions, delegation, plans, reviews, revisions and the workspace agents' computers. | 214 | TASK_KIND => { |
| Cache hits are counted, not only their bytes | 215 | for (name, value) in work.env.clone().unwrap_or_default() { |
| 216 | let value = match value { | |
| 217 | serde_json::Value::String(text) => text, | |
| 218 | other => other.to_string(), | |
| 219 | }; | |
| 220 | env.insert(name, value); | |
| 221 | } | |
| The runner no longer runs g1t's own agents. Cloud and self-hosted runners now do what a forge's runner does: Actions jobs, required checks, merge checks and merge-queue test runs, deployments, dependency bumps and backups. Everything that started Claude Code in a sandbox on an issue or pull request is gone, with its prompts, model access, mentions, delegation, plans, reviews, revisions and the workspace agents' computers. | 222 | match env.get("MODE").map(String::as_str) { |
| 223 | Some(mode) if TASK_MODES.contains(&mode) => {} | |
| 224 | Some(mode) => bail!("this runner does not run g1t work of mode {mode}"), | |
| 225 | None => bail!("this runner does not run agents' work"), | |
| Cache hits are counted, not only their bytes | 226 | } |
| 227 | // Where its API calls go: this installation's. | |
| 228 | env.insert("G1T_API".into(), config.api.clone()); | |
| 229 | } | |
| 230 | other => bail!("this runner does not know work of kind {other}; update it"), | |
| 231 | } | |
| 232 | // The machine is the workspace's own: no mining watch. | |
| 233 | env.insert("G1T_ABUSE".into(), "off".into()); | |
| 234 | Ok(env) | |
| 235 | } | |
| 236 | ||
| 237 | /// Starts work. The harness reports a workflow job's steps and log itself; | |
| 238 | /// the runner tells g1t how the process ended. | |
| Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r9 | 239 | pub fn start(config: &Config, isolation: Isolation, folder: &Path, work: &Assignment) -> Result<Running> { |
| Cache hits are counted, not only their bytes | 240 | let env = harness_env(config, work)?; |
| Merge the runner sandbox: g1t-runner sandbox up makes a person's own computer a self-hosted runner that nothing a job runs can get out of. Linux jobs run in a Linux VM of the runner's own (a WSL 2 distribution with no host mounts, interop or Docker socket on Windows; vfkit on macOS; Sysbox, or QEMU and KVM, on Linux), each in a fresh container under Sysbox inside it; on Windows 11 Pro and up, Windows jobs each run in a Windows Sandbox discarded after the job. Runners report their isolation, and the Runners page shows it. | 241 | match isolation { |
| 242 | Isolation::Host => { | |
| 243 | refuse_public_on_host(work)?; | |
| 244 | directly(config, work, env) | |
| 245 | } | |
| 246 | Isolation::Vm => in_native_vm(config, folder, work, env), | |
| 247 | _ => in_docker(config, isolation, folder, work, env), | |
| Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r9 | 248 | } |
| 249 | } | |
| 250 | ||
| Merge the runner sandbox: g1t-runner sandbox up makes a person's own computer a self-hosted runner that nothing a job runs can get out of. Linux jobs run in a Linux VM of the runner's own (a WSL 2 distribution with no host mounts, interop or Docker socket on Windows; vfkit on macOS; Sysbox, or QEMU and KVM, on Linux), each in a fresh container under Sysbox inside it; on Windows 11 Pro and up, Windows jobs each run in a Windows Sandbox discarded after the job. Runners report their isolation, and the Runners page shows it. | 251 | /// A memory size as `--memory` takes it (`8g`, `4096m`), in MiB. |
| 252 | pub fn mib(text: &str) -> Option<u64> { | |
| 253 | let text = text.trim().to_ascii_lowercase(); | |
| 254 | let (number, unit) = text.split_at(text.find(|c: char| !c.is_ascii_digit()).unwrap_or(text.len())); | |
| 255 | let number: u64 = number.parse().ok()?; | |
| 256 | match unit.trim_end_matches('b').trim_end_matches('i') { | |
| 257 | "" | "m" => Some(number), | |
| 258 | "g" => Some(number * 1024), | |
| 259 | "k" => Some(number / 1024), | |
| 260 | _ => None, | |
| 261 | } | |
| 262 | } | |
| 263 | ||
| 264 | /// A job native to this OS, in a throwaway VM of its own (native.rs): | |
| The runner no longer runs g1t's own agents. Cloud and self-hosted runners now do what a forge's runner does: Actions jobs, required checks, merge checks and merge-queue test runs, deployments, dependency bumps and backups. Everything that started Claude Code in a sandbox on an issue or pull request is gone, with its prompts, model access, mentions, delegation, plans, reviews, revisions and the workspace agents' computers. | 265 | /// Windows Sandbox on Windows. g1t's own work needs its Linux sandbox |
| Merge the runner sandbox: g1t-runner sandbox up makes a person's own computer a self-hosted runner that nothing a job runs can get out of. Linux jobs run in a Linux VM of the runner's own (a WSL 2 distribution with no host mounts, interop or Docker socket on Windows; vfkit on macOS; Sysbox, or QEMU and KVM, on Linux), each in a fresh container under Sysbox inside it; on Windows 11 Pro and up, Windows jobs each run in a Windows Sandbox discarded after the job. Runners report their isolation, and the Runners page shows it. | 266 | /// layout and runs on a Linux runner. |
| 267 | fn in_native_vm(config: &Config, folder: &Path, work: &Assignment, env: BTreeMap<String, String>) -> Result<Running> { | |
| 268 | if work.kind != "workflow" { | |
| The runner no longer runs g1t's own agents. Cloud and self-hosted runners now do what a forge's runner does: Actions jobs, required checks, merge checks and merge-queue test runs, deployments, dependency bumps and backups. Everything that started Claude Code in a sandbox on an issue or pull request is gone, with its prompts, model access, mentions, delegation, plans, reviews, revisions and the workspace agents' computers. | 269 | bail!("g1t's own work runs on Linux runners: this one runs jobs native to {}", std::env::consts::OS); |
| Merge the runner sandbox: g1t-runner sandbox up makes a person's own computer a self-hosted runner that nothing a job runs can get out of. Linux jobs run in a Linux VM of the runner's own (a WSL 2 distribution with no host mounts, interop or Docker socket on Windows; vfkit on macOS; Sysbox, or QEMU and KVM, on Linux), each in a fresh container under Sysbox inside it; on Windows 11 Pro and up, Windows jobs each run in a Windows Sandbox discarded after the job. Runners report their isolation, and the Runners page shows it. | 270 | } |
| 271 | if !cfg!(windows) { | |
| 272 | bail!("this runner has no sandbox for jobs native to {}", std::env::consts::OS); | |
| 273 | } | |
| 274 | let memory = config.memory.as_deref().and_then(mib).unwrap_or(4096); | |
| 275 | let drop = folder.join("native").join(container_name(&work.id)); | |
| 276 | let job = WsbJob::start(drop, &env, memory)?; | |
| 277 | let child = match job.exec().spawn() { | |
| 278 | Ok(child) => child, | |
| 279 | Err(error) => { | |
| 280 | job.clean(); | |
| 281 | return Err(error).context("could not run wsb exec"); | |
| 282 | } | |
| 283 | }; | |
| 284 | log(&format!( | |
| 285 | "Running {} {} in a Windows Sandbox of its own ({memory} MiB): no folder of this machine's but its read-only harness, discarded after", | |
| 286 | work.kind, work.name | |
| 287 | )); | |
| 288 | Ok(Running { | |
| 289 | id: work.id.clone(), | |
| 290 | repo: work.repo.clone(), | |
| 291 | kind: work.kind.clone(), | |
| 292 | name: work.name.clone(), | |
| 293 | child, | |
| 294 | container: None, | |
| 295 | network: None, | |
| 296 | volume: None, | |
| 297 | engine: None, | |
| 298 | folder: None, | |
| 299 | native: Some(job), | |
| 300 | exit: None, | |
| 301 | deadline: deadline(work), | |
| 302 | }) | |
| 303 | } | |
| 304 | ||
| Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r9 | 305 | /// Host mode does not take a public repository's work: anyone can propose |
| 306 | /// code to one, and here it would run on the machine itself. | |
| 307 | fn refuse_public_on_host(work: &Assignment) -> Result<()> { | |
| 308 | match work.visibility.as_deref() { | |
| 309 | Some("public") => bail!("{}", sandbox::host_mode_refuses(&work.repo)), | |
| 310 | Some(_) => Ok(()), | |
| 311 | None => { | |
| 312 | log(&format!("g1t did not say whether {} is public (an older g1t), so it runs; host mode refuses public repositories' work once it does.", work.repo)); | |
| 313 | Ok(()) | |
| 314 | } | |
| 315 | } | |
| 316 | } | |
| 317 | ||
| 318 | /// Each job container's limits. | |
| 319 | #[derive(Debug, Clone, PartialEq)] | |
| 320 | pub struct Limits { | |
| 321 | pub memory: String, | |
| 322 | pub cpus: String, | |
| 323 | pub pids: u32, | |
| 324 | } | |
| 325 | ||
| 326 | /// Three quarters of `MemTotal` in `/proc/meminfo`, in MiB, which under | |
| 327 | /// Sysbox is the runner container's own memory. | |
| 328 | fn default_memory(meminfo: &str) -> Option<String> { | |
| 329 | let kb: u64 = meminfo.lines().find_map(|l| l.strip_prefix("MemTotal:"))?.trim().trim_end_matches("kB").trim().parse().ok()?; | |
| 330 | let mib = kb / 1024 * 3 / 4; | |
| 331 | (mib >= 256).then(|| format!("{mib}m")) | |
| 332 | } | |
| 333 | ||
| 334 | pub fn limits(config: &Config) -> Limits { | |
| 335 | let memory = config | |
| 336 | .memory | |
| 337 | .clone() | |
| 338 | .or_else(|| default_memory(&std::fs::read_to_string("/proc/meminfo").unwrap_or_default())) | |
| 339 | .unwrap_or_else(|| "4g".into()); | |
| 340 | let cpus = config.cpus.clone().unwrap_or_else(|| std::thread::available_parallelism().map(|n| n.get()).unwrap_or(2).to_string()); | |
| 341 | Limits { memory, cpus, pids: config.pids_limit.unwrap_or(DEFAULT_PIDS_LIMIT) } | |
| 342 | } | |
| 343 | ||
| 344 | /// Everything a job's `docker run` is made from. | |
| 345 | pub struct Plan<'a> { | |
| 346 | pub name: &'a str, | |
| 347 | pub runner: &'a str, | |
| 348 | pub image: &'a str, | |
| 349 | pub harness: &'a Path, | |
| 350 | pub network: &'a str, | |
| 351 | /// The job's work volume, for a workflow job's `/home/runner`. | |
| 352 | pub volume: Option<&'a str>, | |
| 353 | pub limits: &'a Limits, | |
| 354 | /// The names of the harness's variables, whose values come from the | |
| 355 | /// runner's process, never its command line. | |
| 356 | pub env_names: Vec<&'a str>, | |
| 357 | /// What the job's own engine adds (`JobEngine::job_args`). | |
| 358 | pub engine_args: Vec<String>, | |
| 359 | } | |
| 360 | ||
| 361 | /// The arguments of `docker` that start a job's container. | |
| 362 | pub fn run_args(plan: &Plan) -> Vec<String> { | |
| 363 | let mut args: Vec<String> = Vec::new(); | |
| 364 | fn add(args: &mut Vec<String>, items: &[&str]) { | |
| 365 | args.extend(items.iter().map(|s| s.to_string())); | |
| 366 | } | |
| 367 | add(&mut args, &["run", "--rm", "--name", plan.name, "--label", &format!("sh.g1t.runner={}", plan.runner)]); | |
| 368 | add(&mut args, &["--pull", "missing", "--init"]); | |
| 369 | // The walls. | |
| 370 | add(&mut args, &["--cap-drop", "ALL"]); | |
| The runner no longer runs g1t's own agents. Cloud and self-hosted runners now do what a forge's runner does: Actions jobs, required checks, merge checks and merge-queue test runs, deployments, dependency bumps and backups. Everything that started Claude Code in a sandbox on an issue or pull request is gone, with its prompts, model access, mentions, delegation, plans, reviews, revisions and the workspace agents' computers. | 371 | add(&mut args, &["--security-opt", "no-new-privileges"]); |
| 372 | for cap in JOB_CAPS { | |
| Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r9 | 373 | add(&mut args, &["--cap-add", cap]); |
| 374 | } | |
| 375 | let pids = plan.limits.pids.to_string(); | |
| 376 | add(&mut args, &["--pids-limit", &pids, "--memory", &plan.limits.memory, "--memory-swap", &plan.limits.memory, "--cpus", &plan.limits.cpus]); | |
| 377 | add(&mut args, &["--network", plan.network, "--ipc", "private"]); | |
| 378 | // The job is in its `container:` image already: the harness inside | |
| 379 | // does not start it again (actions/containers.rs). | |
| 380 | add(&mut args, &["-e", "G1T_JOB_CONTAINER=1"]); | |
| 381 | for name in &plan.env_names { | |
| 382 | add(&mut args, &["-e", name]); | |
| 383 | } | |
| 384 | // Its only mounts: the harness, read-only, and its own volume. | |
| 385 | add(&mut args, &["-v", &format!("{}:/opt/g1t/g1t-runner:ro", plan.harness.display())]); | |
| 386 | if let Some(volume) = plan.volume { | |
| 387 | add(&mut args, &["-v", &format!("{volume}:/home/runner")]); | |
| 388 | } | |
| 389 | args.extend(plan.engine_args.iter().cloned()); | |
| 390 | add(&mut args, &["--entrypoint", "/opt/g1t/g1t-runner", plan.image]); | |
| 391 | args | |
| Cache hits are counted, not only their bytes | 392 | } |
| 393 | ||
| Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r9 | 394 | fn in_docker(config: &Config, isolation: Isolation, folder: &Path, work: &Assignment, env: BTreeMap<String, String>) -> Result<Running> { |
| The runner no longer runs g1t's own agents. Cloud and self-hosted runners now do what a forge's runner does: Actions jobs, required checks, merge checks and merge-queue test runs, deployments, dependency bumps and backups. Everything that started Claude Code in a sandbox on an issue or pull request is gone, with its prompts, model access, mentions, delegation, plans, reviews, revisions and the workspace agents' computers. | 395 | // g1t's own work needs only git, which the default image has. |
| 396 | let image = work.image.clone().or_else(|| config.image.clone()).unwrap_or_else(|| DEFAULT_IMAGE.to_owned()); | |
| Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r9 | 397 | #[cfg(unix)] |
| 398 | if super::engine::in_use() { | |
| 399 | super::engine::ensure_running()?; | |
| 400 | } | |
| Cache hits are counted, not only their bytes | 401 | let harness = update::linux_harness(config, folder)?; |
| 402 | let name = container_name(&work.id); | |
| Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r9 | 403 | // What an earlier run of this job may have left. |
| 404 | quietly(docker().args(["rm", "-f", "-v", &name])); | |
| 405 | quietly(docker().args(["network", "rm", &name])); | |
| 406 | let output = docker() | |
| 407 | .args(["network", "create", "--label", &format!("sh.g1t.runner={}", config.runner), &name]) | |
| 408 | .output() | |
| 409 | .context("could not run docker: is Docker installed and running?")?; | |
| 410 | if !output.status.success() { | |
| 411 | bail!("could not make the job's network: {}", String::from_utf8_lossy(&output.stderr).trim()); | |
| Cache hits are counted, not only their bytes | 412 | } |
| Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r9 | 413 | let volume = (work.kind == "workflow").then(|| format!("{name}-work")); |
| 414 | if let Some(volume) = &volume { | |
| 415 | quietly(docker().args(["volume", "rm", "-f", volume])); | |
| 416 | quietly(docker().args(["volume", "create", "--label", &format!("sh.g1t.runner={}", config.runner), volume])); | |
| 417 | } | |
| 418 | // A job's own Docker, only inside the runner's own sandbox: on a | |
| 419 | // shared engine it would be the machine's. | |
| 420 | let engine: Option<JobEngine> = job_engine(config, isolation, &name); | |
| 421 | let limits = limits(config); | |
| 422 | let plan = Plan { | |
| 423 | name: &name, | |
| 424 | runner: &config.runner, | |
| 425 | image: &image, | |
| 426 | harness: &harness, | |
| 427 | network: &name, | |
| 428 | volume: volume.as_deref(), | |
| 429 | limits: &limits, | |
| 430 | env_names: env.keys().map(String::as_str).collect(), | |
| 431 | engine_args: engine.as_ref().map(JobEngine::job_args).unwrap_or_default(), | |
| 432 | }; | |
| 433 | let mut command = docker(); | |
| 434 | command.args(run_args(&plan)); | |
| Cache hits are counted, not only their bytes | 435 | command.envs(&env); |
| Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r9 | 436 | log(&format!( |
| 437 | "Running {} {} in {image}: its own container and network, capabilities dropped, {} memory, {} CPUs, {} processes{}", | |
| 438 | work.kind, | |
| 439 | work.name, | |
| 440 | limits.memory, | |
| 441 | limits.cpus, | |
| 442 | limits.pids, | |
| 443 | if engine.is_some() { ", Docker of its own when it uses it" } else { "" } | |
| 444 | )); | |
| 445 | let child = match command.spawn() { | |
| 446 | Ok(child) => child, | |
| 447 | Err(error) => { | |
| 448 | if let Some(engine) = &engine { | |
| 449 | engine.remove(); | |
| 450 | } | |
| 451 | quietly(docker().args(["network", "rm", &name])); | |
| 452 | return Err(error).context("could not run docker: is Docker installed and running?"); | |
| 453 | } | |
| 454 | }; | |
| 455 | Ok(Running { | |
| 456 | id: work.id.clone(), | |
| Merge the runner sandbox: g1t-runner sandbox up makes a person's own computer a self-hosted runner that nothing a job runs can get out of. Linux jobs run in a Linux VM of the runner's own (a WSL 2 distribution with no host mounts, interop or Docker socket on Windows; vfkit on macOS; Sysbox, or QEMU and KVM, on Linux), each in a fresh container under Sysbox inside it; on Windows 11 Pro and up, Windows jobs each run in a Windows Sandbox discarded after the job. Runners report their isolation, and the Runners page shows it. | 457 | repo: work.repo.clone(), |
| Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r9 | 458 | kind: work.kind.clone(), |
| 459 | name: work.name.clone(), | |
| 460 | child, | |
| 461 | container: Some(name.clone()), | |
| 462 | network: Some(name), | |
| 463 | volume, | |
| 464 | engine, | |
| 465 | folder: None, | |
| Merge the runner sandbox: g1t-runner sandbox up makes a person's own computer a self-hosted runner that nothing a job runs can get out of. Linux jobs run in a Linux VM of the runner's own (a WSL 2 distribution with no host mounts, interop or Docker socket on Windows; vfkit on macOS; Sysbox, or QEMU and KVM, on Linux), each in a fresh container under Sysbox inside it; on Windows 11 Pro and up, Windows jobs each run in a Windows Sandbox discarded after the job. Runners report their isolation, and the Runners page shows it. | 466 | native: None, |
| 467 | exit: None, | |
| Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r9 | 468 | deadline: deadline(work), |
| 469 | }) | |
| 470 | } | |
| 471 | ||
| 472 | #[cfg(unix)] | |
| 473 | fn job_engine(config: &Config, isolation: Isolation, name: &str) -> Option<JobEngine> { | |
| 474 | if isolation != Isolation::NestedContainer || !config.job_docker { | |
| 475 | return None; | |
| 476 | } | |
| 477 | let image = config.job_docker_image.as_deref().unwrap_or(super::engine::DEFAULT_JOB_ENGINE_IMAGE); | |
| 478 | match JobEngine::prepare(name, &config.runner, image) { | |
| 479 | Ok(engine) => Some(engine), | |
| 480 | Err(error) => { | |
| 481 | log(&format!("This job has no Docker of its own: {error:#}")); | |
| 482 | None | |
| 483 | } | |
| 484 | } | |
| Cache hits are counted, not only their bytes | 485 | } |
| 486 | ||
| Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r9 | 487 | #[cfg(not(unix))] |
| 488 | fn job_engine(_config: &Config, _isolation: Isolation, _name: &str) -> Option<JobEngine> { | |
| 489 | None | |
| 490 | } | |
| 491 | ||
| Cache hits are counted, not only their bytes | 492 | fn directly(config: &Config, work: &Assignment, env: BTreeMap<String, String>) -> Result<Running> { |
| 493 | let me = std::env::current_exe().context("could not find this program to run the job")?; | |
| 494 | let mut command = Command::new(me); | |
| 495 | let mut folder = None; | |
| 496 | if work.kind == "workflow" { | |
| 497 | // GitHub's layout, in a folder of its own. | |
| 498 | let root = config.work_dir.join(container_name(&work.id)); | |
| 499 | let _ = std::fs::remove_dir_all(&root); | |
| 500 | std::fs::create_dir_all(&root).with_context(|| format!("could not make {}", root.display()))?; | |
| 501 | command.env("G1T_RUNNER_ROOT", &root); | |
| 502 | folder = Some(root); | |
| 503 | } else if !cfg!(target_os = "linux") || std::fs::create_dir_all("/work").is_err() { | |
| The runner no longer runs g1t's own agents. Cloud and self-hosted runners now do what a forge's runner does: Actions jobs, required checks, merge checks and merge-queue test runs, deployments, dependency bumps and backups. Everything that started Claude Code in a sandbox on an issue or pull request is gone, with its prompts, model access, mentions, delegation, plans, reviews, revisions and the workspace agents' computers. | 504 | // g1t's own work expects its sandbox layout (/work), which only a |
| Cache hits are counted, not only their bytes | 505 | // Linux machine (or a container) set aside for it has. |
| The runner no longer runs g1t's own agents. Cloud and self-hosted runners now do what a forge's runner does: Actions jobs, required checks, merge checks and merge-queue test runs, deployments, dependency bumps and backups. Everything that started Claude Code in a sandbox on an issue or pull request is gone, with its prompts, model access, mentions, delegation, plans, reviews, revisions and the workspace agents' computers. | 506 | bail!("g1t's own work runs in Docker, or directly on Linux where /work can be written; this runner can do neither"); |
| Cache hits are counted, not only their bytes | 507 | } |
| 508 | // Its own variables, less anything of the runner's. | |
| 509 | for (name, _) in std::env::vars_os() { | |
| 510 | if name.to_string_lossy().starts_with("G1T_RUNNER_") && name != "G1T_RUNNER_ROOT" { | |
| 511 | command.env_remove(&name); | |
| 512 | } | |
| 513 | } | |
| 514 | command.envs(&env); | |
| Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r9 | 515 | // The job's log says it ran unsandboxed (actions/mod.rs). |
| 516 | command.env(JOB_NOTICE, sandbox::HOST_MODE_IN_JOB); | |
| Cache hits are counted, not only their bytes | 517 | // A group of its own, so stopping it stops what its steps started. |
| 518 | #[cfg(unix)] | |
| 519 | std::os::unix::process::CommandExt::process_group(&mut command, 0); | |
| Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r9 | 520 | log(&format!("Running {} {} on this machine, unsandboxed (host mode, deprecated)", work.kind, work.name)); |
| Cache hits are counted, not only their bytes | 521 | let child = command.spawn().context("could not start the job's process")?; |
| Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r9 | 522 | Ok(Running { |
| 523 | id: work.id.clone(), | |
| Merge the runner sandbox: g1t-runner sandbox up makes a person's own computer a self-hosted runner that nothing a job runs can get out of. Linux jobs run in a Linux VM of the runner's own (a WSL 2 distribution with no host mounts, interop or Docker socket on Windows; vfkit on macOS; Sysbox, or QEMU and KVM, on Linux), each in a fresh container under Sysbox inside it; on Windows 11 Pro and up, Windows jobs each run in a Windows Sandbox discarded after the job. Runners report their isolation, and the Runners page shows it. | 524 | repo: work.repo.clone(), |
| Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r9 | 525 | kind: work.kind.clone(), |
| 526 | name: work.name.clone(), | |
| 527 | child, | |
| 528 | container: None, | |
| 529 | network: None, | |
| 530 | volume: None, | |
| 531 | engine: None, | |
| 532 | folder, | |
| Merge the runner sandbox: g1t-runner sandbox up makes a person's own computer a self-hosted runner that nothing a job runs can get out of. Linux jobs run in a Linux VM of the runner's own (a WSL 2 distribution with no host mounts, interop or Docker socket on Windows; vfkit on macOS; Sysbox, or QEMU and KVM, on Linux), each in a fresh container under Sysbox inside it; on Windows 11 Pro and up, Windows jobs each run in a Windows Sandbox discarded after the job. Runners report their isolation, and the Runners page shows it. | 533 | native: None, |
| 534 | exit: None, | |
| Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r9 | 535 | deadline: deadline(work), |
| 536 | }) | |
| Cache hits are counted, not only their bytes | 537 | } |
| 538 | ||
| Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r9 | 539 | /// A warning the harness puts at the top of the job's log. |
| 540 | pub const JOB_NOTICE: &str = "G1T_JOB_NOTICE"; | |
| 541 | ||
| Cache hits are counted, not only their bytes | 542 | fn deadline(work: &Assignment) -> Instant { |
| 543 | Instant::now() + Duration::from_secs(u64::from(work.timeout_minutes.max(1)) * 60 + 10 * 60) | |
| 544 | } | |
| 545 | ||
| 546 | /// Whether Docker answers. | |
| 547 | pub fn docker_ready() -> bool { | |
| Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r9 | 548 | docker() |
| Cache hits are counted, not only their bytes | 549 | .args(["version", "--format", "{{.Server.Version}}"]) |
| 550 | .stdout(Stdio::null()) | |
| 551 | .stderr(Stdio::null()) | |
| 552 | .status() | |
| 553 | .is_ok_and(|status| status.success()) | |
| 554 | } | |
| 555 | ||
| Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r9 | 556 | /// Removes what jobs of this runner's left behind, if it stopped while one ran. |
| 557 | pub fn sweep(config: &Config) { | |
| 558 | let label = format!("label=sh.g1t.runner={}", config.runner); | |
| 559 | for (list, remove) in [ | |
| 560 | (vec!["ps", "-aq", "--filter", &label], vec!["rm", "-f", "-v"]), | |
| 561 | (vec!["network", "ls", "-q", "--filter", &label], vec!["network", "rm"]), | |
| 562 | (vec!["volume", "ls", "-q", "--filter", &label], vec!["volume", "rm", "-f"]), | |
| 563 | ] { | |
| 564 | let Ok(output) = docker().args(&list).output() else { return }; | |
| 565 | let ids: Vec<String> = String::from_utf8_lossy(&output.stdout).split_whitespace().map(str::to_owned).collect(); | |
| 566 | if !ids.is_empty() { | |
| 567 | quietly(docker().args(&remove).args(&ids)); | |
| 568 | } | |
| 569 | } | |
| 570 | } | |
| 571 | ||
| Cache hits are counted, not only their bytes | 572 | #[cfg(test)] |
| 573 | mod tests { | |
| 574 | use super::*; | |
| 575 | use serde_json::json; | |
| 576 | ||
| 577 | fn config() -> Config { | |
| 578 | Config { | |
| 579 | url: "https://g1t.sh".into(), | |
| 580 | api: "https://api.g1t.sh".into(), | |
| 581 | runner: "rnr_1".into(), | |
| 582 | name: "a".into(), | |
| 583 | credential: "g1tr_secret".into(), | |
| 584 | workspace: "acme".into(), | |
| 585 | repo: None, | |
| 586 | group: None, | |
| 587 | labels: vec![], | |
| 588 | ephemeral: false, | |
| 589 | work_dir: PathBuf::from("/tmp/w"), | |
| 590 | docker: true, | |
| 591 | image: None, | |
| 592 | harness: None, | |
| 593 | auto_update: false, | |
| Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r9 | 594 | memory: None, |
| 595 | cpus: None, | |
| 596 | pids_limit: None, | |
| 597 | job_docker: true, | |
| 598 | job_docker_image: None, | |
| Merge the runner sandbox: g1t-runner sandbox up makes a person's own computer a self-hosted runner that nothing a job runs can get out of. Linux jobs run in a Linux VM of the runner's own (a WSL 2 distribution with no host mounts, interop or Docker socket on Windows; vfkit on macOS; Sysbox, or QEMU and KVM, on Linux), each in a fresh container under Sysbox inside it; on Windows 11 Pro and up, Windows jobs each run in a Windows Sandbox discarded after the job. Runners report their isolation, and the Runners page shows it. | 599 | native: false, |
| Cache hits are counted, not only their bytes | 600 | } |
| 601 | } | |
| 602 | ||
| 603 | fn assignment(kind: &str) -> Assignment { | |
| 604 | serde_json::from_value(json!({ | |
| 605 | "kind": kind, "id": "job_1", "name": "build", "repo": "acme/web", "timeout_minutes": 60, | |
| The runner no longer runs g1t's own agents. Cloud and self-hosted runners now do what a forge's runner does: Actions jobs, required checks, merge checks and merge-queue test runs, deployments, dependency bumps and backups. Everything that started Claude Code in a sandbox on an issue or pull request is gone, with its prompts, model access, mentions, delegation, plans, reviews, revisions and the workspace agents' computers. | 606 | "token": "jobtoken", "env": { "MODE": "queue", "G1T_TOKEN": "g1t_run", "G1T_API": "https://elsewhere" } |
| Cache hits are counted, not only their bytes | 607 | })) |
| 608 | .unwrap() | |
| 609 | } | |
| 610 | ||
| 611 | #[test] | |
| 612 | fn a_workflow_job_gets_its_own_token_and_never_the_runners() { | |
| 613 | let env = harness_env(&config(), &assignment("workflow")).unwrap(); | |
| 614 | assert_eq!(env["MODE"], "actions"); | |
| 615 | assert_eq!(env["ACTIONS_JOB"], "job_1"); | |
| 616 | assert_eq!(env["ACTIONS_TOKEN"], "jobtoken"); | |
| 617 | assert_eq!(env["G1T_API"], "https://api.g1t.sh"); | |
| 618 | assert!(env.values().all(|value| !value.contains("g1tr_"))); | |
| Merge main into rm-agents: mirroring removal, open signups, free-tier limits, the g1t CLI and runner fixes, with the agent removal kept. The CLI's table of operations is regenerated from the API without the agent, customize and AI credit operations; no CLI command used them. | 619 | // That token is spent on fetching the job. |
| 620 | assert_eq!(env[crate::actions::ROTATE_TOKEN], "1"); | |
| 621 | assert!(!harness_env(&config(), &assignment("agent")).unwrap().contains_key(crate::actions::ROTATE_TOKEN)); | |
| Cache hits are counted, not only their bytes | 622 | } |
| 623 | ||
| 624 | #[test] | |
| The runner no longer runs g1t's own agents. Cloud and self-hosted runners now do what a forge's runner does: Actions jobs, required checks, merge checks and merge-queue test runs, deployments, dependency bumps and backups. Everything that started Claude Code in a sandbox on an issue or pull request is gone, with its prompts, model access, mentions, delegation, plans, reviews, revisions and the workspace agents' computers. | 625 | fn g1ts_own_work_gets_the_sandboxs_environment_and_this_api() { |
| 626 | let env = harness_env(&config(), &assignment(TASK_KIND)).unwrap(); | |
| 627 | assert_eq!(env["MODE"], "queue"); | |
| Cache hits are counted, not only their bytes | 628 | assert_eq!(env["G1T_TOKEN"], "g1t_run"); |
| 629 | assert_eq!(env["G1T_API"], "https://api.g1t.sh"); | |
| 630 | assert_eq!(env["G1T_ABUSE"], "off"); | |
| 631 | } | |
| 632 | ||
| 633 | #[test] | |
| 634 | fn unknown_work_is_refused() { | |
| 635 | assert!(harness_env(&config(), &assignment("deploy")).is_err()); | |
| 636 | } | |
| 637 | ||
| 638 | #[test] | |
| The runner no longer runs g1t's own agents. Cloud and self-hosted runners now do what a forge's runner does: Actions jobs, required checks, merge checks and merge-queue test runs, deployments, dependency bumps and backups. Everything that started Claude Code in a sandbox on an issue or pull request is gone, with its prompts, model access, mentions, delegation, plans, reviews, revisions and the workspace agents' computers. | 639 | fn an_agents_run_from_an_older_g1t_is_refused_before_it_starts() { |
| 640 | for env in [json!({ "MODE": "revise", "G1T_TOKEN": "g1t_run" }), json!({ "PROMPT": "Fix it", "G1T_TOKEN": "g1t_run" })] { | |
| 641 | let mut work = assignment(TASK_KIND); | |
| 642 | work.env = serde_json::from_value(env).unwrap(); | |
| 643 | assert!(harness_env(&config(), &work).is_err()); | |
| 644 | assert!(start(&config(), Isolation::Host, Path::new("/tmp"), &work).is_err()); | |
| 645 | } | |
| 646 | } | |
| 647 | ||
| 648 | #[test] | |
| Cache hits are counted, not only their bytes | 649 | fn container_names_are_dockers() { |
| 650 | assert_eq!(container_name("job_01ab"), "g1t-job_01ab"); | |
| 651 | assert_eq!(container_name("a/b c"), "g1t-a-b-c"); | |
| 652 | } | |
| Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r9 | 653 | |
| 654 | fn args_for(kind: &str, engine_args: Vec<String>) -> (Vec<String>, BTreeMap<String, String>) { | |
| 655 | let config = config(); | |
| 656 | let env = harness_env(&config, &assignment(kind)).unwrap(); | |
| 657 | let limits = Limits { memory: "6144m".into(), cpus: "4".into(), pids: 4096 }; | |
| 658 | let harness = PathBuf::from("/usr/local/bin/g1t-runner"); | |
| 659 | let plan = Plan { | |
| 660 | name: "g1t-job_1", | |
| 661 | runner: "rnr_1", | |
| 662 | image: DEFAULT_IMAGE, | |
| 663 | harness: &harness, | |
| 664 | network: "g1t-job_1", | |
| 665 | volume: (kind == "workflow").then_some("g1t-job_1-work"), | |
| 666 | limits: &limits, | |
| 667 | env_names: env.keys().map(String::as_str).collect(), | |
| 668 | engine_args, | |
| 669 | }; | |
| 670 | (run_args(&plan), env.clone()) | |
| 671 | } | |
| 672 | ||
| 673 | fn value_of<'a>(args: &'a [String], flag: &str) -> Vec<&'a str> { | |
| 674 | args.windows(2).filter(|w| w[0] == flag).map(|w| w[1].as_str()).collect() | |
| 675 | } | |
| 676 | ||
| 677 | #[test] | |
| 678 | fn a_jobs_container_is_walled_in() { | |
| 679 | let (args, _) = args_for("workflow", Vec::new()); | |
| 680 | assert_eq!(args[0], "run"); | |
| 681 | assert!(args.contains(&"--rm".to_owned())); | |
| 682 | assert_eq!(value_of(&args, "--cap-drop"), vec!["ALL"]); | |
| 683 | assert_eq!(value_of(&args, "--cap-add"), JOB_CAPS.to_vec()); | |
| 684 | assert_eq!(value_of(&args, "--security-opt"), vec!["no-new-privileges"]); | |
| 685 | assert_eq!(value_of(&args, "--pids-limit"), vec!["4096"]); | |
| 686 | assert_eq!(value_of(&args, "--memory"), vec!["6144m"]); | |
| 687 | assert_eq!(value_of(&args, "--memory-swap"), vec!["6144m"]); | |
| 688 | assert_eq!(value_of(&args, "--cpus"), vec!["4"]); | |
| 689 | assert_eq!(value_of(&args, "--network"), vec!["g1t-job_1"]); | |
| 690 | for forbidden in ["--privileged", "--pid", "--userns", "--device", "host"] { | |
| 691 | assert!(!args.iter().any(|a| a == forbidden), "{forbidden} in {args:?}"); | |
| 692 | } | |
| 693 | assert!(!JOB_CAPS.iter().any(|c| c.starts_with("SYS_") || *c == "NET_ADMIN" || *c == "NET_RAW")); | |
| 694 | } | |
| 695 | ||
| 696 | #[test] | |
| 697 | fn a_jobs_only_mounts_are_the_harness_and_its_own_volume() { | |
| 698 | let (args, _) = args_for("workflow", Vec::new()); | |
| 699 | let mounts = value_of(&args, "-v"); | |
| 700 | assert_eq!(mounts, vec!["/usr/local/bin/g1t-runner:/opt/g1t/g1t-runner:ro", "g1t-job_1-work:/home/runner"]); | |
| 701 | assert!(!args.iter().any(|a| a.contains("docker.sock")), "no engine socket: {args:?}"); | |
| 702 | assert!(!args.iter().any(|a| a == "--mount")); | |
| 703 | } | |
| 704 | ||
| 705 | #[test] | |
| 706 | fn credentials_go_by_name_and_the_runners_never() { | |
| 707 | let (args, env) = args_for("workflow", Vec::new()); | |
| 708 | let passed = value_of(&args, "-e"); | |
| 709 | assert!(passed.contains(&"ACTIONS_TOKEN")); | |
| 710 | // Never a value on the command line, and never the runner's credential. | |
| 711 | assert!(!args.iter().any(|a| a.contains("jobtoken"))); | |
| 712 | assert!(!args.iter().any(|a| a.contains("g1tr_"))); | |
| 713 | assert!(env.values().all(|v| !v.contains("g1tr_secret"))); | |
| 714 | assert!(!env.contains_key("G1T_RUNNER_TOKEN")); | |
| 715 | } | |
| 716 | ||
| 717 | #[test] | |
| The runner no longer runs g1t's own agents. Cloud and self-hosted runners now do what a forge's runner does: Actions jobs, required checks, merge checks and merge-queue test runs, deployments, dependency bumps and backups. Everything that started Claude Code in a sandbox on an issue or pull request is gone, with its prompts, model access, mentions, delegation, plans, reviews, revisions and the workspace agents' computers. | 718 | fn g1ts_own_work_has_every_wall_a_job_has() { |
| 719 | let (args, _) = args_for(TASK_KIND, Vec::new()); | |
| 720 | assert_eq!(value_of(&args, "--security-opt"), vec!["no-new-privileges"]); | |
| 721 | assert_eq!(value_of(&args, "--cap-add"), JOB_CAPS.to_vec()); | |
| Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r9 | 722 | assert_eq!(value_of(&args, "--cap-drop"), vec!["ALL"]); |
| 723 | assert_eq!(value_of(&args, "-v"), vec!["/usr/local/bin/g1t-runner:/opt/g1t/g1t-runner:ro"]); | |
| 724 | } | |
| 725 | ||
| 726 | #[test] | |
| 727 | fn a_job_with_docker_gets_a_socket_of_its_own_not_the_runners() { | |
| 728 | let engine_args = vec!["-v".into(), "/run/g1t-runner/jobs/g1t-job_1/proxy:/run/g1t-docker:ro".into(), "-e".into(), "DOCKER_HOST=unix:///run/g1t-docker/docker.sock".into()]; | |
| 729 | let (args, _) = args_for("workflow", engine_args); | |
| 730 | let mounts = value_of(&args, "-v"); | |
| 731 | assert!(mounts.contains(&"/run/g1t-runner/jobs/g1t-job_1/proxy:/run/g1t-docker:ro")); | |
| 732 | assert!(!mounts.iter().any(|m| m.starts_with("/var/run/docker.sock") || m.starts_with("/run/g1t-runner/docker.sock"))); | |
| 733 | // The entrypoint and image stay last. | |
| 734 | assert_eq!(&args[args.len() - 3..], ["--entrypoint", "/opt/g1t/g1t-runner", DEFAULT_IMAGE]); | |
| 735 | } | |
| 736 | ||
| 737 | #[test] | |
| 738 | fn limits_default_sensibly() { | |
| 739 | assert_eq!(default_memory("MemTotal: 16384000 kB\nMemFree: 1 kB\n"), Some("12000m".into())); | |
| 740 | assert_eq!(default_memory(""), None); | |
| 741 | let mut config = config(); | |
| 742 | config.memory = Some("2g".into()); | |
| 743 | config.pids_limit = Some(100); | |
| 744 | let limits = limits(&config); | |
| 745 | assert_eq!(limits.memory, "2g"); | |
| 746 | assert_eq!(limits.pids, 100); | |
| 747 | assert!(limits.cpus.parse::<f64>().unwrap() >= 1.0); | |
| 748 | } | |
| 749 | ||
| 750 | #[test] | |
| 751 | fn host_mode_refuses_a_public_repositorys_work() { | |
| 752 | let mut work = assignment("workflow"); | |
| 753 | work.visibility = Some("public".into()); | |
| 754 | let refused = refuse_public_on_host(&work).unwrap_err().to_string(); | |
| 755 | assert!(refused.contains("acme/web is public"), "{refused}"); | |
| 756 | work.visibility = Some("private".into()); | |
| 757 | assert!(refuse_public_on_host(&work).is_ok()); | |
| 758 | work.visibility = None; | |
| 759 | assert!(refuse_public_on_host(&work).is_ok()); | |
| 760 | // And refuses it before anything runs. | |
| 761 | work.visibility = Some("public".into()); | |
| 762 | assert!(start(&config(), Isolation::Host, Path::new("/tmp"), &work).is_err()); | |
| 763 | } | |
| Cache hits are counted, not only their bytes | 764 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.