Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t.sh
715 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.

Cache hits are counted, not only their bytes1//! `g1t-runner` on your own machine: a self-hosted runner for g1t.
2//!
3//! ```text
4//! g1t-runner register --url https://g1t.sh --token g1trt_… [--name build-01]
5//! [--labels gpu,cuda] [--group Default] [--ephemeral] [--work-dir DIR]
The runner no longer runs g1t's own agents. Cloud and self-hosted runners now do what a forge's runner does: Actions jobs, required checks, merge checks and merge-queue test runs, deployments, dependency bumps and backups. Everything that started Claude Code in a sandbox on an issue or pull request is gone, with its prompts, model access, mentions, delegation, plans, reviews, revisions and the workspace agents' computers.6//! [--no-docker] [--image IMAGE] [--replace]
Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r97//! [--memory 8g] [--cpus 4] [--pids-limit 4096] [--no-job-docker]
Cache hits are counted, not only their bytes8//! g1t-runner run [--once]
9//! g1t-runner service install|start|stop|status|uninstall
10//! g1t-runner remove
11//! g1t-runner update
12//! g1t-runner version
13//! ```
14//!
15//! It only ever calls out, to `api.g1t.sh` over HTTPS: it polls for work
16//! (a poll waits up to 20 seconds for some, and is the runner's heartbeat),
17//! runs what it is given with the same harness g1t's sandboxes run (this
18//! program in another mode, see `exec`), and says how it ended. Nothing
19//! listens on the machine. Every command takes `--dir` (default
20//! `~/.g1t-runner`, or `G1T_RUNNER_DIR`), where its configuration is kept.
Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r921//!
22//! How it keeps jobs from the machine is `sandbox`: in its image under
23//! Sysbox (`G1T_RUNNER_SANDBOX=nested`) it runs its own Docker Engine
24//! (`engine`) and refuses to start if it can reach the machine's.
Cache hits are counted, not only their bytes25
26mod api;
27mod config;
Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r928#[cfg(unix)]
29mod engine;
Cache hits are counted, not only their bytes30mod exec;
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.31mod launcher;
32mod native;
Merge main into rm-agents: mirroring removal, open signups, free-tier limits, the g1t CLI and runner fixes, with the agent removal kept. The CLI's table of operations is regenerated from the API without the agent, customize and AI credit operations; no CLI command used them.33mod private;
Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r934mod sandbox;
Cache hits are counted, not only their bytes35mod service;
36mod update;
37
38use std::path::{Path, PathBuf};
39use std::sync::atomic::{AtomicBool, Ordering};
40use std::time::{Duration, Instant};
41
42use anyhow::{Context, Result, bail};
43use serde_json::json;
44
45use api::{Api, Failure};
46use config::Config;
47
48pub const VERSION: &str = env!("CARGO_PKG_VERSION");
49
50/// Set to stop the loop between polls: a Windows service's stop request.
51pub static STOP: AtomicBool = AtomicBool::new(false);
52
53/// How long a busy runner waits between heartbeats.
54const HEARTBEAT: Duration = Duration::from_secs(10);
55/// How long an idle poll waits for work.
56const IDLE_WAIT_MS: u64 = 20_000;
57/// How often an idle runner checks for a new version.
58const UPDATE_EVERY: Duration = Duration::from_secs(6 * 60 * 60);
59
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.60const COMMANDS: &[&str] = &["register", "register-and-run", "run", "service", "sandbox", "remove", "update", "version", "--version", "help", "--help", "-h"];
Cache hits are counted, not only their bytes61
62/// Whether `args` (without the program) are a command of this one, rather
63/// than the harness's `MODE`.
64pub fn is_command(args: &[String]) -> bool {
65 args.first().is_some_and(|first| COMMANDS.contains(&first.as_str()))
66}
67
68pub fn log(message: &str) {
69 let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis() as u64).unwrap_or(0);
70 println!("{} {message}", g1t_now(now));
71}
72
73/// RFC 3339 for the log, without pulling in a date library.
74fn g1t_now(ms: u64) -> String {
75 let seconds = ms / 1000;
76 let (days, rest) = (seconds / 86_400, seconds % 86_400);
77 let z = days as i64 + 719_468;
78 let era = z.div_euclid(146_097);
79 let doe = z.rem_euclid(146_097);
80 let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
81 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
82 let mp = (5 * doy + 2) / 153;
83 let day = doy - (153 * mp + 2) / 5 + 1;
84 let month = if mp < 10 { mp + 3 } else { mp - 9 };
85 let year = yoe + era * 400 + i64::from(month <= 2);
86 format!("{year:04}-{month:02}-{day:02}T{:02}:{:02}:{:02}Z", rest / 3600, rest % 3600 / 60, rest % 60)
87}
88
89/// `--name value` and `--flag` options.
90struct Options {
91 values: Vec<(String, String)>,
92 flags: Vec<String>,
93 rest: Vec<String>,
94}
95
96impl Options {
97 fn parse(args: &[String], takes_value: &[&str]) -> Result<Options> {
98 let mut options = Options { values: Vec::new(), flags: Vec::new(), rest: Vec::new() };
99 let mut i = 0;
100 while i < args.len() {
101 let arg = &args[i];
102 if let Some(name) = arg.strip_prefix("--") {
103 if let Some((name, value)) = name.split_once('=') {
104 options.values.push((name.to_owned(), value.to_owned()));
105 } else if takes_value.contains(&name) {
106 let Some(value) = args.get(i + 1) else { bail!("--{name} needs a value") };
107 options.values.push((name.to_owned(), value.clone()));
108 i += 1;
109 } else {
110 options.flags.push(name.to_owned());
111 }
112 } else {
113 options.rest.push(arg.clone());
114 }
115 i += 1;
116 }
117 Ok(options)
118 }
119
120 fn value(&self, name: &str) -> Option<&str> {
121 self.values.iter().rev().find(|(n, _)| n == name).map(|(_, v)| v.as_str())
122 }
123
124 fn flag(&self, name: &str) -> bool {
125 self.flags.iter().any(|f| f == name)
126 }
127
128 /// For `register-and-run` in a container: each option may come from
129 /// `G1T_RUNNER_<NAME>` instead (`G1T_RUNNER_TOKEN`, `G1T_RUNNER_LABELS`,
130 /// `G1T_RUNNER_EPHEMERAL=1`), so a Kubernetes secret can hold the token.
131 fn with_environment(mut self) -> Options {
132 let env_name = |name: &str| format!("G1T_RUNNER_{}", name.to_ascii_uppercase().replace('-', "_"));
Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r9133 for name in VALUE_OPTIONS {
Cache hits are counted, not only their bytes134 if self.value(name).is_none()
135 && let Ok(value) = std::env::var(env_name(name))
136 && !value.is_empty()
137 {
138 self.values.push((name.to_owned(), value));
139 }
140 }
Merge the runner sandbox: g1t-runner sandbox up makes a person's own computer a self-hosted runner that nothing a job runs can get out of. Linux jobs run in a Linux VM of the runner's own (a WSL 2 distribution with no host mounts, interop or Docker socket on Windows; vfkit on macOS; Sysbox, or QEMU and KVM, on Linux), each in a fresh container under Sysbox inside it; on Windows 11 Pro and up, Windows jobs each run in a Windows Sandbox discarded after the job. Runners report their isolation, and the Runners page shows it.141 for name in ["ephemeral", "no-docker", "replace", "no-auto-update", "no-job-docker", "native"] {
Cache hits are counted, not only their bytes142 if !self.flag(name) && std::env::var(env_name(name)).is_ok_and(|v| matches!(v.as_str(), "1" | "true" | "yes")) {
143 self.flags.push(name.to_owned());
144 }
145 }
146 self
147 }
148}
149
Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r9150/// The options that take a value.
The runner no longer runs g1t's own agents. Cloud and self-hosted runners now do what a forge's runner does: Actions jobs, required checks, merge checks and merge-queue test runs, deployments, dependency bumps and backups. Everything that started Claude Code in a sandbox on an issue or pull request is gone, with its prompts, model access, mentions, delegation, plans, reviews, revisions and the workspace agents' computers.151const VALUE_OPTIONS: [&str; 13] =
152 ["url", "api", "token", "name", "labels", "group", "work-dir", "image", "harness", "memory", "cpus", "pids-limit", "job-docker-image"];
Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r9153
Cache hits are counted, not only their bytes154const HELP: &str = "g1t-runner: a self-hosted runner for g1t.
155
156 g1t-runner register --url https://g1t.sh --token <registration token>
157 [--name NAME] [--labels a,b] [--group GROUP] [--ephemeral]
The runner no longer runs g1t's own agents. Cloud and self-hosted runners now do what a forge's runner does: Actions jobs, required checks, merge checks and merge-queue test runs, deployments, dependency bumps and backups. Everything that started Claude Code in a sandbox on an issue or pull request is gone, with its prompts, model access, mentions, delegation, plans, reviews, revisions and the workspace agents' computers.158 [--work-dir DIR] [--no-docker] [--image IMAGE]
Cache hits are counted, not only their bytes159 [--api URL] [--harness PATH] [--replace] [--no-auto-update]
Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r9160 [--memory 8g] [--cpus 4] [--pids-limit 4096] [--no-job-docker]
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.161 [--job-docker-image IMAGE] [--native]
Cache hits are counted, not only their bytes162 g1t-runner run [--once] poll for work and run it
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.163 g1t-runner sandbox up|down|status|update [--json]
164 on your own computer: a Linux VM of the
165 runner's own for Linux jobs, and Windows
166 Sandbox for Windows jobs; see
167 `g1t-runner sandbox help`
Cache hits are counted, not only their bytes168 g1t-runner register-and-run ... both, for containers; options may be
169 G1T_RUNNER_URL, _TOKEN, _LABELS, _EPHEMERAL…
170 g1t-runner service install|start|stop|status|uninstall
171 g1t-runner remove unregister this runner
172 g1t-runner update update to the newest release
173 g1t-runner version
174
Merge branch 'worktree-agent-a4d35ee70c50683d0' into claude/cool-newton-7nm8r9175In a workflow step (the g1t/deploy and g1t/detect actions use these):
176 g1t-runner deploy build this checkout and put it on g1t.page
177 g1t-runner detect [--root-dir DIR] what that build would find
178
Cache hits are counted, not only their bytes179Every command takes --dir DIR (default ~/.g1t-runner, or G1T_RUNNER_DIR).
Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r9180Run it as a container under Sysbox so neither it nor its jobs reach the
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.181machine: docker run --runtime=sysbox-runc … g1t.sh/flagon-io/g1t-runner,
182or on a desktop, g1t-runner sandbox up. --native runs jobs native to this
183OS, each in a throwaway VM (Windows Sandbox). --no-docker (host mode) is
184deprecated.
Cache hits are counted, not only their bytes185Make a registration token under Settings, Runners on g1t.sh.
186Docs: https://docs.g1t.sh/guides/self-hosted-runners/";
187
188/// Runs a command. Returns the process's exit code.
189pub fn main(args: Vec<String>) -> 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.190 // Options of its own, and output the desktop app reads.
191 if args[0] == "sandbox" {
192 return launcher::main(&args[1..]);
193 }
Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r9194 let mut takes = VALUE_OPTIONS.to_vec();
195 takes.push("dir");
Cache hits are counted, not only their bytes196 let options = match Options::parse(&args[1..], &takes) {
197 Ok(options) => options,
198 Err(error) => {
199 eprintln!("g1t-runner: {error:#}");
200 return 2;
201 }
202 };
203 let folder = config::folder(options.value("dir"));
204 let outcome = match args[0].as_str() {
205 "register" => register(&options, &folder),
206 // A container's one command: register unless it already is, then run.
207 "register-and-run" => {
208 let options = options.with_environment();
209 let ready = if config::load(&folder).is_ok() { Ok(()) } else { register(&options, &folder) };
210 ready.and_then(|()| run(&folder, options.flag("once")))
211 }
212 "run" => run(&folder, options.flag("once")),
213 "service" => match options.rest.first().map(String::as_str) {
214 Some(action) => config::load(&folder).and_then(|config| service::main(action, &config, &folder)),
215 None => Err(anyhow::anyhow!("service takes install, uninstall, start, stop or status")),
216 },
217 "remove" => remove(&folder),
218 "update" => config::load(&folder).and_then(|config| match update::update(&config)? {
219 Some(version) => {
220 log(&format!("Updated to {version}. Restart the runner (or its service) to use it."));
221 Ok(())
222 }
223 None => {
224 log(&format!("{VERSION} is the newest release."));
225 Ok(())
226 }
227 }),
228 "version" | "--version" => {
229 println!("g1t-runner {VERSION} ({})", update::platform());
230 Ok(())
231 }
232 _ => {
233 println!("{HELP}");
234 Ok(())
235 }
236 };
237 match outcome {
238 Ok(()) => 0,
239 Err(error) => {
240 eprintln!("g1t-runner: {error:#}");
241 1
242 }
243 }
244}
245
246fn hostname() -> String {
247 for name in ["COMPUTERNAME", "HOSTNAME"] {
248 if let Ok(value) = std::env::var(name).map(|v| v.trim().to_owned())
249 && !value.is_empty()
250 {
251 return value;
252 }
253 }
254 std::process::Command::new("hostname")
255 .output()
256 .ok()
257 .map(|out| String::from_utf8_lossy(&out.stdout).trim().to_owned())
258 .filter(|name| !name.is_empty())
259 .unwrap_or_else(|| "runner".to_owned())
260}
261
262/// A runner's name from a machine's: what g1t accepts.
263fn runner_name(given: &str) -> String {
264 let name: String = given
265 .chars()
266 .map(|c| if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.') { c } else { '-' })
267 .take(64)
268 .collect();
269 if name.is_empty() { "runner".into() } else { name }
270}
271
272fn register(options: &Options, folder: &Path) -> Result<()> {
273 let Some(url) = options.value("url") else { bail!("--url is needed: the g1t you register with, such as https://g1t.sh") };
274 let Some(token) = options.value("token") else { bail!("--token is needed: make a registration token under Settings, Runners") };
275 let url = url.trim_end_matches('/').to_owned();
276 let api = options.value("api").map(|a| a.trim_end_matches('/').to_owned()).unwrap_or_else(|| config::api_for(&url));
277 let name = runner_name(options.value("name").map(str::to_owned).unwrap_or_else(hostname).as_str());
278 let labels: Vec<String> = options
279 .value("labels")
280 .unwrap_or_default()
281 .split(',')
282 .map(|l| l.trim().to_owned())
283 .filter(|l| !l.is_empty())
284 .collect();
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.285 let native = options.flag("native");
286 if native && options.flag("no-docker") {
287 bail!("--native runs each job in a VM of its own, and --no-docker runs it on this machine: pick one");
288 }
289 let docker = !options.flag("no-docker") && !native;
290 let isolation = isolation(docker, native)?;
Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r9291 if isolation == sandbox::Isolation::Host {
292 log(sandbox::HOST_MODE_AT_START);
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.293 } else if !native && !sandbox::nested_requested() && !exec::docker_ready() {
Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r9294 log("Docker does not answer here. Jobs will fail until it does.");
Cache hits are counted, not only their bytes295 }
Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r9296 let pids_limit = match options.value("pids-limit") {
297 Some(text) => Some(text.parse::<u32>().ok().filter(|n| *n > 0).with_context(|| format!("--pids-limit takes a number of processes, not {text}"))?),
298 None => None,
299 };
Cache hits are counted, not only their bytes300 let existing = config::load(folder).ok();
301 if existing.is_some() && !options.flag("replace") {
302 bail!("{} already holds a registered runner: run `g1t-runner remove` first, or register with --replace or another --dir", folder.display());
303 }
304 // The OS jobs run on: in Docker that is Linux, whatever the machine
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.305 // (Docker Desktop runs Linux containers on macOS and Windows). A
306 // runner says it runs jobs native to this OS only when it sandboxes
307 // them (--native), or when the person chose host mode (--no-docker).
Cache hits are counted, not only their bytes308 let os = match std::env::consts::OS {
309 _ if docker => "linux",
310 "macos" => "macos",
311 "windows" => "windows",
312 _ => "linux",
313 };
314 let arch = if std::env::consts::ARCH == "aarch64" { "arm64" } else { "x64" };
315 let registered = Api::register(
316 &api,
317 &json!({
318 "token": token,
319 "name": name,
320 "labels": labels,
321 "os": os,
322 "arch": arch,
323 "version": VERSION,
324 "ephemeral": options.flag("ephemeral"),
325 "group": options.value("group"),
326 "replace": options.flag("replace"),
Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r9327 "isolation": isolation.as_str(),
Cache hits are counted, not only their bytes328 }),
329 )?;
330 let work_dir = options.value("work-dir").map(PathBuf::from).unwrap_or_else(|| folder.join("work"));
331 let config = Config {
332 url,
333 api,
334 runner: registered.runner.id.clone(),
335 name: registered.runner.name.clone(),
336 credential: registered.credential,
337 workspace: registered.runner.workspace.clone(),
338 repo: registered.runner.repo.clone(),
339 group: registered.runner.group.clone(),
340 labels: registered.runner.labels.clone(),
341 ephemeral: options.flag("ephemeral"),
342 work_dir,
343 docker,
344 image: options.value("image").map(str::to_owned),
345 harness: options.value("harness").map(PathBuf::from),
346 auto_update: !options.flag("no-auto-update"),
Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r9347 memory: options.value("memory").map(str::to_owned),
348 cpus: options.value("cpus").map(str::to_owned),
349 pids_limit,
350 job_docker: !options.flag("no-job-docker"),
351 job_docker_image: options.value("job-docker-image").map(str::to_owned),
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.352 native,
Cache hits are counted, not only their bytes353 };
354 config::save(folder, &config)?;
355 let place = match &config.repo {
356 Some(repo) => format!("for {repo}"),
357 None => format!("in {}{}", config.workspace, config.group.as_ref().map(|g| format!(", group {g}")).unwrap_or_default()),
358 };
359 log(&format!("Registered {} {place}, with labels {}.", config.name, config.labels.join(", ")));
360 log("Start it with `g1t-runner run`, or `g1t-runner service install` to keep it running.");
361 Ok(())
362}
363
Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r9364/// How this runner keeps work from its machine; refuses a nested runner
365/// that can reach the machine (sandbox.rs).
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.366fn isolation(docker: bool, native: bool) -> Result<sandbox::Isolation> {
Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r9367 use sandbox::{Isolation, SandboxDriver};
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.368 if native {
369 let Some(driver) = sandbox::native_driver() else {
370 bail!("--native: there is no sandbox for jobs native to {}; Linux jobs run in containers", std::env::consts::OS);
371 };
372 return driver.preflight().with_context(|| format!("--native needs {}", driver.name()));
373 }
Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r9374 if !docker {
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.375 // What would sandbox jobs native to this OS instead.
376 if let Some(driver) = sandbox::native_driver() {
377 match driver.preflight() {
378 Ok(_) => log(&format!("This machine can run {} jobs sandboxed, each in {}: register with --native instead of --no-docker.", std::env::consts::OS, driver.name())),
379 Err(why) => log(&format!("Sandboxed {} jobs, in {}: {why:#}", std::env::consts::OS, driver.name())),
380 }
Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r9381 }
382 return Ok(Isolation::Host);
383 }
384 if !sandbox::nested_requested() {
385 return sandbox::SharedDocker.preflight();
386 }
387 if !cfg!(target_os = "linux") {
388 bail!("{}=nested is for the runner's Linux image", sandbox::SANDBOX_ENV);
389 }
390 let isolation = sandbox::NestedDocker { allow_unsafe: sandbox::unsafe_allowed() }.preflight()?;
391 if isolation != Isolation::NestedContainer {
392 for problem in sandbox::Findings::read().problems() {
393 log(&format!("UNSAFE: {problem}"));
394 }
395 log(&format!("UNSAFE: {}=1 is set, so the runner starts anyway, and its jobs can reach this machine.", sandbox::UNSAFE_ENV));
396 }
397 Ok(isolation)
398}
399
400/// A nested runner's own Docker Engine, which it uses whatever else it
401/// can see (with the override, it starts despite that).
402fn prepare(docker: bool) -> Result<()> {
403 if !docker || !sandbox::nested_requested() {
404 return Ok(());
405 }
406 #[cfg(unix)]
407 {
408 let version = engine::start()?;
409 log(&format!("Started this runner's own Docker Engine {version}, inside its container."));
410 }
411 Ok(())
412}
413
Cache hits are counted, not only their bytes414fn remove(folder: &Path) -> Result<()> {
415 let config = config::load(folder)?;
416 let api = Api { base: config.api.clone(), runner: config.runner.clone(), credential: config.credential.clone() };
417 match api.remove() {
418 Ok(()) | Err(Failure::Refused(401, _)) | Err(Failure::Refused(404, _)) => {}
419 Err(failure) => bail!("{failure}; the runner is still registered"),
420 }
421 config::forget(folder);
422 log(&format!("Removed {}.", config.name));
423 Ok(())
424}
425
426/// The loop, for a Windows service: its exit code.
427#[cfg_attr(not(windows), allow(dead_code))]
428pub fn serve_from_service() -> i32 {
429 let folder = config::folder(None);
430 let folder = std::env::args()
431 .collect::<Vec<_>>()
432 .windows(2)
433 .find(|pair| pair[0] == "--dir")
434 .map(|pair| PathBuf::from(&pair[1]))
435 .unwrap_or(folder);
436 match run(&folder, false) {
437 Ok(()) => 0,
438 Err(error) => {
439 eprintln!("g1t-runner: {error:#}");
440 1
441 }
442 }
443}
444
445/// Sleeps up to `duration`, waking early when `stop` says so.
446fn nap(duration: Duration, mut stop: impl FnMut() -> bool) {
447 let until = Instant::now() + duration;
448 while Instant::now() < until {
449 if STOP.load(Ordering::SeqCst) || stop() {
450 return;
451 }
452 std::thread::sleep(Duration::from_millis(250));
453 }
454}
455
456fn run(folder: &Path, once: bool) -> Result<()> {
Merge main into rm-agents: mirroring removal, open signups, free-tier limits, the g1t CLI and runner fixes, with the agent removal kept. The CLI's table of operations is regenerated from the API without the agent, customize and AI credit operations; no CLI command used them.457 // Its folder holds its credential and, while a Windows job runs, that
458 // job's script: a runner whose folder others can read does not start.
Cache hits are counted, not only their bytes459 let mut config = config::load(folder)?;
Merge main into rm-agents: mirroring removal, open signups, free-tier limits, the g1t CLI and runner fixes, with the agent removal kept. The CLI's table of operations is regenerated from the API without the agent, customize and AI credit operations; no CLI command used them.460 private::folder(folder, "--dir")?;
Cache hits are counted, not only their bytes461 let mut api = Api { base: config.api.clone(), runner: config.runner.clone(), credential: config.credential.clone() };
462 std::fs::create_dir_all(&config.work_dir).with_context(|| format!("could not make {}", config.work_dir.display()))?;
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.463 let isolation = isolation(config.docker, config.native)?;
Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r9464 prepare(config.docker)?;
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.465 match isolation {
466 sandbox::Isolation::Host => {}
467 sandbox::Isolation::Vm => native::sweep(folder),
468 _ => exec::sweep(&config),
Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r9469 }
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.470 // A drain or stop asked for before this start is done with.
471 let _ = std::fs::remove_file(drain_path(folder));
472 let _ = std::fs::remove_file(folder.join("stop"));
Cache hits are counted, not only their bytes473 log(&format!(
Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r9474 "g1t-runner {VERSION}: {} ({}) is listening for work from {}. Isolation: {}.",
Cache hits are counted, not only their bytes475 config.name,
476 config.labels.join(", "),
477 config.url,
Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r9478 isolation.describe()
Cache hits are counted, not only their bytes479 ));
Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r9480 if isolation == sandbox::Isolation::Host {
481 log(sandbox::HOST_MODE_AT_START);
482 }
Cache hits are counted, not only their bytes483 let mut current: Option<exec::Running> = None;
484 let mut ran_one = false;
485 let mut backoff = Duration::from_secs(2);
486 let mut last_update_check = Instant::now().checked_sub(UPDATE_EVERY).unwrap_or_else(Instant::now);
487 let mut said_no_key = false;
488 loop {
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.489 // Asked to stop now (`g1t-runner sandbox down`): as a service's stop.
490 if std::fs::remove_file(folder.join("stop")).is_ok() {
491 STOP.store(true, Ordering::SeqCst);
492 }
Cache hits are counted, not only their bytes493 if STOP.load(Ordering::SeqCst) {
494 if let Some(mut running) = current.take() {
495 log(&format!("Stopping: {} is cut short.", running.name));
496 running.stop();
497 let _ = api.finished(&running.id, 1, Some("The self-hosted runner was stopped."));
498 running.clean();
499 }
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.500 say_status(folder, isolation, None, "stopped");
Cache hits are counted, not only their bytes501 return Ok(());
502 }
503 // What was running has ended: say how.
504 if let Some(running) = current.as_mut()
505 && let Some(code) = running.ended()
506 {
507 let mut running = current.take().expect("checked above");
508 log(&format!("{} {} ended ({code}).", running.kind, running.name));
509 if let Err(failure) = api.finished(&running.id, code, None) {
510 log(&format!("Could not say how it ended: {failure}"));
511 }
512 running.clean();
513 ran_one = true;
514 if config.ephemeral || once {
515 if config.ephemeral {
516 let _ = api.remove();
517 config::forget(folder);
518 log("The ephemeral runner ran its job and removed itself.");
519 }
520 return Ok(());
521 }
522 }
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.523 // Asked to drain (`g1t-runner sandbox update`, before it swaps the
524 // sandbox's image): no new work, and once idle, stop.
525 let draining = drain_path(folder).exists();
526 if draining && current.is_none() {
527 let _ = std::fs::remove_file(drain_path(folder));
528 say_status(folder, isolation, None, "stopped");
529 log("Drained: no job is running, so the runner stops here.");
530 return Ok(());
531 }
532 let state = match (&current, draining) {
533 (Some(_), true) => "draining",
534 (Some(_), false) => "busy",
535 (None, _) => "idle",
536 };
537 say_status(folder, isolation, current.as_ref(), state);
Cache hits are counted, not only their bytes538 // Idle: a newer release, now and then.
539 if current.is_none() && config.auto_update && last_update_check.elapsed() >= UPDATE_EVERY {
540 last_update_check = Instant::now();
541 if update::can_update() {
542 match update::update(&config) {
543 Ok(Some(version)) => {
544 log(&format!("Updated to {version}; starting it."));
545 return restart();
546 }
547 Ok(None) => {}
548 Err(error) => log(&format!("Could not check for a new version: {error:#}")),
549 }
550 } else if !said_no_key {
551 said_no_key = true;
552 log("This build cannot check releases, so it will not update itself.");
553 }
554 }
555 let running: Vec<String> = current.iter().map(|r| r.id.clone()).collect();
556 let wait = if current.is_some() { 0 } else { IDLE_WAIT_MS };
Merge GitHub pull request #8 (claude/cool-newton-7nm8r9): Docker deployments on g1t.page behind a switch that stays off until Cloudflare's container APIs are checked live, workflows a workspace manages in its repositories, tarball and zipball downloads, refs/pull/* kept to g1t alone, framework detection for g1t.page builds, and a workflow's event payload matching GitHub's.557 let poll = match api.poll(&running, wait, isolation.as_str()) {
Cache hits are counted, not only their bytes558 Ok(poll) => {
559 backoff = Duration::from_secs(2);
560 poll
561 }
562 Err(Failure::Refused(401, message)) => {
563 if let Some(mut running) = current.take() {
564 running.stop();
565 running.clean();
566 }
567 bail!("g1t no longer knows this runner ({message}). Register it again.");
568 }
569 Err(failure) => {
570 log(&format!("{failure}; trying again in {}s", backoff.as_secs()));
571 nap(backoff, || false);
572 backoff = (backoff * 2).min(Duration::from_secs(60));
573 continue;
574 }
575 };
576 if let Some(fresh) = poll.credential {
577 config.credential = fresh.clone();
578 api.credential = fresh;
579 config::save(folder, &config)?;
580 }
581 if poll.removed {
582 config::forget(folder);
583 log("This runner was removed from g1t; stopping.");
584 return Ok(());
585 }
586 if let Some(running) = current.as_mut()
587 && poll.cancel.contains(&running.id)
588 {
589 log(&format!("{} was cancelled; stopping it.", running.name));
590 running.stop();
591 }
592 if let Some(running) = current.as_mut()
593 && running.overdue()
594 {
595 log(&format!("{} ran past its time limit; stopping it.", running.name));
596 running.stop();
597 }
598 if let Some(work) = poll.assignment {
599 log(&format!("Took {} {} ({}).", work.kind, work.name, work.repo));
Merge branch 'worktree-agent-ac79a505eca749d4d' into claude/cool-newton-7nm8r9600 match exec::start(&config, isolation, folder, &work) {
Cache hits are counted, not only their bytes601 Ok(running) => current = Some(running),
602 Err(error) => {
603 log(&format!("Could not start it: {error:#}"));
604 let _ = api.finished(&work.id, 1, Some(&format!("The self-hosted runner {} could not start it: {error:#}", config.name)));
605 if config.ephemeral || once {
606 return Ok(());
607 }
608 }
609 }
610 continue;
611 }
612 if once && ran_one {
613 return Ok(());
614 }
615 // Busy: a heartbeat every few seconds until it ends.
616 if current.is_some() {
617 let mut check = || current.as_mut().is_some_and(|r| r.ended().is_some());
618 nap(HEARTBEAT, &mut check);
619 }
620 }
621}
622
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.623/// The file whose presence asks a runner to drain: take no new work, and
624/// stop once idle.
625pub fn drain_path(folder: &Path) -> PathBuf {
626 folder.join("drain")
627}
628
629/// The file a runner keeps saying what it is doing, for `g1t-runner
630/// sandbox status` (which reads it from inside the sandbox).
631pub fn status_path(folder: &Path) -> PathBuf {
632 folder.join("status.json")
633}
634
635/// What a runner says it is doing, in its status file.
636#[derive(Clone, Debug, Default, serde::Serialize, serde::Deserialize, PartialEq)]
637pub struct RunnerStatus {
638 pub version: String,
639 pub isolation: String,
640 /// `idle`, `busy`, `draining` or `stopped`.
641 pub state: String,
642 pub job: Option<JobStatus>,
643 pub updated_at: String,
644}
645
646#[derive(Clone, Debug, Default, serde::Serialize, serde::Deserialize, PartialEq)]
647pub struct JobStatus {
The runner no longer runs g1t's own agents. Cloud and self-hosted runners now do what a forge's runner does: Actions jobs, required checks, merge checks and merge-queue test runs, deployments, dependency bumps and backups. Everything that started Claude Code in a sandbox on an issue or pull request is gone, with its prompts, model access, mentions, delegation, plans, reviews, revisions and the workspace agents' computers.648 /// `workflow`, or `agent` for g1t's own work (a merge check or a
649 /// state of the merge queue).
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.650 pub kind: String,
651 pub id: String,
652 pub name: String,
653 pub repo: String,
654}
655
656fn say_status(folder: &Path, isolation: sandbox::Isolation, running: Option<&exec::Running>, state: &str) {
657 let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis() as u64).unwrap_or(0);
658 let status = RunnerStatus {
659 version: VERSION.to_owned(),
660 isolation: isolation.as_str().to_owned(),
661 state: state.to_owned(),
662 job: running.map(|r| JobStatus { kind: r.kind.clone(), id: r.id.clone(), name: r.name.clone(), repo: r.repo.clone() }),
663 updated_at: g1t_now(now),
664 };
665 let temp = folder.join("status.json.new");
666 if serde_json::to_vec_pretty(&status).ok().is_some_and(|bytes| std::fs::write(&temp, bytes).is_ok()) {
667 let _ = std::fs::rename(&temp, status_path(folder));
668 }
669}
670
Cache hits are counted, not only their bytes671/// Runs the updated program in this one's place, with the same arguments.
672fn restart() -> Result<()> {
673 let me = std::env::current_exe()?;
674 let status = std::process::Command::new(me).args(std::env::args().skip(1)).status()?;
675 std::process::exit(status.code().unwrap_or(1));
676}
677
678#[cfg(test)]
679mod tests {
680 use super::*;
681
682 fn args(items: &[&str]) -> Vec<String> {
683 items.iter().map(|s| s.to_string()).collect()
684 }
685
686 #[test]
687 fn commands_are_told_apart_from_the_harness() {
688 assert!(is_command(&args(&["register", "--url", "x"])));
689 assert!(is_command(&args(&["run"])));
690 assert!(!is_command(&args(&[])));
691 assert!(!is_command(&args(&["something"])));
692 }
693
694 #[test]
695 fn options_read_both_ways() {
696 let options = Options::parse(&args(&["--url", "https://g1t.sh", "--labels=gpu,cuda", "--ephemeral", "install"]), &["url", "labels"]).unwrap();
697 assert_eq!(options.value("url"), Some("https://g1t.sh"));
698 assert_eq!(options.value("labels"), Some("gpu,cuda"));
699 assert!(options.flag("ephemeral"));
700 assert_eq!(options.rest, vec!["install".to_owned()]);
701 assert!(Options::parse(&args(&["--url"]), &["url"]).is_err());
702 }
703
704 #[test]
705 fn a_machines_name_becomes_a_runners() {
706 assert_eq!(runner_name("Build Box #1"), "Build-Box--1");
707 assert_eq!(runner_name(""), "runner");
708 assert_eq!(runner_name(&"x".repeat(80)).len(), 64);
709 }
710
711 #[test]
712 fn the_log_says_when() {
713 assert_eq!(g1t_now(1_790_918_179_123), "2026-10-02T05:16:19Z");
714 }
715}

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