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.

Fast pages, required checks on the branch, self-hosted runners, honest incidents1//! 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.
Fast pages, required checks on the branch, self-hosted runners, honest incidents17//!
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.
Fast pages, required checks on the branch, self-hosted runners, honest incidents21
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};
Fast pages, required checks on the branch, self-hosted runners, honest incidents32use 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"];
Fast pages, required checks on the branch, self-hosted runners, honest incidents56
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
Fast pages, required checks on the branch, self-hosted runners, honest incidents64/// Work in progress.
65pub struct Running {
66 pub id: String,
67 pub kind: String,
68 pub name: String,
Self-hosted runners on a person's own computer are sandboxes that nothing a job runs can get out of. `g1t-runner sandbox up|down|status|update [--json]` gives a Windows, macOS or Linux computer up to two capabilities, each a runner of its own: Linux jobs in a Linux VM the runner owns, where the runner's image runs under Sysbox and each job is a fresh container inside it (nested-container, three walls from the computer); and jobs native to Windows, each in a Windows Sandbox of its own, discarded after, with one read-only folder holding the harness (a new `vm` isolation level, also `g1t-runner register --native`). On Windows the VM is a WSL 2 distribution imported from the release's signed root filesystem: its wsl.conf turns off drives, Windows programs and Windows' PATH, and the supervisor inside unmounts what WSL shares with every distribution anyway (/mnt/wsl, where Docker Desktop keeps its sockets to Windows, WSLg, the GPU driver folders), refuses to run while anything of the computer's is reachable, and drops containers' traffic to the computer's address but for DNS. It needs no administrator once WSL is installed, and works on Windows 11 Home. On macOS the VM runs under vfkit and on Linux without Sysbox under QEMU with KVM, with no shared folders and nothing forwarded in (built and tested as commands, not yet run on those machines); on Linux with Sysbox no VM is needed. `status --json` reports the state, versions, isolation, the current job and a sentence when something is wrong, and each capability's availability with its cause and fix, for the desktop app. `update` drains the runner, swaps in the newest signed image and starts again without registering again. The sandbox's images are built from deploy/sandbox/Dockerfile by scripts/sandbox-image.mjs and listed with their SHA-256 in the release's signed manifest, beside the runner image's digest. The release workflow now builds its images on g1t's own machines (g1t-4core) rather than waiting forever for a self-hosted runner, registering QEMU for arm64 where the machine allows it and building amd64 alone with a warning where it does not, and signs and publishes once the images are built. The Runners page shows each runner's isolation (Sandboxed, VM per job, Shared Docker, Unsandboxed), which the API now returns as `isolation`. The Self-hosted runners guide has a new "Sandbox on your computer" section, with what it cannot stop.69 pub repo: String,
Fast pages, required checks on the branch, self-hosted runners, honest incidents70 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>,
Fast pages, required checks on the branch, self-hosted runners, honest incidents77 folder: Option<PathBuf>,
Self-hosted runners on a person's own computer are sandboxes that nothing a job runs can get out of. `g1t-runner sandbox up|down|status|update [--json]` gives a Windows, macOS or Linux computer up to two capabilities, each a runner of its own: Linux jobs in a Linux VM the runner owns, where the runner's image runs under Sysbox and each job is a fresh container inside it (nested-container, three walls from the computer); and jobs native to Windows, each in a Windows Sandbox of its own, discarded after, with one read-only folder holding the harness (a new `vm` isolation level, also `g1t-runner register --native`). On Windows the VM is a WSL 2 distribution imported from the release's signed root filesystem: its wsl.conf turns off drives, Windows programs and Windows' PATH, and the supervisor inside unmounts what WSL shares with every distribution anyway (/mnt/wsl, where Docker Desktop keeps its sockets to Windows, WSLg, the GPU driver folders), refuses to run while anything of the computer's is reachable, and drops containers' traffic to the computer's address but for DNS. It needs no administrator once WSL is installed, and works on Windows 11 Home. On macOS the VM runs under vfkit and on Linux without Sysbox under QEMU with KVM, with no shared folders and nothing forwarded in (built and tested as commands, not yet run on those machines); on Linux with Sysbox no VM is needed. `status --json` reports the state, versions, isolation, the current job and a sentence when something is wrong, and each capability's availability with its cause and fix, for the desktop app. `update` drains the runner, swaps in the newest signed image and starts again without registering again. The sandbox's images are built from deploy/sandbox/Dockerfile by scripts/sandbox-image.mjs and listed with their SHA-256 in the release's signed manifest, beside the runner image's digest. The release workflow now builds its images on g1t's own machines (g1t-4core) rather than waiting forever for a self-hosted runner, registering QEMU for arm64 where the machine allows it and building amd64 alone with a warning where it does not, and signs and publishes once the images are built. The Runners page shows each runner's isolation (Sandboxed, VM per job, Shared Docker, Unsandboxed), which the API now returns as `isolation`. The Self-hosted runners guide has a new "Sandbox on your computer" section, with what it cannot stop.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>,
Fast pages, required checks on the branch, self-hosted runners, honest incidents84 /// Past its time limit and a grace for g1t to say so: stopped here.
85 deadline: Instant,
86}
87
Self-hosted runners on a person's own computer are sandboxes that nothing a job runs can get out of. `g1t-runner sandbox up|down|status|update [--json]` gives a Windows, macOS or Linux computer up to two capabilities, each a runner of its own: Linux jobs in a Linux VM the runner owns, where the runner's image runs under Sysbox and each job is a fresh container inside it (nested-container, three walls from the computer); and jobs native to Windows, each in a Windows Sandbox of its own, discarded after, with one read-only folder holding the harness (a new `vm` isolation level, also `g1t-runner register --native`). On Windows the VM is a WSL 2 distribution imported from the release's signed root filesystem: its wsl.conf turns off drives, Windows programs and Windows' PATH, and the supervisor inside unmounts what WSL shares with every distribution anyway (/mnt/wsl, where Docker Desktop keeps its sockets to Windows, WSLg, the GPU driver folders), refuses to run while anything of the computer's is reachable, and drops containers' traffic to the computer's address but for DNS. It needs no administrator once WSL is installed, and works on Windows 11 Home. On macOS the VM runs under vfkit and on Linux without Sysbox under QEMU with KVM, with no shared folders and nothing forwarded in (built and tested as commands, not yet run on those machines); on Linux with Sysbox no VM is needed. `status --json` reports the state, versions, isolation, the current job and a sentence when something is wrong, and each capability's availability with its cause and fix, for the desktop app. `update` drains the runner, swaps in the newest signed image and starts again without registering again. The sandbox's images are built from deploy/sandbox/Dockerfile by scripts/sandbox-image.mjs and listed with their SHA-256 in the release's signed manifest, beside the runner image's digest. The release workflow now builds its images on g1t's own machines (g1t-4core) rather than waiting forever for a self-hosted runner, registering QEMU for arm64 where the machine allows it and building amd64 alone with a warning where it does not, and signs and publishes once the images are built. The Runners page shows each runner's isolation (Sandboxed, VM per job, Shared Docker, Unsandboxed), which the API now returns as `isolation`. The Self-hosted runners guide has a new "Sandbox on your computer" section, with what it cannot stop.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
Fast pages, required checks on the branch, self-hosted runners, honest incidents117impl 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> {
Self-hosted runners on a person's own computer are sandboxes that nothing a job runs can get out of. `g1t-runner sandbox up|down|status|update [--json]` gives a Windows, macOS or Linux computer up to two capabilities, each a runner of its own: Linux jobs in a Linux VM the runner owns, where the runner's image runs under Sysbox and each job is a fresh container inside it (nested-container, three walls from the computer); and jobs native to Windows, each in a Windows Sandbox of its own, discarded after, with one read-only folder holding the harness (a new `vm` isolation level, also `g1t-runner register --native`). On Windows the VM is a WSL 2 distribution imported from the release's signed root filesystem: its wsl.conf turns off drives, Windows programs and Windows' PATH, and the supervisor inside unmounts what WSL shares with every distribution anyway (/mnt/wsl, where Docker Desktop keeps its sockets to Windows, WSLg, the GPU driver folders), refuses to run while anything of the computer's is reachable, and drops containers' traffic to the computer's address but for DNS. It needs no administrator once WSL is installed, and works on Windows 11 Home. On macOS the VM runs under vfkit and on Linux without Sysbox under QEMU with KVM, with no shared folders and nothing forwarded in (built and tested as commands, not yet run on those machines); on Linux with Sysbox no VM is needed. `status --json` reports the state, versions, isolation, the current job and a sentence when something is wrong, and each capability's availability with its cause and fix, for the desktop app. `update` drains the runner, swaps in the newest signed image and starts again without registering again. The sandbox's images are built from deploy/sandbox/Dockerfile by scripts/sandbox-image.mjs and listed with their SHA-256 in the release's signed manifest, beside the runner image's digest. The release workflow now builds its images on g1t's own machines (g1t-4core) rather than waiting forever for a self-hosted runner, registering QEMU for arm64 where the machine allows it and building amd64 alone with a warning where it does not, and signs and publishes once the images are built. The Runners page shows each runner's isolation (Sandboxed, VM per job, Shared Docker, Unsandboxed), which the API now returns as `isolation`. The Self-hosted runners guide has a new "Sandbox on your computer" section, with what it cannot stop.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 }
Fast pages, required checks on the branch, self-hosted runners, honest incidents136 Ok(Some(status)) => Some(status.code().unwrap_or(1)),
137 Ok(None) => None,
138 Err(_) => Some(1),
Self-hosted runners on a person's own computer are sandboxes that nothing a job runs can get out of. `g1t-runner sandbox up|down|status|update [--json]` gives a Windows, macOS or Linux computer up to two capabilities, each a runner of its own: Linux jobs in a Linux VM the runner owns, where the runner's image runs under Sysbox and each job is a fresh container inside it (nested-container, three walls from the computer); and jobs native to Windows, each in a Windows Sandbox of its own, discarded after, with one read-only folder holding the harness (a new `vm` isolation level, also `g1t-runner register --native`). On Windows the VM is a WSL 2 distribution imported from the release's signed root filesystem: its wsl.conf turns off drives, Windows programs and Windows' PATH, and the supervisor inside unmounts what WSL shares with every distribution anyway (/mnt/wsl, where Docker Desktop keeps its sockets to Windows, WSLg, the GPU driver folders), refuses to run while anything of the computer's is reachable, and drops containers' traffic to the computer's address but for DNS. It needs no administrator once WSL is installed, and works on Windows 11 Home. On macOS the VM runs under vfkit and on Linux without Sysbox under QEMU with KVM, with no shared folders and nothing forwarded in (built and tested as commands, not yet run on those machines); on Linux with Sysbox no VM is needed. `status --json` reports the state, versions, isolation, the current job and a sentence when something is wrong, and each capability's availability with its cause and fix, for the desktop app. `update` drains the runner, swaps in the newest signed image and starts again without registering again. The sandbox's images are built from deploy/sandbox/Dockerfile by scripts/sandbox-image.mjs and listed with their SHA-256 in the release's signed manifest, beside the runner image's digest. The release workflow now builds its images on g1t's own machines (g1t-4core) rather than waiting forever for a self-hosted runner, registering QEMU for arm64 where the machine allows it and building amd64 alone with a warning where it does not, and signs and publishes once the images are built. The Runners page shows each runner's isolation (Sandboxed, VM per job, Shared Docker, Unsandboxed), which the API now returns as `isolation`. The Self-hosted runners guide has a new "Sandbox on your computer" section, with what it cannot stop.139 };
140 self.exit
Fast pages, required checks on the branch, self-hosted runners, honest incidents141 }
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]));
Fast pages, required checks on the branch, self-hosted runners, honest incidents151 }
Self-hosted runners on a person's own computer are sandboxes that nothing a job runs can get out of. `g1t-runner sandbox up|down|status|update [--json]` gives a Windows, macOS or Linux computer up to two capabilities, each a runner of its own: Linux jobs in a Linux VM the runner owns, where the runner's image runs under Sysbox and each job is a fresh container inside it (nested-container, three walls from the computer); and jobs native to Windows, each in a Windows Sandbox of its own, discarded after, with one read-only folder holding the harness (a new `vm` isolation level, also `g1t-runner register --native`). On Windows the VM is a WSL 2 distribution imported from the release's signed root filesystem: its wsl.conf turns off drives, Windows programs and Windows' PATH, and the supervisor inside unmounts what WSL shares with every distribution anyway (/mnt/wsl, where Docker Desktop keeps its sockets to Windows, WSLg, the GPU driver folders), refuses to run while anything of the computer's is reachable, and drops containers' traffic to the computer's address but for DNS. It needs no administrator once WSL is installed, and works on Windows 11 Home. On macOS the VM runs under vfkit and on Linux without Sysbox under QEMU with KVM, with no shared folders and nothing forwarded in (built and tested as commands, not yet run on those machines); on Linux with Sysbox no VM is needed. `status --json` reports the state, versions, isolation, the current job and a sentence when something is wrong, and each capability's availability with its cause and fix, for the desktop app. `update` drains the runner, swaps in the newest signed image and starts again without registering again. The sandbox's images are built from deploy/sandbox/Dockerfile by scripts/sandbox-image.mjs and listed with their SHA-256 in the release's signed manifest, beside the runner image's digest. The release workflow now builds its images on g1t's own machines (g1t-4core) rather than waiting forever for a self-hosted runner, registering QEMU for arm64 where the machine allows it and building amd64 alone with a warning where it does not, and signs and publishes once the images are built. The Runners page shows each runner's isolation (Sandboxed, VM per job, Shared Docker, Unsandboxed), which the API now returns as `isolation`. The Self-hosted runners guide has a new "Sandbox on your computer" section, with what it cannot stop.152 if let Some(job) = &self.native {
153 job.stop();
154 }
Fast pages, required checks on the branch, self-hosted runners, honest incidents155 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]));
Fast pages, required checks on the branch, self-hosted runners, honest incidents158 } 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}")]));
Fast pages, required checks on the branch, self-hosted runners, honest incidents161 }
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.
Fast pages, required checks on the branch, self-hosted runners, honest incidents168 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();
Fast pages, required checks on the branch, self-hosted runners, honest incidents174 }
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 }
Fast pages, required checks on the branch, self-hosted runners, honest incidents181 if let Some(folder) = &self.folder {
182 let _ = std::fs::remove_dir_all(folder);
183 }
Self-hosted runners on a person's own computer are sandboxes that nothing a job runs can get out of. `g1t-runner sandbox up|down|status|update [--json]` gives a Windows, macOS or Linux computer up to two capabilities, each a runner of its own: Linux jobs in a Linux VM the runner owns, where the runner's image runs under Sysbox and each job is a fresh container inside it (nested-container, three walls from the computer); and jobs native to Windows, each in a Windows Sandbox of its own, discarded after, with one read-only folder holding the harness (a new `vm` isolation level, also `g1t-runner register --native`). On Windows the VM is a WSL 2 distribution imported from the release's signed root filesystem: its wsl.conf turns off drives, Windows programs and Windows' PATH, and the supervisor inside unmounts what WSL shares with every distribution anyway (/mnt/wsl, where Docker Desktop keeps its sockets to Windows, WSLg, the GPU driver folders), refuses to run while anything of the computer's is reachable, and drops containers' traffic to the computer's address but for DNS. It needs no administrator once WSL is installed, and works on Windows 11 Home. On macOS the VM runs under vfkit and on Linux without Sysbox under QEMU with KVM, with no shared folders and nothing forwarded in (built and tested as commands, not yet run on those machines); on Linux with Sysbox no VM is needed. `status --json` reports the state, versions, isolation, the current job and a sentence when something is wrong, and each capability's availability with its cause and fix, for the desktop app. `update` drains the runner, swaps in the newest signed image and starts again without registering again. The sandbox's images are built from deploy/sandbox/Dockerfile by scripts/sandbox-image.mjs and listed with their SHA-256 in the release's signed manifest, beside the runner image's digest. The release workflow now builds its images on g1t's own machines (g1t-4core) rather than waiting forever for a self-hosted runner, registering QEMU for arm64 where the machine allows it and building amd64 alone with a warning where it does not, and signs and publishes once the images are built. The Runners page shows each runner's isolation (Sandboxed, VM per job, Shared Docker, Unsandboxed), which the API now returns as `isolation`. The Self-hosted runners guide has a new "Sandbox on your computer" section, with what it cannot stop.184 if let Some(job) = self.native.take() {
185 job.clean();
186 }
Fast pages, required checks on the branch, self-hosted runners, honest incidents187 }
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> {
Fast pages, required checks on the branch, self-hosted runners, honest incidents231 let env = harness_env(config, work)?;
Self-hosted runners on a person's own computer are sandboxes that nothing a job runs can get out of. `g1t-runner sandbox up|down|status|update [--json]` gives a Windows, macOS or Linux computer up to two capabilities, each a runner of its own: Linux jobs in a Linux VM the runner owns, where the runner's image runs under Sysbox and each job is a fresh container inside it (nested-container, three walls from the computer); and jobs native to Windows, each in a Windows Sandbox of its own, discarded after, with one read-only folder holding the harness (a new `vm` isolation level, also `g1t-runner register --native`). On Windows the VM is a WSL 2 distribution imported from the release's signed root filesystem: its wsl.conf turns off drives, Windows programs and Windows' PATH, and the supervisor inside unmounts what WSL shares with every distribution anyway (/mnt/wsl, where Docker Desktop keeps its sockets to Windows, WSLg, the GPU driver folders), refuses to run while anything of the computer's is reachable, and drops containers' traffic to the computer's address but for DNS. It needs no administrator once WSL is installed, and works on Windows 11 Home. On macOS the VM runs under vfkit and on Linux without Sysbox under QEMU with KVM, with no shared folders and nothing forwarded in (built and tested as commands, not yet run on those machines); on Linux with Sysbox no VM is needed. `status --json` reports the state, versions, isolation, the current job and a sentence when something is wrong, and each capability's availability with its cause and fix, for the desktop app. `update` drains the runner, swaps in the newest signed image and starts again without registering again. The sandbox's images are built from deploy/sandbox/Dockerfile by scripts/sandbox-image.mjs and listed with their SHA-256 in the release's signed manifest, beside the runner image's digest. The release workflow now builds its images on g1t's own machines (g1t-4core) rather than waiting forever for a self-hosted runner, registering QEMU for arm64 where the machine allows it and building amd64 alone with a warning where it does not, and signs and publishes once the images are built. The Runners page shows each runner's isolation (Sandboxed, VM per job, Shared Docker, Unsandboxed), which the API now returns as `isolation`. The Self-hosted runners guide has a new "Sandbox on your computer" section, with what it cannot stop.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
Self-hosted runners on a person's own computer are sandboxes that nothing a job runs can get out of. `g1t-runner sandbox up|down|status|update [--json]` gives a Windows, macOS or Linux computer up to two capabilities, each a runner of its own: Linux jobs in a Linux VM the runner owns, where the runner's image runs under Sysbox and each job is a fresh container inside it (nested-container, three walls from the computer); and jobs native to Windows, each in a Windows Sandbox of its own, discarded after, with one read-only folder holding the harness (a new `vm` isolation level, also `g1t-runner register --native`). On Windows the VM is a WSL 2 distribution imported from the release's signed root filesystem: its wsl.conf turns off drives, Windows programs and Windows' PATH, and the supervisor inside unmounts what WSL shares with every distribution anyway (/mnt/wsl, where Docker Desktop keeps its sockets to Windows, WSLg, the GPU driver folders), refuses to run while anything of the computer's is reachable, and drops containers' traffic to the computer's address but for DNS. It needs no administrator once WSL is installed, and works on Windows 11 Home. On macOS the VM runs under vfkit and on Linux without Sysbox under QEMU with KVM, with no shared folders and nothing forwarded in (built and tested as commands, not yet run on those machines); on Linux with Sysbox no VM is needed. `status --json` reports the state, versions, isolation, the current job and a sentence when something is wrong, and each capability's availability with its cause and fix, for the desktop app. `update` drains the runner, swaps in the newest signed image and starts again without registering again. The sandbox's images are built from deploy/sandbox/Dockerfile by scripts/sandbox-image.mjs and listed with their SHA-256 in the release's signed manifest, beside the runner image's digest. The release workflow now builds its images on g1t's own machines (g1t-4core) rather than waiting forever for a self-hosted runner, registering QEMU for arm64 where the machine allows it and building amd64 alone with a warning where it does not, and signs and publishes once the images are built. The Runners page shows each runner's isolation (Sandboxed, VM per job, Shared Docker, Unsandboxed), which the API now returns as `isolation`. The Self-hosted runners guide has a new "Sandbox on your computer" section, with what it cannot stop.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
Fast pages, required checks on the branch, self-hosted runners, honest incidents389}
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> {
Fast pages, required checks on the branch, self-hosted runners, honest incidents392 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"
Fast pages, required checks on the branch, self-hosted runners, honest incidents397 ),
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 }
Fast pages, required checks on the branch, self-hosted runners, honest incidents405 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());
Fast pages, required checks on the branch, self-hosted runners, honest incidents416 }
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));
Fast pages, required checks on the branch, self-hosted runners, honest incidents440 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(),
Self-hosted runners on a person's own computer are sandboxes that nothing a job runs can get out of. `g1t-runner sandbox up|down|status|update [--json]` gives a Windows, macOS or Linux computer up to two capabilities, each a runner of its own: Linux jobs in a Linux VM the runner owns, where the runner's image runs under Sysbox and each job is a fresh container inside it (nested-container, three walls from the computer); and jobs native to Windows, each in a Windows Sandbox of its own, discarded after, with one read-only folder holding the harness (a new `vm` isolation level, also `g1t-runner register --native`). On Windows the VM is a WSL 2 distribution imported from the release's signed root filesystem: its wsl.conf turns off drives, Windows programs and Windows' PATH, and the supervisor inside unmounts what WSL shares with every distribution anyway (/mnt/wsl, where Docker Desktop keeps its sockets to Windows, WSLg, the GPU driver folders), refuses to run while anything of the computer's is reachable, and drops containers' traffic to the computer's address but for DNS. It needs no administrator once WSL is installed, and works on Windows 11 Home. On macOS the VM runs under vfkit and on Linux without Sysbox under QEMU with KVM, with no shared folders and nothing forwarded in (built and tested as commands, not yet run on those machines); on Linux with Sysbox no VM is needed. `status --json` reports the state, versions, isolation, the current job and a sentence when something is wrong, and each capability's availability with its cause and fix, for the desktop app. `update` drains the runner, swaps in the newest signed image and starts again without registering again. The sandbox's images are built from deploy/sandbox/Dockerfile by scripts/sandbox-image.mjs and listed with their SHA-256 in the release's signed manifest, beside the runner image's digest. The release workflow now builds its images on g1t's own machines (g1t-4core) rather than waiting forever for a self-hosted runner, registering QEMU for arm64 where the machine allows it and building amd64 alone with a warning where it does not, and signs and publishes once the images are built. The Runners page shows each runner's isolation (Sandboxed, VM per job, Shared Docker, Unsandboxed), which the API now returns as `isolation`. The Self-hosted runners guide has a new "Sandbox on your computer" section, with what it cannot stop.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,
Self-hosted runners on a person's own computer are sandboxes that nothing a job runs can get out of. `g1t-runner sandbox up|down|status|update [--json]` gives a Windows, macOS or Linux computer up to two capabilities, each a runner of its own: Linux jobs in a Linux VM the runner owns, where the runner's image runs under Sysbox and each job is a fresh container inside it (nested-container, three walls from the computer); and jobs native to Windows, each in a Windows Sandbox of its own, discarded after, with one read-only folder holding the harness (a new `vm` isolation level, also `g1t-runner register --native`). On Windows the VM is a WSL 2 distribution imported from the release's signed root filesystem: its wsl.conf turns off drives, Windows programs and Windows' PATH, and the supervisor inside unmounts what WSL shares with every distribution anyway (/mnt/wsl, where Docker Desktop keeps its sockets to Windows, WSLg, the GPU driver folders), refuses to run while anything of the computer's is reachable, and drops containers' traffic to the computer's address but for DNS. It needs no administrator once WSL is installed, and works on Windows 11 Home. On macOS the VM runs under vfkit and on Linux without Sysbox under QEMU with KVM, with no shared folders and nothing forwarded in (built and tested as commands, not yet run on those machines); on Linux with Sysbox no VM is needed. `status --json` reports the state, versions, isolation, the current job and a sentence when something is wrong, and each capability's availability with its cause and fix, for the desktop app. `update` drains the runner, swaps in the newest signed image and starts again without registering again. The sandbox's images are built from deploy/sandbox/Dockerfile by scripts/sandbox-image.mjs and listed with their SHA-256 in the release's signed manifest, beside the runner image's digest. The release workflow now builds its images on g1t's own machines (g1t-4core) rather than waiting forever for a self-hosted runner, registering QEMU for arm64 where the machine allows it and building amd64 alone with a warning where it does not, and signs and publishes once the images are built. The Runners page shows each runner's isolation (Sandboxed, VM per job, Shared Docker, Unsandboxed), which the API now returns as `isolation`. The Self-hosted runners guide has a new "Sandbox on your computer" section, with what it cannot stop.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 }
Fast pages, required checks on the branch, self-hosted runners, honest incidents490}
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
Fast pages, required checks on the branch, self-hosted runners, honest incidents497fn 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);
Fast pages, required checks on the branch, self-hosted runners, honest incidents522 // 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));
Fast pages, required checks on the branch, self-hosted runners, honest incidents526 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(),
Self-hosted runners on a person's own computer are sandboxes that nothing a job runs can get out of. `g1t-runner sandbox up|down|status|update [--json]` gives a Windows, macOS or Linux computer up to two capabilities, each a runner of its own: Linux jobs in a Linux VM the runner owns, where the runner's image runs under Sysbox and each job is a fresh container inside it (nested-container, three walls from the computer); and jobs native to Windows, each in a Windows Sandbox of its own, discarded after, with one read-only folder holding the harness (a new `vm` isolation level, also `g1t-runner register --native`). On Windows the VM is a WSL 2 distribution imported from the release's signed root filesystem: its wsl.conf turns off drives, Windows programs and Windows' PATH, and the supervisor inside unmounts what WSL shares with every distribution anyway (/mnt/wsl, where Docker Desktop keeps its sockets to Windows, WSLg, the GPU driver folders), refuses to run while anything of the computer's is reachable, and drops containers' traffic to the computer's address but for DNS. It needs no administrator once WSL is installed, and works on Windows 11 Home. On macOS the VM runs under vfkit and on Linux without Sysbox under QEMU with KVM, with no shared folders and nothing forwarded in (built and tested as commands, not yet run on those machines); on Linux with Sysbox no VM is needed. `status --json` reports the state, versions, isolation, the current job and a sentence when something is wrong, and each capability's availability with its cause and fix, for the desktop app. `update` drains the runner, swaps in the newest signed image and starts again without registering again. The sandbox's images are built from deploy/sandbox/Dockerfile by scripts/sandbox-image.mjs and listed with their SHA-256 in the release's signed manifest, beside the runner image's digest. The release workflow now builds its images on g1t's own machines (g1t-4core) rather than waiting forever for a self-hosted runner, registering QEMU for arm64 where the machine allows it and building amd64 alone with a warning where it does not, and signs and publishes once the images are built. The Runners page shows each runner's isolation (Sandboxed, VM per job, Shared Docker, Unsandboxed), which the API now returns as `isolation`. The Self-hosted runners guide has a new "Sandbox on your computer" section, with what it cannot stop.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,
Self-hosted runners on a person's own computer are sandboxes that nothing a job runs can get out of. `g1t-runner sandbox up|down|status|update [--json]` gives a Windows, macOS or Linux computer up to two capabilities, each a runner of its own: Linux jobs in a Linux VM the runner owns, where the runner's image runs under Sysbox and each job is a fresh container inside it (nested-container, three walls from the computer); and jobs native to Windows, each in a Windows Sandbox of its own, discarded after, with one read-only folder holding the harness (a new `vm` isolation level, also `g1t-runner register --native`). On Windows the VM is a WSL 2 distribution imported from the release's signed root filesystem: its wsl.conf turns off drives, Windows programs and Windows' PATH, and the supervisor inside unmounts what WSL shares with every distribution anyway (/mnt/wsl, where Docker Desktop keeps its sockets to Windows, WSLg, the GPU driver folders), refuses to run while anything of the computer's is reachable, and drops containers' traffic to the computer's address but for DNS. It needs no administrator once WSL is installed, and works on Windows 11 Home. On macOS the VM runs under vfkit and on Linux without Sysbox under QEMU with KVM, with no shared folders and nothing forwarded in (built and tested as commands, not yet run on those machines); on Linux with Sysbox no VM is needed. `status --json` reports the state, versions, isolation, the current job and a sentence when something is wrong, and each capability's availability with its cause and fix, for the desktop app. `update` drains the runner, swaps in the newest signed image and starts again without registering again. The sandbox's images are built from deploy/sandbox/Dockerfile by scripts/sandbox-image.mjs and listed with their SHA-256 in the release's signed manifest, beside the runner image's digest. The release workflow now builds its images on g1t's own machines (g1t-4core) rather than waiting forever for a self-hosted runner, registering QEMU for arm64 where the machine allows it and building amd64 alone with a warning where it does not, and signs and publishes once the images are built. The Runners page shows each runner's isolation (Sandboxed, VM per job, Shared Docker, Unsandboxed), which the API now returns as `isolation`. The Self-hosted runners guide has a new "Sandbox on your computer" section, with what it cannot stop.538 native: None,
539 exit: None,
Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r9540 deadline: deadline(work),
541 })
Fast pages, required checks on the branch, self-hosted runners, honest incidents542}
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
Fast pages, required checks on the branch, self-hosted runners, honest incidents547fn 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()
Fast pages, required checks on the branch, self-hosted runners, honest incidents554 .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
Fast pages, required checks on the branch, self-hosted runners, honest incidents577#[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,
Self-hosted runners on a person's own computer are sandboxes that nothing a job runs can get out of. `g1t-runner sandbox up|down|status|update [--json]` gives a Windows, macOS or Linux computer up to two capabilities, each a runner of its own: Linux jobs in a Linux VM the runner owns, where the runner's image runs under Sysbox and each job is a fresh container inside it (nested-container, three walls from the computer); and jobs native to Windows, each in a Windows Sandbox of its own, discarded after, with one read-only folder holding the harness (a new `vm` isolation level, also `g1t-runner register --native`). On Windows the VM is a WSL 2 distribution imported from the release's signed root filesystem: its wsl.conf turns off drives, Windows programs and Windows' PATH, and the supervisor inside unmounts what WSL shares with every distribution anyway (/mnt/wsl, where Docker Desktop keeps its sockets to Windows, WSLg, the GPU driver folders), refuses to run while anything of the computer's is reachable, and drops containers' traffic to the computer's address but for DNS. It needs no administrator once WSL is installed, and works on Windows 11 Home. On macOS the VM runs under vfkit and on Linux without Sysbox under QEMU with KVM, with no shared folders and nothing forwarded in (built and tested as commands, not yet run on those machines); on Linux with Sysbox no VM is needed. `status --json` reports the state, versions, isolation, the current job and a sentence when something is wrong, and each capability's availability with its cause and fix, for the desktop app. `update` drains the runner, swaps in the newest signed image and starts again without registering again. The sandbox's images are built from deploy/sandbox/Dockerfile by scripts/sandbox-image.mjs and listed with their SHA-256 in the release's signed manifest, beside the runner image's digest. The release workflow now builds its images on g1t's own machines (g1t-4core) rather than waiting forever for a self-hosted runner, registering QEMU for arm64 where the machine allows it and building amd64 alone with a warning where it does not, and signs and publishes once the images are built. The Runners page shows each runner's isolation (Sandboxed, VM per job, Shared Docker, Unsandboxed), which the API now returns as `isolation`. The Self-hosted runners guide has a new "Sandbox on your computer" section, with what it cannot stop.605 native: false,
Fast pages, required checks on the branch, self-hosted runners, honest incidents606 }
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 }
Fast pages, required checks on the branch, self-hosted runners, honest incidents758}

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