Skip to content

Commit

The self-hosted runner runs as a container under Sysbox, with a Docker Engine of its own inside, and each job runs in a fresh container on that engine, so neither the runner nor its jobs reach the machine: `docker run --runtime=sysbox-runc -v g1t-runner:/data -e G1T_RUNNER_URL=… -e G1T_RUNNER_TOKEN=… g1t.sh/flagon-io/g1t-runner`, with docker-init as PID 1 supervising g1t-runner and g1t-runner supervising dockerd. The image refuses to start when it can see the machine's Docker socket mounted in, an engine socket it did not make, DOCKER_HOST, or a root that is the machine's (no Sysbox, or --privileged), unless G1T_RUNNER_UNSAFE_ALLOW_HOST_ACCESS=1 is set, and its log says which isolation it has: nested-container, container or host, which it now also tells g1t when it registers (kept on the runner, shown nowhere yet). Every job container drops all capabilities but eight it needs, has no-new-privileges (agent work keeps sudo for its guardrails), a process, memory and CPU limit (--pids-limit, --memory, --cpus, with defaults), a network and a /home/runner volume of its own removed after it, and no mount but the read-only harness; a job that uses Docker gets an engine of its own beside it, started the first time it is used and removed after, unless the runner is registered with --no-job-docker. --no-docker keeps working but is deprecated: the runner says so when it starts, every job's log starts with a warning that it ran on the host unsandboxed, and it refuses a public repository's work now that g1t says a job's repository visibility. Windows and macOS sandboxes (Hyper-V-isolated containers, ephemeral macOS VMs) are designed and not built. The runner is 0.2.0; the self-hosted runners guide, the limitations page and the New runner command say so.

Claude-Session:
https://claude.ai/code/session_01PnwTRSNaC7vEHTAuBzjhrn
Claudeagent and Claudeagentcommitted Parent84c72b3Browse files
18 files+1731−1060/18 viewed
+1−1
10951095
10961096 [[package]]
10971097 name = "g1t-runner"
1098−version = "0.1.0"
1098+version = "0.2.0"
10991099 dependencies = [
11001100 "anyhow",
11011101 "base64 0.22.1",
+2−1
55 //!
66 //! | Route | Body |
77 //! | --- | --- |
8−//! | `POST /runners/register` | `token`, `name`, `labels`, `os`, `arch`, `version`, `ephemeral`, `group`, `replace` |
8+//! | `POST /runners/register` | `token`, `name`, `labels`, `os`, `arch`, `version`, `ephemeral`, `group`, `replace`, `isolation` |
99 //! | `POST /runners/{id}/poll` | `version`, `running`, `wait_ms` |
1010 //! | `POST /runners/{id}/finished` | `id`, `exit_code`, `reason` |
1111 //! | `POST /runners/{id}/remove` | nothing |
5656 "ephemeral": body["ephemeral"].as_bool().unwrap_or(false),
5757 "group": body["group"].as_str(),
5858 "replace": body["replace"].as_bool().unwrap_or(false),
59+ "isolation": body["isolation"].as_str(),
5960 });
6061 return Ok(Some(answer(services, "runner_register", &args).await?));
6162 }
+28−0
229229 macOS and Windows, on x64 and arm64.
230230 - **Status.** Not scheduled.
231231
232+### Self-hosted Windows and macOS jobs run on the host
233+
234+On a self-hosted runner, a job native to Windows or macOS runs directly on
235+the machine, unsandboxed, in host mode, which is deprecated. Only Linux
236+jobs run in a sandbox of their own, a container inside the runner's own
237+container under Sysbox.
238+
239+- **Why.** Sandboxes for them are not built yet: Windows containers with
240+ Hyper-V isolation, and ephemeral macOS virtual machines (two per Mac at
241+ most, by Apple's licence).
242+- **Instead.** Run Linux jobs in
243+ [the runner's container](/guides/self-hosted-runners/#run-it-as-a-container),
244+ and keep Windows and macOS runners for repositories whose code you
245+ trust. Host mode refuses public repositories' work.
246+- **Status.** Planned. See
247+ [Windows and macOS](/guides/self-hosted-runners/#windows-and-macos).
248+
249+### A self-hosted job's Docker can reach its runner's container
250+
251+On a runner in its container under Sysbox, a job that uses Docker gets an
252+engine of its own that is privileged inside the runner's container. It
253+cannot reach the machine, but it can reach the runner's container,
254+including the runner's credential.
255+
256+- **Instead.** Use ephemeral runners for code you do not trust, or
257+ register with `--no-job-docker`.
258+- **Status.** Not scheduled.
259+
232260 ### No `gh` command in jobs
233261
234262 The runner does not include the `gh` command, and pointing it at g1t
+211−44
55
66 A self-hosted runner is a machine of yours that runs your workflow jobs
77 instead of g1t's sandboxes. It can be a server, a Mac in a cupboard, a
8−Windows box with a GPU, or a pod in your cluster. You install `g1t-runner`
9−on it, register it once, and it picks up jobs that ask for it with
10−`runs-on: self-hosted`.
8+Windows box with a GPU, or a pod in your cluster. On Linux you run
9+`g1t-runner` as a container under [Sysbox](#run-it-as-a-container), so
10+neither the runner nor its jobs can reach the machine; it registers once
11+and picks up jobs that ask for it with `runs-on: self-hosted`.
1112
1213 - **Time on your runners is $0**, on every plan, including the free one.
1314 It shows on [usage](/guides/usage-and-billing/) as self-hosted minutes.
1415 - **It only connects out.** The runner polls `api.g1t.sh` over HTTPS for
1516 work. Nothing needs to reach the machine: no open port, no tunnel.
16−- **Any OS.** Linux, macOS and Windows, on x64 and arm64. Jobs run in a
17− Docker container by default, or directly on the machine.
17+- **Each job in a fresh sandbox.** Every job gets a container of its own,
18+ made for it and removed after it, inside the runner's own container.
19+ See [isolation](#isolation).
20+- **Any OS.** Linux, macOS and Windows, on x64 and arm64. On macOS and
21+ Windows, sandboxed jobs are [not built yet](#windows-and-macos).
1822
1923 | Where | Page | Who manages it |
2024 | --- | --- | --- |
2428 A workspace's runners serve the repositories their [group](#groups) allows.
2529 A project's own runners serve only that project.
2630
31+## Run it as a container
32+
33+On a Linux machine, run the runner's image with the Sysbox runtime. The
34+runner starts a Docker Engine of its own inside its container and runs
35+each job in a container on that engine, so jobs are containers inside the
36+runner's container. Sysbox gives the runner's container a user namespace
37+of its own, so its root is an unprivileged user on the machine, and lets
38+it run Docker without `--privileged` and without the machine's Docker
39+socket.
40+
41+1. Install [Sysbox](https://github.com/nestybox/sysbox) on the machine,
42+ following its install guide. It needs Linux; Docker Desktop on macOS
43+ and Windows cannot use it.
44+2. Make a registration token: open **Runners** and click **New runner**.
45+3. Run the runner:
46+
47+ ```sh
48+ docker run -d --name g1t-runner --restart unless-stopped \
49+ --runtime=sysbox-runc \
50+ -v g1t-runner:/data \
51+ -e G1T_RUNNER_URL=https://g1t.sh \
52+ -e G1T_RUNNER_TOKEN=g1trt_… \
53+ g1t.sh/flagon-io/g1t-runner
54+ ```
55+
56+The image runs `register-and-run`, which registers once and then runs.
57+Its configuration and credential are kept on the `g1t-runner` volume, so
58+the container can be restarted and replaced without registering again.
59+Every [register option](#register-options) can come from
60+`G1T_RUNNER_<OPTION>` in the environment, such as `G1T_RUNNER_LABELS=gpu`
61+or `G1T_RUNNER_MEMORY=8g`, so a secret can hold the token.
62+
63+Its log says which isolation it has:
64+
65+```text
66+… Isolation: nested-container: each job runs in a fresh container on this runner's own Docker Engine, inside the runner's sandboxed container; neither reaches the machine.
67+```
68+
69+Do not give the container the machine's Docker socket
70+(`-v /var/run/docker.sock:/var/run/docker.sock`) or `--privileged`. The
71+runner looks for both when it starts, and refuses to start if it finds
72+them:
73+
74+| It finds | What it says |
75+| --- | --- |
76+| An engine's socket mounted in from the machine (`docker.sock`, `containerd.sock`, `podman.sock`, under any name) | *… is a container engine's socket mounted in from outside this container* |
77+| An engine's socket it did not make, such as from a mounted `/var/run` | *… is a container engine's socket that this runner did not make* |
78+| `DOCKER_HOST` set | *DOCKER_HOST is set …* |
79+| Root in the container is root on the machine (no Sysbox, or `--privileged`) | *root in this container is root on the machine* |
80+
81+To start anyway, with jobs able to reach the machine, set
82+`G1T_RUNNER_UNSAFE_ALLOW_HOST_ACCESS=1`. The runner then logs each
83+problem with `UNSAFE:` in front, and registers as `container` rather than
84+`nested-container`.
85+
86+The engine's images and build cache are kept in the container, so they
87+last until the container is removed. Sysbox keeps them on the machine's
88+disk for that container alone; to keep them when you replace the
89+container, add `-v g1t-runner-docker:/var/lib/docker`, a volume for one
90+runner container at a time.
91+
92+## Isolation
93+
94+How well a runner keeps work from its machine. A runner tells g1t its
95+level when it registers.
96+
97+| Isolation | How you get it | What a job can reach |
98+| --- | --- | --- |
99+| `nested-container` | The runner's image under Sysbox, as above. | Its own container, and its own Docker Engine if it uses Docker. Not the machine, and not other jobs. |
100+| `container` | `g1t-runner` installed on the machine with Docker, or the image given the machine's Docker socket with the override. | Its own container. The runner itself drives the machine's Docker, which can do anything on the machine, so a job that escapes its container, or anything that takes over the runner, controls the machine. |
101+| `host` | `--no-docker`. Deprecated. | Everything the runner's user can: the machine, its files, and what earlier jobs left. |
102+
103+### What a job's container gets
104+
105+With `nested-container` and `container` isolation, each job runs in a
106+container made for it and removed after it:
107+
108+| | |
109+| --- | --- |
110+| Image | The job's `container:`, else the runner's `--image`, else `node:24-bookworm`. |
111+| Mounts | The harness (`g1t-runner`), read-only, and a volume of its own at `/home/runner`, removed after the job. Nothing else of the runner's or the machine's. |
112+| Network | A Docker network of its own, removed after the job. Never the machine's network. |
113+| Capabilities | Every one dropped but `CHOWN`, `DAC_OVERRIDE`, `FOWNER`, `FSETID`, `SETUID`, `SETGID`, `KILL` and `NET_BIND_SERVICE`, which installing packages and running test servers need. Raw sockets (`ping`) are among those dropped. |
114+| Privileges | `no-new-privileges`: a setuid program such as `sudo` gains nothing. Run steps as the image's root user rather than with `sudo`. Agent work keeps `sudo`, which its image uses once to put its guardrails out of the agent's reach. |
115+| Limits | `--memory` (three quarters of the runner's memory unless you set it, with no swap beyond it), `--cpus` (every CPU unless you set it) and `--pids-limit` (4096 unless you set it). |
116+| Credentials | Only the job's own short-lived token, passed by name. Never the runner's credential. |
117+
118+### Docker in jobs
119+
120+With `nested-container` isolation, a job that uses Docker (its
121+`services:`, a Docker action, a `docker://` step, or `docker` in a step)
122+gets a Docker Engine of its own. It starts the first time the job uses
123+Docker, from `docker:28-dind`, beside the job's container and on the job's
124+network, so a service's published ports are on the job's `localhost`. It
125+is removed, with its images and containers, when the job ends. A job that
126+does not use Docker never starts one.
127+
128+The job's engine is privileged inside the runner's container. Sysbox keeps
129+it off the machine, but a job that uses Docker can reach the runner's own
130+container, the runner's credential included. For code you do not trust,
131+use [ephemeral runners](#autoscaling), so each job has a runner container
132+of its own, or register with `--no-job-docker`, which gives jobs no Docker.
133+
134+With `container` isolation, jobs have no Docker of their own, since it
135+would be the machine's: their `services:` are not started, and the log
136+says so.
137+
138+### Host mode is deprecated
139+
140+`--no-docker` still works, and runs each job directly on the machine in a
141+folder of its own. It will be removed. Until then:
142+
143+- The runner's log says so when it starts: *Deprecated: this runner runs
144+ jobs directly on this machine (--no-docker), unsandboxed.*
145+- Every job's log starts with a warning: *This job ran on the host,
146+ unsandboxed …*
147+- It refuses a public repository's work, which anyone can propose code
148+ to: *acme/web is public, and this self-hosted runner runs jobs directly
149+ on its machine (--no-docker), unsandboxed, so it does not take a public
150+ repository's work.* The job fails with that message.
151+
152+Move a Linux runner to [the container](#run-it-as-a-container). A macOS
153+or Windows runner stays in host mode until sandboxes for them are built.
154+
155+### Windows and macOS
156+
157+Sandboxed jobs on Windows and macOS are not built yet; jobs native to
158+those systems run in host mode. What is planned:
159+
160+| | How each job will run |
161+| --- | --- |
162+| Windows | A Windows container with Hyper-V isolation: a small virtual machine with its own kernel, made from a signed image for the job and destroyed after it. |
163+| macOS | An ephemeral macOS virtual machine, using Apple's Virtualization framework, cloned from a signed image for the job and deleted after it. Apple's licence allows two macOS virtual machines per Mac at once, so a Mac will run at most two jobs at a time. |
164+
165+On both, the only part on the machine itself will be a small launcher that
166+makes and destroys those sandboxes from signed images; the runner and the
167+job run inside them.
168+
169+On a Mac or Windows machine with Docker Desktop, a runner installed there
170+without `--no-docker` already runs jobs in Linux containers, with
171+`container` isolation.
172+
27173 ## The Runners page
28174
29175 A workspace's **Runners** page shows g1t cloud and your own runners side
51197
52198 ## Add a runner
53199
200+On Linux, [run it as a container](#run-it-as-a-container). To install
201+`g1t-runner` on a machine instead, as on macOS and Windows:
202+
54203 1. Open **Runners** (a project's: **Settings → Runners**) and click **New runner**. g1t makes a
55204 registration token and shows the commands for Linux, macOS, Windows and
56205 Docker with it filled in. The token lasts an hour, can register any
90239
91240 The runner shows up in the list within a few seconds, with a green dot.
92241
242+Installed this way, it has `container` isolation with Docker, or `host`
243+isolation with `--no-docker`. See [isolation](#isolation).
244+
93245 ### Register options
94246
95247 | Option | |
100252 | `--labels` | Extra labels, comma-separated: `gpu,cuda-12`. See [labels](#labels). |
101253 | `--group` | A workspace runner's [group](#groups). The default group, or the one the token was made for, if left out. |
102254 | `--ephemeral` | Take one job, then remove itself. For [autoscaling](#autoscaling). |
103−| `--no-docker` | Run jobs directly on the machine instead of in containers. |
255+| `--no-docker` | Deprecated. Run jobs directly on the machine instead of in containers ([host mode](#host-mode-is-deprecated)). |
104256 | `--image` | The image workflow jobs run in when they name no `container:`. `node:24-bookworm` if left out. |
105257 | `--agent-image` | The image [agent work](#agents-on-your-runners) runs in. |
258+| `--memory` | Each job container's memory, such as `8g`. Three quarters of the runner's memory if left out. |
259+| `--cpus` | Each job container's CPUs, such as `4` or `1.5`. Every CPU if left out. |
260+| `--pids-limit` | The most processes in a job container. 4096 if left out. |
261+| `--no-job-docker` | Give jobs no [Docker of their own](#docker-in-jobs). |
262+| `--job-docker-image` | The image a job's own Docker Engine runs. `docker:28-dind` if left out. |
106263 | `--work-dir` | Where jobs run with `--no-docker` keep their files. `~/.g1t-runner/work` if left out. |
107264 | `--dir` | Where the runner keeps its configuration and credential. `~/.g1t-runner`, or `G1T_RUNNER_DIR`, if left out. Every command takes it. |
108265 | `--replace` | Take the place of a runner with the same name. |
162319 and `runner.arch` are the machine's, and `RUNNER_ENVIRONMENT` is
163320 `self-hosted`.
164321
165−- **In Docker** (the default), each job gets a fresh container from its
166− `container:` image or the runner's `--image`, removed when it ends. The
167− runner needs Docker, and its user needs to be allowed to use it. Its
168− `services:` are not started, since the job's container has no Docker of
169− its own; the log says so.
170−- **With `--no-docker`**, each job gets a fresh folder under the work
171− folder, removed when it ends, and runs with whatever the machine has
172− installed. A step's default shell is `bash` on Linux and macOS and
173− PowerShell on Windows; `shell: pwsh`, `powershell`, `cmd`, `bash` and
174− `python` work where installed. On a machine with Docker, the job's
175− `services:`, `container:`, `docker://` steps and Docker actions use
176− the machine's Docker, as on GitHub's runners.
322+- **In a container** (the default), each job gets a fresh container from
323+ its `container:` image or the runner's `--image`, removed when it ends,
324+ with the walls in [what a job's container gets](#what-a-jobs-container-gets).
325+ In the runner's image under Sysbox, its `services:` and Docker actions
326+ get [a Docker Engine of its own](#docker-in-jobs).
327+- **With `--no-docker`** (deprecated), each job gets a fresh folder under
328+ the work folder, removed when it ends, and runs with whatever the
329+ machine has installed. A step's default shell is `bash` on Linux and
330+ macOS and PowerShell on Windows; `shell: pwsh`, `powershell`, `cmd`,
331+ `bash` and `python` work where installed. On a machine with Docker, the
332+ job's `services:`, `container:`, `docker://` steps and Docker actions
333+ use the machine's Docker, as on GitHub's runners.
177334
178335 A self-hosted job stops at 60 minutes unless its `timeout-minutes` says
179336 more, up to 24 hours (1440). Jobs on g1t's own machines stop at 60 minutes.
204361 your own [model provider](/guides/models/), g1t charges nothing for the
205362 run; otherwise the model is paid as usual and the machine is free.
206363 - Agent work needs an image with git, Node and the agent's CLI: register
207− the runner with `--agent-image` and an image of yours that has them, or
208− run it with `--no-docker` on a Linux machine set aside for it, where
209− `/work` can be written. g1t does not publish an agent image yet.
364+ the runner with `--agent-image` and an image of yours that has them.
365+ Host mode on a Linux machine set aside for it, where `/work` can be
366+ written, also works, and is deprecated. g1t does not publish an agent
367+ image yet.
210368 - [Guardrails](/guides/guardrails/) still apply to what the agent does, but
211369 their network list cannot be enforced on your machine. The run's session
212370 says so when it starts.
239397 it. It is kept only as a hash on g1t's side, rotates every day, and stops
240398 working the moment the runner is removed. It is never given to a job:
241399 each job gets only its own short-lived token.
242−- **Isolation**: a job in Docker gets a container of its own, removed after.
243− A job with `--no-docker` runs as the runner's user, in a folder of its
244− own; it can reach whatever that user can. Use a dedicated account, and
245− ephemeral runners for untrusted code.
400+- **Isolation**: run the runner's image under Sysbox. Each job then gets
401+ a container of its own inside the runner's container, removed after,
402+ and neither reaches the machine. A job with `--no-docker` runs as the
403+ runner's user and can reach whatever that user can; host mode is
404+ deprecated and refuses public repositories' work. Use ephemeral runners
405+ for code you do not trust. See [isolation](#isolation).
246406 - **Network**: g1t's network guardrails are not enforced on your machines.
247407 A job reaches whatever the machine can.
248408 - **Audit**: making a registration token, registering, removing, and every
255415
256416 ## Docker and Kubernetes
257417
258−The runner's image runs `register-and-run`, which registers once and then
259−runs. Each option can also come from `G1T_RUNNER_<OPTION>` in the
260−environment, such as `G1T_RUNNER_TOKEN` and `G1T_RUNNER_LABELS`, so a
261−secret can hold the token:
418+[Run it as a container](#run-it-as-a-container) covers Docker. The image's
419+settings, besides the register options:
262420
263−```sh
264−docker run -d --name g1t-runner --restart unless-stopped \
265− -v /var/run/docker.sock:/var/run/docker.sock \
266− -v g1t-runner:/data -e G1T_RUNNER_DIR=/data \
267− g1t.sh/flagon-io/g1t-runner register-and-run --url https://g1t.sh --token g1trt_…
268−```
421+| Variable | |
422+| --- | --- |
423+| `G1T_RUNNER_SANDBOX` | `nested` in the image: start a Docker Engine inside the container and run jobs on it. |
424+| `G1T_RUNNER_DIR` | `/data` in the image: where the configuration and credential are kept. |
425+| `G1T_RUNNER_UNSAFE_ALLOW_HOST_ACCESS` | `1` starts the runner even when it can reach the machine. See [above](#run-it-as-a-container). |
269426
270−With the host's Docker socket, each job runs in a sibling container.
427+A runner that ran the image with the machine's Docker socket, as these
428+docs said before, refuses to start with the new image. Remove the
429+`-v /var/run/docker.sock:/var/run/docker.sock` and add
430+`--runtime=sysbox-runc`; the `/data` volume keeps its registration.
271431
272432 ### Autoscaling
273433
274−Ephemeral runners take one job and remove themselves, so each job starts on
275−a clean machine. In Kubernetes, run them with `--no-docker` in pods that
276−make their own registration token from an owner's personal
277−[access token](/guides/authentication/#access-tokens) with only
434+Ephemeral runners take one job and remove themselves, so each job starts in
435+a fresh runner container. In Kubernetes with Sysbox installed on the
436+nodes (its Kubernetes install adds the `sysbox-runc` runtime class), run
437+them in pods that make their own registration token from an owner's
438+personal [access token](/guides/authentication/#access-tokens) with only
278439 `runners:admin`, and let the Deployment replace each pod that finishes:
279440
280441 ```yaml
286447 replicas: 3
287448 selector: { matchLabels: { app: g1t-runner } }
288449 template:
289− metadata: { labels: { app: g1t-runner } }
450+ metadata:
451+ labels: { app: g1t-runner }
452+ annotations: { io.kubernetes.cri-o.userns-mode: "auto:size=65536" }
290453 spec:
454+ runtimeClassName: sysbox-runc
291455 containers:
292456 - name: runner
293457 image: g1t.sh/flagon-io/g1t-runner
303467 - { name: G1T_RUNNER_URL, value: https://g1t.sh }
304468 - { name: G1T_RUNNER_LABELS, value: k8s }
305469 - { name: G1T_RUNNER_EPHEMERAL, value: "1" }
306− - { name: G1T_RUNNER_NO_DOCKER, value: "1" }
307470 - name: G1T_ADMIN_TOKEN
308471 valueFrom: { secretKeyRef: { name: g1t, key: runners-admin-token } }
309472 ```
349512 | `register` says the token is not valid | It expired after an hour, or was mistyped. Make a new one. |
350513 | `register` says a runner with that name is registered | Choose another `--name`, or add `--replace`. |
351514 | `run` says *g1t no longer knows this runner* | It was removed, or its credential was. Register it again. |
352−| *could not run docker* | Install Docker and start it, give the runner's user access to it, or register with `--no-docker`. |
353−| Agent work fails at once | Give the runner `--agent-image`, or run it on Linux with `--no-docker`. |
515+| *this runner is not sandboxed from its machine, so it will not take work* | The runner's container can reach the machine: it lists why. Remove the machine's Docker socket and `--privileged`, and run it with `--runtime=sysbox-runc`. See [run it as a container](#run-it-as-a-container). |
516+| *the runner's own Docker Engine could not start* | The container is not running under Sysbox. Add `--runtime=sysbox-runc`, and check that Sysbox is installed (`docker info` lists `sysbox-runc` under runtimes). |
517+| *… is public, and this self-hosted runner runs jobs directly on its machine* | A runner in host mode does not take public repositories' work. Run the runner's image under Sysbox. |
518+| A step's `sudo` fails | Job containers have `no-new-privileges`. Steps run as the image's user, root in most CI images, so drop the `sudo`. |
519+| *could not run docker* | Install Docker and start it, and give the runner's user access to it. |
520+| Agent work fails at once | Give the runner `--agent-image`. |
354521 | The runner is offline in the list | It has not polled for 90 seconds. Check its log (`journalctl -u g1t-runner-<name>`, `~/.g1t-runner/runner.log`, or the terminal) and that the machine can reach `https://api.g1t.sh`. |
+4−3
150150 case "docker":
151151 return [
152152 {
153− title: "Register and run, with jobs in sibling containers",
153+ title: "Register and run under Sysbox, each job in a container inside the runner's",
154154 lines: [
155− `docker run -d --name g1t-runner --restart unless-stopped -v /var/run/docker.sock:/var/run/docker.sock -v g1t-runner:/data -e G1T_RUNNER_DIR=/data ${RUNNER_IMAGE} register-and-run --url ${url} --token ${value}`,
155+ `docker run -d --name g1t-runner --restart unless-stopped --runtime=sysbox-runc -v g1t-runner:/data ${RUNNER_IMAGE} register-and-run --url ${url} --token ${value}`,
156156 ],
157157 },
158158 ];
232232 ))}
233233 </ol>
234234 <p className="text-xs text-faint">
235− Jobs run in Docker containers by default; add <code className="font-mono">--no-docker</code> to run them on the machine itself. Add{" "}
235+ Each job runs in a container of its own; on Linux, run the Docker command under Sysbox so neither the runner nor its jobs reach the machine.{" "}
236+ <code className="font-mono">--no-docker</code>, which runs jobs on the machine itself, is deprecated. Add{" "}
236237 <code className="font-mono">--labels gpu,cuda</code> for jobs to ask for, or <code className="font-mono">--ephemeral</code> for a runner that takes
237238 one job and removes itself.{" "}
238239 <a href="https://docs.g1t.sh/guides/self-hosted-runners/" className="underline underline-offset-2">
+20−0
358358 /// Replace a runner of the same name instead of refusing.
359359 #[serde(default)]
360360 pub replace: bool,
361+ /// How the runner keeps work from its machine: `nested-container` (a
362+ /// container per job, inside the runner's own sandboxed container),
363+ /// `container` (a container per job, on a Docker Engine it shares with
364+ /// its machine) or `host` (directly on the machine). Kept, and shown
365+ /// nowhere yet; older runners leave it out.
366+ #[serde(default)]
367+ pub isolation: Option<String>,
361368 }
362369
370+/// The isolation levels a runner may say it has (`RegisterArgs::isolation`).
371+pub const ISOLATIONS: &[&str] = &["nested-container", "container", "host"];
372+
363373 #[derive(Clone, Debug, Serialize, Deserialize)]
364374 pub struct Registered {
365375 pub runner: Runner,
406416 /// started with: `MODE`, the run's short-lived credentials, the model
407417 /// proxy's address.
408418 pub env: Option<Map<String, Value>>,
419+ /// The repository's visibility, `public` or `private`, when g1t knows
420+ /// it: a runner that runs work directly on its machine refuses a
421+ /// public repository's. Older g1t leaves it out.
422+ #[serde(default)]
423+ pub visibility: Option<String>,
409424 }
410425
411426 #[derive(Clone, Debug, Default, Serialize, Deserialize)]
686701 image: None,
687702 token: Some("t".into()),
688703 env: None,
704+ visibility: Some("private".into()),
689705 })
690706 .unwrap();
691707 assert_eq!(assignment["timeout_minutes"], 60);
708+ assert_eq!(assignment["visibility"], "private");
709+ // An older runner's registration, without an isolation level.
710+ let register: RegisterArgs = serde_json::from_value(serde_json::json!({ "token": "g1trt_x", "name": "a", "os": "linux", "arch": "x64" })).unwrap();
711+ assert_eq!(register.isolation, None);
692712 }
693713 }
+1−1
11 [package]
22 name = "g1t-runner"
3−version = "0.1.0"
3+version = "0.2.0"
44 edition.workspace = true
55 license.workspace = true
66 description = "The program inside a g1t sandbox: runs an agent, checks, a catch-up merge, a review or a GitHub Actions job, and reports back. Also the self-hosted runner, g1t-runner register/run, on your own machines."
+2−2
275275 if self.docker_output(&["version", "--format", "{{.Server.Version}}"]).is_some() {
276276 return true;
277277 }
278− self.log.line("##[error]This job needs Docker (`services`, `container` or a Docker action), and Docker does not answer here. On a self-hosted runner, run the runner directly on a machine with Docker (`--no-docker`).");
278+ self.log.line("##[error]This job needs Docker (`services`, `container` or a Docker action), and Docker does not answer here. On a self-hosted runner, run the runner's image under Sysbox, which gives each job that uses Docker an engine of its own.");
279279 false
280280 }
281281
353353 // A self-hosted machine without Docker runs the steps as before,
354354 // on the machine, without the containers.
355355 if !self.docker_hosted && self.docker_output(&["version", "--format", "{{.Server.Version}}"]).is_none() {
356− self.log.line("##[warning]Docker does not answer on this runner, so the job's `services` and `container` are not started and its steps run on the machine. Run the runner directly on a machine with Docker (`--no-docker`) to start them.");
356+ self.log.line("##[warning]Docker does not answer on this runner, so the job's `services` and `container` are not started and its steps run on the machine. Run the runner's image under Sysbox to give each job Docker of its own.");
357357 return true;
358358 }
359359 self.log.line("##[group]Initialize containers");
+4−0
750750 job.docker_hosted = false;
751751 }
752752 }
753+ // A self-hosted runner's word on how this job was run (selfhosted/exec.rs).
754+ if let Ok(notice) = std::env::var("G1T_JOB_NOTICE") {
755+ job.log.line(&format!("##[warning]{notice}"));
756+ }
753757 let containers_started = job.start_containers();
754758 job.log.flush();
755759
+3−0
2222 pub token: Option<String>,
2323 #[serde(default)]
2424 pub env: Option<Map<String, Value>>,
25+ /// `public` or `private`; none from an older g1t.
26+ #[serde(default)]
27+ pub visibility: Option<String>,
2528 }
2629
2730 #[derive(Debug, Default, Deserialize)]
+33−0
4545 /// Checks for new versions and updates itself.
4646 #[serde(default = "yes")]
4747 pub auto_update: bool,
48+ /// Each job container's memory limit (`--memory`): `8g`. Three
49+ /// quarters of the machine's (or the runner container's) memory if
50+ /// left out.
51+ #[serde(default)]
52+ pub memory: Option<String>,
53+ /// Each job container's CPUs (`--cpus`): `2`, `1.5`. Every CPU if left out.
54+ #[serde(default)]
55+ pub cpus: Option<String>,
56+ /// The most processes in a job container (`--pids-limit`). 4096 if left out.
57+ #[serde(default)]
58+ pub pids_limit: Option<u32>,
59+ /// With nested-container isolation, a job that uses Docker gets an
60+ /// engine of its own. Off: jobs have no Docker.
61+ #[serde(default = "yes")]
62+ pub job_docker: bool,
63+ /// The image a job's own engine runs: `docker:28-dind` if left out.
64+ #[serde(default)]
65+ pub job_docker_image: Option<String>,
4866 }
4967
5068 fn yes() -> bool {
122140 }
123141
124142 #[test]
143+ fn an_older_configuration_still_reads() {
144+ let old = r#"{"url":"https://g1t.sh","api":"https://api.g1t.sh","runner":"rnr_1","name":"a","credential":"g1tr_x",
145+ "workspace":"acme","labels":["self-hosted"],"work_dir":"/tmp/w"}"#;
146+ let config: Config = serde_json::from_str(old).unwrap();
147+ assert!(config.docker && config.job_docker && config.auto_update);
148+ assert_eq!(config.memory, None);
149+ }
150+
151+ #[test]
125152 fn a_configuration_survives_a_round_trip() {
126153 let dir = std::env::temp_dir().join(format!("g1t-runner-config-{}", std::process::id()));
127154 let config = Config {
141168 agent_image: None,
142169 harness: None,
143170 auto_update: true,
171+ memory: Some("4g".into()),
172+ cpus: None,
173+ pids_limit: None,
174+ job_docker: true,
175+ job_docker_image: None,
144176 };
145177 save(&dir, &config).unwrap();
146178 let back = load(&dir).unwrap();
147179 assert_eq!(back.runner, "rnr_1");
148180 assert_eq!(back.labels, config.labels);
181+ assert_eq!(back.memory.as_deref(), Some("4g"));
149182 forget(&dir);
150183 assert!(load(&dir).is_err());
151184 let _ = std::fs::remove_dir_all(&dir);
+419−0
1+//! Docker Engines of the runner's own, for `nested-container` isolation
2+//! (sandbox.rs):
3+//!
4+//! - **The runner's engine.** In the runner's image under Sysbox
5+//! (`G1T_RUNNER_SANDBOX=nested`), the runner starts `dockerd` inside its
6+//! own container and runs every job's container on it. It is a child of
7+//! the runner, which `docker-init` (tini) supervises as PID 1, and is
8+//! started again before a job if it stopped. Started the way g1t's own
9+//! sandboxes start a job's engine (crate::docker::engine): the cgroup
10+//! controllers handed down first, as moby's `hack/dind` does, and plain
11+//! copies when the disk takes no overlays. Unlike there, Sysbox lets it
12+//! keep its packet filter, so each job gets a bridge network of its own.
13+//! - **A job's engine.** A job that uses Docker (`services:`, a Docker
14+//! action, `docker` in a step) gets an engine of its own, started the
15+//! first time the job's Docker socket is used: a `docker:dind` container
16+//! beside the job's, in the job's network namespace, so what it
17+//! publishes is on the job's `localhost`. The job sees only a socket
18+//! that the runner passes on. The engine is privileged within the
19+//! runner's container, never on the machine (Sysbox maps its root to an
20+//! unprivileged user there), and is removed with its data after the job.
21+
22+use std::io::{self, Read, Write};
23+use std::os::unix::net::{UnixListener, UnixStream};
24+use std::path::{Path, PathBuf};
25+use std::process::{Child, Command, Stdio};
26+use std::sync::atomic::{AtomicBool, Ordering};
27+use std::sync::{Arc, Mutex, OnceLock};
28+use std::time::{Duration, Instant};
29+
30+use anyhow::{Context, Result, bail};
31+use serde_json::{Value, json};
32+
33+use super::log;
34+
35+/// Everything of the runner's engine that is not its data.
36+pub const DIR: &str = "/run/g1t-runner";
37+/// The runner's engine's socket: not `/var/run/docker.sock`, so a socket
38+/// there is never the runner's own (sandbox.rs looks for one).
39+pub const SOCKET: &str = "/run/g1t-runner/docker.sock";
40+/// Where a job's sockets are kept, a folder each.
41+pub const JOBS_DIR: &str = "/run/g1t-runner/jobs";
42+/// Where the job sees its Docker socket.
43+pub const JOB_SOCKET_DIR: &str = "/run/g1t-docker";
44+/// The Docker CLI in the runner's image, lent to a job read-only.
45+pub const DOCKER_CLI: &str = "/usr/local/bin/docker";
46+/// The image a job's engine runs.
47+pub const DEFAULT_JOB_ENGINE_IMAGE: &str = "docker:28-dind";
48+
49+const LOG: &str = "/run/g1t-runner/dockerd.log";
50+/// From moby's `hack/dind`, as crate::docker::engine does it.
51+const CGROUP_NESTING: &str = "if [ -f /sys/fs/cgroup/cgroup.controllers ] && [ -z \"$(cat /sys/fs/cgroup/cgroup.subtree_control)\" ]; then \
52+ mkdir -p /sys/fs/cgroup/init && xargs -rn1 < /sys/fs/cgroup/cgroup.procs > /sys/fs/cgroup/init/cgroup.procs 2>/dev/null; \
53+ sed -e 's/ / +/g' -e 's/^/+/' < /sys/fs/cgroup/cgroup.controllers > /sys/fs/cgroup/cgroup.subtree_control; fi";
54+
55+/// The engine the runner's `docker` commands use: its own, once started.
56+static HOST: OnceLock<String> = OnceLock::new();
57+/// The runner's engine, while it runs.
58+static ENGINE: Mutex<Option<Child>> = Mutex::new(None);
59+
60+/// Whether the runner uses an engine of its own.
61+pub fn in_use() -> bool {
62+ HOST.get().is_some()
63+}
64+
65+/// `docker`, pointed at the runner's own engine when it has one.
66+pub fn docker() -> Command {
67+ let mut command = Command::new("docker");
68+ if let Some(host) = HOST.get() {
69+ command.env("DOCKER_HOST", host);
70+ }
71+ command
72+}
73+
74+/// The runner's engine's `daemon.json`. `copies`: the disk takes no
75+/// overlays. Its own address ranges, away from Docker's defaults, which
76+/// the machine's engine (and so the runner's own address) already uses.
77+pub fn daemon_config(copies: bool) -> Value {
78+ let mut config = json!({
79+ "hosts": [format!("unix://{SOCKET}")],
80+ "pidfile": format!("{DIR}/dockerd.pid"),
81+ "bip": "172.31.254.1/24",
82+ "default-address-pools": [{ "base": "172.30.0.0/16", "size": 24 }],
83+ "registry-mirrors": [crate::docker::engine::MIRROR],
84+ "log-driver": "json-file",
85+ "log-opts": { "max-size": "20m", "max-file": "2" },
86+ });
87+ if copies {
88+ config["storage-driver"] = json!("vfs");
89+ }
90+ config
91+}
92+
93+/// A request to an engine's socket; its status.
94+fn ping(socket: &Path) -> Option<u16> {
95+ let mut stream = UnixStream::connect(socket).ok()?;
96+ stream.set_read_timeout(Some(Duration::from_secs(5))).ok()?;
97+ stream.write_all(b"GET /_ping HTTP/1.1\r\nHost: docker\r\nConnection: close\r\n\r\n").ok()?;
98+ let mut head = [0u8; 12];
99+ stream.read_exact(&mut head).ok()?;
100+ std::str::from_utf8(&head[9..12]).ok()?.parse().ok()
101+}
102+
103+fn tail(path: &Path, lines: usize) -> String {
104+ let text = std::fs::read_to_string(path).unwrap_or_default();
105+ let all: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).collect();
106+ all[all.len().saturating_sub(lines)..].join("\n")
107+}
108+
109+/// Starts the runner's own engine, or finds it running (the runner
110+/// restarted after an update); its version.
111+pub fn start() -> Result<String> {
112+ let _ = HOST.set(format!("unix://{SOCKET}"));
113+ let mut engine = ENGINE.lock().unwrap_or_else(|p| p.into_inner());
114+ if let Some(child) = engine.as_mut()
115+ && matches!(child.try_wait(), Ok(None))
116+ && ping(Path::new(SOCKET)) == Some(200)
117+ {
118+ return Ok(version());
119+ }
120+ if engine.is_none() && ping(Path::new(SOCKET)) == Some(200) {
121+ return Ok(version());
122+ }
123+ std::fs::create_dir_all(DIR).with_context(|| format!("could not make {DIR}"))?;
124+ let _ = Command::new("sh").args(["-c", CGROUP_NESTING]).status();
125+ let mut last = String::new();
126+ for copies in [false, true] {
127+ std::fs::write(format!("{DIR}/daemon.json"), serde_json::to_vec_pretty(&daemon_config(copies))?)?;
128+ let _ = std::fs::remove_file(SOCKET);
129+ let out = std::fs::File::create(LOG).with_context(|| format!("could not write {LOG}"))?;
130+ let err = out.try_clone()?;
131+ let mut child = Command::new("dockerd")
132+ .args(["--config-file", &format!("{DIR}/daemon.json")])
133+ .env_remove("DOCKER_HOST")
134+ .stdin(Stdio::null())
135+ .stdout(out)
136+ .stderr(err)
137+ .spawn()
138+ .context("could not run dockerd: the runner's image has it; is this the runner's image?")?;
139+ let until = Instant::now() + Duration::from_secs(90);
140+ loop {
141+ if ping(Path::new(SOCKET)) == Some(200) {
142+ *engine = Some(child);
143+ let version = version();
144+ return Ok(if copies { format!("{version} (plain-copy storage: this disk takes no overlays)") } else { version });
145+ }
146+ if let Ok(Some(status)) = child.try_wait() {
147+ last = format!("dockerd stopped ({status}):\n{}", tail(Path::new(LOG), 15));
148+ break;
149+ }
150+ if Instant::now() >= until {
151+ let _ = child.kill();
152+ let _ = child.wait();
153+ last = format!("dockerd did not answer in 90 s:\n{}", tail(Path::new(LOG), 15));
154+ break;
155+ }
156+ std::thread::sleep(Duration::from_millis(200));
157+ }
158+ }
159+ bail!(
160+ "the runner's own Docker Engine could not start. Under Sysbox it needs no more than `docker run --runtime=sysbox-runc`; without Sysbox it cannot start without --privileged, which this runner does not use.\n{last}"
161+ )
162+}
163+
164+/// Before a job: the runner's engine is running, started again if not.
165+pub fn ensure_running() -> Result<()> {
166+ let stopped = {
167+ let mut engine = ENGINE.lock().unwrap_or_else(|p| p.into_inner());
168+ match engine.as_mut() {
169+ Some(child) => !matches!(child.try_wait(), Ok(None)),
170+ None => ping(Path::new(SOCKET)) != Some(200),
171+ }
172+ };
173+ if stopped {
174+ log("The runner's Docker Engine stopped; starting it again.");
175+ start()?;
176+ }
177+ Ok(())
178+}
179+
180+fn version() -> String {
181+ docker()
182+ .args(["version", "--format", "{{.Server.Version}}"])
183+ .output()
184+ .ok()
185+ .filter(|o| o.status.success())
186+ .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_owned())
187+ .filter(|v| !v.is_empty())
188+ .unwrap_or_else(|| "?".into())
189+}
190+
191+// --- A job's own engine -------------------------------------------------
192+
193+/// A job's Docker: a socket now, an engine the first time it is used.
194+pub struct JobEngine {
195+ /// `JOBS_DIR/<job container>`: `proxy/docker.sock` (the job's) and
196+ /// `engine/docker.sock` (its engine's).
197+ pub dir: PathBuf,
198+ /// The engine's container and its data's volume.
199+ pub container: String,
200+ pub volume: String,
201+ stop: Arc<AtomicBool>,
202+}
203+
204+/// What starting it needs.
205+#[derive(Clone)]
206+struct EngineSpec {
207+ image: String,
208+ container: String,
209+ volume: String,
210+ /// The job's container, whose network the engine joins.
211+ job: String,
212+ label: String,
213+ engine_dir: PathBuf,
214+}
215+
216+/// The `docker run` for a job's engine. Privileged within the runner's
217+/// container: Sysbox maps its root to an unprivileged user on the
218+/// machine, and gives the runner's container no devices of the machine's.
219+fn engine_args(spec: &EngineSpec) -> Vec<String> {
220+ let mut args: Vec<String> = ["run", "-d", "--rm", "--name", &spec.container, "--label", &spec.label, "--privileged", "--pull", "missing"]
221+ .iter()
222+ .map(|s| s.to_string())
223+ .collect();
224+ args.extend(["--network".into(), format!("container:{}", spec.job)]);
225+ // No TLS certificates and no TCP port: only the socket, which the
226+ // runner passes on.
227+ args.extend(["-e".into(), "DOCKER_TLS_CERTDIR=".into()]);
228+ args.extend(["-v".into(), format!("{}:/run/g1t-engine", spec.engine_dir.display())]);
229+ args.extend(["-v".into(), format!("{}:/var/lib/docker", spec.volume)]);
230+ args.extend([spec.image.clone(), "dockerd".into(), "--host=unix:///run/g1t-engine/docker.sock".into()]);
231+ args
232+}
233+
234+fn start_job_engine(spec: &EngineSpec) -> Result<(), String> {
235+ let begun = Instant::now();
236+ let output = docker().args(engine_args(spec)).output().map_err(|e| format!("could not run docker: {e}"))?;
237+ if !output.status.success() {
238+ return Err(format!("could not start the job's Docker Engine: {}", String::from_utf8_lossy(&output.stderr).trim()));
239+ }
240+ let socket = spec.engine_dir.join("docker.sock");
241+ while begun.elapsed() < Duration::from_secs(120) {
242+ if ping(&socket) == Some(200) {
243+ log(&format!("Started the job's own Docker Engine in {:.1}s.", begun.elapsed().as_secs_f64()));
244+ return Ok(());
245+ }
246+ std::thread::sleep(Duration::from_millis(200));
247+ }
248+ Err("the job's Docker Engine did not answer in 120 s".into())
249+}
250+
251+/// Passes one connection on to the job's engine, starting it first.
252+fn serve(mut client: UnixStream, spec: &EngineSpec, started: &Mutex<Option<Result<(), String>>>) {
253+ let ready = {
254+ let mut started = started.lock().unwrap_or_else(|p| p.into_inner());
255+ if started.is_none() {
256+ *started = Some(start_job_engine(spec));
257+ }
258+ started.clone().unwrap_or(Ok(()))
259+ };
260+ if let Err(problem) = ready {
261+ let body = serde_json::to_string(&json!({ "message": problem })).unwrap_or_default();
262+ let _ = write!(client, "HTTP/1.1 503 Service Unavailable\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len());
263+ return;
264+ }
265+ let Ok(engine) = UnixStream::connect(spec.engine_dir.join("docker.sock")) else { return };
266+ let (Ok(mut client_read), Ok(mut engine_write)) = (client.try_clone(), engine.try_clone()) else { return };
267+ let upstream = std::thread::spawn(move || {
268+ let _ = io::copy(&mut client_read, &mut engine_write);
269+ let _ = engine_write.shutdown(std::net::Shutdown::Write);
270+ });
271+ let mut engine_read = engine;
272+ let _ = io::copy(&mut engine_read, &mut client);
273+ let _ = client.shutdown(std::net::Shutdown::Both);
274+ let _ = upstream.join();
275+}
276+
277+impl JobEngine {
278+ /// Sets a job's Docker socket up, with no engine behind it yet.
279+ pub fn prepare(job: &str, runner: &str, image: &str) -> Result<JobEngine> {
280+ let dir = Path::new(JOBS_DIR).join(job);
281+ let _ = std::fs::remove_dir_all(&dir);
282+ let (proxy, engine_dir) = (dir.join("proxy"), dir.join("engine"));
283+ std::fs::create_dir_all(&proxy).and_then(|()| std::fs::create_dir_all(&engine_dir)).with_context(|| format!("could not make {}", dir.display()))?;
284+ let socket = proxy.join("docker.sock");
285+ let listener = UnixListener::bind(&socket).with_context(|| format!("could not listen on {}", socket.display()))?;
286+ {
287+ use std::os::unix::fs::PermissionsExt;
288+ // Whoever the job's image runs as.
289+ std::fs::set_permissions(&socket, std::fs::Permissions::from_mode(0o666))?;
290+ }
291+ listener.set_nonblocking(true)?;
292+ let spec = EngineSpec {
293+ image: image.to_owned(),
294+ container: format!("{job}-docker"),
295+ volume: format!("{job}-docker"),
296+ job: job.to_owned(),
297+ label: format!("sh.g1t.runner={runner}"),
298+ engine_dir,
299+ };
300+ let stop = Arc::new(AtomicBool::new(false));
301+ let (stopping, thread_spec) = (stop.clone(), spec.clone());
302+ std::thread::spawn(move || {
303+ let started: Arc<Mutex<Option<Result<(), String>>>> = Arc::new(Mutex::new(None));
304+ while !stopping.load(Ordering::SeqCst) {
305+ match listener.accept() {
306+ Ok((client, _)) => {
307+ let _ = client.set_nonblocking(false);
308+ let (spec, started) = (thread_spec.clone(), started.clone());
309+ std::thread::spawn(move || serve(client, &spec, &started));
310+ }
311+ Err(error) if error.kind() == io::ErrorKind::WouldBlock => std::thread::sleep(Duration::from_millis(100)),
312+ Err(_) => std::thread::sleep(Duration::from_millis(100)),
313+ }
314+ }
315+ });
316+ Ok(JobEngine { dir, container: spec.container, volume: spec.volume, stop })
317+ }
318+
319+ /// What the job's `docker run` adds: its socket's folder, read-only
320+ /// (a socket on a read-only mount still connects), the Docker CLI, and
321+ /// `DOCKER_HOST`.
322+ pub fn job_args(&self) -> Vec<String> {
323+ let mut args = vec!["-v".into(), format!("{}:{JOB_SOCKET_DIR}:ro", self.dir.join("proxy").display())];
324+ if Path::new(DOCKER_CLI).exists() {
325+ args.extend(["-v".into(), format!("{DOCKER_CLI}:/usr/local/bin/docker:ro")]);
326+ }
327+ args.extend(["-e".into(), format!("DOCKER_HOST=unix://{JOB_SOCKET_DIR}/docker.sock")]);
328+ args
329+ }
330+
331+ /// Stops the engine and removes it, its data and its sockets.
332+ pub fn remove(&self) {
333+ self.stop.store(true, Ordering::SeqCst);
334+ let quiet = |args: &[&str]| {
335+ let _ = docker().args(args).stdout(Stdio::null()).stderr(Stdio::null()).status();
336+ };
337+ quiet(&["rm", "-f", "-v", &self.container]);
338+ quiet(&["volume", "rm", "-f", &self.volume]);
339+ let _ = std::fs::remove_dir_all(&self.dir);
340+ }
341+}
342+
343+#[cfg(test)]
344+mod tests {
345+ use super::*;
346+
347+ #[test]
348+ fn the_runners_engine_keeps_its_own_socket_and_addresses() {
349+ let config = daemon_config(false);
350+ assert_eq!(config["hosts"][0], "unix:///run/g1t-runner/docker.sock");
351+ assert_ne!(SOCKET, "/var/run/docker.sock");
352+ assert!(config.get("storage-driver").is_none());
353+ assert_eq!(daemon_config(true)["storage-driver"], "vfs");
354+ // Not Docker's default range, which the machine's engine has.
355+ assert!(!config["bip"].as_str().unwrap().starts_with("172.17."));
356+ }
357+
358+ #[test]
359+ fn a_jobs_engine_is_in_the_jobs_network_and_mounts_nothing_of_the_machine() {
360+ let spec = EngineSpec {
361+ image: DEFAULT_JOB_ENGINE_IMAGE.into(),
362+ container: "g1t-job_1-docker".into(),
363+ volume: "g1t-job_1-docker".into(),
364+ job: "g1t-job_1".into(),
365+ label: "sh.g1t.runner=rnr_1".into(),
366+ engine_dir: PathBuf::from("/run/g1t-runner/jobs/g1t-job_1/engine"),
367+ };
368+ let args = engine_args(&spec);
369+ let joined = args.join(" ");
370+ assert!(joined.contains("--network container:g1t-job_1"));
371+ assert!(joined.contains("--rm"));
372+ assert!(!joined.contains("docker.sock:/"), "no engine socket is mounted in: {joined}");
373+ // Its volumes: the runner's own folder for its socket, and a volume.
374+ let mounts: Vec<&String> = args.windows(2).filter(|w| w[0] == "-v").map(|w| &w[1]).collect();
375+ assert_eq!(mounts.len(), 2);
376+ assert!(mounts[0].starts_with("/run/g1t-runner/jobs/"));
377+ assert!(mounts[1].starts_with("g1t-job_1-docker:"));
378+ assert!(args.iter().any(|a| a == "DOCKER_TLS_CERTDIR="));
379+ }
380+
381+ /// Against a real Docker Engine, as root, with `docker:28-dind` at
382+ /// hand: `G1T_TEST_DOCKER=1 cargo test -- --ignored job_engine`.
383+ #[test]
384+ #[ignore]
385+ fn a_jobs_engine_starts_when_the_job_first_uses_it() {
386+ if std::env::var("G1T_TEST_DOCKER").as_deref() != Ok("1") {
387+ return;
388+ }
389+ let job = "g1t-engine-test";
390+ let quiet = |args: &[&str]| {
391+ let _ = docker().args(args).stdout(Stdio::null()).stderr(Stdio::null()).status();
392+ };
393+ quiet(&["rm", "-f", job]);
394+ let engine = JobEngine::prepare(job, "test", DEFAULT_JOB_ENGINE_IMAGE).unwrap();
395+ // A job's container with the job's Docker: the dind image has a CLI.
396+ let mut run = docker();
397+ run.args(["run", "-d", "--rm", "--name", job, "--cap-drop", "ALL", "--security-opt", "no-new-privileges"]);
398+ let mut job_args = engine.job_args();
399+ // This machine's CLI may not run in Alpine; the image has its own.
400+ if let Some(at) = job_args.iter().position(|a| a.starts_with(DOCKER_CLI)) {
401+ job_args.drain(at - 1..=at);
402+ }
403+ run.args(&job_args).args(["--entrypoint", "sleep", DEFAULT_JOB_ENGINE_IMAGE, "300"]);
404+ assert!(run.status().unwrap().success());
405+ // Nothing runs behind the socket until it is used.
406+ assert!(!docker().args(["inspect", &engine.container]).stdout(Stdio::null()).stderr(Stdio::null()).status().unwrap().success());
407+ let version = docker().args(["exec", job, "docker", "version", "--format", "{{.Server.Version}}"]).output().unwrap();
408+ let shown = String::from_utf8_lossy(&version.stdout).trim().to_owned();
409+ let errors = String::from_utf8_lossy(&version.stderr).trim().to_owned();
410+ // The job cannot change its socket's folder.
411+ let wrote = docker().args(["exec", job, "touch", "/run/g1t-docker/x"]).stderr(Stdio::null()).status().unwrap();
412+ quiet(&["rm", "-f", job]);
413+ engine.remove();
414+ assert!(version.status.success(), "docker version in the job: {errors}");
415+ assert!(shown.starts_with("28."), "{shown}");
416+ assert!(!wrote.success());
417+ assert!(!engine.dir.exists());
418+ }
419+}
+432−31
33 //!
44 //! - **In Docker** (the default): one container per job, removed when it
55 //! ends, from the job's `container:` image or the runner's `--image`. A
6−//! Linux build of the harness is mounted into it read-only. Its
7−//! credentials are passed by name (`-e NAME`), never on the command line.
8−//! - **Directly** (`--no-docker`): the harness runs as a child process, in a
9−//! fresh folder under the runner's work folder that is removed after.
6+//! 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.
1017 //!
1118 //! The runner's own credential never reaches either: a job only ever has
12−//! its own short-lived token, as it would in g1t's sandbox.
19+//! 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.
1321
1422 use std::collections::BTreeMap;
1523 use std::path::{Path, PathBuf};
2028
2129 use super::api::Assignment;
2230 use super::config::Config;
31+use super::sandbox::{self, Isolation};
2332 use super::{log, update};
2433
2534 /// The image workflow jobs run in when they name none: Debian with Node,
2635 /// git and Python, as g1t's sandbox has.
2736 pub const DEFAULT_IMAGE: &str = "node:24-bookworm";
37+/// The most processes in a job's container, unless the runner says.
38+pub 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`.
55+pub const JOB_CAPS: &[&str] = &["CHOWN", "DAC_OVERRIDE", "FOWNER", "FSETID", "SETUID", "SETGID", "KILL", "NET_BIND_SERVICE"];
2856
57+/// 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.
62+pub const AGENT_EXTRA_CAPS: &[&str] = &["AUDIT_WRITE"];
63+
2964 /// Work in progress.
3065 pub struct Running {
3166 pub id: String,
3368 pub name: String,
3469 child: Child,
3570 container: Option<String>,
71+ /// The job's network and work volume, removed after.
72+ network: Option<String>,
73+ volume: Option<String>,
74+ /// The job's own Docker Engine, when it may have one.
75+ engine: Option<JobEngine>,
3676 folder: Option<PathBuf>,
3777 /// Past its time limit and a grace for g1t to say so: stopped here.
3878 deadline: Instant,
3979 }
4080
81+#[cfg(unix)]
82+use super::engine::JobEngine;
83+
84+/// No job has an engine of its own off Unix.
85+#[cfg(not(unix))]
86+pub struct JobEngine;
87+
88+#[cfg(not(unix))]
89+impl JobEngine {
90+ fn remove(&self) {}
91+ fn job_args(&self) -> Vec<String> {
92+ Vec::new()
93+ }
94+}
95+
96+/// `docker`, at the runner's own engine when it has one.
97+fn docker() -> Command {
98+ #[cfg(unix)]
99+ return super::engine::docker();
100+ #[cfg(not(unix))]
101+ Command::new("docker")
102+}
103+
104+fn quietly(command: &mut Command) {
105+ let _ = command.stdout(Stdio::null()).stderr(Stdio::null()).status();
106+}
107+
41108 impl Running {
42109 pub fn overdue(&self) -> bool {
43110 Instant::now() > self.deadline
55122 /// Stops it: the container, then the process and everything it started.
56123 pub fn stop(&mut self) {
57124 if let Some(container) = &self.container {
58− let _ = Command::new("docker").args(["kill", container]).stdout(Stdio::null()).stderr(Stdio::null()).status();
125+ quietly(docker().args(["kill", container]));
126+ }
127+ if let Some(engine) = &self.engine {
128+ quietly(docker().args(["kill", &engine.container]));
59129 }
60130 let pid = self.child.id().to_string();
61131 if cfg!(windows) {
62− let _ = Command::new("taskkill").args(["/T", "/F", "/PID", &pid]).stdout(Stdio::null()).stderr(Stdio::null()).status();
132+ quietly(Command::new("taskkill").args(["/T", "/F", "/PID", &pid]));
63133 } else {
64134 // Its own process group (see `directly`): the steps' processes too.
65− let _ = Command::new("kill").args(["-TERM", &format!("-{pid}")]).stdout(Stdio::null()).stderr(Stdio::null()).status();
135+ quietly(Command::new("kill").args(["-TERM", &format!("-{pid}")]));
66136 }
67137 let _ = self.child.kill();
68138 let _ = self.child.wait();
69139 }
70140
71− /// Removes what it left on the machine.
141+ /// Removes what it left: its container, its engine and that engine's
142+ /// data, its network, its work volume, its folder.
72143 pub fn clean(&mut self) {
73144 if let Some(container) = &self.container {
74− let _ = Command::new("docker").args(["rm", "-f", container]).stdout(Stdio::null()).stderr(Stdio::null()).status();
145+ quietly(docker().args(["rm", "-f", "-v", container]));
146+ }
147+ if let Some(engine) = self.engine.take() {
148+ engine.remove();
75149 }
150+ if let Some(network) = &self.network {
151+ quietly(docker().args(["network", "rm", network]));
152+ }
153+ if let Some(volume) = &self.volume {
154+ quietly(docker().args(["volume", "rm", "-f", volume]));
155+ }
76156 if let Some(folder) = &self.folder {
77157 let _ = std::fs::remove_dir_all(folder);
78158 }
119199
120200 /// Starts work. The harness reports a workflow job's steps and log itself;
121201 /// the runner tells g1t how the process ended.
122−pub fn start(config: &Config, folder: &Path, work: &Assignment) -> Result<Running> {
202+pub fn start(config: &Config, isolation: Isolation, folder: &Path, work: &Assignment) -> Result<Running> {
123203 let env = harness_env(config, work)?;
124− if config.docker { in_docker(config, folder, work, env) } else { directly(config, work, env) }
204+ if isolation == Isolation::Host {
205+ refuse_public_on_host(work)?;
206+ return directly(config, work, env);
207+ }
208+ in_docker(config, isolation, folder, work, env)
209+}
210+
211+/// Host mode does not take a public repository's work: anyone can propose
212+/// code to one, and here it would run on the machine itself.
213+fn refuse_public_on_host(work: &Assignment) -> Result<()> {
214+ match work.visibility.as_deref() {
215+ Some("public") => bail!("{}", sandbox::host_mode_refuses(&work.repo)),
216+ Some(_) => Ok(()),
217+ None => {
218+ 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));
219+ Ok(())
220+ }
221+ }
222+}
223+
224+/// Each job container's limits.
225+#[derive(Debug, Clone, PartialEq)]
226+pub struct Limits {
227+ pub memory: String,
228+ pub cpus: String,
229+ pub pids: u32,
230+}
231+
232+/// Three quarters of `MemTotal` in `/proc/meminfo`, in MiB, which under
233+/// Sysbox is the runner container's own memory.
234+fn default_memory(meminfo: &str) -> Option<String> {
235+ let kb: u64 = meminfo.lines().find_map(|l| l.strip_prefix("MemTotal:"))?.trim().trim_end_matches("kB").trim().parse().ok()?;
236+ let mib = kb / 1024 * 3 / 4;
237+ (mib >= 256).then(|| format!("{mib}m"))
238+}
239+
240+pub fn limits(config: &Config) -> Limits {
241+ let memory = config
242+ .memory
243+ .clone()
244+ .or_else(|| default_memory(&std::fs::read_to_string("/proc/meminfo").unwrap_or_default()))
245+ .unwrap_or_else(|| "4g".into());
246+ let cpus = config.cpus.clone().unwrap_or_else(|| std::thread::available_parallelism().map(|n| n.get()).unwrap_or(2).to_string());
247+ Limits { memory, cpus, pids: config.pids_limit.unwrap_or(DEFAULT_PIDS_LIMIT) }
248+}
249+
250+/// Everything a job's `docker run` is made from.
251+pub struct Plan<'a> {
252+ pub kind: &'a str,
253+ pub name: &'a str,
254+ pub runner: &'a str,
255+ pub image: &'a str,
256+ pub harness: &'a Path,
257+ pub network: &'a str,
258+ /// The job's work volume, for a workflow job's `/home/runner`.
259+ pub volume: Option<&'a str>,
260+ pub limits: &'a Limits,
261+ /// The names of the harness's variables, whose values come from the
262+ /// runner's process, never its command line.
263+ pub env_names: Vec<&'a str>,
264+ /// What the job's own engine adds (`JobEngine::job_args`).
265+ pub engine_args: Vec<String>,
266+}
267+
268+/// The arguments of `docker` that start a job's container.
269+pub fn run_args(plan: &Plan) -> Vec<String> {
270+ let mut args: Vec<String> = Vec::new();
271+ fn add(args: &mut Vec<String>, items: &[&str]) {
272+ args.extend(items.iter().map(|s| s.to_string()));
273+ }
274+ add(&mut args, &["run", "--rm", "--name", plan.name, "--label", &format!("sh.g1t.runner={}", plan.runner)]);
275+ add(&mut args, &["--pull", "missing", "--init"]);
276+ // The walls.
277+ add(&mut args, &["--cap-drop", "ALL"]);
278+ let mut caps: Vec<&str> = JOB_CAPS.to_vec();
279+ if plan.kind == "agent" {
280+ caps.extend(AGENT_EXTRA_CAPS);
281+ } else {
282+ add(&mut args, &["--security-opt", "no-new-privileges"]);
283+ }
284+ for cap in caps {
285+ add(&mut args, &["--cap-add", cap]);
286+ }
287+ let pids = plan.limits.pids.to_string();
288+ add(&mut args, &["--pids-limit", &pids, "--memory", &plan.limits.memory, "--memory-swap", &plan.limits.memory, "--cpus", &plan.limits.cpus]);
289+ add(&mut args, &["--network", plan.network, "--ipc", "private"]);
290+ // The job is in its `container:` image already: the harness inside
291+ // does not start it again (actions/containers.rs).
292+ add(&mut args, &["-e", "G1T_JOB_CONTAINER=1"]);
293+ for name in &plan.env_names {
294+ add(&mut args, &["-e", name]);
295+ }
296+ // Its only mounts: the harness, read-only, and its own volume.
297+ add(&mut args, &["-v", &format!("{}:/opt/g1t/g1t-runner:ro", plan.harness.display())]);
298+ if let Some(volume) = plan.volume {
299+ add(&mut args, &["-v", &format!("{volume}:/home/runner")]);
300+ }
301+ args.extend(plan.engine_args.iter().cloned());
302+ add(&mut args, &["--entrypoint", "/opt/g1t/g1t-runner", plan.image]);
303+ args
125304 }
126305
127−fn in_docker(config: &Config, folder: &Path, work: &Assignment, env: BTreeMap<String, String>) -> Result<Running> {
306+fn in_docker(config: &Config, isolation: Isolation, folder: &Path, work: &Assignment, env: BTreeMap<String, String>) -> Result<Running> {
128307 let image = match work.kind.as_str() {
129308 "agent" => match config.agent_image.clone().or_else(|| update::agent_image(config)) {
130309 Some(image) => image,
131310 None => bail!(
132− "agent work needs an image with git, Node and the agent's CLI: register this runner with --agent-image, or run it with --no-docker on Linux"
311+ "agent work needs an image with git, Node and the agent's CLI: register this runner with --agent-image IMAGE"
133312 ),
134313 },
135314 _ => work.image.clone().or_else(|| config.image.clone()).unwrap_or_else(|| DEFAULT_IMAGE.to_owned()),
136315 };
316+ #[cfg(unix)]
317+ if super::engine::in_use() {
318+ super::engine::ensure_running()?;
319+ }
137320 let harness = update::linux_harness(config, folder)?;
138321 let name = container_name(&work.id);
139− let _ = Command::new("docker").args(["rm", "-f", &name]).stdout(Stdio::null()).stderr(Stdio::null()).status();
140− let mut command = Command::new("docker");
141− command.args(["run", "--rm", "--name", &name, "--label", &format!("sh.g1t.runner={}", config.runner)]);
142− command.args(["--pull", "missing", "--init"]);
143− // The job is in its `container:` image already: the harness inside
144− // does not start it again (actions/containers.rs).
145− command.args(["-e", "G1T_JOB_CONTAINER=1"]);
146− for name in env.keys() {
147− command.args(["-e", name]);
322+ // What an earlier run of this job may have left.
323+ quietly(docker().args(["rm", "-f", "-v", &name]));
324+ quietly(docker().args(["network", "rm", &name]));
325+ let output = docker()
326+ .args(["network", "create", "--label", &format!("sh.g1t.runner={}", config.runner), &name])
327+ .output()
328+ .context("could not run docker: is Docker installed and running?")?;
329+ if !output.status.success() {
330+ bail!("could not make the job's network: {}", String::from_utf8_lossy(&output.stderr).trim());
148331 }
332+ let volume = (work.kind == "workflow").then(|| format!("{name}-work"));
333+ if let Some(volume) = &volume {
334+ quietly(docker().args(["volume", "rm", "-f", volume]));
335+ quietly(docker().args(["volume", "create", "--label", &format!("sh.g1t.runner={}", config.runner), volume]));
336+ }
337+ // A job's own Docker, only inside the runner's own sandbox: on a
338+ // shared engine it would be the machine's.
339+ let engine: Option<JobEngine> = job_engine(config, isolation, &name);
340+ let limits = limits(config);
341+ let plan = Plan {
342+ kind: &work.kind,
343+ name: &name,
344+ runner: &config.runner,
345+ image: &image,
346+ harness: &harness,
347+ network: &name,
348+ volume: volume.as_deref(),
349+ limits: &limits,
350+ env_names: env.keys().map(String::as_str).collect(),
351+ engine_args: engine.as_ref().map(JobEngine::job_args).unwrap_or_default(),
352+ };
353+ let mut command = docker();
354+ command.args(run_args(&plan));
149355 command.envs(&env);
150− command.args(["-v", &format!("{}:/opt/g1t/g1t-runner:ro", harness.display())]);
151− command.args(["--entrypoint", "/opt/g1t/g1t-runner", &image]);
152− log(&format!("Running {} {} in {image}", work.kind, work.name));
153− let child = command.spawn().context("could not run docker: is Docker installed and running? (or register with --no-docker)")?;
154− Ok(Running { id: work.id.clone(), kind: work.kind.clone(), name: work.name.clone(), child, container: Some(name), folder: None, deadline: deadline(work) })
356+ log(&format!(
357+ "Running {} {} in {image}: its own container and network, capabilities dropped, {} memory, {} CPUs, {} processes{}",
358+ work.kind,
359+ work.name,
360+ limits.memory,
361+ limits.cpus,
362+ limits.pids,
363+ if engine.is_some() { ", Docker of its own when it uses it" } else { "" }
364+ ));
365+ let child = match command.spawn() {
366+ Ok(child) => child,
367+ Err(error) => {
368+ if let Some(engine) = &engine {
369+ engine.remove();
370+ }
371+ quietly(docker().args(["network", "rm", &name]));
372+ return Err(error).context("could not run docker: is Docker installed and running?");
373+ }
374+ };
375+ Ok(Running {
376+ id: work.id.clone(),
377+ kind: work.kind.clone(),
378+ name: work.name.clone(),
379+ child,
380+ container: Some(name.clone()),
381+ network: Some(name),
382+ volume,
383+ engine,
384+ folder: None,
385+ deadline: deadline(work),
386+ })
387+}
388+
389+#[cfg(unix)]
390+fn job_engine(config: &Config, isolation: Isolation, name: &str) -> Option<JobEngine> {
391+ if isolation != Isolation::NestedContainer || !config.job_docker {
392+ return None;
393+ }
394+ let image = config.job_docker_image.as_deref().unwrap_or(super::engine::DEFAULT_JOB_ENGINE_IMAGE);
395+ match JobEngine::prepare(name, &config.runner, image) {
396+ Ok(engine) => Some(engine),
397+ Err(error) => {
398+ log(&format!("This job has no Docker of its own: {error:#}"));
399+ None
400+ }
401+ }
155402 }
156403
404+#[cfg(not(unix))]
405+fn job_engine(_config: &Config, _isolation: Isolation, _name: &str) -> Option<JobEngine> {
406+ None
407+}
408+
157409 fn directly(config: &Config, work: &Assignment, env: BTreeMap<String, String>) -> Result<Running> {
158410 let me = std::env::current_exe().context("could not find this program to run the job")?;
159411 let mut command = Command::new(me);
177429 }
178430 }
179431 command.envs(&env);
432+ // The job's log says it ran unsandboxed (actions/mod.rs).
433+ command.env(JOB_NOTICE, sandbox::HOST_MODE_IN_JOB);
180434 // A group of its own, so stopping it stops what its steps started.
181435 #[cfg(unix)]
182436 std::os::unix::process::CommandExt::process_group(&mut command, 0);
183− log(&format!("Running {} {} on this machine", work.kind, work.name));
437+ log(&format!("Running {} {} on this machine, unsandboxed (host mode, deprecated)", work.kind, work.name));
184438 let child = command.spawn().context("could not start the job's process")?;
185− Ok(Running { id: work.id.clone(), kind: work.kind.clone(), name: work.name.clone(), child, container: None, folder, deadline: deadline(work) })
439+ Ok(Running {
440+ id: work.id.clone(),
441+ kind: work.kind.clone(),
442+ name: work.name.clone(),
443+ child,
444+ container: None,
445+ network: None,
446+ volume: None,
447+ engine: None,
448+ folder,
449+ deadline: deadline(work),
450+ })
186451 }
187452
453+/// A warning the harness puts at the top of the job's log.
454+pub const JOB_NOTICE: &str = "G1T_JOB_NOTICE";
455+
188456 fn deadline(work: &Assignment) -> Instant {
189457 Instant::now() + Duration::from_secs(u64::from(work.timeout_minutes.max(1)) * 60 + 10 * 60)
190458 }
191459
192460 /// Whether Docker answers.
193461 pub fn docker_ready() -> bool {
194− Command::new("docker")
462+ docker()
195463 .args(["version", "--format", "{{.Server.Version}}"])
196464 .stdout(Stdio::null())
197465 .stderr(Stdio::null())
199467 .is_ok_and(|status| status.success())
200468 }
201469
470+/// Removes what jobs of this runner's left behind, if it stopped while one ran.
471+pub fn sweep(config: &Config) {
472+ let label = format!("label=sh.g1t.runner={}", config.runner);
473+ for (list, remove) in [
474+ (vec!["ps", "-aq", "--filter", &label], vec!["rm", "-f", "-v"]),
475+ (vec!["network", "ls", "-q", "--filter", &label], vec!["network", "rm"]),
476+ (vec!["volume", "ls", "-q", "--filter", &label], vec!["volume", "rm", "-f"]),
477+ ] {
478+ let Ok(output) = docker().args(&list).output() else { return };
479+ let ids: Vec<String> = String::from_utf8_lossy(&output.stdout).split_whitespace().map(str::to_owned).collect();
480+ if !ids.is_empty() {
481+ quietly(docker().args(&remove).args(&ids));
482+ }
483+ }
484+}
485+
202486 #[cfg(test)]
203487 mod tests {
204488 use super::*;
222506 agent_image: None,
223507 harness: None,
224508 auto_update: false,
509+ memory: None,
510+ cpus: None,
511+ pids_limit: None,
512+ job_docker: true,
513+ job_docker_image: None,
225514 }
226515 }
227516
262551 assert_eq!(container_name("job_01ab"), "g1t-job_01ab");
263552 assert_eq!(container_name("a/b c"), "g1t-a-b-c");
264553 }
554+
555+ fn args_for(kind: &str, engine_args: Vec<String>) -> (Vec<String>, BTreeMap<String, String>) {
556+ let config = config();
557+ let env = harness_env(&config, &assignment(kind)).unwrap();
558+ let limits = Limits { memory: "6144m".into(), cpus: "4".into(), pids: 4096 };
559+ let harness = PathBuf::from("/usr/local/bin/g1t-runner");
560+ let plan = Plan {
561+ kind,
562+ name: "g1t-job_1",
563+ runner: "rnr_1",
564+ image: DEFAULT_IMAGE,
565+ harness: &harness,
566+ network: "g1t-job_1",
567+ volume: (kind == "workflow").then_some("g1t-job_1-work"),
568+ limits: &limits,
569+ env_names: env.keys().map(String::as_str).collect(),
570+ engine_args,
571+ };
572+ (run_args(&plan), env.clone())
573+ }
574+
575+ fn value_of<'a>(args: &'a [String], flag: &str) -> Vec<&'a str> {
576+ args.windows(2).filter(|w| w[0] == flag).map(|w| w[1].as_str()).collect()
577+ }
578+
579+ #[test]
580+ fn a_jobs_container_is_walled_in() {
581+ let (args, _) = args_for("workflow", Vec::new());
582+ assert_eq!(args[0], "run");
583+ assert!(args.contains(&"--rm".to_owned()));
584+ assert_eq!(value_of(&args, "--cap-drop"), vec!["ALL"]);
585+ assert_eq!(value_of(&args, "--cap-add"), JOB_CAPS.to_vec());
586+ assert_eq!(value_of(&args, "--security-opt"), vec!["no-new-privileges"]);
587+ assert_eq!(value_of(&args, "--pids-limit"), vec!["4096"]);
588+ assert_eq!(value_of(&args, "--memory"), vec!["6144m"]);
589+ assert_eq!(value_of(&args, "--memory-swap"), vec!["6144m"]);
590+ assert_eq!(value_of(&args, "--cpus"), vec!["4"]);
591+ assert_eq!(value_of(&args, "--network"), vec!["g1t-job_1"]);
592+ for forbidden in ["--privileged", "--pid", "--userns", "--device", "host"] {
593+ assert!(!args.iter().any(|a| a == forbidden), "{forbidden} in {args:?}");
594+ }
595+ assert!(!JOB_CAPS.iter().any(|c| c.starts_with("SYS_") || *c == "NET_ADMIN" || *c == "NET_RAW"));
596+ }
597+
598+ #[test]
599+ fn a_jobs_only_mounts_are_the_harness_and_its_own_volume() {
600+ let (args, _) = args_for("workflow", Vec::new());
601+ let mounts = value_of(&args, "-v");
602+ assert_eq!(mounts, vec!["/usr/local/bin/g1t-runner:/opt/g1t/g1t-runner:ro", "g1t-job_1-work:/home/runner"]);
603+ assert!(!args.iter().any(|a| a.contains("docker.sock")), "no engine socket: {args:?}");
604+ assert!(!args.iter().any(|a| a == "--mount"));
605+ }
606+
607+ #[test]
608+ fn credentials_go_by_name_and_the_runners_never() {
609+ let (args, env) = args_for("workflow", Vec::new());
610+ let passed = value_of(&args, "-e");
611+ assert!(passed.contains(&"ACTIONS_TOKEN"));
612+ // Never a value on the command line, and never the runner's credential.
613+ assert!(!args.iter().any(|a| a.contains("jobtoken")));
614+ assert!(!args.iter().any(|a| a.contains("g1tr_")));
615+ assert!(env.values().all(|v| !v.contains("g1tr_secret")));
616+ assert!(!env.contains_key("G1T_RUNNER_TOKEN"));
617+ }
618+
619+ #[test]
620+ fn agent_work_keeps_sudo_for_its_guardrails_and_every_other_wall() {
621+ let (args, _) = args_for("agent", Vec::new());
622+ assert!(value_of(&args, "--security-opt").is_empty());
623+ assert!(value_of(&args, "--cap-add").contains(&"AUDIT_WRITE"));
624+ assert_eq!(value_of(&args, "--cap-drop"), vec!["ALL"]);
625+ assert_eq!(value_of(&args, "-v"), vec!["/usr/local/bin/g1t-runner:/opt/g1t/g1t-runner:ro"]);
626+ }
627+
628+ #[test]
629+ fn a_job_with_docker_gets_a_socket_of_its_own_not_the_runners() {
630+ 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()];
631+ let (args, _) = args_for("workflow", engine_args);
632+ let mounts = value_of(&args, "-v");
633+ assert!(mounts.contains(&"/run/g1t-runner/jobs/g1t-job_1/proxy:/run/g1t-docker:ro"));
634+ assert!(!mounts.iter().any(|m| m.starts_with("/var/run/docker.sock") || m.starts_with("/run/g1t-runner/docker.sock")));
635+ // The entrypoint and image stay last.
636+ assert_eq!(&args[args.len() - 3..], ["--entrypoint", "/opt/g1t/g1t-runner", DEFAULT_IMAGE]);
637+ }
638+
639+ #[test]
640+ fn limits_default_sensibly() {
641+ assert_eq!(default_memory("MemTotal: 16384000 kB\nMemFree: 1 kB\n"), Some("12000m".into()));
642+ assert_eq!(default_memory(""), None);
643+ let mut config = config();
644+ config.memory = Some("2g".into());
645+ config.pids_limit = Some(100);
646+ let limits = limits(&config);
647+ assert_eq!(limits.memory, "2g");
648+ assert_eq!(limits.pids, 100);
649+ assert!(limits.cpus.parse::<f64>().unwrap() >= 1.0);
650+ }
651+
652+ #[test]
653+ fn host_mode_refuses_a_public_repositorys_work() {
654+ let mut work = assignment("workflow");
655+ work.visibility = Some("public".into());
656+ let refused = refuse_public_on_host(&work).unwrap_err().to_string();
657+ assert!(refused.contains("acme/web is public"), "{refused}");
658+ work.visibility = Some("private".into());
659+ assert!(refuse_public_on_host(&work).is_ok());
660+ work.visibility = None;
661+ assert!(refuse_public_on_host(&work).is_ok());
662+ // And refuses it before anything runs.
663+ work.visibility = Some("public".into());
664+ assert!(start(&config(), Isolation::Host, Path::new("/tmp"), &work).is_err());
665+ }
265666 }
+90−8
44 //! g1t-runner register --url https://g1t.sh --token g1trt_… [--name build-01]
55 //! [--labels gpu,cuda] [--group Default] [--ephemeral] [--work-dir DIR]
66 //! [--no-docker] [--image IMAGE] [--agent-image IMAGE] [--replace]
7+//! [--memory 8g] [--cpus 4] [--pids-limit 4096] [--no-job-docker]
78 //! g1t-runner run [--once]
89 //! g1t-runner service install|start|stop|status|uninstall
910 //! g1t-runner remove
1718 //! program in another mode, see `exec`), and says how it ended. Nothing
1819 //! listens on the machine. Every command takes `--dir` (default
1920 //! `~/.g1t-runner`, or `G1T_RUNNER_DIR`), where its configuration is kept.
21+//!
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.
2025
2126 mod api;
2227 mod config;
28+#[cfg(unix)]
29+mod engine;
2330 mod exec;
31+mod sandbox;
2432 mod service;
2533 mod update;
2634
119127 /// `G1T_RUNNER_EPHEMERAL=1`), so a Kubernetes secret can hold the token.
120128 fn with_environment(mut self) -> Options {
121129 let env_name = |name: &str| format!("G1T_RUNNER_{}", name.to_ascii_uppercase().replace('-', "_"));
122− for name in ["url", "api", "token", "name", "labels", "group", "work-dir", "image", "agent-image", "harness"] {
130+ for name in VALUE_OPTIONS {
123131 if self.value(name).is_none()
124132 && let Ok(value) = std::env::var(env_name(name))
125133 && !value.is_empty()
127135 self.values.push((name.to_owned(), value));
128136 }
129137 }
130− for name in ["ephemeral", "no-docker", "replace", "no-auto-update"] {
138+ for name in ["ephemeral", "no-docker", "replace", "no-auto-update", "no-job-docker"] {
131139 if !self.flag(name) && std::env::var(env_name(name)).is_ok_and(|v| matches!(v.as_str(), "1" | "true" | "yes")) {
132140 self.flags.push(name.to_owned());
133141 }
136144 }
137145 }
138146
147+/// The options that take a value.
148+const VALUE_OPTIONS: [&str; 14] =
149+ ["url", "api", "token", "name", "labels", "group", "work-dir", "image", "agent-image", "harness", "memory", "cpus", "pids-limit", "job-docker-image"];
150+
139151 const HELP: &str = "g1t-runner: a self-hosted runner for g1t.
140152
141153 g1t-runner register --url https://g1t.sh --token <registration token>
142154 [--name NAME] [--labels a,b] [--group GROUP] [--ephemeral]
143155 [--work-dir DIR] [--no-docker] [--image IMAGE] [--agent-image IMAGE]
144156 [--api URL] [--harness PATH] [--replace] [--no-auto-update]
157+ [--memory 8g] [--cpus 4] [--pids-limit 4096] [--no-job-docker]
158+ [--job-docker-image IMAGE]
145159 g1t-runner run [--once] poll for work and run it
146160 g1t-runner register-and-run ... both, for containers; options may be
147161 G1T_RUNNER_URL, _TOKEN, _LABELS, _EPHEMERAL…
151165 g1t-runner version
152166
153167 Every command takes --dir DIR (default ~/.g1t-runner, or G1T_RUNNER_DIR).
168+Run it as a container under Sysbox so neither it nor its jobs reach the
169+machine: docker run --runtime=sysbox-runc … g1t.sh/flagon-io/g1t-runner.
170+--no-docker (host mode) is deprecated.
154171 Make a registration token under Settings, Runners on g1t.sh.
155172 Docs: https://docs.g1t.sh/guides/self-hosted-runners/";
156173
157174 /// Runs a command. Returns the process's exit code.
158175 pub fn main(args: Vec<String>) -> i32 {
159− let takes = ["url", "api", "token", "name", "labels", "group", "work-dir", "image", "agent-image", "harness", "dir"];
176+ let mut takes = VALUE_OPTIONS.to_vec();
177+ takes.push("dir");
160178 let options = match Options::parse(&args[1..], &takes) {
161179 Ok(options) => options,
162180 Err(error) => {
247265 .filter(|l| !l.is_empty())
248266 .collect();
249267 let docker = !options.flag("no-docker");
250− if docker && !exec::docker_ready() {
251− log("Docker does not answer here. Jobs will fail until it does; register with --no-docker to run them on this machine instead.");
268+ let isolation = isolation(docker)?;
269+ if isolation == sandbox::Isolation::Host {
270+ log(sandbox::HOST_MODE_AT_START);
271+ } else if !sandbox::nested_requested() && !exec::docker_ready() {
272+ log("Docker does not answer here. Jobs will fail until it does.");
252273 }
274+ let pids_limit = match options.value("pids-limit") {
275+ Some(text) => Some(text.parse::<u32>().ok().filter(|n| *n > 0).with_context(|| format!("--pids-limit takes a number of processes, not {text}"))?),
276+ None => None,
277+ };
253278 let existing = config::load(folder).ok();
254279 if existing.is_some() && !options.flag("replace") {
255280 bail!("{} already holds a registered runner: run `g1t-runner remove` first, or register with --replace or another --dir", folder.display());
275300 "ephemeral": options.flag("ephemeral"),
276301 "group": options.value("group"),
277302 "replace": options.flag("replace"),
303+ "isolation": isolation.as_str(),
278304 }),
279305 )?;
280306 let work_dir = options.value("work-dir").map(PathBuf::from).unwrap_or_else(|| folder.join("work"));
295321 agent_image: options.value("agent-image").map(str::to_owned),
296322 harness: options.value("harness").map(PathBuf::from),
297323 auto_update: !options.flag("no-auto-update"),
324+ memory: options.value("memory").map(str::to_owned),
325+ cpus: options.value("cpus").map(str::to_owned),
326+ pids_limit,
327+ job_docker: !options.flag("no-job-docker"),
328+ job_docker_image: options.value("job-docker-image").map(str::to_owned),
298329 };
299330 config::save(folder, &config)?;
300331 let place = match &config.repo {
306337 Ok(())
307338 }
308339
340+/// How this runner keeps work from its machine; refuses a nested runner
341+/// that can reach the machine (sandbox.rs).
342+fn isolation(docker: bool) -> Result<sandbox::Isolation> {
343+ use sandbox::{Isolation, SandboxDriver};
344+ if !docker {
345+ // What is coming for jobs native to this OS.
346+ if let Some(driver) = sandbox::native_driver()
347+ && let Err(why) = driver.preflight()
348+ {
349+ log(&format!("Sandboxed {} jobs, in {}: {why}.", std::env::consts::OS, driver.name()));
350+ }
351+ return Ok(Isolation::Host);
352+ }
353+ if !sandbox::nested_requested() {
354+ return sandbox::SharedDocker.preflight();
355+ }
356+ if !cfg!(target_os = "linux") {
357+ bail!("{}=nested is for the runner's Linux image", sandbox::SANDBOX_ENV);
358+ }
359+ let isolation = sandbox::NestedDocker { allow_unsafe: sandbox::unsafe_allowed() }.preflight()?;
360+ if isolation != Isolation::NestedContainer {
361+ for problem in sandbox::Findings::read().problems() {
362+ log(&format!("UNSAFE: {problem}"));
363+ }
364+ log(&format!("UNSAFE: {}=1 is set, so the runner starts anyway, and its jobs can reach this machine.", sandbox::UNSAFE_ENV));
365+ }
366+ Ok(isolation)
367+}
368+
369+/// A nested runner's own Docker Engine, which it uses whatever else it
370+/// can see (with the override, it starts despite that).
371+fn prepare(docker: bool) -> Result<()> {
372+ if !docker || !sandbox::nested_requested() {
373+ return Ok(());
374+ }
375+ #[cfg(unix)]
376+ {
377+ let version = engine::start()?;
378+ log(&format!("Started this runner's own Docker Engine {version}, inside its container."));
379+ }
380+ Ok(())
381+}
382+
309383 fn remove(folder: &Path) -> Result<()> {
310384 let config = config::load(folder)?;
311385 let api = Api { base: config.api.clone(), runner: config.runner.clone(), credential: config.credential.clone() };
352426 let mut config = config::load(folder)?;
353427 let mut api = Api { base: config.api.clone(), runner: config.runner.clone(), credential: config.credential.clone() };
354428 std::fs::create_dir_all(&config.work_dir).with_context(|| format!("could not make {}", config.work_dir.display()))?;
429+ let isolation = isolation(config.docker)?;
430+ prepare(config.docker)?;
431+ if isolation != sandbox::Isolation::Host {
432+ exec::sweep(&config);
433+ }
355434 log(&format!(
356− "g1t-runner {VERSION}: {} ({}) is listening for work from {}, {}.",
435+ "g1t-runner {VERSION}: {} ({}) is listening for work from {}. Isolation: {}.",
357436 config.name,
358437 config.labels.join(", "),
359438 config.url,
360− if config.docker { "in Docker" } else { "on this machine" }
439+ isolation.describe()
361440 ));
441+ if isolation == sandbox::Isolation::Host {
442+ log(sandbox::HOST_MODE_AT_START);
443+ }
362444 let mut current: Option<exec::Running> = None;
363445 let mut ran_one = false;
364446 let mut backoff = Duration::from_secs(2);
456538 }
457539 if let Some(work) = poll.assignment {
458540 log(&format!("Took {} {} ({}).", work.kind, work.name, work.repo));
459− match exec::start(&config, folder, &work) {
541+ match exec::start(&config, isolation, folder, &work) {
460542 Ok(running) => current = Some(running),
461543 Err(error) => {
462544 log(&format!("Could not start it: {error:#}"));
+429−0
1+//! How the runner keeps work from the machine it is on, and what it checks
2+//! before it takes any.
3+//!
4+//! The goal is two walls: the runner in a sandbox of its own, away from
5+//! its machine, and each job in a fresh sandbox of its own inside that,
6+//! made for the job and destroyed after. Where the runner stands today:
7+//!
8+//! | Isolation | How | Wall from the machine |
9+//! | --- | --- | --- |
10+//! | `nested-container` | The runner is a container under Sysbox (`docker run --runtime=sysbox-runc`), with its own Docker Engine inside; each job is a container on that engine. | Two: the job's container, then the runner's, whose root is no one on the machine. |
11+//! | `container` | The runner drives a Docker Engine it shares with the machine (installed on it, or a container with the machine's Docker socket); each job is a container on it. | One: the job's container. The runner itself can do anything the machine's Docker can, which is everything. |
12+//! | `host` | `--no-docker`: each job is a process on the machine, in a folder of its own. Deprecated. | None. |
13+//!
14+//! [`SandboxDriver`] is what makes a job's sandbox. [`NestedDocker`] (and
15+//! [`SharedDocker`], the same on a shared engine) is built: exec.rs runs
16+//! jobs through it. [`HyperVContainer`] and [`MacVm`] are the designs for
17+//! Windows and macOS, declared so the shape is fixed, and not built.
18+
19+use std::path::Path;
20+
21+use anyhow::{Result, bail};
22+
23+/// How well a runner keeps work from its machine, as it tells g1t when it
24+/// registers (`isolation`).
25+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
26+pub enum Isolation {
27+ /// A container per job, inside the runner's own sandboxed container.
28+ NestedContainer,
29+ /// A container per job, on a Docker Engine shared with the machine.
30+ Container,
31+ /// Directly on the machine.
32+ Host,
33+}
34+
35+impl Isolation {
36+ pub fn as_str(self) -> &'static str {
37+ match self {
38+ Isolation::NestedContainer => "nested-container",
39+ Isolation::Container => "container",
40+ Isolation::Host => "host",
41+ }
42+ }
43+
44+ /// For the log at start.
45+ pub fn describe(self) -> &'static str {
46+ match self {
47+ Isolation::NestedContainer => {
48+ "nested-container: each job runs in a fresh container on this runner's own Docker Engine, inside the runner's sandboxed container; neither reaches the machine"
49+ }
50+ Isolation::Container => {
51+ "container: each job runs in a fresh container, but the runner is not sandboxed from the machine (it shares the machine's Docker Engine, or its container's root is the machine's), so the runner, and anything that takes it over, controls the machine. Run the runner's image under Sysbox for nested-container isolation"
52+ }
53+ Isolation::Host => "host: jobs run directly on this machine, unsandboxed",
54+ }
55+ }
56+}
57+
58+/// What `G1T_RUNNER_SANDBOX` asks for: `nested`, the runner's image's
59+/// setting, starts a Docker Engine inside the runner's container.
60+pub const SANDBOX_ENV: &str = "G1T_RUNNER_SANDBOX";
61+
62+/// The override for the checks below, loudly named: with it, a runner
63+/// whose jobs can reach the machine starts anyway.
64+pub const UNSAFE_ENV: &str = "G1T_RUNNER_UNSAFE_ALLOW_HOST_ACCESS";
65+
66+pub fn nested_requested() -> bool {
67+ std::env::var(SANDBOX_ENV).is_ok_and(|v| v.eq_ignore_ascii_case("nested"))
68+}
69+
70+pub fn unsafe_allowed() -> bool {
71+ std::env::var(UNSAFE_ENV).is_ok_and(|v| matches!(v.as_str(), "1" | "true" | "yes"))
72+}
73+
74+// --- Host mode's deprecation ---------------------------------------------
75+
76+/// What the runner's log says when it starts in host mode.
77+pub const HOST_MODE_AT_START: &str = "Deprecated: this runner runs jobs directly on this machine (--no-docker), unsandboxed. Every job can read, change and leave behind anything this user can. Host mode will be removed: run the runner's image under Sysbox instead, see https://docs.g1t.sh/guides/self-hosted-runners/#isolation";
78+
79+/// What every job's log says when it ran in host mode.
80+pub const HOST_MODE_IN_JOB: &str = "This job ran on the host, unsandboxed: the self-hosted runner runs jobs directly on its machine (--no-docker). Host mode will be removed; see https://docs.g1t.sh/guides/self-hosted-runners/#isolation";
81+
82+/// Why a public repository's job is refused in host mode.
83+pub fn host_mode_refuses(repo: &str) -> String {
84+ format!(
85+ "{repo} is public, and this self-hosted runner runs jobs directly on its machine (--no-docker), unsandboxed, so it does not take a public repository's work. Run the runner's image under Sysbox, or register it without --no-docker. See https://docs.g1t.sh/guides/self-hosted-runners/#isolation"
86+ )
87+}
88+
89+// --- What the runner's container can see ----------------------------------
90+
91+/// Socket names that are a container engine's: whoever has one controls
92+/// that engine, and through it the machine it runs on.
93+const ENGINE_SOCKETS: &[&str] = &["docker.sock", "containerd.sock", "podman.sock", "crio.sock", "dockershim.sock", "cri-dockerd.sock"];
94+
95+fn is_engine_socket(path: &str) -> bool {
96+ let name = path.rsplit('/').next().unwrap_or(path);
97+ ENGINE_SOCKETS.contains(&name)
98+}
99+
100+/// mountinfo escapes spaces and the like as `\040`; undoes it.
101+fn unescape(field: &str) -> String {
102+ let bytes = field.as_bytes();
103+ let mut out = Vec::with_capacity(bytes.len());
104+ let mut i = 0;
105+ while i < bytes.len() {
106+ if bytes[i] == b'\\'
107+ && let Some(code) = bytes.get(i + 1..i + 4).and_then(|d| std::str::from_utf8(d).ok()).and_then(|d| u8::from_str_radix(d, 8).ok())
108+ {
109+ out.push(code);
110+ i += 4;
111+ continue;
112+ }
113+ out.push(bytes[i]);
114+ i += 1;
115+ }
116+ String::from_utf8_lossy(&out).into_owned()
117+}
118+
119+/// The engine sockets mounted into this container from outside it, from
120+/// `/proc/self/mountinfo`: a mount point, or the file a mount came from,
121+/// named like an engine's socket (`-v /var/run/docker.sock:/var/run/docker.sock`,
122+/// or the same socket under another name).
123+pub fn mounted_engine_sockets(mountinfo: &str) -> Vec<String> {
124+ let mut found = Vec::new();
125+ for line in mountinfo.lines() {
126+ // `id parent major:minor root mount-point options [optional…] - fstype source super-options`
127+ let fields: Vec<&str> = line.split_whitespace().collect();
128+ if fields.len() < 5 {
129+ continue;
130+ }
131+ let (root, mount_point) = (unescape(fields[3]), unescape(fields[4]));
132+ if is_engine_socket(&mount_point) || is_engine_socket(&root) {
133+ found.push(mount_point);
134+ }
135+ }
136+ found
137+}
138+
139+/// Whether this container runs under Sysbox, whose virtual files
140+/// (`/proc/uptime`, `/sys/kernel`, …) come from `sysboxfs`.
141+pub fn under_sysbox(mountinfo: &str) -> bool {
142+ mountinfo.lines().any(|line| line.split(" - ").nth(1).is_some_and(|rest| rest.split_whitespace().nth(1) == Some("sysboxfs")))
143+}
144+
145+/// Whether root here is root on the machine: no user namespace, every id
146+/// mapped to itself (`/proc/self/uid_map` is `0 0 4294967295`).
147+pub fn root_is_the_machines(uid_map: &str) -> bool {
148+ let rows: Vec<Vec<&str>> = uid_map.lines().map(|l| l.split_whitespace().collect()).filter(|r: &Vec<&str>| r.len() == 3).collect();
149+ rows.iter().any(|r| r[0] == "0" && r[1] == "0" && r[2].parse::<u64>().is_ok_and(|n| n >= 4_294_967_295))
150+}
151+
152+/// What a nested runner found about its container.
153+#[derive(Debug, Default)]
154+pub struct Findings {
155+ /// Engine sockets mounted in from outside.
156+ pub sockets: Vec<String>,
157+ /// An engine socket that is just there, where the runner's own engine
158+ /// does not put one: a directory such as `/var/run` mounted from the
159+ /// machine.
160+ pub stray_sockets: Vec<String>,
161+ /// `DOCKER_HOST` pointing somewhere.
162+ pub docker_host: Option<String>,
163+ pub sysbox: bool,
164+ pub machine_root: bool,
165+}
166+
167+impl Findings {
168+ pub fn read() -> Findings {
169+ let mountinfo = std::fs::read_to_string("/proc/self/mountinfo").unwrap_or_default();
170+ let uid_map = std::fs::read_to_string("/proc/self/uid_map").unwrap_or_default();
171+ let mut findings = Findings::from_text(&mountinfo, &uid_map);
172+ for path in ["/var/run/docker.sock", "/run/docker.sock", "/run/containerd/containerd.sock", "/run/podman/podman.sock"] {
173+ // `/var/run` is often `/run`: the same file is said once.
174+ let same = |a: &str, b: &str| a == b || std::fs::canonicalize(a).ok().zip(std::fs::canonicalize(b).ok()).is_some_and(|(a, b)| a == b);
175+ if Path::new(path).exists() && !findings.sockets.iter().any(|s| same(s, path)) {
176+ findings.stray_sockets.push(path.to_owned());
177+ }
178+ }
179+ findings.docker_host = std::env::var("DOCKER_HOST").ok().filter(|h| !h.is_empty());
180+ findings
181+ }
182+
183+ pub fn from_text(mountinfo: &str, uid_map: &str) -> Findings {
184+ Findings {
185+ sockets: mounted_engine_sockets(mountinfo),
186+ sysbox: under_sysbox(mountinfo),
187+ machine_root: root_is_the_machines(uid_map),
188+ ..Findings::default()
189+ }
190+ }
191+
192+ /// What reaches the machine from here, one reason a line.
193+ pub fn problems(&self) -> Vec<String> {
194+ let mut problems = Vec::new();
195+ for socket in &self.sockets {
196+ problems.push(format!("{socket} is a container engine's socket mounted in from outside this container: whoever uses it controls the machine"));
197+ }
198+ for socket in &self.stray_sockets {
199+ problems.push(format!("{socket} is a container engine's socket that this runner did not make (a folder mounted from the machine?)"));
200+ }
201+ if let Some(host) = &self.docker_host {
202+ problems.push(format!("DOCKER_HOST is set ({host}): jobs would run on that engine, not this runner's own"));
203+ }
204+ if self.machine_root && !self.sysbox {
205+ problems.push("root in this container is root on the machine (it has no user namespace of its own): run it with --runtime=sysbox-runc, and without --privileged".to_owned());
206+ }
207+ problems
208+ }
209+}
210+
211+/// Decides a nested runner's isolation, or refuses to start. With
212+/// [`UNSAFE_ENV`] it starts anyway, as `container`, and says why loudly.
213+pub fn check_nested(findings: &Findings, allow_unsafe: bool) -> Result<Isolation> {
214+ let problems = findings.problems();
215+ if problems.is_empty() {
216+ return Ok(Isolation::NestedContainer);
217+ }
218+ if allow_unsafe {
219+ return Ok(Isolation::Container);
220+ }
221+ bail!(
222+ "this runner is not sandboxed from its machine, so it will not take work:\n - {}\nRun it as `docker run --runtime=sysbox-runc …` without the machine's Docker socket (see https://docs.g1t.sh/guides/self-hosted-runners/#run-it-as-a-container), or set {UNSAFE_ENV}=1 to run anyway with jobs able to reach the machine.",
223+ problems.join("\n - ")
224+ )
225+}
226+
227+// --- Sandbox drivers --------------------------------------------------------
228+
229+/// What makes and destroys a job's sandbox. Each job gets a fresh one,
230+/// which never touches the machine, and which is gone after the job.
231+/// Making and destroying a Docker sandbox is exec.rs; a driver for another
232+/// kind adds its own when it is built.
233+pub trait SandboxDriver {
234+ /// What it is called in the log.
235+ fn name(&self) -> &'static str;
236+ /// Whether this machine can make sandboxes with it: the isolation it
237+ /// gives here, or why it cannot.
238+ fn preflight(&self) -> Result<Isolation>;
239+}
240+
241+/// Linux, built: the runner's own Docker Engine, inside the runner's
242+/// Sysbox container (engine.rs), and a container per job on it (exec.rs),
243+/// with no mounts from the machine. `allow_unsafe`: [`UNSAFE_ENV`] is set.
244+pub struct NestedDocker {
245+ pub allow_unsafe: bool,
246+}
247+
248+impl SandboxDriver for NestedDocker {
249+ fn name(&self) -> &'static str {
250+ "Docker inside the runner's container"
251+ }
252+ fn preflight(&self) -> Result<Isolation> {
253+ check_nested(&Findings::read(), self.allow_unsafe)
254+ }
255+}
256+
257+/// The same containers, on a Docker Engine shared with the machine: a
258+/// runner installed on the machine, or a container given its socket.
259+pub struct SharedDocker;
260+
261+impl SandboxDriver for SharedDocker {
262+ fn name(&self) -> &'static str {
263+ "the machine's Docker"
264+ }
265+ fn preflight(&self) -> Result<Isolation> {
266+ Ok(Isolation::Container)
267+ }
268+}
269+
270+/// The driver for jobs native to this OS, once there is one: what a
271+/// Windows or macOS runner in host mode says is coming.
272+pub fn native_driver() -> Option<Box<dyn SandboxDriver>> {
273+ match std::env::consts::OS {
274+ "windows" => Some(Box::new(HyperVContainer)),
275+ "macos" => Some(Box::new(MacVm)),
276+ _ => None,
277+ }
278+}
279+
280+/// **Windows: not built.** The design:
281+///
282+/// - A minimal launcher on the host, the only part that runs there. It
283+/// polls nothing and runs no job code: it creates a sandbox from a
284+/// signed image when the runner inside asks for one, and destroys it
285+/// when the job ends. Its whole job is "create from signed image" and
286+/// "destroy", so there is little to attack.
287+/// - Each job is a Windows container with Hyper-V isolation
288+/// (`docker run --isolation=hyperv`, or the HCS API directly): its own
289+/// utility VM and kernel, so a job that escapes its container is still
290+/// in a VM, not on the host. Process isolation shares the host's kernel
291+/// and is not enough.
292+/// - The image is pinned by digest and its signature checked before it is
293+/// used; the job gets the harness read-only, its own token, and a
294+/// scratch disk destroyed with the VM.
295+///
296+/// It needs a Windows host with Hyper-V and the Containers feature, and
297+/// cannot be built or tested on Linux. Until it is, Windows jobs run in
298+/// host mode, which is deprecated.
299+pub struct HyperVContainer;
300+
301+impl SandboxDriver for HyperVContainer {
302+ fn name(&self) -> &'static str {
303+ "Windows containers with Hyper-V isolation"
304+ }
305+ fn preflight(&self) -> Result<Isolation> {
306+ bail!("Hyper-V-isolated Windows containers are not built yet; Windows jobs run on the host (deprecated)")
307+ }
308+}
309+
310+/// **macOS: not built.** The design:
311+///
312+/// - The same minimal host launcher as on Windows, using Apple's
313+/// Virtualization framework (`VZVirtualMachine`) to boot an ephemeral
314+/// macOS VM per job from a signed base image, cloned copy-on-write
315+/// (APFS clones) so a fresh VM costs seconds, and deleted after.
316+/// - Apple's licence allows at most two macOS VMs per Mac at once, so a
317+/// Mac runs two jobs at a time at most, and the launcher refuses a
318+/// third.
319+/// - The runner and harness run inside the VM; the host keeps only the
320+/// launcher and the signed images.
321+///
322+/// It needs Apple silicon and macOS 13 or later, and cannot be built or
323+/// tested on Linux. Until it is, macOS jobs run in host mode, which is
324+/// deprecated.
325+pub struct MacVm;
326+
327+impl MacVm {
328+ /// Apple's macOS licence: two virtual copies per Mac.
329+ pub const MAX_VMS: u32 = 2;
330+}
331+
332+impl SandboxDriver for MacVm {
333+ fn name(&self) -> &'static str {
334+ "ephemeral macOS VMs"
335+ }
336+ fn preflight(&self) -> Result<Isolation> {
337+ bail!("ephemeral macOS VMs (at most {} per Mac) are not built yet; macOS jobs run on the host (deprecated)", MacVm::MAX_VMS)
338+ }
339+}
340+
341+#[cfg(test)]
342+mod tests {
343+ use super::*;
344+
345+ const PLAIN: &str = "\
346+611 590 0:52 / / rw,relatime master:180 - overlay overlay rw,lowerdir=/var/lib/docker/overlay2/l/A,upperdir=/var/lib/docker/overlay2/x/diff
347+612 611 0:55 / /proc rw,nosuid,nodev,noexec,relatime - proc proc rw
348+620 611 254:1 /var/lib/docker/volumes/g1t-runner/_data /data rw,relatime - ext4 /dev/vda1 rw
349+621 611 254:1 /var/lib/docker/containers/abc/resolv.conf /etc/resolv.conf rw,relatime - ext4 /dev/vda1 rw
350+";
351+
352+ const WITH_SOCKET: &str = "\
353+611 590 0:52 / / rw,relatime master:180 - overlay overlay rw,lowerdir=/var/lib/docker/overlay2/l/A
354+622 611 0:26 /docker.sock /run/docker.sock rw,nosuid,nodev,noexec,relatime - tmpfs tmpfs rw,size=3280376k,mode=755
355+";
356+
357+ const RENAMED_SOCKET: &str = "\
358+623 611 0:26 /docker.sock /tmp/engine rw,nosuid,nodev - tmpfs tmpfs rw
359+624 611 0:27 /containerd/containerd.sock /run/containerd/containerd.sock rw - tmpfs tmpfs rw
360+";
361+
362+ const SYSBOX: &str = "\
363+1000 990 0:60 / / rw,relatime - shiftfs /var/lib/sysbox/shiftfs/x rw
364+1001 1000 0:61 / /proc rw,nosuid,nodev,noexec,relatime - proc proc rw
365+1002 1001 0:62 /proc/uptime /proc/uptime rw,nosuid,nodev,relatime - fuse sysboxfs rw,user_id=0,group_id=0,default_permissions,allow_other
366+1003 1000 254:1 /var/lib/sysbox/docker/abc /var/lib/docker rw,relatime - ext4 /dev/vda1 rw
367+";
368+
369+ #[test]
370+ fn a_mounted_docker_socket_is_found() {
371+ assert!(mounted_engine_sockets(PLAIN).is_empty());
372+ assert_eq!(mounted_engine_sockets(WITH_SOCKET), vec!["/run/docker.sock".to_owned()]);
373+ // The same socket under another name, and containerd's.
374+ assert_eq!(mounted_engine_sockets(RENAMED_SOCKET), vec!["/tmp/engine".to_owned(), "/run/containerd/containerd.sock".to_owned()]);
375+ // Sysbox's own Docker data mount is not a socket.
376+ assert!(mounted_engine_sockets(SYSBOX).is_empty());
377+ }
378+
379+ #[test]
380+ fn escaped_paths_are_read() {
381+ assert_eq!(mounted_engine_sockets("1 0 0:1 /a\\040b/docker.sock /x\\040y rw - tmpfs tmpfs rw"), vec!["/x y".to_owned()]);
382+ }
383+
384+ #[test]
385+ fn sysbox_and_a_user_namespace_are_told() {
386+ assert!(under_sysbox(SYSBOX));
387+ assert!(!under_sysbox(PLAIN));
388+ assert!(root_is_the_machines(" 0 0 4294967295\n"));
389+ assert!(!root_is_the_machines(" 0 165536 65536\n"));
390+ }
391+
392+ #[test]
393+ fn a_nested_runner_refuses_the_machines_socket_unless_told_loudly() {
394+ let sysbox = Findings::from_text(SYSBOX, "0 165536 65536");
395+ assert_eq!(check_nested(&sysbox, false).unwrap(), Isolation::NestedContainer);
396+
397+ let socket = Findings::from_text(&format!("{SYSBOX}{WITH_SOCKET}"), "0 165536 65536");
398+ let refused = check_nested(&socket, false).unwrap_err().to_string();
399+ assert!(refused.contains("/run/docker.sock"), "{refused}");
400+ assert!(refused.contains(UNSAFE_ENV), "{refused}");
401+ assert_eq!(check_nested(&socket, true).unwrap(), Isolation::Container);
402+
403+ // --privileged, without Sysbox: root here is the machine's.
404+ let privileged = Findings::from_text(PLAIN, "0 0 4294967295");
405+ assert!(check_nested(&privileged, false).unwrap_err().to_string().contains("sysbox-runc"));
406+
407+ let host = Findings { docker_host: Some("tcp://10.0.0.1:2375".into()), ..Findings::from_text(SYSBOX, "0 165536 65536") };
408+ assert!(check_nested(&host, false).is_err());
409+ }
410+
411+ #[test]
412+ fn host_mode_says_it_is_deprecated() {
413+ assert!(HOST_MODE_AT_START.starts_with("Deprecated:"));
414+ assert!(HOST_MODE_AT_START.contains("will be removed"));
415+ assert!(HOST_MODE_IN_JOB.starts_with("This job ran on the host, unsandboxed"));
416+ assert!(HOST_MODE_IN_JOB.contains("Host mode will be removed"));
417+ assert!(host_mode_refuses("acme/web").contains("acme/web is public"));
418+ assert_eq!(Isolation::NestedContainer.as_str(), "nested-container");
419+ assert_eq!(Isolation::Host.as_str(), "host");
420+ }
421+
422+ #[test]
423+ fn the_unbuilt_drivers_say_so() {
424+ assert!(HyperVContainer.preflight().is_err());
425+ assert!(MacVm.preflight().unwrap_err().to_string().contains("at most 2"));
426+ assert_eq!(SharedDocker.preflight().unwrap(), Isolation::Container);
427+ assert_eq!(MacVm::MAX_VMS, 2);
428+ }
429+}
+27−13
11 # syntax=docker/dockerfile:1.7
22 #
3−# The self-hosted runner's image: `g1t-runner`, the Docker CLI (to start
4−# each job in a sibling container through the host's Docker socket), and
5−# what a job needs to run in this container itself with --no-docker
6−# (git, bash, Node), for Kubernetes pods that take one job each.
3+# The self-hosted runner's image: `g1t-runner` with a Docker Engine of its
4+# own, for Sysbox. The runner starts `dockerd` inside this container and
5+# runs each job in a fresh container on it, so jobs are containers inside
6+# the runner's container and nothing reaches the machine: Sysbox gives the
7+# container its own user namespace (its root is no one on the machine) and
8+# lets it run Docker without --privileged. Never mount the machine's
9+# /var/run/docker.sock: the runner refuses to start when it sees one.
710 #
8−# docker run -d --restart unless-stopped \
9−# -v /var/run/docker.sock:/var/run/docker.sock -v g1t-runner:/data \
11+# docker run -d --name g1t-runner --restart unless-stopped \
12+# --runtime=sysbox-runc \
13+# -v g1t-runner:/data \
1014 # -e G1T_RUNNER_URL=https://g1t.sh -e G1T_RUNNER_TOKEN=g1trt_… \
1115 # g1t.sh/flagon-io/g1t-runner
1216 #
17+# PID 1 is docker-init (tini), which supervises g1t-runner and reaps what it
18+# leaves; g1t-runner supervises dockerd (crates/runner/src/selfhosted/engine.rs).
19+# git, bash and Node stay for host mode (G1T_RUNNER_NO_DOCKER=1), which is
20+# deprecated.
21+#
1322 # Build context: a folder holding `g1t-runner` for the image's platform,
1423 # the static Linux build that scripts/runner-release.mjs makes (or
1524 # scripts/build-runner.mjs, for x64). The release workflow builds and
1625 # pushes it for linux/amd64 and linux/arm64.
1726
18−FROM docker:28-cli AS docker
27+# Docker's static builds: the engine, containerd, runc, the CLI, tini.
28+FROM docker:28-dind AS docker
1929
2030 FROM node:24-bookworm-slim
2131 RUN apt-get update \
22− && apt-get install -y --no-install-recommends git ca-certificates curl \
32+ && apt-get install -y --no-install-recommends git ca-certificates curl iptables procps \
2333 && rm -rf /var/lib/apt/lists/* \
24− && mkdir -p /data /home/runner && chown -R node:node /data /home/runner
25−COPY --from=docker /usr/local/bin/docker /usr/local/bin/docker
34+ && mkdir -p /data /home/runner
35+COPY --from=docker /usr/local/bin/dockerd /usr/local/bin/containerd /usr/local/bin/containerd-shim-runc-v2 \
36+ /usr/local/bin/ctr /usr/local/bin/runc /usr/local/bin/docker-proxy /usr/local/bin/docker-init \
37+ /usr/local/bin/docker /usr/local/bin/
2638 COPY --chmod=0755 g1t-runner /usr/local/bin/g1t-runner
27−# Its configuration and credential live on this volume.
28−ENV G1T_RUNNER_DIR=/data
39+# Its configuration and credential live on this volume. Sysbox keeps the
40+# engine's /var/lib/docker on the machine's disk for this container alone.
41+ENV G1T_RUNNER_DIR=/data \
42+ G1T_RUNNER_SANDBOX=nested
2943 VOLUME /data
30−ENTRYPOINT ["g1t-runner"]
44+ENTRYPOINT ["docker-init", "--", "g1t-runner"]
3145 CMD ["register-and-run"]
+4−0
1+-- How a self-hosted runner keeps work from its machine, as it said when it
2+-- registered: nested-container, container or host. Null for runners that
3+-- registered before runners said so.
4+ALTER TABLE runners ADD COLUMN isolation TEXT;
+21−2
826826 } else if total >= model::MAX_RUNNERS {
827827 return Ok(fail(FailureCode::Conflict, format!("A workspace has at most {} self-hosted runners.", model::MAX_RUNNERS)));
828828 }
829+ // How it keeps work from its machine, if it said (older runners do not).
830+ let isolation = a.isolation.as_deref().filter(|level| model::ISOLATIONS.contains(level));
829831 let id = new_id("rnr", at);
830832 let credential = format!("{}{}", model::CREDENTIAL_PREFIX, random_hex(32));
831833 let created = rfc3339(at);
832834 self.db
833835 .prepare(
834836 "INSERT INTO runners (id, workspace, repo_id, repo, group_id, name, labels, os, arch, version, ephemeral, credential_hash,
835− rotated_at, last_seen_at, created_at, created_by)
836− VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
837+ rotated_at, last_seen_at, created_at, created_by, isolation)
838+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
837839 )
838840 .bind(&[
839841 id.as_str().into(),
852854 created.as_str().into(),
853855 created.as_str().into(),
854856 JsValue::NULL,
857+ optional(isolation),
855858 ])?
856859 .run()
857860 .await?;
10731076 // A job that deploys: its run's deployment is under way.
10741077 self.job_started(&job.id).await?;
10751078 let image = self.job_image(&job).await?;
1079+ let visibility = self.visibility(&job.repo_id).await;
10761080 return Ok(Some(Assignment {
10771081 kind: "workflow".into(),
10781082 id: job.id.clone(),
10821086 image,
10831087 token: Some(token),
10841088 env: None,
1089+ visibility,
10851090 }));
10861091 }
10871092
11441149 format!("The self-hosted runner {} took agent work: {}.", runner.name, task.title),
11451150 )])
11461151 .await;
1152+ let visibility = match &task.repo_id {
1153+ Some(repo_id) => self.visibility(repo_id).await,
1154+ None => None,
1155+ };
11471156 return Ok(Some(Assignment {
11481157 kind: "agent".into(),
11491158 id: task.id.clone(),
11531162 image: None,
11541163 token: None,
11551164 env: Some(env),
1165+ visibility,
11561166 }));
11571167 }
11581168 Ok(None)
11591169 }
11601170
1171+ /// A repository's visibility for a runner, `public` or `private`; none
1172+ /// when it cannot be read, and the runner decides without it.
1173+ async fn visibility(&self, repo_id: &str) -> Option<String> {
1174+ match self.repo_by_id(repo_id).await {
1175+ Ok(Some((repo, _))) => Some(if repo.is_private { "private" } else { "public" }.to_owned()),
1176+ _ => None,
1177+ }
1178+ }
1179+
11611180 /// The image a job's `container:` names, when it names one plainly.
11621181 async fn job_image(&self, job: &JobRow) -> Result<Option<String>> {
11631182 let Some(run) = self.run_row(&job.run_id).await? else { return Ok(None) };