Skip to content
758 linesCodeBlameRaw

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.

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

This file's history is long; its oldest lines are credited to the oldest commit read.