Skip to content

Commit

Merge branch 'main' into actions-toolkit-oidc-artifacts

# Conflicts: # apps/docs/src/content/docs/guides/actions.md

syntaqxcommitted Parents329cf74abbbeaeBrowse files
69 files+5049−1460/69 viewed
+29−9
1616 # with `deployment: false`, so a dry run or a change that deploys nothing
1717 # makes no deployment.
1818 #
19−# Needs the repository secret CLOUDFLARE_API_TOKEN (a Production row), the
20−# variable CLOUDFLARE_ACCOUNT_ID, and api.cloudflare.com among the project's
21−# workflow-only domains for deploy.yml in production (Settings, Guardrails),
22−# and registry.cloudflare.com there too, to find the runner's image. See
23−# docs/DEPLOYING.md.
19+# Needs the repository secret CLOUDFLARE_API_TOKEN (a Production row, with
20+# Containers write), the variable CLOUDFLARE_ACCOUNT_ID, and
21+# api.cloudflare.com among the project's workflow-only domains for
22+# deploy.yml in production (Settings, Guardrails), and
23+# registry.cloudflare.com there too, to find, pull and push the runner's
24+# image. A job that must build that image (the `runner-image` group) does
25+# so with its own Docker Engine, on a larger machine. See docs/DEPLOYING.md.
2426 name: Deploy
2527
2628 on:
126128 # Runs when nothing before it failed: a migrate job skipped for having
127129 # nothing to apply is not a failure.
128130 if: ${{ !failure() && !cancelled() && needs.plan.outputs.has_core == 'true' && inputs.dry_run != true }}
129− # Rust builds get 4 vCPUs; everything else the standard machine.
130− runs-on: ${{ matrix.rust && 'g1t-4core' || 'ubuntu-latest' }}
131+ # Rust builds and the runner's image get 4 vCPUs; everything else the
132+ # standard machine.
133+ runs-on: ${{ (matrix.rust || matrix.image) && 'g1t-4core' || 'ubuntu-latest' }}
131134 environment:
132135 name: production
133136 url: https://g1t.sh
185188 restore-keys: |
186189 cargo-target-${{ runner.os }}-${{ matrix.group }}-
187190 cargo-target-${{ runner.os }}-
191+ # The runner's image: its binary, built natively for musl (the base
192+ # has musl-gcc; the target is added here), with its Cargo target kept
193+ # between runs. The image itself is built and pushed with the job's
194+ # own Docker Engine (scripts/deploy/image.mjs).
195+ - name: Rust for the runner
196+ if: ${{ matrix.image }}
197+ run: rustup target add x86_64-unknown-linux-musl
198+ - name: Cache the runner's build
199+ if: ${{ matrix.image }}
200+ uses: actions/cache@v4
201+ with:
202+ path: |
203+ ~/.cargo/registry/cache
204+ target/x86_64-unknown-linux-musl/release
205+ !target/**/incremental
206+ key: runner-musl-${{ runner.os }}-${{ hashFiles('Cargo.lock', 'services/runner/base.json') }}
207+ restore-keys: runner-musl-${{ runner.os }}-
188208 - name: Install
189209 run: node scripts/deploy.mjs install --only "${{ matrix.units }}"
190210 - name: Deploy ${{ matrix.units }}
196216 name: edge (${{ matrix.group }})
197217 needs: [plan, migrate, core]
198218 if: ${{ !failure() && !cancelled() && needs.plan.outputs.has_edge == 'true' && inputs.dry_run != true }}
199− runs-on: ${{ matrix.rust && 'g1t-4core' || 'ubuntu-latest' }}
219+ runs-on: ${{ (matrix.rust || matrix.image) && 'g1t-4core' || 'ubuntu-latest' }}
200220 environment:
201221 name: production
202222 url: https://g1t.sh
211231 name: front (${{ matrix.group }})
212232 needs: [plan, migrate, core, edge]
213233 if: ${{ !failure() && !cancelled() && needs.plan.outputs.has_front == 'true' && inputs.dry_run != true }}
214− runs-on: ${{ matrix.rust && 'g1t-4core' || 'ubuntu-latest' }}
234+ runs-on: ${{ (matrix.rust || matrix.image) && 'g1t-4core' || 'ubuntu-latest' }}
215235 environment:
216236 name: production
217237 url: https://g1t.sh
+5−3
77 # Weekly, for security updates and new stable toolchains; when the base's
88 # folder changes on main (a change that forgot to rebuild it); and by hand.
99 #
10−# It needs Docker, which g1t's own sandboxes do not have, so it runs on a
11−# self-hosted runner with the `docker` label. Until one is registered, run
12−# the same thing by hand on a machine with Docker:
10+# It runs on a self-hosted runner with the `docker` label. g1t's own
11+# machines have Docker now, but this build's own downloads (Docker's apt
12+# repository over HTTPS) do not yet trust a guarded job's egress
13+# certificate, so it stays where the network is open. Until a runner is
14+# registered, run the same thing by hand on a machine with Docker:
1315 #
1416 # node scripts/deploy.mjs build-base
1517 #
+2−1
7474 image:
7575 name: Container image
7676 needs: binaries
77− # Needs Docker, which g1t's own sandboxes do not have.
77+ # Builds for arm64 as well as amd64, which needs QEMU's emulators
78+ # registered on the machine: a self-hosted runner's, for now.
7879 runs-on: [self-hosted, docker]
7980 environment: production
8081 timeout-minutes: 30
+33−16
184184
185185 ## Actions and runners
186186
187−### No Docker in g1t's sandboxes
187+### Docker shares the job's network
188188
189−On g1t's own machines, a job's `container:` image is not used (its steps
190−run on g1t's runner image instead), and a step cannot run `docker build`.
191−Docker container actions (`uses: docker://…`, or an action that runs as a
192−Docker image) and `services:` containers, such as a database, do not run
193−on any runner yet, self-hosted ones included.
189+A job's Docker Engine runs its containers on the job's own network, not on
190+networks of their own. A service is reached at `localhost` and by its
191+name, as on GitHub, but two containers cannot listen on the same port, and
192+`docker network create` gives no separation between containers.
194193
195−- **Why.** Jobs run in Cloudflare Containers, which offer no supported way
196− to run Docker or another image builder inside a container.
197−- **Instead.** Run `container:` jobs and image builds on a
198− [self-hosted runner](/guides/self-hosted-runners/). A runner in Docker
199− mode runs each job in its `container:` image. To build images, register a
200− runner with `--no-docker` on a machine that has Docker, and its steps can
201− call `docker build` and `docker push`. Self-hosted time costs nothing. For
202− a database, start it from a `run:` step on a self-hosted runner.
203−- **Status.** Docker container actions and `services:` are planned. Image
204− builds on g1t's machines depend on Cloudflare.
194+- **Why.** Jobs run in Cloudflare Containers, which let a container run
195+ Docker but not route a container network of its own out, or change its
196+ packet filter. Sharing the job's network is also what keeps the job's
197+ guardrails on every container.
198+- **Instead.** Give containers that would clash different ports.
199+- **Status.** Not scheduled.
200+
201+### No `type=gha` build cache
202+
203+Buildx's GitHub Actions cache backend (`cache-to: type=gha`) is skipped on
204+g1t, and the build runs without a cache.
205+
206+- **Why.** It talks to GitHub's cache service, which g1t's cache does not
207+ speak yet.
208+- **Instead.** Use a registry cache in g1t's container registry
209+ (`type=registry`), or `type=local` with `actions/cache`. See
210+ [caching image builds](/guides/actions/#caching-image-builds).
211+- **Status.** Planned.
212+
213+### No multi-platform image builds on g1t's machines
214+
215+Building an image for another platform, such as `linux/arm64`, needs QEMU's
216+emulators, which g1t's machines do not have set up.
217+
218+- **Instead.** Build other platforms on a
219+ [self-hosted runner](/guides/self-hosted-runners/) of that architecture,
220+ or one with QEMU set up.
221+- **Status.** Planned.
205222
206223 ### Linux only on g1t's machines
207224
+172−8
4747 | `actions/cache`, `actions/cache/restore`, `actions/cache/save` | Kept per repository, found by `key` or the newest under a `restore-keys` prefix. `path` takes globs and `!` exclusions. Up to 2 GiB each; see [the cache](#the-cache). |
4848 | Actions that cache through the toolkit, such as `actions/setup-node` with `cache: npm` or `Swatinem/rust-cache` | The same: they save to and restore from the repository's cache. See [actions built on the toolkit](#actions-built-on-the-toolkit). |
4949 | `permissions: id-token: write` | The job can ask for an OIDC token, and trade it for a cloud provider's credentials. See [OIDC tokens](#oidc-tokens). |
50+| `docker build`, `push`, `run`, `login`, `compose`, Buildx | The same, with a Docker Engine of the job's own. See [Docker](#docker). |
51+| `services:` | The same: each service starts before the steps, health checks are waited for, and it is reached at `localhost` on its port and by its name. |
52+| `container:` | The same: every step runs inside the image. |
53+| `uses: docker://image`, Docker actions (`runs.using: docker`) | The same: built from the action's Dockerfile or pulled, and run with GitHub's `/github/workspace` layout. |
54+| `docker/setup-buildx-action`, `docker/build-push-action`, `docker/login-action` | The same. `setup-buildx-action` picks the job's own Engine as the builder. |
5055
5156 The **Actions** page of a workflow says, under *How this runs on g1t*,
5257 anything in it that runs differently.
5762 job with `runs-on: windows-latest` or `macos-latest` fails, and says so.
5863 [Self-hosted runners](/guides/self-hosted-runners/) of any OS run them:
5964 `runs-on: [self-hosted, windows]`.
60−- **Docker** container actions, `services:` containers and `container:` on
61− g1t's machines. A job's `container:` is ignored there and its steps run on
62− g1t's image; a [self-hosted runner](/guides/self-hosted-runners/#what-a-job-gets)
63− that runs jobs in Docker uses it.
65+- **Docker's `type=gha` build cache.** Buildx skips it on g1t, and the
66+ build runs without a cache. Use a registry cache instead; see
67+ [caching image builds](#caching-image-builds).
6468 - **Reusable workflows from other repositories** (`uses: owner/repo/.github/workflows/x.yml@v1`); ones in the same repository work.
6569 - **Actions that upload artifacts with the toolkit's artifact library
6670 themselves.** The library refuses to run against any server but
8084 ## The runner
8185
8286 Jobs run in a fresh sandbox each: Debian with Node 24, Python 3, Go, Rust,
83−`build-essential`, `git`, `curl`, `jq` and passwordless `sudo`, in GitHub's
84−layout (`/home/runner/work`, `RUNNER_TEMP`, `RUNNER_TOOL_CACHE`).
87+`build-essential`, `git`, `curl`, `jq`, Docker (with Buildx and Compose)
88+and passwordless `sudo`, in GitHub's layout (`/home/runner/work`,
89+`RUNNER_TEMP`, `RUNNER_TOOL_CACHE`).
8590 `runner.os` is `Linux`. `ubuntu-latest`, `ubuntu-24.04` and other Linux
8691 labels all run here. A job whose `runs-on` names `self-hosted` waits for one
8792 of your [self-hosted runners](/guides/self-hosted-runners/) instead. Setup actions such as
124129 What builds need is the package registries (npm, PyPI, crates.io, the Go
125130 proxy, RubyGems, Packagist, NuGet, Maven and Gradle, Debian's mirrors),
126131 GitHub, where `uses:` actions and the setup actions' downloads come from,
127−and the toolchains' download sites (`nodejs.org`, `go.dev`,
128−`static.rust-lang.org`). A request anywhere else gets `403` with
132+the toolchains' download sites (`nodejs.org`, `go.dev`,
133+`static.rust-lang.org`), and the public container registries (Docker Hub,
134+GitHub's, Quay, and `mirror.gcr.io`, the mirror of Docker Hub that a job's
135+Engine asks first). A request anywhere else gets `403` with
129136 the reason. To reach another host, someone with the Maintain [role](/guides/access-and-roles/) or
130137 higher adds it to the project's
131138 allowed domains under **Settings → Guardrails**; a project whose guardrails
143150 looks like it is mining is stopped. See
144151 [abuse and mining](/guides/guardrails/#abuse-and-mining).
145152
153+## Docker
154+
155+Each job on g1t's machines has a Docker Engine of its own, inside the
156+job's sandbox. Nothing runs until the job uses it: the first `docker`
157+command, or a job's `services:` or `container:`, starts it, in a second
158+or two, and the log says so. It ends with the job, with every image,
159+container and build cache in it. No other job, repository or workspace
160+ever shares it.
161+
162+```yaml
163+jobs:
164+ test:
165+ runs-on: ubuntu-latest
166+ services:
167+ postgres:
168+ image: postgres:17
169+ env:
170+ POSTGRES_PASSWORD: ${{ secrets.DB_PASSWORD }}
171+ ports: ["5432:5432"]
172+ options: >-
173+ --health-cmd pg_isready --health-interval 5s --health-retries 10
174+ steps:
175+ - uses: actions/checkout@v5
176+ - run: docker compose up -d --wait
177+ - run: npm test
178+ env:
179+ DATABASE_URL: postgres://postgres:${{ secrets.DB_PASSWORD }}@localhost:5432/postgres
180+```
181+
182+### What works
183+
184+| | On g1t's machines |
185+| --- | --- |
186+| `docker build`, `buildx build`, `run`, `exec`, `pull`, `push`, `login`, `compose` | Work as they do on GitHub's runners. The Engine, Buildx and Compose are current releases. |
187+| `services:` | Pulled and started before the first step, with `env`, `ports`, `volumes`, `options` and `credentials`. Services with a health check are waited for; one that turns unhealthy fails the job with its log. Each service's log is printed when the job ends. `job.services.<id>.id`, `.network` and `.ports` are set. |
188+| `container:` | Every `run` step and JavaScript action runs inside the image, with its `env`, `options`, `volumes` and `credentials`. The workspace, `RUNNER_TEMP` and the tool cache are mounted at the same paths as on g1t's runner. |
189+| `uses: docker://image` | Pulled and run, with `with.args` and `with.entrypoint`. |
190+| Docker actions | Built from the action's Dockerfile (or pulled, for `image: docker://…`), and run with its `args`, `env` and `entrypoint`, its inputs as `INPUT_*` variables, and `pre-entrypoint` and `post-entrypoint`. |
191+| `docker/setup-buildx-action` | Selects the job's own Engine as the builder (BuildKit). Its `name`, `driver`, `platforms` and `nodes` outputs are set. `driver`, `driver-opts` and `buildkitd-*` are not used, and the log says so. |
192+| `docker/build-push-action` | Works, with `push`, `load`, `tags`, `labels`, `build-args`, `secrets`, `target`, `provenance` and `sbom`. |
193+| `docker/login-action` | Works, for g1t's registry, Docker Hub, GitHub's registry, Cloudflare's (`registry.cloudflare.com`) and any registry the job can reach. |
194+
195+### Services and the network
196+
197+Every container a job starts shares the job's own network, the one its
198+[guardrails](/guides/guardrails/) apply to. So:
199+
200+- **A service is at `localhost`** on its port, from steps and from other
201+ containers. `ports: ["5432:5432"]` and `ports: ["5432"]` both mean
202+ `localhost:5432`.
203+- **A port mapped to another number** (`ports: ["6543:5432"]`, or
204+ `docker run -p 8080:80`) is forwarded: `localhost:6543` reaches the
205+ service's 5432. `job.services.<id>.ports` says which port to use, and
206+ `docker inspect` and `docker port` report it.
207+- **A service is also reached by its name**, as it is from a job
208+ container on GitHub: `postgres:5432` works from steps, from the job's
209+ container and from any container started later. So do the names of
210+ containers and Compose services, and their network aliases.
211+- **Two containers cannot listen on the same port.** A job with a
212+ `redis` service and a Compose file that starts another Redis on 6379
213+ gets an error from the second; give one of them another port.
214+
215+A container that asks for `--network none` gets none, and
216+`--network container:<name>` shares that container's.
217+
218+### Job containers
219+
220+With `container:`, the steps run inside the image as its default user,
221+usually `root`. A few things differ from GitHub's runner:
222+
223+- The workspace is at the same path as on g1t's runner
224+ (`/home/runner/work/…`), not `/__w`. `github.workspace` is correct
225+ either way.
226+- JavaScript actions run inside the container with g1t's Node 24, which
227+ needs an image with glibc and `libstdc++` (Debian, Ubuntu and most
228+ language images have both). In an image without them, such as Alpine,
229+ they run beside the container, on g1t's runner, with the same files,
230+ and the log says so.
231+- `actions/checkout`, `actions/cache` and the artifact actions run on
232+ g1t's runner, with the same files.
233+
234+### Building and pushing images
235+
236+On g1t's machines, a job is signed in to g1t's container registry from
237+the start, with its own `G1T_TOKEN`, so it can push to and pull from its
238+workspace's images without a login step. A run that gets no secrets is
239+not signed in. See [container registry](/guides/containers/#in-workflows).
240+
241+```yaml
242+jobs:
243+ image:
244+ runs-on: g1t-4core
245+ steps:
246+ - uses: actions/checkout@v5
247+ - uses: docker/setup-buildx-action@v3
248+ - uses: docker/build-push-action@v6
249+ with:
250+ push: true
251+ tags: g1t.sh/${{ github.repository }}:${{ github.sha }}
252+ cache-from: type=registry,ref=g1t.sh/${{ github.repository }}:buildcache
253+ cache-to: type=registry,ref=g1t.sh/${{ github.repository }}:buildcache,mode=max
254+```
255+
256+For other registries, sign in with `docker/login-action` or
257+`docker login`, as on GitHub. Docker Hub's images are pulled through its
258+public mirror first, so jobs are rarely held up by Docker Hub's limits on
259+anonymous pulls.
260+
261+#### Caching image builds
262+
263+The Engine starts empty in every job, so a build's layers are rebuilt
264+unless the job brings a cache:
265+
266+- **A registry cache** (`cache-to: type=registry,ref=…,mode=max`), in g1t's
267+ registry or any other, is the simplest and is shared by every branch.
268+- **A local cache** (`cache-to: type=local,dest=/tmp/buildx-cache`) saved
269+ and restored with `actions/cache`, within [the cache's limits](#the-cache).
270+- **`type=gha`** is not used on g1t yet: Buildx skips it, and the build
271+ runs without a cache.
272+
273+### Limits
274+
275+- **Machine.** Containers share the job's machine: its vCPUs, memory and
276+ disk ([machine sizes](#machine-sizes)). Image builds and databases want
277+ `g1t-2core` or `g1t-4core`. `--cpus` and `--memory` limit a container
278+ within that.
279+- **Disk.** Images take room on the job's disk. On a machine whose disk
280+ cannot hold layered images, the Engine stores plain copies, which take
281+ more room; the log says when it does.
282+- **Linux, amd64.** Images for other platforms need QEMU's emulators,
283+ which g1t's machines do not have set up; `docker/setup-qemu-action` is
284+ not supported there yet.
285+- **Privileged containers** (`--privileged`) run, with no more reach than
286+ the job itself has: the job's sandbox is the boundary.
287+
288+### How Docker is kept safe
289+
290+- **One Engine per job.** It runs inside the job's own sandbox, a virtual
291+ machine of its own, and is gone with it. No Docker socket of g1t's, or of
292+ any machine, is shared with a job.
293+- **The job's guardrails hold.** Containers use the job's network, so a
294+ container, a build step or an image pull reaches only what the job may
295+ reach. A host off the list gets `403` with the reason, as any step does.
296+- **HTTPS keeps working.** In a job whose network is restricted, every
297+ container and build step is given the certificate the job's HTTPS is
298+ checked with, in `/dev/g1t-egress`, and `SSL_CERT_FILE`,
299+ `NODE_EXTRA_CA_CERTS`, `REQUESTS_CA_BUNDLE`, `CURL_CA_BUNDLE`, `PIP_CERT`,
300+ `GIT_SSL_CAINFO` and `CARGO_HTTP_CAINFO` pointing at it, unless the
301+ container sets them itself. None of it is written into an image's layers.
302+ Tools that keep their own list of certificates, such as Java's, need it
303+ added in the build that uses them.
304+- **Short-lived credentials.** The registry sign-in uses the run's own
305+ token, which ends with the run; `credentials:` for a service or a job
306+ container are used for that pull only.
307+- **No miners.** A container whose image or command names a miner is not
308+ created, as a step's script is not run.
309+
146310 ## The cache
147311
148312 `actions/cache` keeps what a job saves for the repository's later jobs:
+5−1
253253 your key.
254254
255255 `GET /openai/v1/models` lists what the workspace can use: its own
256−providers' models first, then g1t's, cheapest Claude first. Each has
256+providers' models first, then g1t's, starting with the Claude g1t suggests
257+starting with (Claude Haiku 5.5 today). g1t adds models as providers
258+release them, once their prices are confirmed, so the list and the tables
259+below grow over time; a model a provider stops offering is listed until it
260+is retired. Each has
257261 `billed_to` (`workspace` or `g1t`), `connection` (your provider's name) and,
258262 on g1t's models, `pricing` in dollars per million tokens:
259263
+4−2
7272 docker push g1t.sh/${{ github.repository }}:${{ github.sha }}
7373 ```
7474
75−Runs that get no secrets (a pull request from someone without Write) get an
76−empty token, and cannot push. See
75+On g1t's own machines a job is already signed in to g1t.sh with that
76+token when it starts, so the sign-in step can be left out there; it does
77+no harm. Runs that get no secrets (a pull request from someone without
78+Write) get an empty token, are not signed in, and cannot push. See
7779 [secrets and variables](/guides/actions/#secrets-and-variables).
7880
7981 ## The 100 MB limit
+13−4
103103 - every package registry above, whatever the project turned on for its
104104 agents, and RubyGems, Packagist, NuGet, Maven Central, Gradle and
105105 Debian's mirrors;
106+- container registries, for a job's own Docker Engine: Docker Hub
107+ (`registry-1.docker.io`, `auth.docker.io` and the CDNs its layers come
108+ from), `mirror.gcr.io`, Quay (`quay.io` and its CDNs), and Docker's
109+ package repository, `download.docker.com`;
106110 - for deploy builds, Cloudflare's API, which the build uploads its app to.
107111
108112 The repository itself is cloned from g1t, which is always reachable. A
109113 project whose guardrails set **Only allowed hosts** to Open runs its jobs
110114 and builds with an open network too.
111115
116+The containers a job starts with [Docker](/guides/actions/#docker), its
117+services and its build steps share the job's network, so this list is
118+theirs too: an image from another registry, or a build step that
119+downloads from another host, needs that host allowed, as a step would.
120+
112121 ### Workflow-only domains
113122
114123 Some hosts only a workflow should reach: the API a deploy uploads to, a
279288 - **No pool to reach.** No mining pool is on any allowed list, so a
280289 restricted sandbox's miner has nowhere to send its work.
281290 - **Miners by name.** A shell command an agent runs, a check, a build
282− command or a workflow step that names a known miner (`xmrig`,
283− `cpuminer`, `t-rex` and others), a pool address (`stratum+tcp://`) or a
284− miner's flags (`--donate-level`, `--algo=rx/0`) is refused, whatever the
285− project's rules. A running process whose command line names one stops
291+ command, a workflow step or a container a job starts whose image or
292+ command names a known miner (`xmrig`, `cpuminer`, `t-rex` and others), a
293+ pool address (`stratum+tcp://`) or a miner's flags (`--donate-level`,
294+ `--algo=rx/0`) is refused, whatever the project's rules. A running process whose command line names one stops
286295 the sandbox at once.
287296 - **The CPU signature.** Every sandbox samples itself every 30 seconds:
288297 CPU use, file and disk I/O, network bytes, new processes, and whether the
+7−0
4747 of the same kind, Auto moves that work down a tier there; when a model
4848 keeps failing, up.
4949
50+The models behind the tiers are today's. g1t keeps up with new models
51+as providers release them: it checks for new ones every day, and when g1t
52+moves a tier to a new model, your runs use it within a minute, with
53+nothing for you to change. Nobody picks a model; Auto keeps choosing by the
54+work. A model a provider retires is never used again: the next model for
55+that tier runs instead, and the run says so.
56+
5057 Every run says which model it used and why, in one line on its run and in
5158 its pull request's session, such as *Used a fast model (Claude Haiku 5.5):
5259 small change, 3 files and 80 lines.* The full rules are in
+6−2
139139
140140 - **In Docker** (the default), each job gets a fresh container from its
141141 `container:` image or the runner's `--image`, removed when it ends. The
142− runner needs Docker, and its user needs to be allowed to use it.
142+ runner needs Docker, and its user needs to be allowed to use it. Its
143+ `services:` are not started, since the job's container has no Docker of
144+ its own; the log says so.
143145 - **With `--no-docker`**, each job gets a fresh folder under the work
144146 folder, removed when it ends, and runs with whatever the machine has
145147 installed. A step's default shell is `bash` on Linux and macOS and
146148 PowerShell on Windows; `shell: pwsh`, `powershell`, `cmd`, `bash` and
147− `python` work where installed.
149+ `python` work where installed. On a machine with Docker, the job's
150+ `services:`, `container:`, `docker://` steps and Docker actions use
151+ the machine's Docker, as on GitHub's runners.
148152
149153 A self-hosted job stops at 60 minutes unless its `timeout-minutes` says
150154 more, up to 24 hours (1440). Jobs on g1t's own machines stop at 60 minutes.
+4−0
452452 | Standard | Claude Sonnet 5.5 | Most changes and reviews |
453453 | Most capable | Claude Opus 5.5 | Hard work, and work that failed on the standard model |
454454
455+The models are today's: when g1t moves a tier to a newer model, runs use
456+it within a minute. A model its provider retires is never used; the next
457+model for the tier runs, and the run's line says so.
458+
455459 The job starts on its tier:
456460
457461 | Work | Starts on |
+23−1
102102 to Stripe's hosted invoice page. An invoice also goes out on its own as
103103 each month closes: one Stripe invoice, a line per workspace for what it
104104 owes, net 30, emailed by Stripe.
105+- **Agents & models** (`/agents`, under Platform): billing's model
106+ catalogue, every model g1t can use (`admin_models`). **Check for new
107+ models** lists each provider's models now, through the model proxy's
108+ `Discovery` entrypoint (the `MODELS` binding), as the daily check does;
109+ the result says what each provider listed, what is new or gone, or why a
110+ provider could not be listed. **Defaults**: the model behind each of
111+ Auto's tiers, the harness's background model and the AI Gateway's first
112+ Claude (an available, priced Claude each, shown with what a typical run
113+ costs on it), and each kind of job's starting tier and effort. A change
114+ needs a reason and shows a review first, the current and new value side
115+ by side with what a typical run would cost on each, before **Save**
116+ (`admin_set_model_default`); runs pick it up within a minute. A default
117+ that has fallen back (its model retired or no longer listed) says so.
118+ **New models**: each model a check found, with its prices filled in
119+ where known; confirm its name, tier and prices per million tokens (and
120+ long-prompt prices) and **Approve**, or **Retire** it
121+ (`admin_decide_model`). **Catalogue**: every other model with its status,
122+ context, prices, typical run and when its provider last listed it, each
123+ with **Retire** or **Restore**. **Checks**: the latest checks of each
124+ provider. Every change names the staff member and why in the audit log
125+ (account `models`). See docs/BILLING_OPERATIONS.md, "The model
126+ catalogue".
105127 - **Stripe**: whether billing's key is in test or live mode (or off), the
106128 webhook Stripe calls (URL, endpoint id, events, who registered it and
107129 when), and the events Stripe sent lately with what billing did with
193215
194216 ```sh
195217 npm run typecheck -w @g1t/sudo
196−npm test -w @g1t/sudo # JWT verification, forms, money, the workspace join, paging, nav, charts, signals
218+npm test -w @g1t/sudo # JWT verification, forms, money, the workspace join, paging, nav, charts, signals, models
197219 npm run build -w @g1t/sudo
198220 ```
199221
+9−0
6767 */
6868 export function accountPath(account: string | null | undefined): string | null {
6969 const id = (account ?? "").trim().toLowerCase();
70+ // The model catalogue's changes (billing's catalogue.rs).
71+ if (id === "models") return "/agents";
7072 const status = /^(incident|maintenance):([a-z0-9-]{1,64})$/.exec(id);
7173 if (status) return status[1] === "incident" ? `/incidents/${status[2]}` : `/incidents/maintenance/${status[2]}`;
7274 if (ENTERPRISE.test(id)) return `/enterprises/${encodeURIComponent(id)}`;
7779 /** What to call an account: a workspace by its slug, an enterprise by its name when known. */
7880 export function accountName(account: string, names: Map<string, string> = new Map()): string {
7981 if (names.has(account)) return names.get(account) as string;
82+ if (account === "models") return "Agents & models";
8083 if (account.startsWith("incident:")) return "Incident";
8184 if (account.startsWith("maintenance:")) return "Maintenance";
8285 if (account.startsWith("ws_")) return account.slice(3);
133136 maintenance_cancelled: "Maintenance cancelled",
134137 credit_revoked: "Credit revoked",
135138 credit_expired: "Credit expired",
139+ // The model catalogue (Agents & models).
140+ models_discovered: "Models found or gone",
141+ model_approved: "Model approved",
142+ model_retired: "Model retired",
143+ model_restored: "Model restored",
144+ model_default: "Model default changed",
136145 };
137146
138147 /**
+134−0
1+import assert from "node:assert/strict";
2+import { test } from "node:test";
3+
4+import type { CatalogueModel } from "@g1t/contracts";
5+
6+import { choicesFor, describeDefault, impact, parseApproval, parseDefault, parsePerMillion, perMillion, tokens } from "./models.ts";
7+
8+function model(id: string, name: string, typical: number, extra: Partial<CatalogueModel> = {}): CatalogueModel {
9+ return {
10+ model: id,
11+ name,
12+ provider: "anthropic",
13+ kind: "chat",
14+ inputMicros: 1_000_000,
15+ outputMicros: 5_000_000,
16+ cacheReadMicros: 100_000,
17+ cacheWriteMicros: 1_250_000,
18+ aliases: [],
19+ family: "haiku",
20+ tierHint: "small",
21+ contextWindow: 0,
22+ maxOutput: 0,
23+ capabilities: [],
24+ dimensions: 0,
25+ status: "available",
26+ priced: true,
27+ source: "staff",
28+ firstSeenAt: null,
29+ lastSeenAt: null,
30+ missingSince: null,
31+ approvedBy: null,
32+ approvedAt: null,
33+ note: "",
34+ typicalRunMicros: typical,
35+ ...extra,
36+ };
37+}
38+
39+const catalogue = [
40+ model("claude-haiku-5-5", "Claude Haiku 5.5", 76_000),
41+ model("claude-sonnet-5-5", "Claude Sonnet 5.5", 1_340_000, { tierHint: "large" }),
42+ model("claude-haiku-6", "Claude Haiku 6", 0, { status: "new", priced: false }),
43+ model("@cf/openai/gpt-oss-120b", "gpt-oss-120b", 30_000, { provider: "workers-ai" }),
44+ model("claude-haiku-4-5", "Claude Haiku 4.5", 600_000, { status: "retired" }),
45+];
46+
47+function form(fields: Record<string, string>): FormData {
48+ const data = new FormData();
49+ for (const [name, value] of Object.entries(fields)) data.set(name, value);
50+ return data;
51+}
52+
53+test("prices per million are typed in dollars to a millionth", () => {
54+ assert.equal(parsePerMillion("0.125"), 125_000);
55+ assert.equal(parsePerMillion("$2"), 2_000_000);
56+ assert.equal(parsePerMillion("12.50"), 12_500_000);
57+ assert.equal(parsePerMillion("0.000001"), 1);
58+ assert.equal(parsePerMillion(""), 0);
59+ assert.equal(parsePerMillion("-1"), null);
60+ assert.equal(parsePerMillion("0.0000001"), null);
61+ assert.equal(parsePerMillion("abc"), null);
62+ assert.equal(perMillion(125_000), "0.125");
63+ assert.equal(perMillion(100_000), "0.10");
64+ assert.equal(perMillion(2_000_000), "2");
65+});
66+
67+test("approving a model takes its name, tier, prices and why", () => {
68+ const approved = parseApproval(
69+ form({ name: "Claude Haiku 6", tier_hint: "small", input: "0.08", output: "0.40", cache_read: "0.008", cache_write: "0.10", reason: "Anthropic's price page" }),
70+ "chat",
71+ );
72+ assert.ok(approved.ok);
73+ assert.equal(approved.value.prices.inputMicros, 80_000);
74+ assert.equal(approved.value.prices.outputMicros, 400_000);
75+ // No hour-long price: what a five-minute write costs.
76+ assert.equal(approved.value.prices.cacheWrite1hMicros, 100_000);
77+ assert.equal(approved.value.prices.threshold, 0);
78+ assert.equal(parseApproval(form({ name: "X", input: "1", output: "", reason: "r" }), "chat").ok, false);
79+ assert.equal(parseApproval(form({ name: "X", input: "0.012", reason: "r" }), "embeddings").ok, true);
80+ assert.deepEqual(parseApproval(form({ name: "X", input: "1", output: "5", reason: "" }), "chat"), { ok: false, error: "Say why, for whoever looks next." });
81+ assert.equal(parseApproval(form({ name: "X", input: "1", output: "5", over_input: "2", reason: "r" }), "chat").ok, false);
82+ const long = parseApproval(form({ name: "X", input: "1", output: "5", threshold: "200,000", over_input: "2", over_output: "10", reason: "r" }), "chat");
83+ assert.ok(long.ok);
84+ assert.equal(long.value.prices.threshold, 200_000);
85+ assert.equal(parseApproval(form({ name: "X", tier_hint: "huge", input: "1", output: "5", reason: "r" }), "chat").ok, false);
86+ assert.equal(parseApproval(form({ name: "", input: "1", output: "5", reason: "r" }), "chat").ok, false);
87+});
88+
89+test("a model purpose takes only an available, priced Claude", () => {
90+ const choices = choicesFor(catalogue);
91+ assert.deepEqual(choices.map((m) => m.model), ["claude-haiku-5-5", "claude-sonnet-5-5"]);
92+ const set = parseDefault(form({ purpose: "tier_small", model: "claude-sonnet-5-5", reason: "testing" }), choices);
93+ assert.deepEqual(set, { ok: true, value: { purpose: "tier_small", model: "claude-sonnet-5-5", tier: null, effort: null, reason: "testing" } });
94+ assert.equal(parseDefault(form({ purpose: "tier_small", model: "claude-haiku-6", reason: "x" }), choices).ok, false);
95+ assert.equal(parseDefault(form({ purpose: "background", model: "@cf/openai/gpt-oss-120b", reason: "x" }), choices).ok, false);
96+ assert.equal(parseDefault(form({ purpose: "tier_small", model: "claude-haiku-5-5", reason: "" }), choices).ok, false);
97+ assert.equal(parseDefault(form({ purpose: "nonsense", model: "claude-haiku-5-5", reason: "x" }), choices).ok, false);
98+});
99+
100+test("a job takes a tier and an effort, or the harness's own", () => {
101+ const choices = choicesFor(catalogue);
102+ assert.deepEqual(parseDefault(form({ purpose: "job_plan", tier: "small", effort: "xhigh", reason: "x" }), choices), {
103+ ok: true,
104+ value: { purpose: "job_plan", model: null, tier: "small", effort: "xhigh", reason: "x" },
105+ });
106+ const own = parseDefault(form({ purpose: "job_update", tier: "small", effort: "", reason: "x" }), choices);
107+ assert.ok(own.ok);
108+ assert.equal(own.value.effort, null);
109+ // Only a review is sized by the change.
110+ assert.equal(parseDefault(form({ purpose: "job_review", tier: "change", reason: "x" }), choices).ok, true);
111+ assert.equal(parseDefault(form({ purpose: "job_plan", tier: "change", reason: "x" }), choices).ok, false);
112+ assert.equal(parseDefault(form({ purpose: "job_plan", tier: "small", effort: "huge", reason: "x" }), choices).ok, false);
113+});
114+
115+test("a change shows what a typical run would cost before it is saved", () => {
116+ const before = { purpose: "tier_small", chosen: "claude-haiku-5-5", model: catalogue[0], capabilities: [], note: null };
117+ assert.equal(
118+ impact(before, catalogue[1], catalogue),
119+ "A typical run: about $1.34 on Claude Sonnet 5.5, 17.6 times $0.076 on Claude Haiku 5.5.",
120+ );
121+ assert.equal(impact({ ...before, model: catalogue[1] }, catalogue[0], catalogue), "A typical run: about $0.076 on Claude Haiku 5.5, 94% less than $1.34 on Claude Sonnet 5.5.");
122+ assert.equal(impact(before, catalogue[0], catalogue), "No change: Claude Haiku 5.5 already runs it, at about $0.076 a typical run.");
123+ assert.equal(impact(undefined, catalogue[1], catalogue), "A typical run would cost about $1.34 on Claude Sonnet 5.5.");
124+ assert.equal(impact(before, undefined, catalogue), null);
125+});
126+
127+test("defaults and sizes read plainly", () => {
128+ assert.equal(describeDefault({ model: "claude-haiku-5-5", tier: null, effort: null }, catalogue), "Claude Haiku 5.5");
129+ assert.equal(describeDefault({ model: null, tier: "small", effort: "high" }, catalogue), "Fast, high effort");
130+ assert.equal(describeDefault({ model: null, tier: "change", effort: null }, catalogue), "By the change's size, the harness's own effort");
131+ assert.equal(tokens(1_000_000), "1M");
132+ assert.equal(tokens(200_000), "200k");
133+ assert.equal(tokens(0), "—");
134+});
+235−0
1+/**
2+ * Agents & models, as the page reads its forms and states its figures:
3+ * prices per million tokens, each purpose's default, and what a change
4+ * does to the cost of a typical run. Billing checks every value again. No
5+ * Workers imports, so it can be tested under Node.
6+ */
7+import type { CatalogueModel, ModelDefault, ModelPrices, ResolvedModel } from "@g1t/contracts";
8+
9+import type { Parsed } from "./forms.ts";
10+
11+/** The longest reason billing keeps. */
12+export const MAX_REASON = 500;
13+
14+/** A model purpose: what it is called and what it is for. */
15+export type PurposeInfo = { purpose: string; label: string; about: string };
16+
17+/** The model purposes, in the order the page shows them. */
18+export const MODEL_PURPOSES: PurposeInfo[] = [
19+ { purpose: "tier_small", label: "Fast", about: "Auto's fast tier: catching up, answers, plans, small reviews." },
20+ { purpose: "tier_large", label: "Standard", about: "Auto's standard tier: making and revising changes, most reviews." },
21+ { purpose: "tier_frontier", label: "Most capable", about: "Auto's top tier: large reviews, architecture, work that failed twice." },
22+ { purpose: "background", label: "Background", about: "The harness's own small tasks in every run, such as titles and summaries." },
23+ {
24+ purpose: "gateway_first",
25+ label: "AI Gateway's first Claude",
26+ about: "Listed first by GET /openai/v1/models, the one people start with.",
27+ },
28+];
29+
30+/** Each kind of agent job, as the page names it. */
31+export const JOBS: { kind: string; label: string }[] = [
32+ { kind: "implement", label: "Making a change" },
33+ { kind: "revise", label: "Revising a change" },
34+ { kind: "answer", label: "Answering a question" },
35+ { kind: "review", label: "Reviewing" },
36+ { kind: "update", label: "Catching up" },
37+ { kind: "plan", label: "Planning" },
38+];
39+
40+export const TIER_LABELS: Record<string, string> = {
41+ small: "Fast",
42+ large: "Standard",
43+ frontier: "Most capable",
44+ change: "By the change's size",
45+};
46+
47+export const EFFORTS = ["low", "medium", "high", "xhigh", "max"] as const;
48+
49+/** What a purpose is called anywhere on the page or in a notice. */
50+export function purposeLabel(purpose: string): string {
51+ const model = MODEL_PURPOSES.find((p) => p.purpose === purpose);
52+ if (model) return model.label;
53+ const job = JOBS.find((j) => `job_${j.kind}` === purpose);
54+ return job ? job.label : purpose;
55+}
56+
57+/** The catalogue models a purpose may be set to: available, priced Claude chat models. */
58+export function choicesFor(catalogue: CatalogueModel[]): CatalogueModel[] {
59+ return catalogue.filter((m) => m.status === "available" && m.priced && m.provider === "anthropic" && (m.kind ?? "chat") === "chat");
60+}
61+
62+/** A price per million tokens, in dollars, to show or to fill a field: `0.125`, `2`, `12.50`. */
63+export function perMillion(micros: number | null | undefined): string {
64+ if (micros == null) return "";
65+ const text = (Math.max(0, micros) / 1_000_000).toFixed(6).replace(/0+$/, "").replace(/\.$/, "");
66+ const [whole, cents] = text.split(".");
67+ return cents && cents.length === 1 ? `${whole}.${cents}0` : text;
68+}
69+
70+/**
71+ * Micros from a price per million tokens as typed: `0.125`, `$2`, `12.50`.
72+ * Up to six decimal places (a millionth of a dollar per million tokens);
73+ * an empty field is 0.
74+ */
75+export function parsePerMillion(input: string): number | null {
76+ const text = input.trim().replace(/^\$/, "").replace(/,/g, "");
77+ if (text === "") return 0;
78+ const match = /^(\d{1,4})(?:\.(\d{1,6}))?$/.exec(text);
79+ if (!match) return null;
80+ return Number(match[1]) * 1_000_000 + Number((match[2] ?? "").padEnd(6, "0"));
81+}
82+
83+/** The price fields of the approval form, by the name each is posted as. */
84+export const PRICE_FIELDS: { name: string; key: keyof ModelPrices; label: string }[] = [
85+ { name: "input", key: "inputMicros", label: "Input" },
86+ { name: "output", key: "outputMicros", label: "Output" },
87+ { name: "cache_read", key: "cacheReadMicros", label: "Cache read" },
88+ { name: "cache_write", key: "cacheWriteMicros", label: "Cache write, 5 min" },
89+ { name: "cache_write_1h", key: "cacheWrite1hMicros", label: "Cache write, 1 h" },
90+];
91+
92+export const OVER_FIELDS: { name: string; key: keyof ModelPrices; label: string }[] = [
93+ { name: "over_input", key: "overInputMicros", label: "Input" },
94+ { name: "over_output", key: "overOutputMicros", label: "Output" },
95+ { name: "over_cache_read", key: "overCacheReadMicros", label: "Cache read" },
96+ { name: "over_cache_write", key: "overCacheWriteMicros", label: "Cache write, 5 min" },
97+ { name: "over_cache_write_1h", key: "overCacheWrite1hMicros", label: "Cache write, 1 h" },
98+];
99+
100+/** A model's prices as the catalogue has them, for the approval form. */
101+export function pricesOf(model: CatalogueModel): ModelPrices {
102+ return {
103+ inputMicros: model.inputMicros,
104+ outputMicros: model.outputMicros,
105+ cacheReadMicros: model.cacheReadMicros,
106+ cacheWriteMicros: model.cacheWriteMicros,
107+ cacheWrite1hMicros: model.cacheWrite1hMicros ?? 0,
108+ threshold: model.threshold ?? 0,
109+ overInputMicros: model.overInputMicros ?? 0,
110+ overOutputMicros: model.overOutputMicros ?? 0,
111+ overCacheReadMicros: model.overCacheReadMicros ?? 0,
112+ overCacheWriteMicros: model.overCacheWriteMicros ?? 0,
113+ overCacheWrite1hMicros: model.overCacheWrite1hMicros ?? 0,
114+ };
115+}
116+
117+/** A reason as the forms take it: required, kept short. */
118+export function parseReason(raw: string): Parsed<string> {
119+ const reason = raw.trim();
120+ if (!reason) return { ok: false, error: "Say why, for whoever looks next." };
121+ if ([...reason].length > MAX_REASON) return { ok: false, error: `Keep the reason to ${MAX_REASON} characters.` };
122+ return { ok: true, value: reason };
123+}
124+
125+export type Approval = { name: string; tierHint: string; prices: ModelPrices; reason: string };
126+
127+/**
128+ * The approval form: the name people see, the tier it suits, its prices
129+ * per million tokens (and above a long-prompt threshold, if it has one),
130+ * and why. Input is required; a chat model needs an output price too.
131+ */
132+export function parseApproval(form: FormData, kind: string): Parsed<Approval> {
133+ const get = (name: string) => {
134+ const value = form.get(name);
135+ return typeof value === "string" ? value : "";
136+ };
137+ const name = get("name").trim();
138+ if (!name) return { ok: false, error: "Give the name people see, such as Claude Haiku 6." };
139+ if (name.length > 120) return { ok: false, error: "Keep the name to 120 characters." };
140+ const tierHint = get("tier_hint").trim();
141+ if (tierHint && !["small", "large", "frontier"].includes(tierHint)) return { ok: false, error: "Choose a tier it suits, or none." };
142+ const prices = {} as ModelPrices;
143+ for (const field of [...PRICE_FIELDS, ...OVER_FIELDS]) {
144+ const micros = parsePerMillion(get(field.name));
145+ if (micros == null) return { ok: false, error: `${field.label}: a price in dollars per million tokens, such as 0.125.` };
146+ prices[field.key] = micros;
147+ }
148+ const threshold = get("threshold").trim().replace(/,/g, "");
149+ if (threshold && !/^\d{1,9}$/.test(threshold)) return { ok: false, error: "The long-prompt threshold is a number of tokens, such as 200000." };
150+ prices.threshold = threshold ? Number(threshold) : 0;
151+ if (prices.inputMicros === 0) return { ok: false, error: "Give the input price per million tokens." };
152+ if (kind === "chat" && prices.outputMicros === 0) return { ok: false, error: "Give the output price per million tokens." };
153+ // An hour-long write with no price of its own costs what a five-minute one does.
154+ if (prices.cacheWrite1hMicros === 0) prices.cacheWrite1hMicros = prices.cacheWriteMicros;
155+ const over = prices.overInputMicros + prices.overOutputMicros + prices.overCacheReadMicros + prices.overCacheWriteMicros;
156+ if (prices.threshold === 0 && over > 0) return { ok: false, error: "Long-prompt prices need the prompt length they start above." };
157+ if (prices.threshold > 0 && prices.overInputMicros === 0) return { ok: false, error: "With a long-prompt threshold, give the prices above it." };
158+ if (prices.threshold > 0 && prices.overCacheWrite1hMicros === 0) prices.overCacheWrite1hMicros = prices.overCacheWriteMicros;
159+ const reason = parseReason(get("reason"));
160+ if (!reason.ok) return reason;
161+ return { ok: true, value: { name, tierHint, prices, reason: reason.value } };
162+}
163+
164+/** A default as the forms post it: a model, or a job's tier and effort. */
165+export type DefaultChange = { purpose: string; model: string | null; tier: string | null; effort: string | null; reason: string };
166+
167+/**
168+ * One default's form: a model purpose takes one of `choices`; a job takes
169+ * a tier (`change` only for reviews) and an effort, or none for the
170+ * harness's own.
171+ */
172+export function parseDefault(form: FormData, choices: CatalogueModel[]): Parsed<DefaultChange> {
173+ const get = (name: string) => {
174+ const value = form.get(name);
175+ return typeof value === "string" ? value.trim() : "";
176+ };
177+ const purpose = get("purpose");
178+ const reason = parseReason(get("reason"));
179+ if (MODEL_PURPOSES.some((p) => p.purpose === purpose)) {
180+ const model = get("model");
181+ if (!choices.some((m) => m.model === model)) return { ok: false, error: "Choose an available Claude model." };
182+ if (!reason.ok) return reason;
183+ return { ok: true, value: { purpose, model, tier: null, effort: null, reason: reason.value } };
184+ }
185+ const job = JOBS.find((j) => `job_${j.kind}` === purpose);
186+ if (!job) return { ok: false, error: "That is not something a default is chosen for." };
187+ const tier = get("tier");
188+ const tiers = job.kind === "review" ? ["small", "large", "frontier", "change"] : ["small", "large", "frontier"];
189+ if (!tiers.includes(tier)) return { ok: false, error: `Choose ${job.kind === "review" ? "a tier, or by the change's size" : "a tier"}.` };
190+ const effort = get("effort");
191+ if (effort && !(EFFORTS as readonly string[]).includes(effort)) return { ok: false, error: "Effort is low, medium, high, xhigh or max, or the harness's own." };
192+ if (!reason.ok) return reason;
193+ return { ok: true, value: { purpose, model: null, tier, effort: effort || null, reason: reason.value } };
194+}
195+
196+/** How a default reads: a model's name, or `Fast, high effort`. */
197+export function describeDefault(value: { model: string | null; tier: string | null; effort: string | null }, catalogue: CatalogueModel[]): string {
198+ if (value.model) return catalogue.find((m) => m.model === value.model)?.name ?? value.model;
199+ const tier = TIER_LABELS[value.tier ?? ""] ?? value.tier ?? "none";
200+ return value.effort ? `${tier}, ${value.effort} effort` : `${tier}, the harness's own effort`;
201+}
202+
203+/** What a change to a default does to a typical run's cost, in a sentence; null for a job. */
204+export function impact(before: ResolvedModel | undefined, after: CatalogueModel | undefined, catalogue: CatalogueModel[]): string | null {
205+ if (!after) return null;
206+ const was = before?.model ? catalogue.find((m) => m.model === before.model!.model) : undefined;
207+ const now = after.typicalRunMicros;
208+ const money = (micros: number) => (micros < 1_000_000 ? `$${(micros / 1_000_000).toFixed(3)}` : `$${(micros / 1_000_000).toFixed(2)}`);
209+ if (!was || was.typicalRunMicros <= 0) return `A typical run would cost about ${money(now)} on ${after.name}.`;
210+ if (was.model === after.model) return `No change: ${after.name} already runs it, at about ${money(now)} a typical run.`;
211+ const ratio = now / was.typicalRunMicros;
212+ const change = Math.round((ratio - 1) * 100);
213+ const direction =
214+ ratio >= 2
215+ ? `${Number(ratio.toFixed(1))} times`
216+ : change === 0
217+ ? "the same as"
218+ : change > 0
219+ ? `${change}% more than`
220+ : `${-change}% less than`;
221+ return `A typical run: about ${money(now)} on ${after.name}, ${direction} ${money(was.typicalRunMicros)} on ${was.name}.`;
222+}
223+
224+/** The current default for each purpose, by purpose. */
225+export function defaultsByPurpose(defaults: ModelDefault[]): Map<string, ModelDefault> {
226+ return new Map(defaults.map((d) => [d.purpose, d]));
227+}
228+
229+/** A context window or output limit: `1M`, `200k`, or a dash when not known. */
230+export function tokens(n: number): string {
231+ if (!n) return "—";
232+ if (n >= 1_000_000) return `${Number((n / 1_000_000).toFixed(1))}M`;
233+ if (n >= 1_000) return `${Math.round(n / 1_000)}k`;
234+ return String(n);
235+}
+2−2
4040
4141 test("the built pages are not marked soon", () => {
4242 const built = navItems().filter((item) => !item.soon).map((item) => item.to);
43− assert.deepEqual(built, ["/", "/reach-out", "/workspaces", "/enterprises", "/invites", "/aliases", "/requests", "/overages", "/velocity", "/invoices", "/credits", "/stripe", "/costs", "/abuse", "/incidents", "/audit"]);
43+ assert.deepEqual(built, ["/", "/reach-out", "/workspaces", "/enterprises", "/invites", "/aliases", "/requests", "/overages", "/velocity", "/invoices", "/credits", "/stripe", "/costs", "/agents", "/abuse", "/incidents", "/audit"]);
4444 });
4545
4646 test("every soon page says what it will do, why, and what it will have", () => {
4747 const soon = soonItems();
48− assert.ok(soon.length >= 8);
48+ assert.ok(soon.length >= 7);
4949 for (const item of soon) {
5050 assert.ok(item.soon.summary.length >= 1 && item.soon.summary.length <= 4, item.label);
5151 assert.ok(item.soon.plans.length >= 3 && item.soon.plans.length <= 6, item.label);
+1−13
223223 label: "Agents & models",
224224 to: "/agents",
225225 icon: "agents",
226− about: "The models agents run on, what each costs, and how runs are going.",
227− soon: {
228− summary: [
229− "The models g1t's agents run on, and how they are doing: runs, failures, tokens and cost per model, and which workspaces bring their own provider. It is where staff decide which models to offer and see what a change in a provider's price means.",
230− "Hosted models are open to some workspaces and not others; that list belongs here, edited and recorded, not in configuration.",
231− ],
232− plans: [
233− "Runs, failures and cost per model, per day",
234− "Who may use g1t's hosted models, and the free allowance's pool",
235− "Workspaces on their own provider, and the sandbox time their runs use",
236− "Stuck or long-running agents, with a way to stop one",
237− ],
238− },
226+ about: "Every model g1t can use, new ones to approve, and which model each tier and job uses by default.",
239227 },
240228 {
241229 label: "Abuse & fraud",
+4−0
11 import { env } from "cloudflare:workers";
22
33 import {
4+ type ModelDiscoveryApi,
45 type StatusAdminApi,
56 accountsAdminClient,
67 billingAdminClient,
3031 /** The status page's incidents (apps/status's `StatusAdmin` entrypoint). */
3132 export const statusAdmin = env.STATUS as unknown as StatusAdminApi;
3233
34+/** "Check for new models": the model proxy's `Discovery` entrypoint (services/models). */
35+export const modelDiscovery = env.MODELS as unknown as ModelDiscoveryApi;
36+
3337 /** STAFF_EMAILS, for suggesting staff in the incident roles. */
3438 export const staffEmails = (): string => env.STAFF_EMAILS ?? "";
+1−0
1919 route("velocity", "routes/velocity.tsx"),
2020 route("costs", "routes/costs.tsx"),
2121 route("costs/bill", "routes/costs-bill.tsx"),
22+ route("agents", "routes/agents.tsx"),
2223 route("abuse", "routes/abuse.tsx"),
2324 route("invoices", "routes/invoices.tsx"),
2425 route("credits", "routes/credits.tsx"),
+575−0
1+import { Check, RefreshCw } from "lucide-react";
2+import { data, redirect } from "react-router";
3+
4+import type { AdminModels, CatalogueModel, DiscoveryResult, ModelStatus } from "@g1t/contracts";
5+
6+import type { Route } from "./+types/agents";
7+import { Badge, Button, ButtonLink, EmptyState, Field, Input, Notice, PageHeader, Section, Select, When } from "~/components/ui";
8+import { text } from "~/lib/forms";
9+import {
10+ EFFORTS,
11+ JOBS,
12+ MAX_REASON,
13+ MODEL_PURPOSES,
14+ OVER_FIELDS,
15+ PRICE_FIELDS,
16+ TIER_LABELS,
17+ type DefaultChange,
18+ choicesFor,
19+ defaultsByPurpose,
20+ describeDefault,
21+ impact,
22+ parseApproval,
23+ parseDefault,
24+ parseReason,
25+ perMillion,
26+ pricesOf,
27+ purposeLabel,
28+ tokens,
29+} from "~/lib/models";
30+import { usd } from "~/lib/money";
31+import { admin, modelDiscovery } from "~/lib/services.server";
32+import { settle } from "~/lib/settle";
33+import { requireStaff } from "~/lib/staff";
34+
35+export const meta: Route.MetaFunction = () => [{ title: "Agents & models · sudo" }, { name: "robots", content: "noindex, nofollow" }];
36+
37+export async function loader({ request, context }: Route.LoaderArgs) {
38+ requireStaff(context);
39+ const url = new URL(request.url);
40+ const models = await settle(admin.models());
41+ const done = url.searchParams.get("done");
42+ const subject = url.searchParams.get("subject") ?? "";
43+ const message: Record<string, string> = {
44+ approved: `${subject} is available now: defaults can use it, and the AI Gateway offers it.`,
45+ retired: `${subject} is retired. Any default that chose it uses the next suitable model until you choose another.`,
46+ restored: `${subject} is back where it stood.`,
47+ default: `The default for ${subject} is saved. Runs pick it up within a minute.`,
48+ };
49+ return {
50+ models: models.ok ? models.value : null,
51+ error: models.ok ? null : models.error,
52+ done: done && message[done] ? message[done] : null,
53+ };
54+}
55+
56+type Review = { change: DefaultChange; before: string; after: string; impact: string | null };
57+
58+type ActionData =
59+ | { kind: "error"; error: string; target: string; values?: Record<string, string> }
60+ | { kind: "review"; review: Review }
61+ | { kind: "checked"; results: DiscoveryResult[] | null; error: string | null };
62+
63+const back = (done: string, subject: string, anchor: string) =>
64+ redirect(`/agents?done=${done}&subject=${encodeURIComponent(subject)}#${anchor}`);
65+
66+/**
67+ * Checking for new models, approving, retiring and restoring them, and
68+ * changing a default. Billing checks each again and records it in the
69+ * audit log with the staff member and why; a default shows what it does
70+ * to a typical run's cost before it is saved.
71+ */
72+export async function action({ request, context }: Route.ActionArgs) {
73+ const staff = requireStaff(context);
74+ const form = await request.formData();
75+ const failed = (error: string, target: string, values?: Record<string, string>) =>
76+ data<ActionData>({ kind: "error", error, target, values }, { status: 422 });
77+ const intent = text(form, "intent");
78+
79+ if (intent === "check") {
80+ const results = await settle(modelDiscovery.check(staff.email));
81+ return { kind: "checked", results: results.ok ? results.value : null, error: results.ok ? null : results.error } satisfies ActionData;
82+ }
83+
84+ const catalogue = await admin.models().then((m) => m.catalogue);
85+ if (intent === "approve" || intent === "retire" || intent === "restore") {
86+ const id = text(form, "model");
87+ const model = catalogue.find((m) => m.model === id);
88+ if (!model) return failed(`${id} is not in the catalogue.`, `model-${id}`);
89+ if (intent === "approve") {
90+ const approval = parseApproval(form, model.kind ?? "chat");
91+ if (!approval.ok) return failed(approval.error, `model-${id}`, Object.fromEntries([...form.entries()].map(([k, v]) => [k, String(v)])));
92+ const { name, tierHint, prices, reason } = approval.value;
93+ const result = await admin.decideModel(id, "approve", { name, tierHint: tierHint || "", prices }, reason, staff.email);
94+ if (!result.ok) return failed(result.error.message, `model-${id}`);
95+ throw back("approved", result.value.name, "catalogue");
96+ }
97+ const reason = parseReason(text(form, "reason"));
98+ if (!reason.ok) return failed(reason.error, `model-${id}`);
99+ const result = await admin.decideModel(id, intent, {}, reason.value, staff.email);
100+ if (!result.ok) return failed(result.error.message, `model-${id}`);
101+ throw back(intent === "retire" ? "retired" : "restored", result.value.name, "catalogue");
102+ }
103+
104+ if (intent === "default") {
105+ const parsed = parseDefault(form, choicesFor(catalogue));
106+ const purpose = text(form, "purpose");
107+ if (!parsed.ok) return failed(parsed.error, `default-${purpose}`, { reason: text(form, "reason") });
108+ const change = parsed.value;
109+ if (text(form, "confirm") !== "yes") {
110+ const models = await admin.models();
111+ const current = defaultsByPurpose(models.defaults).get(change.purpose);
112+ const resolved = models.resolved.models.find((m) => m.purpose === change.purpose);
113+ return {
114+ kind: "review",
115+ review: {
116+ change,
117+ before: current ? describeDefault(current, catalogue) : "nothing",
118+ after: describeDefault(change, catalogue),
119+ impact: change.model ? impact(resolved, catalogue.find((m) => m.model === change.model), catalogue) : null,
120+ },
121+ } satisfies ActionData;
122+ }
123+ const result = await admin.setModelDefault(change.purpose, change, change.reason, staff.email);
124+ if (!result.ok) return failed(result.error.message, `default-${change.purpose}`);
125+ throw back("default", purposeLabel(change.purpose), "defaults");
126+ }
127+ return data<ActionData>({ kind: "error", error: "Unknown action.", target: "" }, { status: 400 });
128+}
129+
130+const STATUS: Record<ModelStatus, { label: string; tone: "mint" | "lavender" | "warn" | "danger" }> = {
131+ available: { label: "Available", tone: "mint" },
132+ new: { label: "New", tone: "lavender" },
133+ deprecated: { label: "Deprecated", tone: "warn" },
134+ retired: { label: "Retired", tone: "danger" },
135+};
136+
137+const PROVIDER: Record<string, string> = { anthropic: "Anthropic", "workers-ai": "Workers AI" };
138+
139+/** A typical run's cost, with the cents a fast model's run is measured in. */
140+function runCost(micros: number): string {
141+ return micros > 0 ? usd(micros) : "—";
142+}
143+
144+export default function Agents({ loaderData, actionData }: Route.ComponentProps) {
145+ const { models, error, done } = loaderData;
146+ const result = actionData as ActionData | undefined;
147+ const failure = result?.kind === "error" ? result : null;
148+ return (
149+ <main id="top" className="mx-auto max-w-6xl scroll-mt-20 px-4 py-8 sm:py-10">
150+ <PageHeader
151+ title="Agents & models"
152+ description="Every model g1t can use, found by listing each provider daily; new ones wait here for their prices to be confirmed. Staff choose which model each tier and job uses. Customers never pick a model: Auto routes their work with these."
153+ actions={
154+ <form method="post" action="/agents#top">
155+ <input type="hidden" name="intent" value="check" />
156+ <Button type="submit" variant="lavender">
157+ <RefreshCw size={14} />
158+ Check for new models
159+ </Button>
160+ </form>
161+ }
162+ />
163+ <div className="mt-6 space-y-3">
164+ {done && <Notice tone="ok">{done}</Notice>}
165+ {error && <Notice tone="error">Billing did not answer: {error}</Notice>}
166+ {failure && !failure.target && <Notice tone="error">{failure.error}</Notice>}
167+ {result?.kind === "checked" && <Checked results={result.results} error={result.error} />}
168+ </div>
169+ {result?.kind === "review" && <ReviewPanel review={result.review} />}
170+ {models && <Page models={models} failure={failure} />}
171+ </main>
172+ );
173+}
174+
175+function Checked({ results, error }: { results: DiscoveryResult[] | null; error: string | null }) {
176+ if (!results) return <Notice tone="error">The model proxy did not answer: {error}</Notice>;
177+ return (
178+ <Notice tone={results.some((r) => r.error) ? "warn" : "ok"}>
179+ <ul className="space-y-1">
180+ {results.map((r) => (
181+ <li key={r.provider}>
182+ <span className="font-medium text-fg">{PROVIDER[r.provider] ?? r.provider}</span>:{" "}
183+ {r.error
184+ ? `could not be listed (${r.error}). Nothing changed.`
185+ : `${r.listed} listed; ${r.added.length ? `new: ${r.added.join(", ")}` : "nothing new"}${r.deprecated.length ? `; no longer listed: ${r.deprecated.join(", ")}` : ""}${r.restored.length ? `; listed again: ${r.restored.join(", ")}` : ""}.`}
186+ </li>
187+ ))}
188+ </ul>
189+ </Notice>
190+ );
191+}
192+
193+function ReviewPanel({ review }: { review: Review }) {
194+ const { change } = review;
195+ return (
196+ <Section id="review" className="mt-6 border-accent/40" title={`Change ${purposeLabel(change.purpose)}?`} description="Nothing is saved until you confirm.">
197+ <dl className="grid gap-x-6 gap-y-2 text-sm sm:grid-cols-[max-content_minmax(0,1fr)]">
198+ <dt className="text-muted">Now</dt>
199+ <dd>{review.before}</dd>
200+ <dt className="text-muted">After</dt>
201+ <dd className="font-medium">{review.after}</dd>
202+ {review.impact && (
203+ <>
204+ <dt className="text-muted">Cost</dt>
205+ <dd>{review.impact}</dd>
206+ </>
207+ )}
208+ <dt className="text-muted">Why</dt>
209+ <dd className="text-fg-soft">{change.reason}</dd>
210+ </dl>
211+ <form method="post" action="/agents#defaults" className="mt-4 flex flex-wrap gap-2">
212+ <input type="hidden" name="intent" value="default" />
213+ <input type="hidden" name="confirm" value="yes" />
214+ <input type="hidden" name="purpose" value={change.purpose} />
215+ {change.model && <input type="hidden" name="model" value={change.model} />}
216+ {change.tier && <input type="hidden" name="tier" value={change.tier} />}
217+ <input type="hidden" name="effort" value={change.effort ?? ""} />
218+ <input type="hidden" name="reason" value={change.reason} />
219+ <Button type="submit" variant="lavender">
220+ <Check size={14} />
221+ Save
222+ </Button>
223+ <ButtonLink to="/agents#defaults" variant="quiet">
224+ Cancel
225+ </ButtonLink>
226+ </form>
227+ </Section>
228+ );
229+}
230+
231+type Failure = Extract<ActionData, { kind: "error" }> | null;
232+
233+function Page({ models, failure }: { models: AdminModels; failure: Failure }) {
234+ const waiting = models.catalogue.filter((m) => m.status === "new");
235+ const t = models.typical;
236+ return (
237+ <>
238+ <Defaults models={models} failure={failure} />
239+ <Section
240+ id="waiting"
241+ className="mt-6"
242+ title="New models"
243+ description="Found by a check and not used for anything yet. Confirm the name and prices (from the provider's price page) to make one available; until then nothing routes to it, offers it or charges for it."
244+ >
245+ {waiting.length === 0 ? (
246+ <EmptyState title="Nothing waiting">A model a provider starts listing appears here after the next check.</EmptyState>
247+ ) : (
248+ <ul className="space-y-4">
249+ {waiting.map((model) => (
250+ <Waiting key={model.model} model={model} failure={failure?.target === `model-${model.model}` ? failure : null} />
251+ ))}
252+ </ul>
253+ )}
254+ </Section>
255+ <Section
256+ id="catalogue"
257+ className="mt-6"
258+ title="Catalogue"
259+ description={`Prices per million tokens. A typical run is ${t.requests} requests of ${t.input.toLocaleString("en-US")} input, ${t.output.toLocaleString("en-US")} output, ${t.cacheRead.toLocaleString("en-US")} cache-read and ${t.cacheWrite.toLocaleString("en-US")} cache-write tokens: an estimate for comparing models, never a charge.`}
260+ >
261+ <Catalogue catalogue={models.catalogue} failure={failure} />
262+ </Section>
263+ <Section id="checks" className="mt-6" title="Checks" description="Each provider is listed daily at 05:29 UTC, and whenever someone checks from here. Listing models is free; nothing calls a model.">
264+ {models.checks.length === 0 ? (
265+ <EmptyState title="No checks yet">Check for new models above, or wait for the daily check.</EmptyState>
266+ ) : (
267+ <ul className="divide-y divide-line text-sm">
268+ {models.checks.map((check) => (
269+ <li key={check.id} className="flex flex-col gap-1 py-2.5 first:pt-0 last:pb-0 sm:flex-row sm:items-baseline sm:gap-4">
270+ <span className="w-28 shrink-0 font-medium">{PROVIDER[check.provider] ?? check.provider}</span>
271+ <span className="w-44 shrink-0 text-muted">
272+ <When at={check.checkedAt} time />
273+ </span>
274+ <span className="min-w-0 flex-1 text-fg-soft">
275+ {check.error ? (
276+ <span className="text-danger">Failed: {check.error}</span>
277+ ) : (
278+ <>
279+ {check.listed} listed
280+ {check.added.length > 0 && <>; new: {check.added.join(", ")}</>}
281+ {check.deprecated.length > 0 && <>; gone: {check.deprecated.join(", ")}</>}
282+ </>
283+ )}
284+ </span>
285+ <span className="text-xs text-faint">{check.by === "schedule" ? "Daily check" : check.by}</span>
286+ </li>
287+ ))}
288+ </ul>
289+ )}
290+ </Section>
291+ </>
292+ );
293+}
294+
295+function Defaults({ models, failure }: { models: AdminModels; failure: Failure }) {
296+ const choices = choicesFor(models.catalogue);
297+ const current = defaultsByPurpose(models.defaults);
298+ return (
299+ <Section
300+ id="defaults"
301+ className="mt-6"
302+ title="Defaults"
303+ description="What Auto runs each tier on, the harness's background model, the AI Gateway's first Claude, and where each kind of job starts and how hard it thinks. Runs read these within a minute; a model that is retired or no longer listed is never used, and the next suitable one runs instead."
304+ >
305+ <ul className="divide-y divide-line">
306+ {MODEL_PURPOSES.map((info) => {
307+ const row = current.get(info.purpose);
308+ const resolved = models.resolved.models.find((m) => m.purpose === info.purpose);
309+ const running = resolved?.model ? models.catalogue.find((m) => m.model === resolved.model!.model) : undefined;
310+ const error = failure?.target === `default-${info.purpose}` ? failure : null;
311+ return (
312+ <li key={info.purpose} id={`default-${info.purpose}`} className="scroll-mt-20 py-4 first:pt-0 last:pb-0">
313+ <div className="flex flex-wrap items-baseline justify-between gap-x-4 gap-y-1">
314+ <div>
315+ <h3 className="text-sm font-semibold">{info.label}</h3>
316+ <p className="text-xs text-muted">{info.about}</p>
317+ </div>
318+ <div className="text-right text-sm">
319+ <span className="font-medium">{running?.name ?? resolved?.chosen ?? "Not set"}</span>
320+ {running && running.typicalRunMicros > 0 && <span className="ml-2 text-xs text-muted">{runCost(running.typicalRunMicros)} a typical run</span>}
321+ </div>
322+ </div>
323+ {resolved?.note && (
324+ <div className="mt-2">
325+ <Notice tone="warn">{resolved.note}</Notice>
326+ </div>
327+ )}
328+ {row && (
329+ <p className="mt-1 text-xs text-faint">
330+ Set by {row.updatedBy} <When at={row.updatedAt} />
331+ {row.reason ? `: ${row.reason}` : ""}
332+ </p>
333+ )}
334+ <form method="post" action={`/agents#review`} className="mt-3 grid gap-2 sm:grid-cols-[minmax(0,14rem)_minmax(0,1fr)_auto] sm:items-end">
335+ <input type="hidden" name="intent" value="default" />
336+ <input type="hidden" name="purpose" value={info.purpose} />
337+ <Select name="model" defaultValue={row?.model ?? ""} aria-label={`Model for ${info.label}`}>
338+ {choices.map((m) => (
339+ <option key={m.model} value={m.model}>
340+ {m.name} · {runCost(m.typicalRunMicros)}
341+ </option>
342+ ))}
343+ </Select>
344+ <Input name="reason" required maxLength={MAX_REASON} placeholder="Why" aria-label={`Why change ${info.label}`} defaultValue={error?.values?.reason} />
345+ <Button type="submit" variant="quiet">
346+ Review
347+ </Button>
348+ </form>
349+ {error && (
350+ <div className="mt-2">
351+ <Notice tone="error">{error.error}</Notice>
352+ </div>
353+ )}
354+ </li>
355+ );
356+ })}
357+ </ul>
358+ <h3 className="mt-6 border-t border-line pt-4 text-sm font-semibold">Jobs</h3>
359+ <p className="text-xs text-muted">
360+ Where each kind of job starts. Failures, labels and the repository's own history still move it up or down. Effort applies on models that take
361+ it; on one that does not, the harness's own.
362+ </p>
363+ <ul className="mt-3 divide-y divide-line">
364+ {JOBS.map((job) => {
365+ const purpose = `job_${job.kind}`;
366+ const row = current.get(purpose);
367+ const error = failure?.target === `default-${purpose}` ? failure : null;
368+ const tiers = job.kind === "review" ? ["small", "large", "frontier", "change"] : ["small", "large", "frontier"];
369+ return (
370+ <li key={job.kind} id={`default-${purpose}`} className="scroll-mt-20 py-3 first:pt-0 last:pb-0">
371+ <form method="post" action="/agents#review" className="grid gap-2 sm:grid-cols-[10rem_minmax(0,11rem)_minmax(0,9rem)_minmax(0,1fr)_auto] sm:items-center">
372+ <input type="hidden" name="intent" value="default" />
373+ <input type="hidden" name="purpose" value={purpose} />
374+ <div>
375+ <span className="text-sm font-medium">{job.label}</span>
376+ {row && (
377+ <span className="block text-xs text-faint">
378+ {row.updatedBy} <When at={row.updatedAt} />
379+ </span>
380+ )}
381+ </div>
382+ <Select name="tier" defaultValue={row?.tier ?? "large"} aria-label={`Starting tier for ${job.label}`}>
383+ {tiers.map((tier) => (
384+ <option key={tier} value={tier}>
385+ {TIER_LABELS[tier]}
386+ </option>
387+ ))}
388+ </Select>
389+ <Select name="effort" defaultValue={row?.effort ?? ""} aria-label={`Effort for ${job.label}`}>
390+ <option value="">Harness's own</option>
391+ {EFFORTS.map((effort) => (
392+ <option key={effort} value={effort}>
393+ {effort} effort
394+ </option>
395+ ))}
396+ </Select>
397+ <Input name="reason" required maxLength={MAX_REASON} placeholder="Why" aria-label={`Why change ${job.label}`} />
398+ <Button type="submit" variant="quiet">
399+ Review
400+ </Button>
401+ </form>
402+ {error && (
403+ <div className="mt-2">
404+ <Notice tone="error">{error.error}</Notice>
405+ </div>
406+ )}
407+ </li>
408+ );
409+ })}
410+ </ul>
411+ </Section>
412+ );
413+}
414+
415+function Waiting({ model, failure }: { model: CatalogueModel; failure: Failure }) {
416+ const prices = pricesOf(model);
417+ const value = (name: string, fallback: string) => failure?.values?.[name] ?? fallback;
418+ return (
419+ <li id={`model-${model.model}`} className="scroll-mt-20 rounded-lg border border-line bg-bg p-4 sm:p-5">
420+ <div className="flex flex-wrap items-center gap-2">
421+ <span className="font-medium">{model.name}</span>
422+ <code className="text-xs text-muted">{model.model}</code>
423+ <Badge>{PROVIDER[model.provider] ?? model.provider}</Badge>
424+ {!model.priced && <Badge tone="warn">No price known</Badge>}
425+ </div>
426+ <p className="mt-1 text-xs text-muted">
427+ Found <When at={model.firstSeenAt} /> · {model.kind === "embeddings" ? "Embeddings" : "Chat"} · context {tokens(model.contextWindow)}
428+ {model.capabilities.length > 0 && <> · {model.capabilities.join(", ")}</>}
429+ {model.priced && " · prices filled from the provider; check them"}
430+ </p>
431+ <form method="post" action={`/agents#model-${model.model}`} className="mt-4 space-y-3">
432+ <input type="hidden" name="intent" value="approve" />
433+ <input type="hidden" name="model" value={model.model} />
434+ <div className="grid gap-3 sm:grid-cols-[minmax(0,1fr)_12rem]">
435+ <Field label="Name people see">
436+ <Input name="name" required maxLength={120} defaultValue={value("name", model.name)} />
437+ </Field>
438+ <Field label="Suits the tier">
439+ <Select name="tier_hint" defaultValue={value("tier_hint", model.tierHint)}>
440+ <option value="">None</option>
441+ <option value="small">Fast</option>
442+ <option value="large">Standard</option>
443+ <option value="frontier">Most capable</option>
444+ </Select>
445+ </Field>
446+ </div>
447+ <fieldset>
448+ <legend className="mb-1.5 text-sm font-medium text-muted">Dollars per million tokens</legend>
449+ <div className="grid grid-cols-2 gap-3 sm:grid-cols-5">
450+ {PRICE_FIELDS.map((field) => (
451+ <Field key={field.name} label={field.label}>
452+ <Input name={field.name} inputMode="decimal" className="font-mono" defaultValue={value(field.name, model.priced ? perMillion(prices[field.key]) : "")} />
453+ </Field>
454+ ))}
455+ </div>
456+ </fieldset>
457+ <details className="rounded-md border border-line px-3 py-2" open={prices.threshold > 0}>
458+ <summary className="cursor-pointer text-sm text-muted">Priced by prompt length</summary>
459+ <div className="mt-3 space-y-3">
460+ <Field label="Above this many prompt tokens" hint="The whole request is charged at the prices below once its prompt (input and cache tokens) is longer.">
461+ <Input name="threshold" inputMode="numeric" className="font-mono" defaultValue={value("threshold", prices.threshold ? String(prices.threshold) : "")} />
462+ </Field>
463+ <div className="grid grid-cols-2 gap-3 sm:grid-cols-5">
464+ {OVER_FIELDS.map((field) => (
465+ <Field key={field.name} label={field.label}>
466+ <Input name={field.name} inputMode="decimal" className="font-mono" defaultValue={value(field.name, prices[field.key] ? perMillion(prices[field.key]) : "")} />
467+ </Field>
468+ ))}
469+ </div>
470+ </div>
471+ </details>
472+ <Field label="Why" hint="Kept with the model, and in the audit log.">
473+ <Input name="reason" required maxLength={MAX_REASON} placeholder="e.g. Prices from the provider's price page, 2026-10-08." defaultValue={value("reason", "")} />
474+ </Field>
475+ {failure && <Notice tone="error">{failure.error}</Notice>}
476+ <div className="flex justify-end">
477+ <Button type="submit" variant="lavender">
478+ <Check size={14} />
479+ Approve
480+ </Button>
481+ </div>
482+ </form>
483+ <Decide model={model} intent="retire" label="Retire instead" />
484+ </li>
485+ );
486+}
487+
488+/** A retire or restore form, folded away until opened. */
489+function Decide({ model, intent, label }: { model: CatalogueModel; intent: "retire" | "restore"; label: string }) {
490+ return (
491+ <details className="mt-3">
492+ <summary className="cursor-pointer text-xs text-muted hover:text-fg">{label}</summary>
493+ <form method="post" action={`/agents#model-${model.model}`} className="mt-2 flex flex-col gap-2 sm:flex-row sm:items-center">
494+ <input type="hidden" name="intent" value={intent} />
495+ <input type="hidden" name="model" value={model.model} />
496+ <Input name="reason" required maxLength={MAX_REASON} placeholder="Why" aria-label={`Why ${intent} ${model.name}`} />
497+ <Button type="submit" variant={intent === "retire" ? "danger" : "quiet"}>
498+ {intent === "retire" ? "Retire" : "Restore"}
499+ </Button>
500+ </form>
501+ </details>
502+ );
503+}
504+
505+function Catalogue({ catalogue, failure }: { catalogue: CatalogueModel[]; failure: Failure }) {
506+ const listed = catalogue.filter((m) => m.status !== "new");
507+ return (
508+ <div className="-mx-4 overflow-x-auto sm:-mx-5">
509+ <table className="w-full min-w-[56rem] text-sm">
510+ <thead>
511+ <tr className="border-b border-line text-left text-xs text-muted">
512+ <th className="px-4 py-2 font-medium sm:px-5">Model</th>
513+ <th className="px-4 py-2 font-medium">Status</th>
514+ <th className="px-4 py-2 font-medium">Suits</th>
515+ <th className="px-4 py-2 text-right font-medium">Context</th>
516+ <th className="px-4 py-2 text-right font-medium">In / out</th>
517+ <th className="px-4 py-2 text-right font-medium">Cache read</th>
518+ <th className="px-4 py-2 text-right font-medium">Typical run</th>
519+ <th className="px-4 py-2 font-medium sm:pr-5">Listed</th>
520+ </tr>
521+ </thead>
522+ <tbody>
523+ {listed.map((model) => {
524+ const status = STATUS[model.status] ?? STATUS.available;
525+ const error = failure?.target === `model-${model.model}` ? failure : null;
526+ return (
527+ <tr key={model.model} id={`model-${model.model}`} className="scroll-mt-20 border-b border-line align-top last:border-0">
528+ <td className="px-4 py-2.5 sm:px-5">
529+ <span className="font-medium">{model.name}</span>
530+ <code className="block text-xs text-muted">{model.model}</code>
531+ {model.aliases.length > 0 && <span className="block text-xs text-faint">also {model.aliases.join(", ")}</span>}
532+ <span className="block text-xs text-faint">{PROVIDER[model.provider] ?? model.provider}</span>
533+ {error && <span className="mt-1 block text-xs text-danger">{error.error}</span>}
534+ </td>
535+ <td className="px-4 py-2.5">
536+ <Badge tone={status.tone}>{status.label}</Badge>
537+ {!model.priced && (
538+ <span className="mt-1 block">
539+ <Badge tone="warn">Unpriced</Badge>
540+ </span>
541+ )}
542+ {model.status === "available" ? (
543+ <Decide model={model} intent="retire" label="Retire" />
544+ ) : (
545+ <Decide model={model} intent="restore" label="Restore" />
546+ )}
547+ </td>
548+ <td className="px-4 py-2.5 text-muted">{model.kind === "embeddings" ? `Embeddings${model.dimensions ? `, ${model.dimensions}` : ""}` : TIER_LABELS[model.tierHint] ?? "—"}</td>
549+ <td className="px-4 py-2.5 text-right font-mono tabular-nums">{tokens(model.contextWindow)}</td>
550+ <td className="px-4 py-2.5 text-right font-mono tabular-nums whitespace-nowrap">
551+ ${perMillion(model.inputMicros)}
552+ {model.kind !== "embeddings" && <> / ${perMillion(model.outputMicros)}</>}
553+ {model.threshold ? <span className="block text-xs text-faint">over {tokens(model.threshold)}: ${perMillion(model.overInputMicros)} / ${perMillion(model.overOutputMicros)}</span> : null}
554+ </td>
555+ <td className="px-4 py-2.5 text-right font-mono tabular-nums">{model.kind === "embeddings" ? "—" : `$${perMillion(model.cacheReadMicros)}`}</td>
556+ <td className="px-4 py-2.5 text-right font-mono tabular-nums">{runCost(model.typicalRunMicros)}</td>
557+ <td className="px-4 py-2.5 text-xs text-muted sm:pr-5">
558+ {model.missingSince ? (
559+ <span className="text-warn">
560+ Not since <When at={model.missingSince} />
561+ </span>
562+ ) : model.lastSeenAt ? (
563+ <When at={model.lastSeenAt} />
564+ ) : (
565+ "Not checked yet"
566+ )}
567+ </td>
568+ </tr>
569+ );
570+ })}
571+ </tbody>
572+ </table>
573+ </div>
574+ );
575+}
+2−0
88 EVENTS: ServiceBinding;
99 /** apps/status's `StatusAdmin` entrypoint: see `StatusAdminApi`. */
1010 STATUS: Fetcher;
11+ /** services/models's `Discovery` entrypoint: see `ModelDiscoveryApi`. */
12+ MODELS: Fetcher;
1113 ASSETS: Fetcher;
1214 ACCESS_TEAM_DOMAIN: string;
1315 ACCESS_AUD: string;
+5−1
2323 { "binding": "EVENTS", "service": "g1t-events" },
2424 // The status page's staff-only entrypoint: posting incidents to
2525 // status.g1t.sh (apps/status). Only bindings reach it.
26− { "binding": "STATUS", "service": "g1t-status", "entrypoint": "StatusAdmin" }
26+ { "binding": "STATUS", "service": "g1t-status", "entrypoint": "StatusAdmin" },
27+ // "Check for new models" on Agents & models: the model proxy's
28+ // `Discovery` entrypoint lists each provider's models now and records
29+ // them with billing's catalogue. Only bindings reach it.
30+ { "binding": "MODELS", "service": "g1t-models", "entrypoint": "Discovery" }
2731 ],
2832 "vars": {
2933 // The Zero Trust team domain, such as `g1t.cloudflareaccess.com`.
+17−0
33
44 import {
55 type Merged,
6+ placePushes,
67 change,
78 checksFact,
89 confidenceAsk,
370371 assert.match(why, /self-hosted runner/);
371372 assert.doesNotMatch(why, /agent/);
372373 });
374+
375+test("each push to the default branch places the commits it brought, at its time, from one read of the history", () => {
376+ const c = (hash: string, parents = ["p"], author = "Chase Pierce") => ({ hash, parents, author: { name: author } });
377+ // Newest first: a Wednesday push of two, a Monday push of one, a merge, and g1t's own commit.
378+ const history = [c("w2"), c("w1"), c("m1"), c("merge", ["a", "b"]), c("bot", ["p"], "g1t"), c("old")];
379+ const pushes = [
380+ { time: "2026-10-07T12:00:00Z", data: { after: "w2", before: "m1" } },
381+ { time: "2026-10-05T12:00:00Z", data: { after: "m1", before: "old" } },
382+ { time: "2026-10-01T12:00:00Z", data: { after: "gone", before: "older" } },
383+ ];
384+ assert.deepEqual(placePushes(history, pushes), [
385+ { hash: "m1", at: "2026-10-05T12:00:00Z" },
386+ { hash: "w2", at: "2026-10-07T12:00:00Z" },
387+ { hash: "w1", at: "2026-10-07T12:00:00Z" },
388+ ]);
389+});
+23−0
448448 return brought.filter((commit) => commit.parents.length <= 1 && !isAgent(commit.author.name));
449449 }
450450
451+/**
452+ * Each commit of `history` (newest first) a push in `pushes` (newest
453+ * first) brought, at that push's time. A commit pushed twice (after a
454+ * force push, say) counts once, at its first landing; a push whose `after`
455+ * is no longer in the history (rewritten) brings nothing.
456+ */
457+export function placePushes<C extends { hash: string; parents: string[]; author: { name: string } }>(
458+ history: C[],
459+ pushes: { time: string; data: { after: string; before?: string } }[],
460+): { hash: string; at: string }[] {
461+ const index = new Map(history.map((commit, i) => [commit.hash, i]));
462+ const seen = new Map<string, string>();
463+ for (const push of [...pushes].reverse()) {
464+ const start = index.get(push.data.after);
465+ if (start === undefined) continue;
466+ const end = push.data.before ? index.get(push.data.before) : undefined;
467+ for (const commit of pushedCommits(history.slice(start, end ?? history.length), undefined)) {
468+ if (!seen.has(commit.hash)) seen.set(commit.hash, push.time);
469+ }
470+ }
471+ return [...seen].map(([hash, at]) => ({ hash, at }));
472+}
473+
451474 export type Merged = {
452475 repo: RepoPath;
453476 number: number;
+28−17
3939 import {
4040 type Fact,
4141 type Merged,
42− pushedCommits,
42+ placePushes,
4343 type NeedRow,
4444 type QuickAction,
4545 RUN_LABEL,
152152 return data<DelegateResult>({ error: null, notStarted: refused });
153153 }
154154
155−/** Pushes to the default branch read for the week, per project, and commits read back from each. */
156−const PUSHES_READ = 40;
157−const PUSH_DEPTH = 60;
155+/**
156+ * Push events read per project, a page at a time, back two weeks. Pushes
157+ * to every branch are in the log, so a fixed count (it was 40) lost a busy
158+ * week's earlier days to agents' branches.
159+ */
160+const PUSH_PAGE = 200;
161+const PUSH_PAGES = 5;
162+/** Commits of the default branch read once, to place each push's commits. */
163+const HISTORY_READ = 1000;
158164
159165 /**
160166 * The commits people pushed straight to a project's default branch in the
163169 */
164170 async function directCommits(repo: Repo, viewer: Viewer): Promise<{ hash: string; at: string }[]> {
165171 const since = Date.now() - 14 * TIME.DAY;
166− const pushes = (await eventLog.list({ repoId: repo.id, types: ["git.push"], limit: PUSHES_READ })).filter(
167− (event): event is G1tEvent<"git.push"> => event.type === "git.push" && event.data.defaultBranch && Date.parse(event.time) >= since,
168− );
172+ const pushes: G1tEvent<"git.push">[] = [];
173+ let before: string | undefined;
174+ for (let page = 0; page < PUSH_PAGES; page++) {
175+ const batch = await eventLog.list({ repoId: repo.id, types: ["git.push"], limit: PUSH_PAGE, ...(before ? { before } : {}) });
176+ for (const event of batch) {
177+ if (event.type === "git.push" && event.data.defaultBranch && Date.parse(event.time) >= since) pushes.push(event as G1tEvent<"git.push">);
178+ }
179+ const oldest = batch.at(-1);
180+ if (batch.length < PUSH_PAGE || !oldest || Date.parse(oldest.time) < since) break;
181+ before = oldest.id;
182+ }
183+ if (pushes.length === 0) return [];
184+ // One read of the branch from the newest push back; each push brought
185+ // what lies between its `after` and its `before` in that history.
169186 const path = { namespace: repo.namespace, name: repo.name };
170− const read = await Promise.all(
171− pushes.map(async (push) => {
172− const history = await reposApi.log(path, viewer, push.data.after, PUSH_DEPTH).catch(() => null);
173− return history?.ok ? pushedCommits(history.value, push.data.before).map((commit) => ({ hash: commit.hash, at: push.time })) : [];
174− }),
175− );
176− // A commit pushed twice (after a force push, say) counts once, at its first landing.
177− const seen = new Map<string, string>();
178− for (const commit of read.flat().reverse()) if (!seen.has(commit.hash)) seen.set(commit.hash, commit.at);
179− return [...seen].map(([hash, at]) => ({ hash, at }));
187+ const history = await reposApi.log(path, viewer, pushes[0].data.after, HISTORY_READ).catch(() => null);
188+ if (!history?.ok) return [];
189+ return placePushes(history.value, pushes);
180190 }
181191
192+
182193 export async function loader({ context, request }: Route.LoaderArgs) {
183194 const viewer = getViewer(context);
184195 if (!viewer) {
+6−4
237237 failing holds it for a person. There is no model or
238238 agent count to choose: to put more agents to work, assign more issues.
239239 On g1t's hosted models, Auto routes each job to the cheapest of three
240− tiers that can do it, fast, standard and most capable: catching up,
241− answering and reviews of small changes that touch no sensitive path
242− start fast; making changes, revising, planning and other reviews start
243− standard; reviews of very large changes and issues labelled
240+ tiers that can do it, fast, standard and most capable (the model behind
241+ each is today's, and moves to newer models as g1t adopts them; a
242+ retired model is never used): catching up, answering, planning and
243+ reviews of small changes that touch no sensitive path start fast;
244+ making changes, revising and other reviews start standard; reviews of
245+ very large changes and issues labelled
244246 `architecture` start most capable. A failed attempt moves the next one
245247 up a tier (two in a row: most capable), and a repository's own recent
246248 runs move work down or up. Each run states its model and why in one
+15−5
408408 }
409409 }
410410 if spec.contains_key("services") {
411− note(Severity::Unsupported, Some(id), "`services` containers (such as a database) are not started on g1t yet.".to_owned());
411+ note(
412+ Severity::Info,
413+ Some(id),
414+ "`services`: each service runs in Docker beside the steps and is reached at `localhost:<port>`. On g1t's machines it is the job's own Docker Engine, the service is also reached by its name, and two services cannot listen on the same port.".to_owned(),
415+ );
412416 }
413417 if spec.contains_key("container") {
414− note(Severity::Warning, Some(id), "`container`: steps run on g1t's runner image instead of that container.".to_owned());
418+ note(
419+ Severity::Info,
420+ Some(id),
421+ "`container`: the steps run inside that image, in Docker (on g1t's machines, the job's own Engine), with the workspace at the same path as on the runner (`/home/runner/work`), not `/__w`.".to_owned(),
422+ );
415423 }
416424 if spec.contains_key("environment") {
417425 note(Severity::Info, Some(id), "`environment`: the job gets the values its secrets and variables give this environment; protection rules (approvals, wait timers, branch limits) are not enforced on g1t yet. Unless it says `deployment: false`, the run records a deployment to it.".to_owned());
472480 /// `caches`: the step sets a `cache` input.
473481 fn action_note(uses: &str, caches: bool) -> Option<(Severity, String)> {
474482 if uses.starts_with("docker://") {
475− return Some((Severity::Unsupported, format!("`{uses}`: Docker actions do not run on g1t yet.")));
483+ return Some((Severity::Info, format!("`{uses}` runs in Docker (on g1t's machines, the job's own Engine).")));
476484 }
477485 let name = uses.split('@').next().unwrap_or(uses).to_ascii_lowercase();
478486 match name.as_str() {
586594 workflow.notes.iter().filter(|n| n.severity == Severity::Unsupported).map(|n| n.message.as_str()).collect();
587595 assert!(unsupported.iter().any(|m| m.contains("`release`")));
588596 assert!(unsupported.iter().any(|m| m.contains("windows-latest")));
589− assert!(unsupported.iter().any(|m| m.contains("services")));
590− assert!(unsupported.iter().any(|m| m.contains("docker://alpine")));
597+ assert!(!unsupported.iter().any(|m| m.contains("services")));
598+ assert!(!unsupported.iter().any(|m| m.contains("docker://alpine")));
599+ assert!(workflow.notes.iter().any(|n| n.severity == Severity::Info && n.message.contains("own Docker Engine") && n.message.contains("localhost")));
600+ assert!(workflow.notes.iter().any(|n| n.severity == Severity::Info && n.message.starts_with("`docker://alpine`")));
591601 assert!(unsupported.iter().any(|m| m.contains("pwsh")));
592602 assert!(workflow.notes.iter().any(|n| n.severity == Severity::Info && n.message.contains("actions/cache")));
593603 assert!(workflow.notes.iter().any(|n| n.severity == Severity::Warning && n.message.contains("actions/setup-node")));
+7−4
174174 let deploy = read("deploy.yml");
175175 for stage in ["core", "edge", "front"] {
176176 let job = deploy.jobs.iter().find(|j| j.id == stage).unwrap();
177− let on = |rust: bool| {
177+ let on = |rust: bool, image: bool| {
178178 let mut contexts = Map::new();
179− contexts.insert("matrix".into(), json!({ "group": "g", "units": "u", "rust": rust }));
179+ contexts.insert("matrix".into(), json!({ "group": "g", "units": "u", "rust": rust, "image": image }));
180180 let scope = Scope { contexts: &contexts, status: Status::Success, hash_files: None };
181181 expr::interpolate_value(&job.runs_on, &scope).unwrap()
182182 };
183− assert_eq!(on(true), json!("g1t-4core"), "{stage}");
184− assert_eq!(on(false), json!("ubuntu-latest"), "{stage}");
183+ assert_eq!(on(true, false), json!("g1t-4core"), "{stage}");
184+ // The runner's image is built with the job's own Docker Engine.
185+ assert_eq!(on(false, true), json!("g1t-4core"), "{stage}");
186+ assert_eq!(on(false, false), json!("ubuntu-latest"), "{stage}");
185187 }
186188 let source = std::fs::read_to_string(workflows_dir().join("deploy.yml")).unwrap();
187189 assert!(source.contains("target/wasm32-unknown-unknown/release"));
188190 assert!(source.contains("!target/**/incremental"));
191+ assert!(source.contains("target/x86_64-unknown-linux-musl/release"));
189192 }
190193
191194 #[test]
+318−0
539539 "anthropic".to_owned()
540540 }
541541
542+// --- The model catalogue ----------------------------------------------------
543+//
544+// Every model g1t can use, in one table (billing's `gateway_models`): the
545+// models agents run on, the AI Gateway's, and the embeddings model. New
546+// models are found by the models service listing each provider daily
547+// (`record_discovery`) and wait as `new` until staff approve them in sudo.
548+// Which model each purpose uses by default is staff's choice
549+// (`model_defaults`), read by the runner and the AI Gateway.
550+
551+/// Where a model stands. Only `available` models are routed to; the AI
552+/// Gateway offers `available` and `deprecated` ones that have a price.
553+pub mod model_status {
554+ /// Approved and priced: routed to and offered.
555+ pub const AVAILABLE: &str = "available";
556+ /// Found by discovery and not approved yet: never routed to, offered or charged.
557+ pub const NEW: &str = "new";
558+ /// The provider stopped listing it: still offered to anyone who names
559+ /// it, but no default routes to it.
560+ pub const DEPRECATED: &str = "deprecated";
561+ /// Staff retired it: neither routed to nor offered.
562+ pub const RETIRED: &str = "retired";
563+}
564+
565+/// One model in the catalogue: its prices (as the AI Gateway reads them)
566+/// and what g1t knows about it. `admin_models` returns these.
567+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
568+#[serde(rename_all = "camelCase")]
569+pub struct CatalogueModel {
570+ #[serde(flatten)]
571+ pub prices: GatewayModel,
572+ /// Other ids the provider lists it by, such as a dated one.
573+ #[serde(default)]
574+ pub aliases: Vec<String>,
575+ /// `haiku`, `sonnet`, `opus`, `fable`, or a Workers AI author.
576+ #[serde(default)]
577+ pub family: String,
578+ /// The agent tier it suits: `small`, `large`, `frontier`, or empty.
579+ #[serde(default)]
580+ pub tier_hint: String,
581+ /// Tokens it reads at most; 0 when not known.
582+ #[serde(default)]
583+ pub context_window: u64,
584+ /// Tokens it writes at most; 0 when not known.
585+ #[serde(default)]
586+ pub max_output: u64,
587+ /// Any of `effort`, `thinking`, `tools`, `vision`, `embeddings`.
588+ #[serde(default)]
589+ pub capabilities: Vec<String>,
590+ /// An embeddings model's vector length; 0 otherwise or when not known.
591+ #[serde(default)]
592+ pub dimensions: u32,
593+ /// `available`, `new`, `deprecated` or `retired` (`model_status`).
594+ pub status: String,
595+ /// Whether its prices are known. An unpriced model is never routed to,
596+ /// offered or charged for.
597+ pub priced: bool,
598+ /// `discovered` (found by listing its provider) or `staff`.
599+ pub source: String,
600+ #[serde(default)]
601+ pub first_seen_at: Option<String>,
602+ /// When its provider last listed it.
603+ #[serde(default)]
604+ pub last_seen_at: Option<String>,
605+ /// Since when its provider has not listed it.
606+ #[serde(default)]
607+ pub missing_since: Option<String>,
608+ #[serde(default)]
609+ pub approved_by: Option<String>,
610+ #[serde(default)]
611+ pub approved_at: Option<String>,
612+ #[serde(default)]
613+ pub note: String,
614+ /// What a typical agent run would cost on it, in millionths of a
615+ /// dollar, from its prices (`typical_run` in billing's catalogue.rs);
616+ /// 0 for an embeddings or unpriced model.
617+ #[serde(default)]
618+ pub typical_run_micros: i64,
619+}
620+
621+/// A provider's list price per million tokens, as its listing gives it, in
622+/// millionths of a dollar.
623+#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
624+#[serde(rename_all = "camelCase")]
625+pub struct ListedPrice {
626+ pub input_micros: i64,
627+ #[serde(default)]
628+ pub output_micros: i64,
629+}
630+
631+/// One model as its provider lists it, from the models service's
632+/// discovery.
633+#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
634+#[serde(rename_all = "camelCase")]
635+pub struct ProviderModel {
636+ /// The provider's id: `claude-haiku-5-5`, `@cf/openai/gpt-oss-120b`.
637+ pub id: String,
638+ /// For people, as the provider names it.
639+ #[serde(default)]
640+ pub name: String,
641+ /// `chat`, `embeddings`, or anything else (not added to the catalogue,
642+ /// but still counted as listed).
643+ #[serde(default)]
644+ pub kind: String,
645+ #[serde(default)]
646+ pub context_window: u64,
647+ #[serde(default)]
648+ pub max_output: u64,
649+ #[serde(default)]
650+ pub capabilities: Vec<String>,
651+ /// Workers AI lists a price with each model; Anthropic does not.
652+ #[serde(default)]
653+ pub price: Option<ListedPrice>,
654+}
655+
656+/// `record_discovery`: what one provider lists now, from the models
657+/// service (daily, or when staff press "Check for new models"). Billing
658+/// adds new ids as `new`, marks ones no longer listed `deprecated`, records
659+/// the check and emails staff about anything new. With `error` (the listing
660+/// failed) only the check is recorded. Returns `DiscoveryResult`.
661+#[derive(Clone, Debug, Default, Serialize, Deserialize)]
662+#[serde(rename_all = "camelCase")]
663+pub struct RecordDiscoveryArgs {
664+ /// `anthropic` or `workers-ai`.
665+ pub provider: String,
666+ #[serde(default)]
667+ pub models: Vec<ProviderModel>,
668+ /// The staff member who asked, or `schedule`.
669+ pub by: String,
670+ #[serde(default)]
671+ pub error: Option<String>,
672+}
673+
674+/// What one check found.
675+#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
676+#[serde(rename_all = "camelCase")]
677+pub struct DiscoveryResult {
678+ pub provider: String,
679+ pub checked_at: String,
680+ pub by: String,
681+ /// Ids the provider listed.
682+ pub listed: u32,
683+ /// Ids added to the catalogue as `new`.
684+ pub added: Vec<String>,
685+ /// Catalogue models the provider no longer lists, now `deprecated`.
686+ pub deprecated: Vec<String>,
687+ /// Deprecated models listed again.
688+ pub restored: Vec<String>,
689+ /// The listing failed: nothing changed.
690+ #[serde(default)]
691+ pub error: Option<String>,
692+}
693+
694+/// Which model, tier or effort one purpose uses by default, as staff last
695+/// set it.
696+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
697+#[serde(rename_all = "camelCase")]
698+pub struct ModelDefault {
699+ /// `tier_small`, `tier_large`, `tier_frontier`, `background`,
700+ /// `gateway_first`, or `job_<kind>` for `implement`, `revise`,
701+ /// `answer`, `review`, `update` and `plan`.
702+ pub purpose: String,
703+ /// The model, for a model purpose.
704+ #[serde(default)]
705+ pub model: Option<String>,
706+ /// For a job: `small`, `large`, `frontier`, or `change` (sized by the change).
707+ #[serde(default)]
708+ pub tier: Option<String>,
709+ /// For a job: `low`, `medium`, `high`, `xhigh` or `max`; none for the harness's own.
710+ #[serde(default)]
711+ pub effort: Option<String>,
712+ pub updated_at: String,
713+ pub updated_by: String,
714+ #[serde(default)]
715+ pub reason: String,
716+}
717+
718+/// A model purpose's default as it applies now: the chosen model, or the
719+/// one routing falls back to when the chosen one cannot be used.
720+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
721+#[serde(rename_all = "camelCase")]
722+pub struct ResolvedModel {
723+ pub purpose: String,
724+ /// The model staff chose.
725+ pub chosen: String,
726+ /// The model to use, with its prices; none when neither the chosen
727+ /// model nor any other suits (callers keep their own fallback).
728+ #[serde(default)]
729+ pub model: Option<GatewayModel>,
730+ #[serde(default)]
731+ pub capabilities: Vec<String>,
732+ /// Why it is not the chosen one, in a sentence: `Claude Haiku 5.5 is
733+ /// retired; using Claude Haiku 4.5.`
734+ #[serde(default)]
735+ pub note: Option<String>,
736+}
737+
738+/// One kind of agent job's starting tier and effort.
739+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
740+#[serde(rename_all = "camelCase")]
741+pub struct JobDefault {
742+ /// `implement`, `revise`, `answer`, `review`, `update` or `plan`.
743+ pub kind: String,
744+ /// `small`, `large`, `frontier` or `change`.
745+ pub tier: String,
746+ #[serde(default)]
747+ pub effort: Option<String>,
748+}
749+
750+/// `model_defaults` takes nothing and returns this: every purpose's model
751+/// as it applies now, and each job's tier and effort. Read by the runner
752+/// (cached a minute) and the AI Gateway.
753+#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
754+#[serde(rename_all = "camelCase")]
755+pub struct ModelDefaults {
756+ pub models: Vec<ResolvedModel>,
757+ pub jobs: Vec<JobDefault>,
758+}
759+
760+/// A check of one provider, as `model_checks` keeps it.
761+#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
762+#[serde(rename_all = "camelCase")]
763+pub struct ModelCheck {
764+ pub id: String,
765+ pub provider: String,
766+ pub checked_at: String,
767+ pub by: String,
768+ pub listed: u32,
769+ pub added: Vec<String>,
770+ pub deprecated: Vec<String>,
771+ #[serde(default)]
772+ pub error: Option<String>,
773+}
774+
775+/// `admin_models` takes nothing and returns this: sudo's Agents & models.
776+#[derive(Clone, Debug, Default, Serialize, Deserialize)]
777+#[serde(rename_all = "camelCase")]
778+pub struct AdminModels {
779+ /// Every model, `new` ones first, then by provider and position.
780+ pub catalogue: Vec<CatalogueModel>,
781+ pub defaults: Vec<ModelDefault>,
782+ pub resolved: ModelDefaults,
783+ /// The latest checks, newest first.
784+ pub checks: Vec<ModelCheck>,
785+ /// The token mix `typical_run_micros` prices, for the page to state.
786+ pub typical: TypicalRun,
787+}
788+
789+/// The tokens of the typical agent run estimates are priced from.
790+#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]
791+#[serde(rename_all = "camelCase")]
792+pub struct TypicalRun {
793+ pub requests: u64,
794+ /// Per request.
795+ pub input: u64,
796+ pub output: u64,
797+ pub cache_read: u64,
798+ pub cache_write: u64,
799+}
800+
801+/// A model's prices as staff confirm them, per million tokens in
802+/// millionths of a dollar.
803+#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
804+pub struct ModelPrices {
805+ pub input_micros: i64,
806+ pub output_micros: i64,
807+ pub cache_read_micros: i64,
808+ pub cache_write_micros: i64,
809+ #[serde(default)]
810+ pub cache_write_1h_micros: i64,
811+ #[serde(default)]
812+ pub threshold: u64,
813+ #[serde(default)]
814+ pub over_input_micros: i64,
815+ #[serde(default)]
816+ pub over_output_micros: i64,
817+ #[serde(default)]
818+ pub over_cache_read_micros: i64,
819+ #[serde(default)]
820+ pub over_cache_write_micros: i64,
821+ #[serde(default)]
822+ pub over_cache_write_1h_micros: i64,
823+}
824+
825+/// `admin_decide_model`: `approve` a model (its prices confirmed, made
826+/// available), `retire` one, or `restore` a retired or deprecated one.
827+/// Audited. Returns `Outcome<CatalogueModel>`.
828+#[derive(Clone, Debug, Default, Serialize, Deserialize)]
829+pub struct AdminDecideModelArgs {
830+ pub model: String,
831+ pub decision: String,
832+ /// On approval: the name people see, and the prices.
833+ #[serde(default)]
834+ pub name: Option<String>,
835+ #[serde(default)]
836+ pub tier_hint: Option<String>,
837+ #[serde(default)]
838+ pub prices: Option<ModelPrices>,
839+ pub reason: String,
840+ pub by: String,
841+}
842+
843+/// `admin_set_model_default`: one purpose's default. A model purpose takes
844+/// `model` (available, priced, and suited to the purpose); a job takes
845+/// `tier` and `effort`. Audited with the old and new values and why.
846+/// Returns `Outcome<ModelDefault>`.
847+#[derive(Clone, Debug, Default, Serialize, Deserialize)]
848+pub struct AdminSetModelDefaultArgs {
849+ pub purpose: String,
850+ #[serde(default)]
851+ pub model: Option<String>,
852+ #[serde(default)]
853+ pub tier: Option<String>,
854+ #[serde(default)]
855+ pub effort: Option<String>,
856+ pub reason: String,
857+ pub by: String,
858+}
859+
542860 /// `gateway_admit`: whether a workspace's next AI Gateway request may go to
543861 /// g1t's models. Fails with `payment_required` and what to do when it may
544862 /// not: over its spend limit, out of AI credit, or not on the plan. Returns
+806−0
1+//! Containers a job asks for, as GitHub's runner starts them, with the
2+//! `docker` command: its `services:` (a database beside the steps), its
3+//! `container:` (every step run inside an image), `uses: docker://image`
4+//! steps and Docker actions (`runs.using: docker`).
5+//!
6+//! On g1t's machines the Engine is the job's own (crate::docker). Every
7+//! container shares the job's network there, so a service is reached at
8+//! `localhost:<port>` as on GitHub's runner, and by its name as from a job
9+//! container: the API proxy makes each service's name mean 127.0.0.1.
10+
11+use std::collections::{BTreeMap, BTreeSet};
12+use std::path::{Path, PathBuf};
13+use std::process::Command;
14+use std::time::{Duration, Instant};
15+
16+use g1t_actions::expr;
17+use serde_json::{Map, Value, json};
18+
19+use super::files::StepFiles;
20+use super::process::{self, Commands, Ended};
21+use super::Job;
22+
23+/// Set in a job a self-hosted runner started inside its `container:` image.
24+pub(crate) const IN_JOB_CONTAINER: &str = "G1T_JOB_CONTAINER";
25+/// Where a job container finds the runner's Node, for JavaScript actions.
26+pub(crate) const CONTAINER_NODE: &str = "/__e/node24/bin/node";
27+/// GitHub's folders inside a Docker action's container.
28+const GITHUB_WORKSPACE: &str = "/github/workspace";
29+const GITHUB_HOME: &str = "/github/home";
30+const GITHUB_WORKFLOW: &str = "/github/workflow";
31+const GITHUB_FILE_COMMANDS: &str = "/github/file_commands";
32+
33+/// The job's own container, when it has `container:`.
34+pub(crate) struct JobContainer {
35+ pub(crate) id: String,
36+ /// `bash`, or `sh` in an image without it.
37+ pub(crate) shell: &'static str,
38+ /// Whether the runner's Node runs in it.
39+ pub(crate) node: bool,
40+ /// The image's own `PATH`.
41+ pub(crate) path: String,
42+}
43+
44+/// A `services:` entry or `container:`, read.
45+#[derive(Debug, Default, PartialEq)]
46+pub(crate) struct ContainerSpec {
47+ pub(crate) image: String,
48+ pub(crate) env: BTreeMap<String, String>,
49+ pub(crate) ports: Vec<String>,
50+ pub(crate) volumes: Vec<String>,
51+ pub(crate) options: Vec<String>,
52+ pub(crate) credentials: Option<(String, String)>,
53+ pub(crate) command: Vec<String>,
54+ pub(crate) entrypoint: Option<String>,
55+}
56+
57+/// Splits a command line as a shell would: words, with `'…'`, `"…"` and
58+/// `\` quoting. No variables are expanded.
59+pub(crate) fn split_words(text: &str) -> Vec<String> {
60+ let mut words = Vec::new();
61+ let mut word = String::new();
62+ let mut started = false;
63+ let mut chars = text.chars().peekable();
64+ while let Some(c) = chars.next() {
65+ match c {
66+ '\'' => {
67+ started = true;
68+ for c in chars.by_ref() {
69+ if c == '\'' {
70+ break;
71+ }
72+ word.push(c);
73+ }
74+ }
75+ '"' => {
76+ started = true;
77+ while let Some(c) = chars.next() {
78+ match c {
79+ '"' => break,
80+ '\\' if matches!(chars.peek(), Some('"' | '\\' | '$' | '`')) => word.push(chars.next().unwrap_or('\\')),
81+ c => word.push(c),
82+ }
83+ }
84+ }
85+ '\\' => {
86+ started = true;
87+ if let Some(next) = chars.next()
88+ && next != '\n'
89+ {
90+ word.push(next);
91+ }
92+ }
93+ c if c.is_whitespace() => {
94+ if started {
95+ words.push(std::mem::take(&mut word));
96+ started = false;
97+ }
98+ }
99+ c => {
100+ started = true;
101+ word.push(c);
102+ }
103+ }
104+ }
105+ if started {
106+ words.push(word);
107+ }
108+ words
109+}
110+
111+fn texts(value: Option<&Value>) -> Vec<String> {
112+ match value {
113+ Some(Value::Array(items)) => items.iter().map(expr::to_text).filter(|s| !s.is_empty()).collect(),
114+ Some(Value::Null) | None => Vec::new(),
115+ Some(other) => vec![expr::to_text(other)].into_iter().filter(|s| !s.is_empty()).collect(),
116+ }
117+}
118+
119+/// Reads a `services:` entry or `container:`, its expressions already
120+/// read: a string (the image) or a mapping.
121+pub(crate) fn container_spec(value: &Value) -> ContainerSpec {
122+ match value {
123+ Value::String(image) => ContainerSpec { image: image.trim().to_owned(), ..ContainerSpec::default() },
124+ Value::Object(fields) => ContainerSpec {
125+ image: fields.get("image").map(expr::to_text).unwrap_or_default().trim().to_owned(),
126+ env: fields
127+ .get("env")
128+ .and_then(Value::as_object)
129+ .map(|env| env.iter().map(|(k, v)| (k.clone(), expr::to_text(v))).collect())
130+ .unwrap_or_default(),
131+ ports: texts(fields.get("ports")),
132+ volumes: texts(fields.get("volumes")),
133+ options: fields.get("options").map(|o| split_words(&expr::to_text(o))).unwrap_or_default(),
134+ credentials: fields.get("credentials").and_then(Value::as_object).and_then(|c| {
135+ let username = c.get("username").map(expr::to_text).unwrap_or_default();
136+ let password = c.get("password").map(expr::to_text).unwrap_or_default();
137+ (!username.is_empty() || !password.is_empty()).then_some((username, password))
138+ }),
139+ command: fields.get("command").map(|c| split_words(&expr::to_text(c))).unwrap_or_default(),
140+ entrypoint: fields.get("entrypoint").map(expr::to_text).filter(|e| !e.is_empty()),
141+ },
142+ _ => ContainerSpec::default(),
143+ }
144+}
145+
146+/// `job.services.<id>.ports`: each container port, and the host port it
147+/// is reached on. On g1t's machines a port left for Docker to choose is
148+/// the container's own.
149+pub(crate) fn port_map(ports: &[String]) -> BTreeMap<String, String> {
150+ let mut map = BTreeMap::new();
151+ for port in ports {
152+ let without_protocol = port.split('/').next().unwrap_or(port);
153+ let parts: Vec<&str> = without_protocol.split(':').collect();
154+ let (host, container) = match parts.as_slice() {
155+ [container] => (*container, *container),
156+ [host, container] => (if host.is_empty() { *container } else { *host }, *container),
157+ [_, host, container] => (if host.is_empty() { *container } else { *host }, *container),
158+ _ => continue,
159+ };
160+ if !container.is_empty() {
161+ map.insert(container.to_owned(), host.to_owned());
162+ }
163+ }
164+ map
165+}
166+
167+/// The registry an image is pulled from, for signing in with
168+/// `credentials:`.
169+pub(crate) fn registry_of(image: &str) -> String {
170+ match image.split_once('/') {
171+ Some((first, _)) if first.contains('.') || first.contains(':') || first == "localhost" => first.to_owned(),
172+ _ => "https://index.docker.io/v1/".to_owned(),
173+ }
174+}
175+
176+/// A name for Docker from any text: lower case, letters, digits, `_`,
177+/// `.` and `-`.
178+pub(crate) fn docker_name(text: &str) -> String {
179+ let name: String = text
180+ .to_ascii_lowercase()
181+ .chars()
182+ .map(|c| if c.is_ascii_alphanumeric() || matches!(c, '_' | '.' | '-') { c } else { '_' })
183+ .collect();
184+ name.trim_matches(['_', '.', '-']).chars().take(100).collect()
185+}
186+
187+/// The `docker create` arguments of a container. `env` is passed by name
188+/// (`-e NAME`), its values in the command's environment, so secrets never
189+/// stand on a command line. `keep_alive`: a job container, which waits
190+/// while steps run in it.
191+pub(crate) fn create_args(name: &str, network: Option<&str>, alias: Option<&str>, spec: &ContainerSpec, mounts: &[(String, String)], keep_alive: bool) -> Vec<String> {
192+ let mut args: Vec<String> = vec!["create".into(), "--name".into(), name.into(), "--label".into(), "g1t-job".into()];
193+ if let Some(network) = network {
194+ args.extend(["--network".into(), network.into()]);
195+ if let Some(alias) = alias {
196+ args.extend(["--network-alias".into(), alias.into()]);
197+ }
198+ }
199+ for port in &spec.ports {
200+ args.extend(["-p".into(), port.clone()]);
201+ }
202+ for volume in &spec.volumes {
203+ args.extend(["-v".into(), volume.clone()]);
204+ }
205+ for (from, to) in mounts {
206+ args.extend(["-v".into(), format!("{from}:{to}")]);
207+ }
208+ for name in spec.env.keys() {
209+ args.extend(["-e".into(), name.clone()]);
210+ }
211+ args.extend(spec.options.iter().cloned());
212+ if keep_alive {
213+ args.extend(["--entrypoint".into(), "tail".into(), spec.image.clone(), "-f".into(), "/dev/null".into()]);
214+ } else {
215+ if let Some(entrypoint) = &spec.entrypoint {
216+ args.extend(["--entrypoint".into(), entrypoint.clone()]);
217+ }
218+ args.push(spec.image.clone());
219+ args.extend(spec.command.iter().cloned());
220+ }
221+ args
222+}
223+
224+/// A Docker action's run, or a `docker://` step's.
225+#[derive(Clone, Debug)]
226+pub(crate) struct DockerRun {
227+ pub(crate) image: String,
228+ pub(crate) entrypoint: Option<String>,
229+ pub(crate) args: Vec<String>,
230+ /// The step's variables, `INPUT_*` included.
231+ pub(crate) env: BTreeMap<String, String>,
232+}
233+
234+/// What a command line shows: secrets are passed by name, so nothing
235+/// needs hiding, but long lists are kept readable.
236+fn shown(args: &[String]) -> String {
237+ args.iter().map(|a| if a.contains(' ') || a.is_empty() { format!("'{a}'") } else { a.clone() }).collect::<Vec<_>>().join(" ")
238+}
239+
240+impl Job {
241+ /// Runs `docker` with `args`, its output in the log. `env`: the
242+ /// variables it passes to a container by name.
243+ pub(crate) fn docker(&mut self, args: &[String], env: &BTreeMap<String, String>, timeout: Duration) -> bool {
244+ self.log.line(&format!("[command]docker {}", shown(args)));
245+ let mut command = Command::new("docker");
246+ command.args(args).env_clear().envs(self.docker_cli_env()).envs(env);
247+ let mut commands = Commands::default();
248+ matches!(process::run(command, timeout.min(self.remaining_time()), &mut self.log, &mut commands), Ok(Ended::Exited(0)))
249+ }
250+
251+ /// What `docker` prints, or None if it fails.
252+ pub(crate) fn docker_output(&self, args: &[&str]) -> Option<String> {
253+ let output = Command::new("docker").args(args).env_clear().envs(self.docker_cli_env()).output().ok()?;
254+ output.status.success().then(|| String::from_utf8_lossy(&output.stdout).trim().to_owned())
255+ }
256+
257+ /// What the `docker` command itself needs of the sandbox's variables:
258+ /// where it is, where its config is, and how to reach the Engine.
259+ fn docker_cli_env(&self) -> BTreeMap<String, String> {
260+ ["PATH", "HOME", "DOCKER_HOST", "DOCKER_CONFIG", "DOCKER_CONTEXT", "DOCKER_CERT_PATH", "DOCKER_TLS_VERIFY"]
261+ .iter()
262+ .filter_map(|name| self.base_env_value(name).map(|value| (name.to_string(), value)))
263+ .collect()
264+ }
265+
266+ /// Makes sure Docker answers before a job's containers start: the
267+ /// job's own Engine, on g1t's machines.
268+ fn docker_ready(&mut self) -> bool {
269+ if self.docker_hosted {
270+ let started = crate::docker::engine::ensure_started();
271+ self.log_docker_notes();
272+ // Why it could not start is among the notes just logged.
273+ return started.is_ok();
274+ }
275+ if self.docker_output(&["version", "--format", "{{.Server.Version}}"]).is_some() {
276+ return true;
277+ }
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`).");
279+ false
280+ }
281+
282+ pub(crate) fn log_docker_notes(&mut self) {
283+ for line in crate::docker::take_notes() {
284+ self.log.line(&line);
285+ }
286+ }
287+
288+ /// Pulls an image, signed in with `credentials` if it has them.
289+ fn pull(&mut self, image: &str, credentials: Option<&(String, String)>) -> bool {
290+ let Some((username, password)) = credentials else {
291+ return self.docker(&["pull".into(), image.into()], &BTreeMap::new(), Duration::from_secs(1800));
292+ };
293+ // A config of its own, so the credentials go no further than this pull.
294+ let config = self.temp.join(format!("docker-config-{:x}", super::rand_id()));
295+ let _ = std::fs::create_dir_all(&config);
296+ let registry = registry_of(image);
297+ let config_text = config.display().to_string();
298+ self.log.line(&format!("[command]docker --config {config_text} login {registry} --username *** --password-stdin"));
299+ let login = Command::new("docker")
300+ .args(["--config", &config_text, "login", &registry, "--username", username, "--password-stdin"])
301+ .env_clear()
302+ .envs(self.docker_cli_env())
303+ .stdin(std::process::Stdio::piped())
304+ .stdout(std::process::Stdio::piped())
305+ .stderr(std::process::Stdio::piped())
306+ .spawn()
307+ .and_then(|mut child| {
308+ use std::io::Write;
309+ if let Some(mut stdin) = child.stdin.take() {
310+ let _ = stdin.write_all(password.as_bytes());
311+ }
312+ child.wait_with_output()
313+ });
314+ let ok = match login {
315+ Ok(output) if output.status.success() => {
316+ self.docker(&["--config".into(), config_text.clone(), "pull".into(), image.into()], &BTreeMap::new(), Duration::from_secs(1800))
317+ }
318+ Ok(output) => {
319+ self.log.line(&format!("##[error]Could not sign in to {registry}: {}", String::from_utf8_lossy(&output.stderr).trim()));
320+ false
321+ }
322+ Err(error) => {
323+ self.log.line(&format!("##[error]Could not run docker login: {error}"));
324+ false
325+ }
326+ };
327+ let _ = std::fs::remove_dir_all(&config);
328+ ok
329+ }
330+
331+ /// Reads a `services:` entry or `container:` with the job's contexts.
332+ fn read_container(&self, value: &Value) -> ContainerSpec {
333+ let frame = super::Frame::default();
334+ let env = self.env_context(&frame);
335+ let contexts = self.contexts_for(&frame, &env);
336+ let value = self.with_scope(&contexts, |scope| expr::interpolate_value(value, scope)).unwrap_or(Value::Null);
337+ container_spec(&value)
338+ }
339+
340+ /// Starts the job's services and its container, before its steps:
341+ /// GitHub's "Initialize containers". Returns whether they all started.
342+ pub(crate) fn start_containers(&mut self) -> bool {
343+ let services: Vec<(String, Value)> = self.spec["spec"]["services"].as_object().map(|s| s.iter().map(|(k, v)| (k.clone(), v.clone())).collect()).unwrap_or_default();
344+ let container = self.spec["spec"].get("container").filter(|c| !c.is_null()).cloned();
345+ let container = container.map(|c| self.read_container(&c)).filter(|c| !c.image.is_empty());
346+ let services: Vec<(String, ContainerSpec)> = services.into_iter().map(|(name, value)| (name, self.read_container(&value))).filter(|(_, spec)| !spec.image.is_empty()).collect();
347+ // A self-hosted runner in Docker mode started this job in its
348+ // `container:` image already (selfhosted/exec.rs).
349+ let container = container.filter(|_| std::env::var_os(IN_JOB_CONTAINER).is_none());
350+ if services.is_empty() && container.is_none() {
351+ return true;
352+ }
353+ // A self-hosted machine without Docker runs the steps as before,
354+ // on the machine, without the containers.
355+ 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.");
357+ return true;
358+ }
359+ self.log.line("##[group]Initialize containers");
360+ let ok = self.docker_ready() && self.start_containers_now(services, container);
361+ self.log.line("##[endgroup]");
362+ ok
363+ }
364+
365+ fn start_containers_now(&mut self, services: Vec<(String, ContainerSpec)>, container: Option<ContainerSpec>) -> bool {
366+ let job_id = docker_name(&format!("{}_{:x}", self.spec["name"].as_str().unwrap_or("job"), super::rand_id() & 0xffff_ffff));
367+ let network = format!("g1t_{job_id}");
368+ if !self.docker(&["network".into(), "create".into(), "--label".into(), "g1t-job".into(), network.clone()], &BTreeMap::new(), Duration::from_secs(60)) {
369+ self.log.line("##[error]Could not make the job's network.");
370+ return false;
371+ }
372+ self.network = Some(network.clone());
373+
374+ let mut services_context = Map::new();
375+ for (service, spec) in &services {
376+ if !self.pull(&spec.image, spec.credentials.as_ref()) {
377+ self.log.line(&format!("##[error]Could not pull {} for the service `{service}`.", spec.image));
378+ return false;
379+ }
380+ let name = docker_name(&format!("{service}_{job_id}"));
381+ let args = create_args(&name, Some(&network), Some(service), spec, &[], false);
382+ if !self.docker(&args, &spec.env, Duration::from_secs(300)) {
383+ self.log.line(&format!("##[error]Could not create the service `{service}`."));
384+ return false;
385+ }
386+ self.services.push((service.clone(), name.clone()));
387+ if !self.docker(&["start".into(), name.clone()], &BTreeMap::new(), Duration::from_secs(300)) {
388+ self.log.line(&format!("##[error]Could not start the service `{service}`."));
389+ return false;
390+ }
391+ let id = self.docker_output(&["inspect", "--format", "{{.Id}}", &name]).unwrap_or_default();
392+ let ports: Map<String, Value> = port_map(&spec.ports).into_iter().map(|(k, v)| (k, json!(v))).collect();
393+ services_context.insert(service.clone(), json!({ "id": id, "network": network, "ports": ports }));
394+ }
395+
396+ if let Some(spec) = container {
397+ if !self.pull(&spec.image, spec.credentials.as_ref()) {
398+ self.log.line(&format!("##[error]Could not pull {} for the job's container.", spec.image));
399+ return false;
400+ }
401+ let name = docker_name(&format!("job_{job_id}"));
402+ let mounts = self.container_mounts();
403+ let mut spec = spec;
404+ spec.env.insert("HOME".into(), GITHUB_HOME.into());
405+ spec.env.insert("CI".into(), "true".into());
406+ spec.env.insert("GITHUB_ACTIONS".into(), "true".into());
407+ let mut args = create_args(&name, Some(&network), None, &spec, &mounts, true);
408+ // Steps start in the workspace.
409+ args.splice(1..1, ["-w".to_owned(), self.workspace.display().to_string()]);
410+ if !self.docker(&args, &spec.env, Duration::from_secs(300)) || !self.docker(&["start".into(), name.clone()], &BTreeMap::new(), Duration::from_secs(300)) {
411+ self.log.line("##[error]Could not start the job's container.");
412+ return false;
413+ }
414+ let id = self.docker_output(&["inspect", "--format", "{{.Id}}", &name]).unwrap_or_default();
415+ let has_bash = self.docker_output(&["exec", &name, "sh", "-c", "command -v bash"]).is_some_and(|out| !out.is_empty());
416+ let node = self.docker_output(&["exec", &name, CONTAINER_NODE, "--version"]).is_some();
417+ let path = self.docker_output(&["exec", &name, "sh", "-c", "printf %s \"$PATH\""]).unwrap_or_else(|| "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin".into());
418+ if !node {
419+ self.log.line("##[warning]The runner's Node does not run in this image (it needs glibc and libstdc++), so JavaScript actions run beside the container, on g1t's image, with the same files.");
420+ }
421+ self.job_context.insert("container".into(), json!({ "id": id, "network": network }));
422+ self.container = Some(JobContainer { id: name, shell: if has_bash { "bash" } else { "sh" }, node, path });
423+ }
424+
425+ // Services with a health check are waited for.
426+ for (service, name) in self.services.clone() {
427+ if !self.wait_healthy(&service, &name) {
428+ return false;
429+ }
430+ }
431+ if !services_context.is_empty() {
432+ self.job_context.insert("services".into(), Value::Object(services_context));
433+ }
434+ self.log_docker_notes();
435+ true
436+ }
437+
438+ /// GitHub's runner's folders, at the same paths, and Docker's socket.
439+ fn container_mounts(&self) -> Vec<(String, String)> {
440+ let home = super::paths::under_home(super::paths::HOME_RUNNER);
441+ let github_home = self.temp.join("_github_home");
442+ let _ = std::fs::create_dir_all(&github_home);
443+ let mut mounts: Vec<(String, String)> = ["work", "_temp", "_actions", "_tool"]
444+ .iter()
445+ .map(|dir| {
446+ let path = home.join(dir);
447+ let _ = std::fs::create_dir_all(&path);
448+ (path.display().to_string(), path.display().to_string())
449+ })
450+ .collect();
451+ mounts.push((github_home.display().to_string(), GITHUB_HOME.into()));
452+ if let Some(node) = which_node() {
453+ mounts.push((node.display().to_string(), format!("{CONTAINER_NODE}:ro")));
454+ }
455+ mounts.push((crate::docker::engine::DOCKER_SOCKET.into(), crate::docker::engine::DOCKER_SOCKET.into()));
456+ mounts
457+ }
458+
459+ fn wait_healthy(&mut self, service: &str, name: &str) -> bool {
460+ let limit = Duration::from_secs(600).min(self.remaining_time());
461+ let until = Instant::now() + limit;
462+ let mut wait = Duration::from_secs(1);
463+ loop {
464+ let status = self.docker_output(&["inspect", "--format", "{{if .Config.Healthcheck}}{{print .State.Health.Status}}{{end}}", name]).unwrap_or_default();
465+ match status.as_str() {
466+ "" => return true,
467+ "healthy" => {
468+ self.log.line(&format!("{service} is healthy."));
469+ return true;
470+ }
471+ "unhealthy" => {
472+ self.log.line(&format!("##[error]The service `{service}` is unhealthy."));
473+ self.service_logs(service, name);
474+ return false;
475+ }
476+ _ => {}
477+ }
478+ if Instant::now() >= until {
479+ self.log.line(&format!("##[error]The service `{service}` did not become healthy in {} s.", limit.as_secs()));
480+ self.service_logs(service, name);
481+ return false;
482+ }
483+ self.log.line(&format!("Waiting for {service} to be healthy ({status})."));
484+ std::thread::sleep(wait);
485+ wait = (wait * 2).min(Duration::from_secs(8));
486+ }
487+ }
488+
489+ fn service_logs(&mut self, service: &str, name: &str) {
490+ self.log.line(&format!("##[group]Service container {service}"));
491+ self.docker(&["logs".into(), "--tail".into(), "200".into(), name.into()], &BTreeMap::new(), Duration::from_secs(60));
492+ self.log.line("##[endgroup]");
493+ }
494+
495+ /// Whether the job has containers to stop when it ends.
496+ pub(crate) fn has_containers(&self) -> bool {
497+ self.network.is_some()
498+ }
499+
500+ /// GitHub's "Stop containers": each service's log, then the job's
501+ /// containers and network removed.
502+ pub(crate) fn stop_containers(&mut self) -> bool {
503+ for (service, name) in self.services.clone() {
504+ self.service_logs(&service, &name);
505+ }
506+ let mut names: Vec<String> = self.services.iter().map(|(_, name)| name.clone()).collect();
507+ if let Some(container) = &self.container {
508+ names.push(container.id.clone());
509+ }
510+ if !names.is_empty() {
511+ let mut args = vec!["rm".to_owned(), "--force".to_owned()];
512+ args.extend(names);
513+ self.docker(&args, &BTreeMap::new(), Duration::from_secs(120));
514+ }
515+ if let Some(network) = self.network.take() {
516+ self.docker(&["network".into(), "rm".into(), network], &BTreeMap::new(), Duration::from_secs(60));
517+ }
518+ self.container = None;
519+ true
520+ }
521+
522+ /// The variables a process inside a container is given: the step's,
523+ /// GitHub's and the job's, but not the sandbox's own (its `PATH`,
524+ /// `HOME` and the like, which mean nothing in another image).
525+ pub(crate) fn container_env(&self, step_env: &BTreeMap<String, String>, full: BTreeMap<String, String>, image_path: &str) -> BTreeMap<String, String> {
526+ let mut out: BTreeMap<String, String> = full
527+ .into_iter()
528+ .filter(|(name, _)| step_env.contains_key(name) || !self.host_env.contains(name) || name.starts_with("GITHUB_") || name.starts_with("RUNNER_"))
529+ .collect();
530+ if !step_env.contains_key("PATH") {
531+ out.remove("PATH");
532+ if !self.path_prepend_entries().is_empty() {
533+ out.insert("PATH".into(), format!("{}:{image_path}", self.path_prepend_entries().join(":")));
534+ }
535+ }
536+ if !step_env.contains_key("HOME") {
537+ out.remove("HOME");
538+ }
539+ out
540+ }
541+
542+ /// A step's command, run inside the job's container instead.
543+ pub(crate) fn in_container(&self, program: &str, args: &[String], dir: &Path, env: BTreeMap<String, String>) -> Option<Command> {
544+ let container = self.container.as_ref()?;
545+ let mut command = Command::new("docker");
546+ command.args(["exec", "-w", &dir.display().to_string()]);
547+ for name in env.keys() {
548+ command.args(["-e", name]);
549+ }
550+ command.arg(&container.id).arg(program).args(args);
551+ command.env_clear().envs(self.docker_cli_env()).envs(env);
552+ Some(command)
553+ }
554+
555+ /// Runs a container for a Docker action or a `docker://` step, as
556+ /// GitHub's runner does: the workspace at /github/workspace, the step's
557+ /// files at /github/file_commands, the job's network.
558+ pub(crate) fn run_docker(&mut self, run: &DockerRun) -> (bool, BTreeMap<String, String>, BTreeMap<String, String>) {
559+ let fail = (false, BTreeMap::new(), BTreeMap::new());
560+ if !self.docker_ready() {
561+ return fail;
562+ }
563+ let id = format!("{:x}", super::rand_id());
564+ let Ok(files) = StepFiles::new(&self.temp, &id) else { return fail };
565+ let commands_dir = self.temp.join("_runner_file_commands");
566+ let workflow_dir = self.temp.join("_github_workflow");
567+ let home_dir = self.temp.join("_github_home");
568+ for dir in [&workflow_dir, &home_dir] {
569+ let _ = std::fs::create_dir_all(dir);
570+ }
571+ let _ = std::fs::copy(self.temp.join("event.json"), workflow_dir.join("event.json"));
572+
573+ let full = self.process_env(&run.env, &files);
574+ let mut env = self.container_env(&run.env, full, "");
575+ env.remove("PATH");
576+ // Paths as the container sees them.
577+ let moved = |path: &Path| format!("{GITHUB_FILE_COMMANDS}/{}", path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default());
578+ env.insert("GITHUB_OUTPUT".into(), moved(&files.output));
579+ env.insert("GITHUB_ENV".into(), moved(&files.env));
580+ env.insert("GITHUB_PATH".into(), moved(&files.path));
581+ env.insert("GITHUB_STATE".into(), moved(&files.state));
582+ env.insert("GITHUB_STEP_SUMMARY".into(), moved(&files.summary));
583+ env.insert("GITHUB_WORKSPACE".into(), GITHUB_WORKSPACE.into());
584+ env.insert("GITHUB_EVENT_PATH".into(), format!("{GITHUB_WORKFLOW}/event.json"));
585+ env.insert("HOME".into(), GITHUB_HOME.into());
586+ env.remove("GITHUB_ACTION_PATH");
587+
588+ let mut args: Vec<String> = vec!["run".into(), "--rm".into(), "--label".into(), "g1t-job".into(), "--workdir".into(), GITHUB_WORKSPACE.into()];
589+ if let Some(network) = &self.network {
590+ args.extend(["--network".into(), network.clone()]);
591+ }
592+ for (from, to) in [
593+ (self.workspace.clone(), GITHUB_WORKSPACE),
594+ (home_dir, GITHUB_HOME),
595+ (workflow_dir, GITHUB_WORKFLOW),
596+ (commands_dir, GITHUB_FILE_COMMANDS),
597+ (PathBuf::from(crate::docker::engine::DOCKER_SOCKET), crate::docker::engine::DOCKER_SOCKET),
598+ ] {
599+ args.extend(["-v".into(), format!("{}:{to}", from.display())]);
600+ }
601+ for name in env.keys() {
602+ args.extend(["-e".into(), name.clone()]);
603+ }
604+ if let Some(entrypoint) = &run.entrypoint {
605+ args.extend(["--entrypoint".into(), entrypoint.clone()]);
606+ }
607+ args.push(run.image.clone());
608+ args.extend(run.args.iter().cloned());
609+
610+ crate::abuse::touch();
611+ if let Some(miner) = crate::abuse::miner_in(&shown(&args)) {
612+ self.log.line(&format!("##[error]g1t does not run cryptocurrency miners ({miner}). This step was not run."));
613+ return fail;
614+ }
615+ self.log.line(&format!("[command]docker {}", shown(&args)));
616+ let mut command = Command::new("docker");
617+ command.args(&args).env_clear().envs(self.docker_cli_env()).envs(&env);
618+ let mut commands = Commands::default();
619+ let ended = process::run(command, self.remaining_time(), &mut self.log, &mut commands);
620+ self.log_docker_notes();
621+ let ok = match ended {
622+ Ok(Ended::Exited(0)) => true,
623+ Ok(Ended::Exited(code)) => {
624+ self.log.line(&format!("##[error]Docker run failed with exit code {code}."));
625+ false
626+ }
627+ Ok(Ended::TimedOut) => {
628+ self.log.line("##[error]The step ran past its time limit and was stopped.");
629+ false
630+ }
631+ Err(error) => {
632+ self.log.line(&format!("##[error]docker could not be started: {error}"));
633+ false
634+ }
635+ };
636+ let (outputs, state) = self.absorb(&files, &commands);
637+ (ok, outputs, state)
638+ }
639+
640+ /// Builds a Docker action's image from its Dockerfile, once per job.
641+ pub(crate) fn build_action_image(&mut self, dir: &Path, dockerfile: &str, tag_of: &str) -> Option<String> {
642+ let tag = format!("g1t-action/{}", docker_name(tag_of));
643+ if self.built_actions.contains(&tag) {
644+ return Some(tag);
645+ }
646+ if !self.docker_ready() {
647+ return None;
648+ }
649+ let file = dir.join(dockerfile);
650+ let args = vec!["build".into(), "-t".into(), tag.clone(), "-f".into(), file.display().to_string(), dir.display().to_string()];
651+ if !self.docker(&args, &BTreeMap::new(), Duration::from_secs(1800)) {
652+ self.log.line("##[error]The action's image did not build.");
653+ return None;
654+ }
655+ self.built_actions.insert(tag.clone());
656+ Some(tag)
657+ }
658+
659+ /// `docker/setup-buildx-action` on g1t's machines: the job's own
660+ /// Engine is the builder (BuildKit, the `docker` driver, with the
661+ /// containerd image store, so cache export and attestations work).
662+ pub(crate) fn setup_buildx(&mut self, with: &BTreeMap<String, String>) -> (bool, BTreeMap<String, String>) {
663+ if !self.docker_ready() {
664+ return (false, BTreeMap::new());
665+ }
666+ if let Some(driver) = with.get("driver").filter(|d| !d.is_empty() && *d != "docker") {
667+ self.log.line(&format!("`driver: {driver}` is not used on g1t's machines: builds run on this job's own Docker Engine (BuildKit, the `docker` driver), which keeps the sandbox's network and guardrails."));
668+ }
669+ for input in ["driver-opts", "buildkitd-flags", "buildkitd-config", "buildkitd-config-inline", "endpoint"] {
670+ if with.get(input).is_some_and(|v| !v.trim().is_empty()) {
671+ self.log.line(&format!("`{input}` is not used on g1t's machines."));
672+ }
673+ }
674+ self.docker(&["buildx".into(), "version".into()], &BTreeMap::new(), Duration::from_secs(60));
675+ let inspect = self.docker_output(&["buildx", "inspect", "default"]).unwrap_or_default();
676+ let platforms = inspect
677+ .lines()
678+ .find_map(|line| line.trim().strip_prefix("Platforms:"))
679+ .map(|p| p.split(',').map(|s| s.trim().trim_end_matches('*').to_owned()).filter(|s| !s.is_empty()).collect::<Vec<_>>().join(","))
680+ .unwrap_or_else(|| "linux/amd64".into());
681+ self.log.line(&format!("Builder: default (this job's Docker Engine), platforms {platforms}"));
682+ let mut outputs = BTreeMap::new();
683+ outputs.insert("name".into(), "default".into());
684+ outputs.insert("driver".into(), "docker".into());
685+ outputs.insert("platforms".into(), platforms.clone());
686+ outputs.insert("endpoint".into(), "default".into());
687+ outputs.insert("status".into(), "running".into());
688+ outputs.insert("flags".into(), String::new());
689+ outputs.insert(
690+ "nodes".into(),
691+ json!([{ "name": "default", "endpoint": "default", "status": "running", "platforms": platforms }]).to_string(),
692+ );
693+ (true, outputs)
694+ }
695+}
696+
697+/// The runner's Node, its real file (not a link), to mount into a job
698+/// container.
699+fn which_node() -> Option<PathBuf> {
700+ let path = std::env::var_os("PATH")?;
701+ std::env::split_paths(&path).map(|dir| dir.join("node")).find(|p| p.is_file()).and_then(|p| std::fs::canonicalize(p).ok())
702+}
703+
704+/// The job context's `container` and `services`, merged with its status.
705+pub(crate) fn job_context(status: &str, extra: &Map<String, Value>) -> Value {
706+ let mut job = Map::new();
707+ job.insert("status".into(), json!(status));
708+ for (key, value) in extra {
709+ job.insert(key.clone(), value.clone());
710+ }
711+ Value::Object(job)
712+}
713+
714+/// Names a set of tags already built in this job.
715+pub(crate) type Built = BTreeSet<String>;
716+
717+#[cfg(test)]
718+mod tests {
719+ use super::*;
720+
721+ #[test]
722+ fn options_split_as_a_shell_would() {
723+ assert_eq!(
724+ split_words(r#"--health-cmd "pg_isready -U postgres" --health-interval 10s --health-retries=5"#),
725+ vec!["--health-cmd", "pg_isready -U postgres", "--health-interval", "10s", "--health-retries=5"]
726+ );
727+ assert_eq!(split_words("--health-cmd 'redis-cli ping' --tmpfs /var/lib/x"), vec!["--health-cmd", "redis-cli ping", "--tmpfs", "/var/lib/x"]);
728+ assert_eq!(split_words(r#"a\ b "c\"d" ''"#), vec!["a b", "c\"d", ""]);
729+ assert!(split_words(" ").is_empty());
730+ }
731+
732+ #[test]
733+ fn services_are_read_from_either_form() {
734+ assert_eq!(container_spec(&json!("redis:7")).image, "redis:7");
735+ let spec = container_spec(&json!({
736+ "image": "postgres:17",
737+ "env": { "POSTGRES_PASSWORD": "secret", "POSTGRES_DB": "app" },
738+ "ports": ["5432:5432", 6543],
739+ "volumes": ["/data:/var/lib/postgresql/data"],
740+ "options": "--health-cmd pg_isready --health-interval 10s",
741+ "credentials": { "username": "me", "password": "token" },
742+ }));
743+ assert_eq!(spec.image, "postgres:17");
744+ assert_eq!(spec.env["POSTGRES_DB"], "app");
745+ assert_eq!(spec.ports, vec!["5432:5432", "6543"]);
746+ assert_eq!(spec.options, vec!["--health-cmd", "pg_isready", "--health-interval", "10s"]);
747+ assert_eq!(spec.credentials, Some(("me".into(), "token".into())));
748+ // An empty image is a service the job leaves out, as on GitHub.
749+ assert_eq!(container_spec(&json!({ "image": "" })).image, "");
750+ }
751+
752+ #[test]
753+ fn ports_map_container_to_host() {
754+ let map = port_map(&["5432:5432".into(), "6543:5432/tcp".into(), "6379".into(), "127.0.0.1:8080:80".into(), ":9000".into()]);
755+ assert_eq!(map["5432"], "6543");
756+ assert_eq!(map["6379"], "6379");
757+ assert_eq!(map["80"], "8080");
758+ assert_eq!(map["9000"], "9000");
759+ }
760+
761+ #[test]
762+ fn credentials_sign_in_to_the_images_registry() {
763+ assert_eq!(registry_of("ghcr.io/acme/db:1"), "ghcr.io");
764+ assert_eq!(registry_of("g1t.sh/acme/web"), "g1t.sh");
765+ assert_eq!(registry_of("localhost:5000/x"), "localhost:5000");
766+ assert_eq!(registry_of("acme/private"), "https://index.docker.io/v1/");
767+ assert_eq!(registry_of("postgres"), "https://index.docker.io/v1/");
768+ }
769+
770+ #[test]
771+ fn a_service_is_created_with_its_values_passed_by_name() {
772+ let spec = container_spec(&json!({
773+ "image": "postgres:17",
774+ "env": { "POSTGRES_PASSWORD": "secret" },
775+ "ports": ["5432:5432"],
776+ "options": "--health-cmd pg_isready",
777+ }));
778+ let args = create_args("postgres_job", Some("g1t_job"), Some("postgres"), &spec, &[], false);
779+ assert_eq!(
780+ args,
781+ vec![
782+ "create", "--name", "postgres_job", "--label", "g1t-job", "--network", "g1t_job", "--network-alias", "postgres", "-p", "5432:5432", "-e",
783+ "POSTGRES_PASSWORD", "--health-cmd", "pg_isready", "postgres:17"
784+ ]
785+ );
786+ assert!(!args.iter().any(|a| a.contains("secret")));
787+ let job = create_args("job_x", Some("g1t_job"), None, &container_spec(&json!("node:24")), &[("/home/runner/work".into(), "/home/runner/work".into())], true);
788+ assert!(job.ends_with(&["--entrypoint".into(), "tail".into(), "node:24".into(), "-f".into(), "/dev/null".into()]));
789+ assert!(job.contains(&"/home/runner/work:/home/runner/work".to_owned()));
790+ }
791+
792+ #[test]
793+ fn names_are_docker_names() {
794+ assert_eq!(docker_name("Build & test_1a2b"), "build___test_1a2b");
795+ assert_eq!(docker_name("docker/login-action@v3"), "docker_login-action_v3");
796+ }
797+
798+ #[test]
799+ fn the_job_context_carries_containers() {
800+ let mut extra = Map::new();
801+ extra.insert("services".into(), json!({ "db": { "ports": { "5432": "5432" } } }));
802+ let job = job_context("success", &extra);
803+ assert_eq!(job["status"], "success");
804+ assert_eq!(job["services"]["db"]["ports"]["5432"], "5432");
805+ }
806+}
+83−6
99 //! job's definition, its contexts and its secrets, is fetched with them.
1010
1111 mod blobs;
12+mod containers;
1213 mod files;
1314 mod glob;
1415 mod paths;
1718 mod uses;
1819 mod zip;
1920
20−use std::collections::BTreeMap;
21+use std::collections::{BTreeMap, BTreeSet};
2122 use std::path::{Path, PathBuf};
2223 use std::process::Command;
2324 use std::time::{Duration, Instant};
5859 pub(crate) enum PostRun {
5960 Node { action_dir: PathBuf, script: String },
6061 CacheSave { key: String, paths: Vec<String> },
62+ /// A Docker action's `post-entrypoint`.
63+ Docker(containers::DockerRun),
6164 }
6265
6366 pub(crate) struct Job {
8386 /// What the last Node process left, for the step that ran it.
8487 pub(crate) last_node_outputs: BTreeMap<String, String>,
8588 pub(crate) last_node_state: BTreeMap<String, String>,
89+ /// The names of the sandbox's own variables, which a container does
90+ /// not get.
91+ host_env: BTreeSet<String>,
92+ /// Whether this job has a Docker Engine of its own (g1t's machines).
93+ pub(crate) docker_hosted: bool,
94+ /// The job's network, once its containers have one.
95+ pub(crate) network: Option<String>,
96+ /// `services:`, by their names, and their containers' names.
97+ pub(crate) services: Vec<(String, String)>,
98+ /// `container:`, once started.
99+ pub(crate) container: Option<containers::JobContainer>,
100+ /// The `job` context's `container` and `services`.
101+ pub(crate) job_context: Map<String, Value>,
102+ /// Docker actions' images built in this job.
103+ pub(crate) built_actions: containers::Built,
86104 }
87105
88106 fn text_map(value: Option<&Value>) -> BTreeMap<String, String> {
107125 self.base_env.get(name).cloned()
108126 }
109127
128+ /// What earlier steps added to `PATH`, newest first.
129+ pub(crate) fn path_prepend_entries(&self) -> &[String] {
130+ &self.path_prepend
131+ }
132+
110133 fn status(&self) -> Status {
111134 if self.failed { Status::Failure } else { Status::Success }
112135 }
116139 let mut contexts = self.contexts.clone();
117140 contexts.insert("env".into(), Value::Object(env.iter().map(|(k, v)| (k.clone(), Value::String(v.clone()))).collect()));
118141 contexts.insert("steps".into(), Value::Object(frame.steps.clone()));
119− contexts.insert("job".into(), json!({ "status": if self.failed { "failure" } else { "success" } }));
142+ contexts.insert("job".into(), containers::job_context(if self.failed { "failure" } else { "success" }, &self.job_context));
120143 if let Some(inputs) = &frame.inputs {
121144 contexts.insert("inputs".into(), inputs.clone());
122145 }
142165
143166 /// The `env` context for a step: the workflow's, the job's, what earlier
144167 /// steps wrote to `GITHUB_ENV`, and the frame's.
145− fn env_context(&self, frame: &Frame) -> BTreeMap<String, String> {
168+ pub(crate) fn env_context(&self, frame: &Frame) -> BTreeMap<String, String> {
146169 let mut env = self.added_env.clone();
147170 env.extend(self.workflow_env.clone());
148171 env.extend(self.job_env.clone());
230253 let (program, args, extension): (String, Vec<String>, &str) = match shell {
231254 // A self-hosted Windows runner, as GitHub's: PowerShell.
232255 None if cfg!(windows) => (windows_powershell(), powershell_args(), "ps1"),
256+ // A job container without bash, as GitHub's runner does.
257+ None if self.container.as_ref().is_some_and(|c| c.shell == "sh") => ("sh".into(), vec!["-e".into(), "{0}".into()], "sh"),
233258 None => ("bash".into(), vec!["-e".into(), "{0}".into()], "sh"),
234259 Some("bash") => ("bash".into(), vec!["--noprofile".into(), "--norc".into(), "-eo".into(), "pipefail".into(), "{0}".into()], "sh"),
235260 Some("sh") => ("sh".into(), vec!["-e".into(), "{0}".into()], "sh"),
276301 Some(dir) => self.workspace.join(dir),
277302 None => self.workspace.clone(),
278303 };
279− let mut command = Command::new(&program);
280− command.args(&args).current_dir(&dir).env_clear().envs(self.process_env(env, &files));
304+ let full = self.process_env(env, &files);
305+ let in_container = self.container.as_ref().map(|c| c.path.clone()).and_then(|image_path| {
306+ let inside = self.container_env(env, full.clone(), &image_path);
307+ self.in_container(&program, &args, &dir, inside)
308+ });
309+ let command = match in_container {
310+ Some(command) => command,
311+ None => {
312+ let mut command = Command::new(&program);
313+ command.args(&args).current_dir(&dir).env_clear().envs(full);
314+ command
315+ }
316+ };
281317 let mut commands = Commands {
282318 debug: self.debug,
283319 ..Commands::default()
483519 std::fs::create_dir_all(&temp).context("could not make the temporary folder")?;
484520 std::fs::write(temp.join("event.json"), serde_json::to_string_pretty(&spec["event"])?)?;
485521
522+ let host_env: BTreeSet<String> = std::env::vars().map(|(name, _)| name).collect();
523+ // Docker of the job's own, on g1t's machines (crate::docker).
524+ let docker_hosted = cfg!(target_os = "linux")
525+ && std::env::var("G1T_DOCKER").as_deref() == Ok("on")
526+ && spec["variables"]["RUNNER_ENVIRONMENT"].as_str() != Some("self-hosted");
486527 // This process's environment, less what only it should see.
487528 let mut base_env: BTreeMap<String, String> =
488529 std::env::vars().filter(|(name, _)| !matches!(name.as_str(), "ACTIONS_TOKEN" | "ACTIONS_JOB" | "MODE") && !name.starts_with("G1T_")).collect();
522563 debug,
523564 last_node_outputs: BTreeMap::new(),
524565 last_node_state: BTreeMap::new(),
566+ host_env,
567+ docker_hosted,
568+ network: None,
569+ services: Vec::new(),
570+ container: None,
571+ job_context: Map::new(),
572+ built_actions: containers::Built::new(),
525573 };
526574
527575 // The workflow's env reads github, secrets, inputs and vars; the job's
577625 {
578626 job.log.line(&format!("Matrix: {}", serde_json::to_string(matrix).unwrap_or_default()));
579627 }
628+ if job.docker_hosted {
629+ let registry = job.contexts["github"]["server_url"].as_str().and_then(crate::docker::engine::registry_host);
630+ let token = job.contexts.get("secrets").and_then(|s| s.get("G1T_TOKEN")).map(expr::to_text).filter(|t| !t.is_empty());
631+ let options = crate::docker::engine::Options { registry: registry.zip(token) };
632+ if let Err(problem) = crate::docker::engine::enable(options) {
633+ job.log.line(&format!("##[warning]Docker is not available in this job: {problem}"));
634+ job.docker_hosted = false;
635+ }
636+ }
637+ let containers_started = job.start_containers();
580638 job.log.flush();
581639
582640 let mut frame = Frame::default();
583− for (index, step) in steps.iter().enumerate() {
641+ if !containers_started {
642+ job.failed = true;
643+ for (index, name) in job.step_names.clone().iter().enumerate() {
644+ job.log.step_state(index as u32 + 1, name, "completed", Some("skipped"));
645+ }
646+ }
647+ for (index, step) in steps.iter().enumerate().filter(|_| containers_started) {
584648 job.step(&mut frame, step, index as u32 + 1, true, &defaults);
649+ job.log_docker_notes();
585650 if job.remaining().is_zero() {
586651 job.log.line("##[error]The job ran past its time limit.");
587652 job.failed = true;
606671 let ok = match &post.run {
607672 PostRun::Node { action_dir, script } => job.run_node(action_dir, script, &post.env),
608673 PostRun::CacheSave { key, paths } => job.cache_save(key, paths),
674+ PostRun::Docker(run) => job.run_docker(run).0,
609675 };
610676 job.log.step_state(number, &post.name, "completed", Some(if ok { "success" } else { "failure" }));
611677 if !ok {
613679 }
614680 }
615681
682+ // GitHub's "Stop containers": services' logs, and everything removed.
683+ if job.has_containers() {
684+ let number = job.step_names.len() as u32 + 1;
685+ job.step_names.push("Stop containers".into());
686+ job.report_steps();
687+ job.log.step(number);
688+ job.log.step_state(number, "Stop containers", "in_progress", None);
689+ job.stop_containers();
690+ job.log.step_state(number, "Stop containers", "completed", Some("success"));
691+ }
692+
616693 // The job's outputs, read now that every step has run.
617694 let env = job.env_context(&frame);
618695 let contexts = job.contexts_for(&frame, &env);
+7−1
165165 log.line(&shown);
166166 }
167167 }
168− Err(mpsc::RecvTimeoutError::Timeout) => log.tick(),
168+ Err(mpsc::RecvTimeoutError::Timeout) => {
169+ // The job's Docker Engine starting, from its own thread.
170+ for note in crate::docker::take_notes() {
171+ log.line(&note);
172+ }
173+ log.tick();
174+ }
169175 Err(mpsc::RecvTimeoutError::Disconnected) => break,
170176 }
171177 if Instant::now() >= deadline {
+114−9
11 //! `uses:` steps: `actions/checkout` done natively against g1t, actions
2−//! fetched from GitHub and run as they are (JavaScript and composite), and
3−//! a few of GitHub's own whose services g1t does not have yet.
2+//! fetched from GitHub and run as they are (JavaScript, composite and
3+//! Docker), `docker://` images, and a few of GitHub's own whose services
4+//! g1t does not have yet.
45
56 use std::collections::BTreeMap;
67 use std::path::{Path, PathBuf};
1314 use g1t_actions::workflow::yaml_to_json;
1415 use serde_json::{Map, Value, json};
1516
17+use super::containers::{self, DockerRun};
1618 use super::files::StepFiles;
1719 use super::process::{self, Commands, Ended};
1820 use super::{Frame, Job, Post, PostRun};
201203 pub(crate) fn run_node(&mut self, action_dir: &Path, script: &str, env: &BTreeMap<String, String>) -> bool {
202204 let id = format!("node{}", super::rand_id());
203205 let Ok(files) = StepFiles::new(&self.temp, &id) else { return false };
204− let mut command = Command::new("node");
205− command.arg(action_dir.join(script)).current_dir(&self.workspace).env_clear().envs(self.process_env(env, &files));
206+ let full = self.process_env(env, &files);
207+ let script_path = action_dir.join(script);
208+ // In a job container whose image runs the runner's Node, the action
209+ // runs there, as on GitHub.
210+ let in_container = self.container.as_ref().filter(|c| c.node).map(|c| c.path.clone()).and_then(|image_path| {
211+ let inside = self.container_env(env, full.clone(), &image_path);
212+ self.in_container(containers::CONTAINER_NODE, &[script_path.display().to_string()], &self.workspace, inside)
213+ });
214+ let command = match in_container {
215+ Some(command) => command,
216+ None => {
217+ let mut command = Command::new("node");
218+ command.arg(&script_path).current_dir(&self.workspace).env_clear().envs(full);
219+ command
220+ }
221+ };
206222 let mut commands = Commands {
207223 debug: false,
208224 ..Commands::default()
237253 _timeout: Duration,
238254 ) -> (bool, BTreeMap<String, String>) {
239255 let uses = uses.trim();
240− if uses.starts_with("docker://") {
241− self.log.line(&format!("##[error]`{uses}`: Docker actions do not run on g1t yet."));
242− return (false, BTreeMap::new());
256+ if let Some(image) = uses.strip_prefix("docker://") {
257+ // `with.args` and `with.entrypoint` are the container's; every
258+ // input is also an `INPUT_` variable, as on GitHub.
259+ let mut step_env = env.clone();
260+ for (input, value) in with {
261+ step_env.insert(format!("INPUT_{}", input.replace(' ', "_").to_ascii_uppercase()), value.clone());
262+ }
263+ let run = DockerRun {
264+ image: image.to_owned(),
265+ entrypoint: with.get("entrypoint").filter(|e| !e.is_empty()).cloned(),
266+ args: with.get("args").map(|a| containers::split_words(a)).unwrap_or_default(),
267+ env: step_env,
268+ };
269+ let (ok, outputs, _) = self.run_docker(&run);
270+ return (ok, outputs);
243271 }
244272 let (name, git_ref) = uses.split_once('@').unwrap_or((uses, ""));
245273 let lower = name.to_ascii_lowercase();
274+ if lower == "docker/setup-buildx-action" && self.docker_hosted {
275+ return self.setup_buildx(with);
276+ }
246277 match lower.as_str() {
247278 "actions/checkout" => return self.checkout(with),
248279 "actions/upload-artifact" => return self.upload_artifact(with),
397428 return (ok, outputs);
398429 }
399430 if using == "docker" {
400− self.log.line(&format!("##[error]`{uses}` is a Docker action, which does not run on g1t yet."));
401− return (false, BTreeMap::new());
431+ return self.docker_action(uses, &dir, &runs, &inputs, &step_env, frame, title);
402432 }
403433 self.log.line(&format!("##[error]`{uses}` runs with `{using}`, which g1t does not know."));
404434 (false, BTreeMap::new())
405435 }
436+
437+ /// A Docker action: its image built from its Dockerfile (or pulled,
438+ /// for `docker://`), then run with its `args`, `entrypoint` and `env`,
439+ /// its inputs as `INPUT_` variables, and `pre-entrypoint` and
440+ /// `post-entrypoint` around it.
441+ #[allow(clippy::too_many_arguments)]
442+ fn docker_action(
443+ &mut self,
444+ uses: &str,
445+ dir: &Path,
446+ runs: &Value,
447+ inputs: &BTreeMap<String, String>,
448+ step_env: &BTreeMap<String, String>,
449+ frame: &Frame,
450+ title: &str,
451+ ) -> (bool, BTreeMap<String, String>) {
452+ let image = runs.get("image").map(expr::to_text).unwrap_or_default();
453+ let image = if let Some(pulled) = image.strip_prefix("docker://") {
454+ pulled.to_owned()
455+ } else if image.is_empty() {
456+ self.log.line(&format!("##[error]`{uses}` has no `runs.image`."));
457+ return (false, BTreeMap::new());
458+ } else {
459+ match self.build_action_image(dir, &image, uses) {
460+ Some(tag) => tag,
461+ None => return (false, BTreeMap::new()),
462+ }
463+ };
464+ // `args` and `env` read with the action's own inputs.
465+ let mut env = step_env.clone();
466+ for (input, value) in inputs {
467+ env.insert(format!("INPUT_{}", input.replace(' ', "_").to_ascii_uppercase()), value.clone());
468+ }
469+ let mut scope_frame = frame.clone();
470+ scope_frame.inputs = Some(Value::Object(inputs.iter().map(|(k, v)| (k.clone(), json!(v))).collect()));
471+ let contexts = self.contexts_for(&scope_frame, &env);
472+ if let Some(Value::Object(own)) = runs.get("env") {
473+ for (name, value) in own {
474+ let value = self.with_scope(&contexts, |scope| expr::interpolate_value(value, scope)).unwrap_or(Value::Null);
475+ env.insert(name.clone(), expr::to_text(&value));
476+ }
477+ }
478+ let args: Vec<String> = match runs.get("args") {
479+ Some(Value::Array(items)) => items
480+ .iter()
481+ .map(|item| {
482+ let value = self.with_scope(&contexts, |scope| expr::interpolate_value(item, scope)).unwrap_or(Value::Null);
483+ expr::to_text(&value)
484+ })
485+ .collect(),
486+ _ => Vec::new(),
487+ };
488+ let entry = |key: &str| runs.get(key).map(expr::to_text).filter(|e| !e.is_empty());
489+ if let Some(pre) = entry("pre-entrypoint") {
490+ let run = DockerRun { image: image.clone(), entrypoint: Some(pre), args: Vec::new(), env: env.clone() };
491+ if !self.run_docker(&run).0 {
492+ return (false, BTreeMap::new());
493+ }
494+ }
495+ let run = DockerRun { image: image.clone(), entrypoint: entry("entrypoint"), args, env: env.clone() };
496+ let (ok, outputs, state) = self.run_docker(&run);
497+ if let Some(post) = entry("post-entrypoint") {
498+ let mut post_env = env;
499+ for (name, value) in state {
500+ post_env.insert(format!("STATE_{name}"), value);
501+ }
502+ self.posts.push(Post {
503+ name: format!("Post {title}"),
504+ condition: runs.get("post-if").map(expr::to_text).unwrap_or_else(|| "always()".into()),
505+ env: BTreeMap::new(),
506+ run: PostRun::Docker(DockerRun { image, entrypoint: Some(post), args: Vec::new(), env: post_env }),
507+ });
508+ }
509+ (ok, outputs)
510+ }
406511 }
+727−0
1+//! The Engine API as a job sees it: `/var/run/docker.sock` is this proxy,
2+//! and the job's own Docker Engine is behind it. It passes everything
3+//! through, and changes the few requests that would not work in a
4+//! sandbox as they are:
5+//!
6+//! - **A container's network.** A sandbox cannot route a container's
7+//! bridge network out (Cloudflare Containers allow no iptables and no IP
8+//! forwarding), so containers join the job's own network (`host`), the
9+//! one its guardrails apply to. The names a container would have had on
10+//! its network (its name, its aliases, its Compose service) resolve to
11+//! 127.0.0.1 in later containers and in the job's own steps, and a port
12+//! published under another number (`-p 8080:80`) is forwarded to it.
13+//! `docker inspect` reports the ports as published, for tools that look
14+//! a container's port up.
15+//! - **Networks joined later** (`docker network connect`): the container
16+//! already has the job's network, so the request only adds its aliases.
17+//! - **The classic builder** (`DOCKER_BUILDKIT=0`): its `RUN` steps use the
18+//! job's network too. BuildKit's are handled where they start (oci.rs).
19+//! - **Miners**: a container whose image or command names one is refused,
20+//! as a step's script would be.
21+
22+use std::collections::{BTreeMap, BTreeSet};
23+use std::io::{self, BufReader, Read, Write};
24+use std::sync::mpsc;
25+use std::sync::{Arc, Mutex};
26+
27+use serde_json::{Map, Value, json};
28+
29+use super::http;
30+
31+/// What the proxy remembers across connections.
32+#[derive(Default)]
33+pub(crate) struct State {
34+ /// Every name a container has been given, which all now mean 127.0.0.1.
35+ pub(crate) aliases: BTreeSet<String>,
36+ /// Containers moved to the job's network: by id and by name, the ports
37+ /// they publish (`80/tcp` → the host port, as text).
38+ pub(crate) published: BTreeMap<String, BTreeMap<String, String>>,
39+}
40+
41+/// What the proxy asks of the sandbox around it.
42+pub(crate) trait Host: Send + Sync {
43+ /// Makes names resolve to 127.0.0.1 in the job's own steps.
44+ fn add_hosts(&self, names: &[String]);
45+ /// Forwards a host port to a container's port on the job's network.
46+ fn forward(&self, host_port: u16, container_port: u16);
47+ /// Makes sure the Engine is running; why not, if it cannot be.
48+ fn ensure_engine(&self) -> Result<(), String>;
49+ /// Connects to the Engine itself.
50+ fn connect(&self) -> io::Result<Box<dyn Duplex>>;
51+}
52+
53+/// A connection, readable and writable from two threads.
54+pub(crate) trait Duplex: Read + Write + Send {
55+ fn try_clone_box(&self) -> io::Result<Box<dyn Duplex>>;
56+ fn shutdown_both(&self);
57+ /// Ends what this side sends, and goes on reading.
58+ fn shutdown_write(&self);
59+}
60+
61+impl Duplex for std::net::TcpStream {
62+ fn try_clone_box(&self) -> io::Result<Box<dyn Duplex>> {
63+ Ok(Box::new(self.try_clone()?))
64+ }
65+ fn shutdown_both(&self) {
66+ let _ = self.shutdown(std::net::Shutdown::Both);
67+ }
68+ fn shutdown_write(&self) {
69+ let _ = self.shutdown(std::net::Shutdown::Write);
70+ }
71+}
72+
73+#[cfg(unix)]
74+impl Duplex for std::os::unix::net::UnixStream {
75+ fn try_clone_box(&self) -> io::Result<Box<dyn Duplex>> {
76+ Ok(Box::new(self.try_clone()?))
77+ }
78+ fn shutdown_both(&self) {
79+ let _ = self.shutdown(std::net::Shutdown::Both);
80+ }
81+ fn shutdown_write(&self) {
82+ let _ = self.shutdown(std::net::Shutdown::Write);
83+ }
84+}
85+
86+/// The Engine's routes start with an optional `/v1.NN`.
87+fn route(target: &str) -> (&str, &str) {
88+ let (path, query) = target.split_once('?').unwrap_or((target, ""));
89+ let path = match path.strip_prefix("/v") {
90+ Some(rest) if rest.starts_with(|c: char| c.is_ascii_digit()) => rest.find('/').map_or(path, |slash| &rest[slash..]),
91+ _ => path,
92+ };
93+ (path, query)
94+}
95+
96+fn query_value<'a>(query: &'a str, name: &str) -> Option<&'a str> {
97+ query.split('&').find_map(|pair| pair.split_once('=').filter(|(key, _)| *key == name).map(|(_, value)| value))
98+}
99+
100+fn decode(text: &str) -> String {
101+ let bytes = text.as_bytes();
102+ let mut out = Vec::with_capacity(bytes.len());
103+ let mut i = 0;
104+ while i < bytes.len() {
105+ let hex = |b: u8| (b as char).to_digit(16);
106+ match bytes[i] {
107+ b'%' if i + 2 < bytes.len() && hex(bytes[i + 1]).is_some() && hex(bytes[i + 2]).is_some() => {
108+ out.push((hex(bytes[i + 1]).unwrap_or(0) * 16 + hex(bytes[i + 2]).unwrap_or(0)) as u8);
109+ i += 3;
110+ continue;
111+ }
112+ b'+' => out.push(b' '),
113+ byte => out.push(byte),
114+ }
115+ i += 1;
116+ }
117+ String::from_utf8_lossy(&out).into_owned()
118+}
119+
120+/// What a request is, to the proxy.
121+#[derive(Debug, PartialEq)]
122+pub(crate) enum Kind {
123+ CreateContainer { name: Option<String> },
124+ InspectContainer { id: String },
125+ ConnectNetwork,
126+ DisconnectNetwork,
127+ ClassicBuild,
128+ Other,
129+}
130+
131+pub(crate) fn classify(method: &str, target: &str) -> Kind {
132+ let (path, query) = route(target);
133+ let parts: Vec<&str> = path.trim_matches('/').split('/').collect();
134+ match (method, parts.as_slice()) {
135+ ("POST", ["containers", "create"]) => Kind::CreateContainer { name: query_value(query, "name").map(decode).filter(|n| !n.is_empty()) },
136+ ("GET", ["containers", id, "json"]) => Kind::InspectContainer { id: decode(id) },
137+ ("POST", ["networks", _, "connect"]) => Kind::ConnectNetwork,
138+ ("POST", ["networks", _, "disconnect"]) => Kind::DisconnectNetwork,
139+ ("POST", ["build"]) => Kind::ClassicBuild,
140+ _ => Kind::Other,
141+ }
142+}
143+
144+/// Whether a network mode is one a sandbox can run as it is.
145+fn keeps_network(mode: &str) -> bool {
146+ matches!(mode, "host" | "none") || mode.starts_with("container:")
147+}
148+
149+/// Whether a name can stand in `/etc/hosts`.
150+fn host_name(name: &str) -> bool {
151+ !name.is_empty()
152+ && name.len() <= 253
153+ && name != "localhost"
154+ && name.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '.' | '_'))
155+ && !name.starts_with(['-', '.'])
156+}
157+
158+/// What changing a container's create request came to.
159+#[derive(Debug, Default, PartialEq)]
160+pub(crate) struct Created {
161+ /// The network it asked for, now the job's.
162+ pub(crate) moved_from: Option<String>,
163+ /// Its own names, now meaning 127.0.0.1.
164+ pub(crate) aliases: Vec<String>,
165+ /// `(host port, container port)` pairs to forward.
166+ pub(crate) forwards: Vec<(u16, u16)>,
167+ /// What `docker inspect` should say it publishes.
168+ pub(crate) published: BTreeMap<String, String>,
169+}
170+
171+fn strings(value: Option<&Value>) -> Vec<String> {
172+ value.and_then(Value::as_array).map(|items| items.iter().filter_map(|v| v.as_str().map(str::to_owned)).collect()).unwrap_or_default()
173+}
174+
175+/// Moves a container to the job's network, unless it asked for `host`,
176+/// `none` or another container's. `known` is every name given so far.
177+pub(crate) fn rewrite_create(body: &mut Value, name: Option<&str>, known: &BTreeSet<String>) -> Created {
178+ let mut created = Created::default();
179+ let Some(config) = body.as_object_mut() else { return created };
180+ let host_config = config.entry("HostConfig").or_insert_with(|| json!({}));
181+ if !host_config.is_object() {
182+ *host_config = json!({});
183+ }
184+ let mode = host_config.get("NetworkMode").and_then(Value::as_str).unwrap_or_default().to_owned();
185+ if keeps_network(&mode) {
186+ return created;
187+ }
188+ created.moved_from = Some(if mode.is_empty() || mode == "default" { "bridge".to_owned() } else { mode.clone() });
189+
190+ // Its names: its own, its aliases on each network, its links' aliases
191+ // and its Compose service.
192+ let mut aliases: Vec<String> = Vec::new();
193+ if let Some(name) = name {
194+ aliases.push(name.trim_start_matches('/').to_owned());
195+ }
196+ if let Some(Value::Object(endpoints)) = config.get("NetworkingConfig").and_then(|n| n.get("EndpointsConfig")) {
197+ for endpoint in endpoints.values() {
198+ aliases.extend(strings(endpoint.get("Aliases")));
199+ aliases.extend(strings(endpoint.get("DNSNames")));
200+ }
201+ }
202+ if let Some(service) = config.get("Labels").and_then(|l| l.get("com.docker.compose.service")).and_then(Value::as_str) {
203+ aliases.push(service.to_owned());
204+ }
205+ let host_config = config.get_mut("HostConfig").and_then(Value::as_object_mut).expect("made above");
206+ for link in strings(host_config.get("Links")) {
207+ // `name:alias`, or `/name:/container/alias` as the Engine stores them.
208+ if let Some((_, alias)) = link.split_once(':') {
209+ aliases.push(alias.rsplit('/').next().unwrap_or(alias).to_owned());
210+ }
211+ }
212+ let mut seen = BTreeSet::new();
213+ aliases.retain(|alias| host_name(alias) && seen.insert(alias.clone()));
214+ created.aliases = aliases.clone();
215+
216+ // Ports: published under the same number, they need nothing; under
217+ // another, a forward. A port left to the Engine to choose is the
218+ // container's own.
219+ if let Some(Value::Object(bindings)) = host_config.get("PortBindings") {
220+ for (port, hosts) in bindings {
221+ let (number, protocol) = port.split_once('/').unwrap_or((port, "tcp"));
222+ let Ok(container_port) = number.parse::<u16>() else { continue };
223+ let host_port = hosts
224+ .as_array()
225+ .and_then(|list| list.iter().find_map(|h| h.get("HostPort").and_then(Value::as_str).filter(|p| !p.is_empty())))
226+ .and_then(|p| p.parse::<u16>().ok())
227+ .unwrap_or(container_port);
228+ created.published.insert(format!("{container_port}/{protocol}"), host_port.to_string());
229+ if host_port != container_port && protocol == "tcp" {
230+ created.forwards.push((host_port, container_port));
231+ }
232+ }
233+ }
234+
235+ host_config.insert("NetworkMode".into(), json!("host"));
236+ host_config.remove("Links");
237+ host_config.insert("PublishAllPorts".into(), json!(false));
238+ let mut extra = strings(host_config.get("ExtraHosts"));
239+ for alias in known.iter().chain(aliases.iter()) {
240+ let entry = format!("{alias}:127.0.0.1");
241+ if !extra.iter().any(|e| e.split(':').next() == Some(alias.as_str())) {
242+ extra.push(entry);
243+ }
244+ }
245+ host_config.insert("ExtraHosts".into(), json!(extra));
246+ config.remove("NetworkingConfig");
247+ config.remove("MacAddress");
248+ created
249+}
250+
251+/// What `docker network connect` adds: the container's aliases there.
252+pub(crate) fn connect_aliases(body: &Value) -> Vec<String> {
253+ let mut aliases = strings(body.get("EndpointConfig").and_then(|e| e.get("Aliases")));
254+ aliases.extend(strings(body.get("EndpointConfig").and_then(|e| e.get("DNSNames"))));
255+ aliases.retain(|alias| host_name(alias));
256+ aliases
257+}
258+
259+/// A container's inspection, with the ports it publishes filled in.
260+pub(crate) fn rewrite_inspect(body: &mut Value, published: &BTreeMap<String, String>) {
261+ let ports: Map<String, Value> = published
262+ .iter()
263+ .map(|(port, host)| (port.clone(), json!([{ "HostIp": "0.0.0.0", "HostPort": host }])))
264+ .collect();
265+ if let Some(settings) = body.get_mut("NetworkSettings").and_then(Value::as_object_mut) {
266+ settings.insert("Ports".into(), Value::Object(ports));
267+ }
268+}
269+
270+/// The classic builder's `RUN` steps on the job's network.
271+pub(crate) fn rewrite_build(target: &str) -> String {
272+ let (path, query) = target.split_once('?').unwrap_or((target, ""));
273+ let mode = query_value(query, "networkmode").unwrap_or_default();
274+ if keeps_network(mode) {
275+ return target.to_owned();
276+ }
277+ let mut pairs: Vec<&str> = query.split('&').filter(|pair| !pair.is_empty() && !pair.starts_with("networkmode=")).collect();
278+ pairs.push("networkmode=host");
279+ format!("{path}?{}", pairs.join("&"))
280+}
281+
282+/// What the response thread is to do with the next response.
283+enum Pending {
284+ /// Pass it through.
285+ Plain { method: String },
286+ /// A container was created: note its id against its ports.
287+ Created { name: Option<String>, published: BTreeMap<String, String> },
288+ /// Fill in an inspection's ports.
289+ Inspect { published: BTreeMap<String, String> },
290+ /// Answer it here: the Engine was never asked.
291+ Answer(Vec<u8>),
292+ /// The connection leaves HTTP after this response.
293+ Upgrade,
294+}
295+
296+/// Serves one connection from a client, until either side closes it.
297+pub(crate) fn serve(client: Box<dyn Duplex>, host: Arc<dyn Host>, state: Arc<Mutex<State>>) {
298+ if let Err(problem) = host.ensure_engine() {
299+ let mut client = client;
300+ let mut reader = BufReader::new(client.try_clone_box().expect("a clone"));
301+ // Answer whatever was asked, so the CLI says why.
302+ if let Ok(Some(head)) = http::read_head(&mut reader) {
303+ let _ = http::read_body(&mut reader, http::request_body(&head));
304+ let _ = client.write_all(&http::json_response(503, "Service Unavailable", &json!({ "message": problem })));
305+ }
306+ client.shutdown_both();
307+ return;
308+ }
309+ let upstream = match host.connect() {
310+ Ok(upstream) => upstream,
311+ Err(_) => {
312+ client.shutdown_both();
313+ return;
314+ }
315+ };
316+ let (Ok(client_writer), Ok(upstream_reader)) = (client.try_clone_box(), upstream.try_clone_box()) else { return };
317+ let (sender, pending) = mpsc::channel::<Pending>();
318+ let responses = {
319+ let state = state.clone();
320+ std::thread::spawn(move || answer(upstream_reader, client_writer, pending, state))
321+ };
322+ let _ = forward(client, upstream, sender, &host, &state);
323+ let _ = responses.join();
324+}
325+
326+/// Requests, client to Engine.
327+fn forward(client: Box<dyn Duplex>, mut upstream: Box<dyn Duplex>, pending: mpsc::Sender<Pending>, host: &Arc<dyn Host>, state: &Arc<Mutex<State>>) -> io::Result<()> {
328+ let closer = client.try_clone_box()?;
329+ let upstream_closer = upstream.try_clone_box()?;
330+ let mut reader = BufReader::new(client);
331+ let result = (|| -> io::Result<()> {
332+ loop {
333+ let Some(mut head) = http::read_head(&mut reader)? else { return Ok(()) };
334+ let body = http::request_body(&head);
335+ let method = head.method().to_owned();
336+ match classify(&method, head.target()) {
337+ Kind::CreateContainer { name } => {
338+ let raw = http::read_body(&mut reader, body)?;
339+ let Ok(mut config) = serde_json::from_slice::<Value>(&raw) else {
340+ upstream.write_all(&http::with_length(head, &raw))?;
341+ let _ = pending.send(Pending::Plain { method });
342+ continue;
343+ };
344+ if let Some(miner) = miner_in_config(&config) {
345+ let refusal = json!({ "message": format!("g1t does not run cryptocurrency miners ({miner}). This container was not created.") });
346+ let _ = pending.send(Pending::Answer(http::json_response(403, "Forbidden", &refusal)));
347+ continue;
348+ }
349+ let known = state.lock().map(|s| s.aliases.clone()).unwrap_or_default();
350+ let created = rewrite_create(&mut config, name.as_deref(), &known);
351+ if created.moved_from.is_some() {
352+ let fresh: Vec<String> = created.aliases.iter().filter(|a| !known.contains(*a)).cloned().collect();
353+ if let Ok(mut state) = state.lock() {
354+ state.aliases.extend(created.aliases.iter().cloned());
355+ // By name now; by id once the Engine says it.
356+ if let Some(name) = &name {
357+ state.published.insert(name.trim_start_matches('/').to_owned(), created.published.clone());
358+ }
359+ }
360+ if !fresh.is_empty() {
361+ host.add_hosts(&fresh);
362+ }
363+ for (host_port, container_port) in &created.forwards {
364+ host.forward(*host_port, *container_port);
365+ }
366+ }
367+ let text = serde_json::to_vec(&config).unwrap_or(raw);
368+ upstream.write_all(&http::with_length(head, &text))?;
369+ let _ = pending.send(if created.moved_from.is_some() {
370+ Pending::Created { name, published: created.published }
371+ } else {
372+ Pending::Plain { method }
373+ });
374+ }
375+ Kind::InspectContainer { id } => {
376+ let published = state.lock().ok().and_then(|s| published_for(&s, &id));
377+ upstream.write_all(&head.to_bytes())?;
378+ http::copy_body(&mut reader, &mut upstream, body)?;
379+ let _ = pending.send(match published {
380+ Some(published) => Pending::Inspect { published },
381+ None => Pending::Plain { method },
382+ });
383+ }
384+ kind @ (Kind::ConnectNetwork | Kind::DisconnectNetwork) => {
385+ let raw = http::read_body(&mut reader, body)?;
386+ let value: Value = serde_json::from_slice(&raw).unwrap_or(Value::Null);
387+ let container = value.get("Container").and_then(Value::as_str).unwrap_or_default().to_owned();
388+ let moved = state.lock().ok().is_some_and(|s| published_for(&s, &container).is_some());
389+ if moved {
390+ // The container is on the job's network already.
391+ if kind == Kind::ConnectNetwork {
392+ let aliases = connect_aliases(&value);
393+ let fresh: Vec<String> = match state.lock() {
394+ Ok(mut state) => aliases.into_iter().filter(|a| state.aliases.insert(a.clone())).collect(),
395+ Err(_) => Vec::new(),
396+ };
397+ if !fresh.is_empty() {
398+ host.add_hosts(&fresh);
399+ }
400+ }
401+ let _ = pending.send(Pending::Answer(http::empty_ok()));
402+ } else {
403+ upstream.write_all(&http::with_length(head, &raw))?;
404+ let _ = pending.send(Pending::Plain { method });
405+ }
406+ }
407+ Kind::ClassicBuild => {
408+ let target = rewrite_build(head.target());
409+ head.set_target(&target);
410+ upstream.write_all(&head.to_bytes())?;
411+ http::copy_body(&mut reader, &mut upstream, body)?;
412+ let _ = pending.send(Pending::Plain { method });
413+ }
414+ Kind::Other => {
415+ let upgrade = head.upgrades();
416+ upstream.write_all(&head.to_bytes())?;
417+ if upgrade {
418+ let _ = pending.send(Pending::Upgrade);
419+ // From here the two ends speak to each other.
420+ io::copy(&mut reader, &mut upstream)?;
421+ upstream_closer.shutdown_write();
422+ return Ok(());
423+ }
424+ http::copy_body(&mut reader, &mut upstream, body)?;
425+ let _ = pending.send(Pending::Plain { method });
426+ }
427+ }
428+ }
429+ })();
430+ // The client is done asking; the Engine's answers may still be coming.
431+ drop(pending);
432+ if result.is_err() {
433+ closer.shutdown_both();
434+ upstream_closer.shutdown_both();
435+ }
436+ result
437+}
438+
439+/// Responses, Engine to client, in the order they were asked for.
440+fn answer(upstream: Box<dyn Duplex>, mut client: Box<dyn Duplex>, pending: mpsc::Receiver<Pending>, state: Arc<Mutex<State>>) {
441+ let closer = upstream.try_clone_box().ok();
442+ let mut reader = BufReader::new(upstream);
443+ let result = (|| -> io::Result<()> {
444+ while let Ok(next) = pending.recv() {
445+ if let Pending::Answer(bytes) = next {
446+ client.write_all(&bytes)?;
447+ client.flush()?;
448+ continue;
449+ }
450+ let Some(head) = http::read_head(&mut reader)? else { return Ok(()) };
451+ match next {
452+ Pending::Upgrade => {
453+ client.write_all(&head.to_bytes())?;
454+ client.flush()?;
455+ io::copy(&mut reader, &mut client)?;
456+ client.shutdown_write();
457+ return Err(io::Error::other("upgraded"));
458+ }
459+ Pending::Plain { method } => {
460+ let body = http::response_body(&head, &method);
461+ client.write_all(&head.to_bytes())?;
462+ http::copy_body(&mut reader, &mut client, body)?;
463+ }
464+ Pending::Created { name, published } => {
465+ let body = http::read_body(&mut reader, http::response_body(&head, "POST"))?;
466+ if (200..300).contains(&head.status())
467+ && let Some(id) = serde_json::from_slice::<Value>(&body).ok().and_then(|v| v.get("Id").and_then(Value::as_str).map(str::to_owned))
468+ && let Ok(mut state) = state.lock()
469+ {
470+ state.published.insert(id, published.clone());
471+ if let Some(name) = name {
472+ state.published.insert(name.trim_start_matches('/').to_owned(), published);
473+ }
474+ }
475+ client.write_all(&http::with_length(head, &body))?;
476+ }
477+ Pending::Inspect { published } => {
478+ let body = http::read_body(&mut reader, http::response_body(&head, "GET"))?;
479+ let rewritten = match serde_json::from_slice::<Value>(&body) {
480+ Ok(mut value) if head.status() == 200 => {
481+ rewrite_inspect(&mut value, &published);
482+ serde_json::to_vec(&value).unwrap_or(body)
483+ }
484+ _ => body,
485+ };
486+ client.write_all(&http::with_length(head, &rewritten))?;
487+ }
488+ Pending::Answer(_) => unreachable!("answered above"),
489+ }
490+ client.flush()?;
491+ }
492+ Ok(())
493+ })();
494+ if matches!(&result, Err(error) if error.to_string() == "upgraded") {
495+ // A hijacked connection ends when both ends have said so.
496+ return;
497+ }
498+ client.shutdown_both();
499+ if let Some(closer) = closer {
500+ closer.shutdown_both();
501+ }
502+}
503+
504+/// The ports of a container moved to the job's network, by its id, the
505+/// start of its id, or its name.
506+fn published_for(state: &State, id: &str) -> Option<BTreeMap<String, String>> {
507+ let id = id.trim_start_matches('/');
508+ if id.is_empty() {
509+ return None;
510+ }
511+ if let Some(found) = state.published.get(id) {
512+ return Some(found.clone());
513+ }
514+ if id.len() >= 6 && id.chars().all(|c| c.is_ascii_hexdigit()) {
515+ let mut matches = state.published.iter().filter(|(key, _)| key.len() == 64 && key.starts_with(id));
516+ if let (Some((_, found)), None) = (matches.next(), matches.next()) {
517+ return Some(found.clone());
518+ }
519+ }
520+ None
521+}
522+
523+/// The miner a container's image or command names, if any.
524+fn miner_in_config(config: &Value) -> Option<&'static str> {
525+ let mut words = vec![config.get("Image").and_then(Value::as_str).unwrap_or_default().to_owned()];
526+ for key in ["Entrypoint", "Cmd"] {
527+ match config.get(key) {
528+ Some(Value::String(text)) => words.push(text.clone()),
529+ other => words.extend(strings(other)),
530+ }
531+ }
532+ crate::abuse::miner_in(&words.join(" "))
533+}
534+
535+#[cfg(test)]
536+mod tests {
537+ use super::*;
538+
539+ #[test]
540+ fn routes_are_read_with_or_without_a_version() {
541+ assert_eq!(classify("POST", "/v1.47/containers/create?name=db"), Kind::CreateContainer { name: Some("db".into()) });
542+ assert_eq!(classify("POST", "/containers/create"), Kind::CreateContainer { name: None });
543+ assert_eq!(classify("GET", "/v1.51/containers/abc123/json?size=false"), Kind::InspectContainer { id: "abc123".into() });
544+ assert_eq!(classify("POST", "/v1.47/networks/app_default/connect"), Kind::ConnectNetwork);
545+ assert_eq!(classify("POST", "/v1.47/build?t=x"), Kind::ClassicBuild);
546+ assert_eq!(classify("GET", "/v1.47/containers/json"), Kind::Other);
547+ assert_eq!(classify("POST", "/v1.47/containers/abc/start"), Kind::Other);
548+ }
549+
550+ #[test]
551+ fn a_compose_service_moves_to_the_jobs_network_and_keeps_its_names() {
552+ let mut body = json!({
553+ "Image": "postgres:17",
554+ "Labels": { "com.docker.compose.service": "db", "com.docker.compose.project": "app" },
555+ "HostConfig": {
556+ "NetworkMode": "app_default",
557+ "PortBindings": { "5432/tcp": [{ "HostIp": "", "HostPort": "15432" }], "8080/tcp": [{ "HostPort": "" }] },
558+ "Links": ["/cache:/app-db-1/redis"],
559+ "ExtraHosts": ["host.docker.internal:host-gateway"],
560+ },
561+ "NetworkingConfig": { "EndpointsConfig": { "app_default": { "Aliases": ["db", "app-db-1"], "MacAddress": "x" } } },
562+ });
563+ let known: BTreeSet<String> = ["cache".to_owned()].into();
564+ let created = rewrite_create(&mut body, Some("app-db-1"), &known);
565+ assert_eq!(created.moved_from.as_deref(), Some("app_default"));
566+ assert_eq!(created.aliases, vec!["app-db-1", "db", "redis"]);
567+ assert_eq!(created.forwards, vec![(15432, 5432)]);
568+ assert_eq!(created.published["5432/tcp"], "15432");
569+ assert_eq!(created.published["8080/tcp"], "8080");
570+ assert_eq!(body["HostConfig"]["NetworkMode"], "host");
571+ assert!(body.get("NetworkingConfig").is_none());
572+ assert!(body["HostConfig"].get("Links").is_none());
573+ let extra = strings(body["HostConfig"].get("ExtraHosts"));
574+ assert!(extra.contains(&"host.docker.internal:host-gateway".to_owned()));
575+ for name in ["cache", "db", "app-db-1", "redis"] {
576+ assert!(extra.contains(&format!("{name}:127.0.0.1")), "{name} in {extra:?}");
577+ }
578+ }
579+
580+ #[test]
581+ fn host_none_and_shared_networks_are_left_alone() {
582+ for mode in ["host", "none", "container:abc"] {
583+ let mut body = json!({ "Image": "alpine", "HostConfig": { "NetworkMode": mode } });
584+ let before = body.clone();
585+ assert_eq!(rewrite_create(&mut body, Some("x"), &BTreeSet::new()), Created::default());
586+ assert_eq!(body, before);
587+ }
588+ // The default network, named or not.
589+ let mut body = json!({ "Image": "alpine" });
590+ assert_eq!(rewrite_create(&mut body, None, &BTreeSet::new()).moved_from.as_deref(), Some("bridge"));
591+ assert_eq!(body["HostConfig"]["NetworkMode"], "host");
592+ }
593+
594+ #[test]
595+ fn names_that_cannot_be_hosts_are_skipped() {
596+ let mut body = json!({ "HostConfig": {}, "NetworkingConfig": { "EndpointsConfig": { "n": { "Aliases": ["ok-name", "bad name", "localhost", ""] } } } });
597+ assert_eq!(rewrite_create(&mut body, None, &BTreeSet::new()).aliases, vec!["ok-name"]);
598+ }
599+
600+ #[test]
601+ fn inspections_report_the_ports_published() {
602+ let mut body = json!({ "Id": "abc", "NetworkSettings": { "Ports": {}, "Networks": { "host": {} } } });
603+ rewrite_inspect(&mut body, &[("5432/tcp".to_owned(), "15432".to_owned())].into());
604+ assert_eq!(body["NetworkSettings"]["Ports"]["5432/tcp"][0]["HostPort"], "15432");
605+ }
606+
607+ #[test]
608+ fn the_classic_builder_runs_on_the_jobs_network() {
609+ assert_eq!(rewrite_build("/v1.47/build?t=app&networkmode=default"), "/v1.47/build?t=app&networkmode=host");
610+ assert_eq!(rewrite_build("/build"), "/build?networkmode=host");
611+ assert_eq!(rewrite_build("/build?networkmode=none"), "/build?networkmode=none");
612+ }
613+
614+ #[test]
615+ fn ids_are_found_by_their_start_or_a_name() {
616+ let mut state = State::default();
617+ let id = "a".repeat(64);
618+ state.published.insert(id.clone(), [("80/tcp".to_owned(), "8080".to_owned())].into());
619+ state.published.insert("web".into(), [("80/tcp".to_owned(), "8080".to_owned())].into());
620+ assert!(published_for(&state, "aaaaaaaaaaaa").is_some());
621+ assert!(published_for(&state, "/web").is_some());
622+ assert!(published_for(&state, "aaa").is_none());
623+ assert!(published_for(&state, "other").is_none());
624+ }
625+
626+ #[test]
627+ fn miners_are_refused_by_image_or_command() {
628+ assert!(miner_in_config(&json!({ "Image": "metal3d/xmrig" })).is_some());
629+ assert!(miner_in_config(&json!({ "Image": "alpine", "Cmd": ["sh", "-c", "./xmrig -o stratum+tcp://pool"] })).is_some());
630+ assert!(miner_in_config(&json!({ "Image": "postgres:17", "Cmd": ["postgres"] })).is_none());
631+ }
632+
633+ /// A pretend Engine on TCP, and the proxy in front of it, end to end.
634+ #[test]
635+ fn requests_and_responses_pass_through_in_order() {
636+ use std::net::{TcpListener, TcpStream};
637+ let engine = TcpListener::bind("127.0.0.1:0").unwrap();
638+ let engine_addr = engine.local_addr().unwrap();
639+ // The Engine: a create, an inspect, a chunked stream, then a hijack.
640+ let fake = std::thread::spawn(move || {
641+ let (stream, _) = engine.accept().unwrap();
642+ let mut reader = BufReader::new(stream.try_clone().unwrap());
643+ let mut writer = stream;
644+ let mut seen = Vec::new();
645+ while let Some(head) = http::read_head(&mut reader).unwrap() {
646+ let body = http::read_body(&mut reader, http::request_body(&head)).unwrap();
647+ seen.push((head.start.clone(), String::from_utf8_lossy(&body).into_owned()));
648+ if head.target().contains("/containers/create") {
649+ writer.write_all(&http::json_response(201, "Created", &json!({ "Id": "c".repeat(64), "Warnings": [] }))).unwrap();
650+ } else if head.target().contains("/json") {
651+ writer
652+ .write_all(b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nTransfer-Encoding: chunked\r\n\r\n")
653+ .unwrap();
654+ let text = json!({ "Id": "c".repeat(64), "NetworkSettings": { "Ports": {} } }).to_string();
655+ writer.write_all(format!("{:x}\r\n{text}\r\n0\r\n\r\n", text.len()).as_bytes()).unwrap();
656+ } else if head.target().contains("/logs") {
657+ writer.write_all(b"HTTP/1.1 200 OK\r\nTransfer-Encoding: chunked\r\n\r\n3\r\none\r\n3\r\ntwo\r\n0\r\n\r\n").unwrap();
658+ } else if head.upgrades() {
659+ writer.write_all(b"HTTP/1.1 101 UPGRADED\r\nConnection: Upgrade\r\nUpgrade: tcp\r\n\r\n").unwrap();
660+ let mut buffer = [0u8; 5];
661+ reader.read_exact(&mut buffer).unwrap();
662+ writer.write_all(&buffer).unwrap();
663+ break;
664+ }
665+ }
666+ seen
667+ });
668+
669+ struct Fake(std::net::SocketAddr, Mutex<Vec<(u16, u16)>>, Mutex<Vec<String>>);
670+ impl Host for Fake {
671+ fn add_hosts(&self, names: &[String]) {
672+ self.2.lock().unwrap().extend(names.iter().cloned());
673+ }
674+ fn forward(&self, host_port: u16, container_port: u16) {
675+ self.1.lock().unwrap().push((host_port, container_port));
676+ }
677+ fn ensure_engine(&self) -> Result<(), String> {
678+ Ok(())
679+ }
680+ fn connect(&self) -> io::Result<Box<dyn Duplex>> {
681+ Ok(Box::new(TcpStream::connect(self.0)?))
682+ }
683+ }
684+ let host = Arc::new(Fake(engine_addr, Mutex::new(Vec::new()), Mutex::new(Vec::new())));
685+ let state = Arc::new(Mutex::new(State::default()));
686+ let proxy = TcpListener::bind("127.0.0.1:0").unwrap();
687+ let proxy_addr = proxy.local_addr().unwrap();
688+ let (host2, state2) = (host.clone() as Arc<dyn Host>, state.clone());
689+ std::thread::spawn(move || {
690+ let (stream, _) = proxy.accept().unwrap();
691+ serve(Box::new(stream), host2, state2);
692+ });
693+
694+ let client = TcpStream::connect(proxy_addr).unwrap();
695+ let mut reader = BufReader::new(client.try_clone().unwrap());
696+ let mut writer = client;
697+ let create = json!({ "Image": "redis", "HostConfig": { "PortBindings": { "6379/tcp": [{ "HostPort": "16379" }] } }, "NetworkingConfig": { "EndpointsConfig": { "job": { "Aliases": ["redis"] } } } }).to_string();
698+ // Pipelined: all asked before any answer is read.
699+ writer
700+ .write_all(format!("POST /v1.47/containers/create?name=cache HTTP/1.1\r\nHost: docker\r\nContent-Type: application/json\r\nContent-Length: {}\r\n\r\n{create}", create.len()).as_bytes())
701+ .unwrap();
702+ writer.write_all(b"GET /v1.47/containers/cache/json HTTP/1.1\r\nHost: docker\r\n\r\n").unwrap();
703+ writer.write_all(b"GET /v1.47/containers/cache/logs?follow=1 HTTP/1.1\r\nHost: docker\r\n\r\n").unwrap();
704+ writer.write_all(b"POST /v1.47/containers/cache/attach?stream=1 HTTP/1.1\r\nHost: docker\r\nConnection: Upgrade\r\nUpgrade: tcp\r\n\r\nhello").unwrap();
705+
706+ let created = http::read_head(&mut reader).unwrap().unwrap();
707+ assert_eq!(created.status(), 201);
708+ let body: Value = serde_json::from_slice(&http::read_body(&mut reader, http::response_body(&created, "POST")).unwrap()).unwrap();
709+ assert_eq!(body["Id"].as_str().unwrap().len(), 64);
710+ let inspected = http::read_head(&mut reader).unwrap().unwrap();
711+ let body: Value = serde_json::from_slice(&http::read_body(&mut reader, http::response_body(&inspected, "GET")).unwrap()).unwrap();
712+ assert_eq!(body["NetworkSettings"]["Ports"]["6379/tcp"][0]["HostPort"], "16379");
713+ let logs = http::read_head(&mut reader).unwrap().unwrap();
714+ assert_eq!(http::read_body(&mut reader, http::response_body(&logs, "GET")).unwrap(), b"onetwo");
715+ let upgraded = http::read_head(&mut reader).unwrap().unwrap();
716+ assert_eq!(upgraded.status(), 101);
717+ let mut echoed = [0u8; 5];
718+ reader.read_exact(&mut echoed).unwrap();
719+ assert_eq!(&echoed, b"hello");
720+
721+ let seen = fake.join().unwrap();
722+ assert!(seen[0].1.contains("\"NetworkMode\":\"host\""), "{}", seen[0].1);
723+ assert!(seen[0].1.contains("redis:127.0.0.1"));
724+ assert_eq!(*host.1.lock().unwrap(), vec![(16379, 6379)]);
725+ assert_eq!(*host.2.lock().unwrap(), vec!["cache".to_owned(), "redis".to_owned()]);
726+ }
727+}
+414−0
1+//! The job's own Docker Engine: set up when the job starts (a socket, and
2+//! nothing running), and started the first time the socket is used, or a
3+//! job's `services:` or `container:` need it.
4+//!
5+//! What Cloudflare Containers allow, and so how it is started: `dockerd`
6+//! as root (a rootless Engine does not start there), with
7+//! `--iptables=false --ip6tables=false --ip-forward=false`, since a sandbox
8+//! may not change its packet filter or forward packets. Containers on a
9+//! bridge network therefore have no way out, which is why the API proxy
10+//! puts them on the job's network (api.rs). The Engine's data goes on the
11+//! sandbox's disk with the overlay filesystem when the disk takes it, and
12+//! with plain copies (containerd's `native` snapshotter) when it does not. Docker Hub's images come
13+//! through Google's public mirror of it first, so jobs from many machines
14+//! sharing addresses do not run into Docker Hub's anonymous limits.
15+
16+use std::path::Path;
17+
18+use serde_json::{Value, json};
19+
20+/// Everything of the Engine's that is not its data.
21+pub(crate) const DIR: &str = "/run/g1t-docker";
22+/// What the job's steps reach: the API proxy.
23+pub(crate) const PROXY_SOCKET: &str = "/run/g1t-docker/docker.sock";
24+/// The Engine itself.
25+pub(crate) const ENGINE_SOCKET: &str = "/run/g1t-docker/engine.sock";
26+/// Where the job's steps look for Docker.
27+pub(crate) const DOCKER_SOCKET: &str = "/var/run/docker.sock";
28+pub(crate) const MIRROR: &str = "https://mirror.gcr.io";
29+
30+/// What the job tells the Engine when it sets it up.
31+#[derive(Clone, Default)]
32+pub(crate) struct Options {
33+ /// g1t's container registry (`g1t.sh`) and the run's token, to be
34+ /// signed in to from the start. None for a run without secrets.
35+ pub(crate) registry: Option<(String, String)>,
36+}
37+
38+/// The Engine's `daemon.json`. `group`: the group whose members may use
39+/// its socket (the job's user). `copies`: the disk does not take overlays.
40+pub(crate) fn daemon_config(group: &str, copies: bool) -> Value {
41+ let mut config = json!({
42+ "hosts": [format!("unix://{ENGINE_SOCKET}")],
43+ "group": group,
44+ "pidfile": format!("{DIR}/dockerd.pid"),
45+ "iptables": false,
46+ "ip6tables": false,
47+ "ip-forward": false,
48+ "registry-mirrors": [MIRROR],
49+ "log-driver": "json-file",
50+ "log-opts": { "max-size": "20m", "max-file": "2" },
51+ });
52+ if copies {
53+ // The containerd image store's name for plain copies (vfs).
54+ config["storage-driver"] = json!("native");
55+ }
56+ config
57+}
58+
59+/// `~/.docker/config.json`, signed in to `registry` with `token` unless it
60+/// is already signed in there.
61+pub(crate) fn with_login(mut config: Value, registry: &str, token: &str) -> Option<Value> {
62+ use base64::Engine;
63+ if !config.is_object() {
64+ config = json!({});
65+ }
66+ let auths = config.as_object_mut()?.entry("auths").or_insert_with(|| json!({}));
67+ let auths = auths.as_object_mut()?;
68+ if auths.contains_key(registry) {
69+ return None;
70+ }
71+ let auth = base64::engine::general_purpose::STANDARD.encode(format!("g1t:{token}"));
72+ auths.insert(registry.to_owned(), json!({ "auth": auth }));
73+ Some(config)
74+}
75+
76+/// The host of a server URL: `https://g1t.sh` is `g1t.sh`.
77+pub(crate) fn registry_host(server_url: &str) -> Option<String> {
78+ let rest = server_url.split_once("://").map_or(server_url, |(_, rest)| rest);
79+ let host = rest.split('/').next().unwrap_or_default();
80+ (!host.is_empty()).then(|| host.to_ascii_lowercase())
81+}
82+
83+/// The last lines of a file, for a message.
84+fn tail(path: &Path, lines: usize) -> String {
85+ let text = std::fs::read_to_string(path).unwrap_or_default();
86+ let all: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).collect();
87+ all[all.len().saturating_sub(lines)..].join("\n")
88+}
89+
90+#[cfg(target_os = "linux")]
91+pub(crate) use linux::{enable, ensure_started};
92+
93+#[cfg(not(target_os = "linux"))]
94+pub(crate) fn enable(_options: Options) -> Result<(), String> {
95+ Err("Docker runs in jobs on g1t's own Linux machines.".into())
96+}
97+
98+#[cfg(not(target_os = "linux"))]
99+pub(crate) fn ensure_started() -> Result<String, String> {
100+ Err("Docker runs in jobs on g1t's own Linux machines.".into())
101+}
102+
103+#[cfg(target_os = "linux")]
104+mod linux {
105+ use std::collections::BTreeSet;
106+ use std::io::{self, Write};
107+ use std::net::{TcpListener, TcpStream};
108+ use std::os::unix::fs::{MetadataExt, PermissionsExt};
109+ use std::os::unix::net::{UnixListener, UnixStream};
110+ use std::path::Path;
111+ use std::process::{Child, Command, Stdio};
112+ use std::sync::{Arc, Mutex, OnceLock};
113+ use std::time::{Duration, Instant};
114+
115+ use serde_json::Value;
116+
117+ use super::super::api::{self, Duplex, Host, State};
118+ use super::{DIR, DOCKER_SOCKET, ENGINE_SOCKET, Options, PROXY_SOCKET, daemon_config, tail, with_login};
119+
120+ const LOG: &str = "/run/g1t-docker/dockerd.log";
121+ /// From moby's `hack/dind`: the sandbox's processes into a group of
122+ /// their own, then every controller enabled for the groups below.
123+ const CGROUP_NESTING: &str = "if [ -f /sys/fs/cgroup/cgroup.controllers ] && [ -z \"$(cat /sys/fs/cgroup/cgroup.subtree_control)\" ]; then \
124+ mkdir -p /sys/fs/cgroup/init && xargs -rn1 < /sys/fs/cgroup/cgroup.procs > /sys/fs/cgroup/init/cgroup.procs 2>/dev/null; \
125+ sed -e 's/ / +/g' -e 's/^/+/' < /sys/fs/cgroup/cgroup.controllers > /sys/fs/cgroup/cgroup.subtree_control; fi";
126+ const CERTS: &str = "/run/g1t-docker/certs";
127+ const BIN: &str = "/run/g1t-docker/bin";
128+
129+ /// The options the job set up with, once set up.
130+ static OPTIONS: OnceLock<Options> = OnceLock::new();
131+ /// How starting went: the Engine's version, or why not. Held while
132+ /// starting, so everything that needs the Engine waits for it.
133+ static STARTED: Mutex<Option<Result<String, String>>> = Mutex::new(None);
134+ /// Host ports forwarded so far.
135+ static FORWARDED: Mutex<BTreeSet<u16>> = Mutex::new(BTreeSet::new());
136+
137+ fn sudo(args: &[&str]) -> bool {
138+ Command::new("sudo").arg("-n").args(args).stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null()).status().is_ok_and(|s| s.success())
139+ }
140+
141+ fn sudo_with_input(args: &[&str], input: &str) -> bool {
142+ let Ok(mut child) = Command::new("sudo").arg("-n").args(args).stdin(Stdio::piped()).stdout(Stdio::null()).stderr(Stdio::null()).spawn() else {
143+ return false;
144+ };
145+ if let Some(mut stdin) = child.stdin.take() {
146+ let _ = stdin.write_all(input.as_bytes());
147+ }
148+ child.wait().is_ok_and(|s| s.success())
149+ }
150+
151+ fn ids() -> (u32, u32) {
152+ std::fs::metadata("/proc/self").map(|m| (m.uid(), m.gid())).unwrap_or((1000, 1000))
153+ }
154+
155+ fn group_name() -> String {
156+ Command::new("id").arg("-gn").output().ok().map(|o| String::from_utf8_lossy(&o.stdout).trim().to_owned()).filter(|g| !g.is_empty()).unwrap_or_else(|| "node".into())
157+ }
158+
159+ /// Sets the socket up, with no Engine behind it yet.
160+ pub(crate) fn enable(options: Options) -> Result<(), String> {
161+ if !Path::new("/usr/bin/dockerd").exists() {
162+ return Err("this sandbox's image has no Docker Engine".into());
163+ }
164+ let (uid, gid) = ids();
165+ if !sudo(&["install", "-d", "-m", "0755", "-o", &uid.to_string(), "-g", &gid.to_string(), DIR]) {
166+ return Err(format!("could not make {DIR}"));
167+ }
168+ let _ = std::fs::remove_file(PROXY_SOCKET);
169+ let listener = UnixListener::bind(PROXY_SOCKET).map_err(|e| format!("could not listen on {PROXY_SOCKET}: {e}"))?;
170+ // Every process in the sandbox is the job's, root or not.
171+ let _ = std::fs::set_permissions(PROXY_SOCKET, std::fs::Permissions::from_mode(0o666));
172+ if !sudo(&["ln", "-sfn", PROXY_SOCKET, DOCKER_SOCKET]) {
173+ return Err(format!("could not link {DOCKER_SOCKET}"));
174+ }
175+ let _ = OPTIONS.set(options);
176+ let host: Arc<dyn Host> = Arc::new(Sandbox);
177+ let state = Arc::new(Mutex::new(State::default()));
178+ std::thread::spawn(move || {
179+ for client in listener.incoming().flatten() {
180+ let (host, state) = (host.clone(), state.clone());
181+ std::thread::spawn(move || api::serve(Box::new(client), host, state));
182+ }
183+ });
184+ Ok(())
185+ }
186+
187+ /// Starts the Engine, once; its version, or why it could not start.
188+ pub(crate) fn ensure_started() -> Result<String, String> {
189+ let mut started = STARTED.lock().unwrap_or_else(|poisoned| poisoned.into_inner());
190+ if let Some(result) = &*started {
191+ return result.clone();
192+ }
193+ let begun = Instant::now();
194+ let result = start();
195+ match &result {
196+ Ok(version) => super::super::note(format!(
197+ "Docker: started this job's own Docker Engine {version} in {:.1}s. Its containers run in this job's sandbox, on the job's network and under its guardrails, and end with the job.",
198+ begun.elapsed().as_secs_f64()
199+ )),
200+ Err(problem) => super::super::note(format!("##[error]Docker could not start: {problem}")),
201+ }
202+ *started = Some(result.clone());
203+ result
204+ }
205+
206+ /// Whether the overlay filesystem works where the Engine keeps its data.
207+ fn overlay_works() -> bool {
208+ let script = "d=/var/lib/docker/.g1t-probe; rm -rf $d; mkdir -p $d/l $d/u $d/w $d/m && echo x > $d/l/f && mount -t overlay overlay -o lowerdir=$d/l,upperdir=$d/u,workdir=$d/w $d/m && umount $d/m; s=$?; rm -rf $d; exit $s";
209+ sudo(&["sh", "-c", script])
210+ }
211+
212+ fn start() -> Result<String, String> {
213+ // This program, as the runc the Engine finds first (oci.rs).
214+ let exe = std::env::current_exe().map_err(|e| e.to_string())?;
215+ std::fs::create_dir_all(BIN).map_err(|e| format!("could not make {BIN}: {e}"))?;
216+ let shim = Path::new(BIN).join("runc");
217+ let _ = std::fs::remove_file(&shim);
218+ std::os::unix::fs::symlink(&exe, &shim).map_err(|e| format!("could not link runc: {e}"))?;
219+
220+ // A guarded job's certificate, for its containers.
221+ let mut ca_dir = None;
222+ if let Ok(ca) = std::env::var("G1T_EGRESS_CA")
223+ && Path::new(&ca).exists()
224+ {
225+ let _ = std::fs::create_dir_all(CERTS);
226+ let copied = std::fs::copy(&ca, Path::new(CERTS).join(super::super::oci::EGRESS)).is_ok()
227+ && std::fs::copy("/etc/ssl/certs/ca-certificates.crt", Path::new(CERTS).join(super::super::oci::BUNDLE)).is_ok();
228+ if copied {
229+ ca_dir = Some(CERTS.to_owned());
230+ }
231+ }
232+ // `-p 80:8080` is forwarded by this process, which is not root.
233+ let _ = sudo(&["sysctl", "-q", "-w", "net.ipv4.ip_unprivileged_port_start=0"]);
234+ // Containers' resource limits (`--cpus`, `--memory`) need the
235+ // cgroup controllers handed down, which cgroup v2 allows only from
236+ // a group with no processes of its own: move ours aside first, as
237+ // the Engine's own Docker-in-Docker image does.
238+ let _ = sudo(&["sh", "-c", CGROUP_NESTING]);
239+
240+ let mut vfs = !overlay_works();
241+ let group = group_name();
242+ let mut last = String::new();
243+ for _ in 0..2 {
244+ let config = daemon_config(&group, vfs);
245+ std::fs::write(format!("{DIR}/daemon.json"), serde_json::to_vec_pretty(&config).unwrap_or_default())
246+ .map_err(|e| format!("could not write daemon.json: {e}"))?;
247+ let mut child = spawn(ca_dir.as_deref())?;
248+ match wait_ready(&mut child, Duration::from_secs(90)) {
249+ Ok(()) => {
250+ let version = engine_version().unwrap_or_else(|| "?".into());
251+ sign_in();
252+ return Ok(if vfs { format!("{version} (plain-copy storage: this disk takes no overlays, so images take more room)") } else { version });
253+ }
254+ Err(problem) => {
255+ last = problem;
256+ let _ = child.kill();
257+ let _ = sudo(&["pkill", "-x", "dockerd"]);
258+ let _ = sudo(&["pkill", "-x", "containerd"]);
259+ if vfs {
260+ break;
261+ }
262+ // Overlays that mount but do not work for the Engine.
263+ vfs = true;
264+ }
265+ }
266+ }
267+ Err(last)
268+ }
269+
270+ fn spawn(ca_dir: Option<&str>) -> Result<Child, String> {
271+ let log = std::fs::File::create(LOG).map_err(|e| format!("could not write {LOG}: {e}"))?;
272+ let err = log.try_clone().map_err(|e| e.to_string())?;
273+ let path = format!("{BIN}:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin");
274+ let mut command = Command::new("sudo");
275+ command.args(["-n", "env", &format!("PATH={path}"), "G1T_REAL_RUNC=/usr/bin/runc"]);
276+ if let Some(dir) = ca_dir {
277+ command.arg(format!("G1T_DOCKER_CA_DIR={dir}"));
278+ }
279+ command.args(["dockerd", "--config-file", &format!("{DIR}/daemon.json")]);
280+ command.stdin(Stdio::null()).stdout(log).stderr(err);
281+ command.spawn().map_err(|e| format!("could not run dockerd: {e}"))
282+ }
283+
284+ /// A request to the Engine; its status and body.
285+ fn ask(method: &str, path: &str) -> io::Result<(u16, Vec<u8>)> {
286+ let mut stream = UnixStream::connect(ENGINE_SOCKET)?;
287+ stream.set_read_timeout(Some(Duration::from_secs(10)))?;
288+ stream.write_all(format!("{method} {path} HTTP/1.1\r\nHost: docker\r\nConnection: close\r\n\r\n").as_bytes())?;
289+ let mut reader = io::BufReader::new(stream);
290+ let head = super::super::http::read_head(&mut reader)?.ok_or_else(|| io::Error::other("no answer"))?;
291+ let body = super::super::http::read_body(&mut reader, super::super::http::response_body(&head, method))?;
292+ Ok((head.status(), body))
293+ }
294+
295+ fn wait_ready(child: &mut Child, limit: Duration) -> Result<(), String> {
296+ let until = Instant::now() + limit;
297+ loop {
298+ if matches!(ask("GET", "/_ping"), Ok((200, _))) {
299+ return Ok(());
300+ }
301+ if let Ok(Some(status)) = child.try_wait() {
302+ return Err(format!("dockerd stopped ({status}):\n{}", tail(Path::new(LOG), 15)));
303+ }
304+ if Instant::now() >= until {
305+ return Err(format!("dockerd did not answer in {} s:\n{}", limit.as_secs(), tail(Path::new(LOG), 15)));
306+ }
307+ std::thread::sleep(Duration::from_millis(100));
308+ }
309+ }
310+
311+ fn engine_version() -> Option<String> {
312+ let (_, body) = ask("GET", "/version").ok()?;
313+ let value: Value = serde_json::from_slice(&body).ok()?;
314+ value.get("Version").and_then(Value::as_str).map(str::to_owned)
315+ }
316+
317+ /// Signs the job in to g1t's registry with the run's own token.
318+ fn sign_in() {
319+ let Some((registry, token)) = OPTIONS.get().and_then(|o| o.registry.clone()) else { return };
320+ let home = std::env::var("HOME").unwrap_or_else(|_| "/home/node".into());
321+ let path = Path::new(&home).join(".docker").join("config.json");
322+ let current: Value = std::fs::read(&path).ok().and_then(|t| serde_json::from_slice(&t).ok()).unwrap_or(Value::Null);
323+ if let Some(config) = with_login(current, &registry, &token) {
324+ let _ = std::fs::create_dir_all(path.parent().expect("a folder"));
325+ if std::fs::write(&path, serde_json::to_vec_pretty(&config).unwrap_or_default()).is_ok() {
326+ let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600));
327+ super::super::note(format!("Docker: signed in to {registry} with this run's token."));
328+ }
329+ }
330+ }
331+
332+ /// The sandbox, as the API proxy sees it.
333+ struct Sandbox;
334+
335+ impl Host for Sandbox {
336+ fn add_hosts(&self, names: &[String]) {
337+ let lines: String = names.iter().map(|name| format!("127.0.0.1\t{name}\n")).collect();
338+ if !sudo_with_input(&["sh", "-c", "cat >> /etc/hosts"], &lines) {
339+ super::super::note(format!("Docker: could not add {} to /etc/hosts.", names.join(", ")));
340+ }
341+ }
342+
343+ fn forward(&self, host_port: u16, container_port: u16) {
344+ if !FORWARDED.lock().is_ok_and(|mut set| set.insert(host_port)) {
345+ return;
346+ }
347+ let listener = match TcpListener::bind(("0.0.0.0", host_port)) {
348+ Ok(listener) => listener,
349+ Err(error) => {
350+ super::super::note(format!("##[warning]Docker: port {host_port} could not be published for port {container_port}: {error}"));
351+ return;
352+ }
353+ };
354+ std::thread::spawn(move || {
355+ for client in listener.incoming().flatten() {
356+ std::thread::spawn(move || {
357+ let Ok(target) = TcpStream::connect(("127.0.0.1", container_port)) else { return };
358+ pipe(client, target);
359+ });
360+ }
361+ });
362+ }
363+
364+ fn ensure_engine(&self) -> Result<(), String> {
365+ ensure_started().map(|_| ())
366+ }
367+
368+ fn connect(&self) -> io::Result<Box<dyn Duplex>> {
369+ Ok(Box::new(UnixStream::connect(ENGINE_SOCKET)?))
370+ }
371+ }
372+
373+ /// Copies two connections into each other until both are done.
374+ fn pipe(a: TcpStream, b: TcpStream) {
375+ let (Ok(mut a_read), Ok(mut b_read)) = (a.try_clone(), b.try_clone()) else { return };
376+ let (mut a_write, mut b_write) = (a, b);
377+ let back = std::thread::spawn(move || {
378+ let _ = io::copy(&mut b_read, &mut a_write);
379+ let _ = a_write.shutdown(std::net::Shutdown::Write);
380+ });
381+ let _ = io::copy(&mut a_read, &mut b_write);
382+ let _ = b_write.shutdown(std::net::Shutdown::Write);
383+ let _ = back.join();
384+ }
385+}
386+
387+#[cfg(test)]
388+mod tests {
389+ use super::*;
390+
391+ #[test]
392+ fn the_engine_runs_as_a_sandbox_allows() {
393+ let config = daemon_config("node", false);
394+ assert_eq!(config["iptables"], false);
395+ assert_eq!(config["ip6tables"], false);
396+ assert_eq!(config["ip-forward"], false);
397+ assert_eq!(config["group"], "node");
398+ assert_eq!(config["hosts"][0], "unix:///run/g1t-docker/engine.sock");
399+ assert_eq!(config["registry-mirrors"][0], MIRROR);
400+ assert!(config.get("storage-driver").is_none());
401+ assert_eq!(daemon_config("node", true)["storage-driver"], "native");
402+ }
403+
404+ #[test]
405+ fn the_run_signs_in_to_g1t_unless_already_signed_in() {
406+ let config = with_login(json!({ "auths": { "ghcr.io": { "auth": "x" } } }), "g1t.sh", "tok").unwrap();
407+ assert_eq!(config["auths"]["g1t.sh"]["auth"], "ZzF0OnRvaw==");
408+ assert_eq!(config["auths"]["ghcr.io"]["auth"], "x");
409+ assert!(with_login(config, "g1t.sh", "other").is_none());
410+ assert!(with_login(Value::Null, "g1t.sh", "tok").is_some());
411+ assert_eq!(registry_host("https://g1t.sh").as_deref(), Some("g1t.sh"));
412+ assert_eq!(registry_host("http://localhost:8787/").as_deref(), Some("localhost:8787"));
413+ }
414+}
+342−0
1+//! Just enough HTTP/1.1 to stand between the Docker CLI and the Engine:
2+//! message heads, and bodies framed by `Content-Length`, chunked, or the
3+//! end of the connection. Bodies pass through as they come (a chunk at a
4+//! time, so `docker logs -f` and a pull's progress keep streaming), unless
5+//! the proxy reads one whole to change it.
6+
7+use std::io::{self, BufRead, Read, Write};
8+
9+/// The most a message head may be. The Engine's and the CLI's are a few
10+/// hundred bytes.
11+const MAX_HEAD: usize = 64 * 1024;
12+/// The most a body read whole may be: a container's config or inspection
13+/// is a few kilobytes.
14+pub(crate) const MAX_BODY: u64 = 16 * 1024 * 1024;
15+
16+/// A request's or a response's start line and headers.
17+#[derive(Clone, Debug, PartialEq)]
18+pub(crate) struct Head {
19+ pub(crate) start: String,
20+ pub(crate) headers: Vec<(String, String)>,
21+}
22+
23+impl Head {
24+ pub(crate) fn header(&self, name: &str) -> Option<&str> {
25+ self.headers.iter().find(|(key, _)| key.eq_ignore_ascii_case(name)).map(|(_, value)| value.as_str())
26+ }
27+
28+ pub(crate) fn remove_header(&mut self, name: &str) {
29+ self.headers.retain(|(key, _)| !key.eq_ignore_ascii_case(name));
30+ }
31+
32+ pub(crate) fn set_header(&mut self, name: &str, value: &str) {
33+ self.remove_header(name);
34+ self.headers.push((name.to_owned(), value.to_owned()));
35+ }
36+
37+ /// A request's method.
38+ pub(crate) fn method(&self) -> &str {
39+ self.start.split(' ').next().unwrap_or_default()
40+ }
41+
42+ /// A request's target: its path and query.
43+ pub(crate) fn target(&self) -> &str {
44+ self.start.split(' ').nth(1).unwrap_or_default()
45+ }
46+
47+ pub(crate) fn set_target(&mut self, target: &str) {
48+ let mut parts: Vec<&str> = self.start.splitn(3, ' ').collect();
49+ if parts.len() == 3 {
50+ parts[1] = target;
51+ self.start = parts.join(" ");
52+ }
53+ }
54+
55+ /// A response's status code.
56+ pub(crate) fn status(&self) -> u16 {
57+ self.start.split(' ').nth(1).and_then(|code| code.parse().ok()).unwrap_or(0)
58+ }
59+
60+ /// Whether the request asks to leave HTTP (`docker attach`, `exec`,
61+ /// BuildKit's `/grpc` and `/session`).
62+ pub(crate) fn upgrades(&self) -> bool {
63+ self.header("upgrade").is_some() || self.header("connection").is_some_and(|value| value.to_ascii_lowercase().contains("upgrade"))
64+ }
65+
66+ pub(crate) fn to_bytes(&self) -> Vec<u8> {
67+ let mut out = String::with_capacity(256);
68+ out.push_str(&self.start);
69+ out.push_str("\r\n");
70+ for (name, value) in &self.headers {
71+ out.push_str(name);
72+ out.push_str(": ");
73+ out.push_str(value);
74+ out.push_str("\r\n");
75+ }
76+ out.push_str("\r\n");
77+ out.into_bytes()
78+ }
79+}
80+
81+/// Reads a message head. `None` when the connection ends before one starts.
82+pub(crate) fn read_head<R: BufRead>(reader: &mut R) -> io::Result<Option<Head>> {
83+ let mut lines: Vec<String> = Vec::new();
84+ let mut size = 0;
85+ loop {
86+ let mut line = Vec::new();
87+ let read = reader.read_until(b'\n', &mut line)?;
88+ if read == 0 {
89+ if lines.is_empty() {
90+ return Ok(None);
91+ }
92+ return Err(io::Error::new(io::ErrorKind::UnexpectedEof, "the connection ended inside a message head"));
93+ }
94+ size += read;
95+ if size > MAX_HEAD {
96+ return Err(io::Error::new(io::ErrorKind::InvalidData, "a message head too large"));
97+ }
98+ let text = String::from_utf8_lossy(&line).trim_end_matches(['\r', '\n']).to_owned();
99+ if text.is_empty() {
100+ // Blank lines before a request are allowed, and skipped.
101+ if lines.is_empty() {
102+ continue;
103+ }
104+ break;
105+ }
106+ lines.push(text);
107+ }
108+ let start = lines.remove(0);
109+ let headers = lines
110+ .into_iter()
111+ .filter_map(|line| line.split_once(':').map(|(name, value)| (name.trim().to_owned(), value.trim().to_owned())))
112+ .collect();
113+ Ok(Some(Head { start, headers }))
114+}
115+
116+/// How a message's body ends.
117+#[derive(Clone, Copy, Debug, PartialEq)]
118+pub(crate) enum Body {
119+ None,
120+ Length(u64),
121+ Chunked,
122+ /// Until the connection closes: a response with no length.
123+ UntilClose,
124+}
125+
126+fn chunked(head: &Head) -> bool {
127+ head.header("transfer-encoding").is_some_and(|value| value.to_ascii_lowercase().contains("chunked"))
128+}
129+
130+fn length(head: &Head) -> Option<u64> {
131+ head.header("content-length").and_then(|value| value.trim().parse().ok())
132+}
133+
134+/// A request's body: chunked, a length, or none.
135+pub(crate) fn request_body(head: &Head) -> Body {
136+ if chunked(head) {
137+ Body::Chunked
138+ } else {
139+ match length(head) {
140+ Some(0) | None => Body::None,
141+ Some(n) => Body::Length(n),
142+ }
143+ }
144+}
145+
146+/// A response's body, which also depends on what was asked.
147+pub(crate) fn response_body(head: &Head, method: &str) -> Body {
148+ let status = head.status();
149+ if method.eq_ignore_ascii_case("HEAD") || (100..200).contains(&status) || status == 204 || status == 304 {
150+ return Body::None;
151+ }
152+ if chunked(head) {
153+ return Body::Chunked;
154+ }
155+ match length(head) {
156+ Some(0) => Body::None,
157+ Some(n) => Body::Length(n),
158+ None => Body::UntilClose,
159+ }
160+}
161+
162+/// Copies a body as it is framed, flushing as each piece arrives.
163+pub(crate) fn copy_body<R: BufRead, W: Write>(reader: &mut R, writer: &mut W, body: Body) -> io::Result<()> {
164+ match body {
165+ Body::None => Ok(()),
166+ Body::Length(n) => {
167+ let copied = io::copy(&mut reader.take(n), writer)?;
168+ writer.flush()?;
169+ if copied < n {
170+ return Err(io::Error::new(io::ErrorKind::UnexpectedEof, "the body ended early"));
171+ }
172+ Ok(())
173+ }
174+ Body::UntilClose => {
175+ io::copy(reader, writer)?;
176+ writer.flush()
177+ }
178+ Body::Chunked => loop {
179+ let mut line = Vec::new();
180+ if reader.read_until(b'\n', &mut line)? == 0 {
181+ return Err(io::Error::new(io::ErrorKind::UnexpectedEof, "a chunked body ended early"));
182+ }
183+ writer.write_all(&line)?;
184+ let size = chunk_size(&line)?;
185+ if size == 0 {
186+ // Trailers, then a blank line.
187+ loop {
188+ let mut trailer = Vec::new();
189+ if reader.read_until(b'\n', &mut trailer)? == 0 {
190+ break;
191+ }
192+ writer.write_all(&trailer)?;
193+ if trailer == b"\r\n" || trailer == b"\n" {
194+ break;
195+ }
196+ }
197+ writer.flush()?;
198+ return Ok(());
199+ }
200+ // The chunk and its CRLF.
201+ let copied = io::copy(&mut reader.take(size + 2), writer)?;
202+ writer.flush()?;
203+ if copied < size + 2 {
204+ return Err(io::Error::new(io::ErrorKind::UnexpectedEof, "a chunk ended early"));
205+ }
206+ },
207+ }
208+}
209+
210+fn chunk_size(line: &[u8]) -> io::Result<u64> {
211+ let text = String::from_utf8_lossy(line);
212+ let hex = text.trim().split(';').next().unwrap_or_default().trim();
213+ u64::from_str_radix(hex, 16).map_err(|_| io::Error::new(io::ErrorKind::InvalidData, format!("not a chunk size: {hex:?}")))
214+}
215+
216+/// Reads a whole body, unframed.
217+pub(crate) fn read_body<R: BufRead>(reader: &mut R, body: Body) -> io::Result<Vec<u8>> {
218+ let mut out = Vec::new();
219+ match body {
220+ Body::None => {}
221+ Body::Length(n) => {
222+ if n > MAX_BODY {
223+ return Err(io::Error::new(io::ErrorKind::InvalidData, "a body too large to read"));
224+ }
225+ reader.take(n).read_to_end(&mut out)?;
226+ if (out.len() as u64) < n {
227+ return Err(io::Error::new(io::ErrorKind::UnexpectedEof, "the body ended early"));
228+ }
229+ }
230+ Body::UntilClose => {
231+ reader.take(MAX_BODY).read_to_end(&mut out)?;
232+ }
233+ Body::Chunked => loop {
234+ let mut line = Vec::new();
235+ if reader.read_until(b'\n', &mut line)? == 0 {
236+ return Err(io::Error::new(io::ErrorKind::UnexpectedEof, "a chunked body ended early"));
237+ }
238+ let size = chunk_size(&line)?;
239+ if size == 0 {
240+ loop {
241+ let mut trailer = Vec::new();
242+ if reader.read_until(b'\n', &mut trailer)? == 0 || trailer == b"\r\n" || trailer == b"\n" {
243+ break;
244+ }
245+ }
246+ break;
247+ }
248+ if out.len() as u64 + size > MAX_BODY {
249+ return Err(io::Error::new(io::ErrorKind::InvalidData, "a body too large to read"));
250+ }
251+ let mut chunk = Vec::new();
252+ reader.take(size).read_to_end(&mut chunk)?;
253+ out.extend_from_slice(&chunk);
254+ let mut crlf = Vec::new();
255+ reader.read_until(b'\n', &mut crlf)?;
256+ },
257+ }
258+ Ok(out)
259+}
260+
261+/// A head with its body framed by length, for a body the proxy rewrote.
262+pub(crate) fn with_length(mut head: Head, body: &[u8]) -> Vec<u8> {
263+ head.remove_header("transfer-encoding");
264+ head.set_header("Content-Length", &body.len().to_string());
265+ let mut out = head.to_bytes();
266+ out.extend_from_slice(body);
267+ out
268+}
269+
270+/// A whole JSON response, as the Engine words its errors.
271+pub(crate) fn json_response(status: u16, reason: &str, body: &serde_json::Value) -> Vec<u8> {
272+ let text = body.to_string();
273+ let head = Head {
274+ start: format!("HTTP/1.1 {status} {reason}"),
275+ headers: vec![("Content-Type".into(), "application/json".into())],
276+ };
277+ with_length(head, text.as_bytes())
278+}
279+
280+/// An empty `200 OK`.
281+pub(crate) fn empty_ok() -> Vec<u8> {
282+ with_length(Head { start: "HTTP/1.1 200 OK".into(), headers: Vec::new() }, b"")
283+}
284+
285+#[cfg(test)]
286+mod tests {
287+ use super::*;
288+ use std::io::BufReader;
289+
290+ #[test]
291+ fn heads_are_read_and_written_back() {
292+ let raw = b"POST /v1.47/containers/create?name=db HTTP/1.1\r\nHost: api.moby.localhost\r\nContent-Type: application/json\r\nContent-Length: 2\r\n\r\n{}";
293+ let mut reader = BufReader::new(&raw[..]);
294+ let head = read_head(&mut reader).unwrap().unwrap();
295+ assert_eq!(head.method(), "POST");
296+ assert_eq!(head.target(), "/v1.47/containers/create?name=db");
297+ assert_eq!(head.header("content-length"), Some("2"));
298+ assert_eq!(request_body(&head), Body::Length(2));
299+ assert_eq!(read_body(&mut reader, Body::Length(2)).unwrap(), b"{}");
300+ assert!(read_head(&mut reader).unwrap().is_none());
301+ let mut again = head.clone();
302+ again.set_target("/v1.47/containers/create");
303+ assert!(String::from_utf8(again.to_bytes()).unwrap().starts_with("POST /v1.47/containers/create HTTP/1.1\r\n"));
304+ }
305+
306+ #[test]
307+ fn chunked_bodies_copy_verbatim_and_read_unframed() {
308+ let raw = b"5\r\nhello\r\n6;ext=1\r\n world\r\n0\r\n\r\nNEXT";
309+ let mut copied = Vec::new();
310+ let mut reader = BufReader::new(&raw[..]);
311+ copy_body(&mut reader, &mut copied, Body::Chunked).unwrap();
312+ assert_eq!(copied, &raw[..raw.len() - 4]);
313+ let mut rest = String::new();
314+ reader.read_to_string(&mut rest).unwrap();
315+ assert_eq!(rest, "NEXT");
316+ let mut reader = BufReader::new(&raw[..]);
317+ assert_eq!(read_body(&mut reader, Body::Chunked).unwrap(), b"hello world");
318+ }
319+
320+ #[test]
321+ fn response_bodies_follow_the_request_and_the_status() {
322+ let head = |start: &str, headers: &[(&str, &str)]| Head {
323+ start: start.into(),
324+ headers: headers.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect(),
325+ };
326+ assert_eq!(response_body(&head("HTTP/1.1 200 OK", &[("Content-Length", "10")]), "HEAD"), Body::None);
327+ assert_eq!(response_body(&head("HTTP/1.1 204 No Content", &[]), "POST"), Body::None);
328+ assert_eq!(response_body(&head("HTTP/1.1 101 UPGRADED", &[]), "POST"), Body::None);
329+ assert_eq!(response_body(&head("HTTP/1.1 200 OK", &[("Transfer-Encoding", "chunked")]), "GET"), Body::Chunked);
330+ assert_eq!(response_body(&head("HTTP/1.1 200 OK", &[]), "GET"), Body::UntilClose);
331+ assert!(head("POST /grpc HTTP/1.1", &[("Connection", "Upgrade"), ("Upgrade", "h2c")]).upgrades());
332+ }
333+
334+ #[test]
335+ fn a_rewritten_body_gets_its_length() {
336+ let head = Head { start: "HTTP/1.1 200 OK".into(), headers: vec![("Transfer-Encoding".into(), "chunked".into())] };
337+ let out = String::from_utf8(with_length(head, b"{\"a\":1}")).unwrap();
338+ assert!(out.contains("Content-Length: 7\r\n"));
339+ assert!(!out.to_ascii_lowercase().contains("chunked"));
340+ assert!(out.ends_with("\r\n\r\n{\"a\":1}"));
341+ }
342+}
+41−0
1+//! Docker in a workflow job on g1t's own machines: a Docker Engine of the
2+//! job's own, inside its sandbox, started the first time anything asks
3+//! for it, and gone with the sandbox when the job ends.
4+//!
5+//! - `engine` starts it, as root inside the sandbox (as Cloudflare
6+//! Containers run Docker), with no iptables and no IP forwarding, which
7+//! a sandbox does not have.
8+//! - `api` is `/var/run/docker.sock`: the Engine's API, with containers
9+//! moved to the job's own network, where its guardrails apply.
10+//! - `oci` is the `runc` the Engine runs containers with: build steps on
11+//! the job's network, and a guarded job's egress certificate in every
12+//! container.
13+//! - `http` is the HTTP/1.1 the proxy reads.
14+//!
15+//! Nothing here is shared with another job: each job has its own sandbox,
16+//! and so its own Engine, images and build cache.
17+
18+// Off g1t's Linux machines only the pure parts are used, by tests.
19+#![cfg_attr(not(target_os = "linux"), allow(dead_code))]
20+
21+pub(crate) mod api;
22+pub(crate) mod engine;
23+pub(crate) mod http;
24+pub(crate) mod oci;
25+
26+use std::sync::Mutex;
27+
28+/// Lines for the job's log, from threads that do not hold it: the Engine
29+/// starting, a port that could not be forwarded.
30+static NOTES: Mutex<Vec<String>> = Mutex::new(Vec::new());
31+
32+pub(crate) fn note(line: impl Into<String>) {
33+ if let Ok(mut notes) = NOTES.lock() {
34+ notes.push(line.into());
35+ }
36+}
37+
38+/// The lines noted since the last call.
39+pub(crate) fn take_notes() -> Vec<String> {
40+ NOTES.lock().map(|mut notes| std::mem::take(&mut *notes)).unwrap_or_default()
41+}
+274−0
1+//! `runc`, as the job's Docker Engine finds it. The Engine is started with
2+//! a folder of g1t's first on its `PATH`, holding this program under the
3+//! name `runc`, so every container it and its builder start passes
4+//! through here on its way to the real runc. Two changes, then the real
5+//! runc runs with the same arguments:
6+//!
7+//! - **BuildKit's `RUN` steps** that would join a bridge network join the
8+//! job's own network instead (their network namespace and libnetwork's
9+//! hook are taken out of the container's config), because a sandbox has
10+//! no route out of a bridge. `RUN --network=none` stays without one.
11+//! - **In a guarded job**, every container (`docker run`, services, build
12+//! steps, `docker exec`) gets the certificate the job's HTTPS is
13+//! re-signed with: the folder of certificates at `/dev/g1t-egress`, and
14+//! the variables that point common tools at it, unless the container
15+//! sets them itself. `/dev` is a filesystem of the container's own, so
16+//! nothing of this is ever written into an image's layers.
17+//!
18+//! Anything unexpected leaves the config as it was: this never stops a
19+//! container from starting.
20+
21+use serde_json::{Value, json};
22+
23+/// Where the certificates are seen inside a container.
24+pub(crate) const CA_MOUNT: &str = "/dev/g1t-egress";
25+/// The system's bundle, with the egress certificate added.
26+pub(crate) const BUNDLE: &str = "ca-certificates.crt";
27+/// The egress certificate alone.
28+pub(crate) const EGRESS: &str = "egress-ca.crt";
29+
30+/// The variables a container is given in a guarded job, as the sandbox's
31+/// own (services/runner egress.ts `EGRESS_ENV`) point its tools.
32+pub(crate) fn ca_variables() -> Vec<(&'static str, String)> {
33+ let bundle = format!("{CA_MOUNT}/{BUNDLE}");
34+ vec![
35+ ("SSL_CERT_FILE", bundle.clone()),
36+ ("NODE_EXTRA_CA_CERTS", format!("{CA_MOUNT}/{EGRESS}")),
37+ ("REQUESTS_CA_BUNDLE", bundle.clone()),
38+ ("CURL_CA_BUNDLE", bundle.clone()),
39+ ("PIP_CERT", bundle.clone()),
40+ ("GIT_SSL_CAINFO", bundle.clone()),
41+ ("CARGO_HTTP_CAINFO", bundle),
42+ ]
43+}
44+
45+/// What runc was asked to do, from its arguments.
46+#[derive(Debug, Default, PartialEq)]
47+pub(crate) struct Call {
48+ pub(crate) command: Option<String>,
49+ pub(crate) bundle: Option<String>,
50+ /// `runc exec --process <file>`.
51+ pub(crate) process: Option<String>,
52+}
53+
54+/// runc's global flags that take a value.
55+const GLOBAL_VALUES: &[&str] = &["--root", "--log", "--log-format", "--criu", "--rootless"];
56+
57+pub(crate) fn parse(args: &[String]) -> Call {
58+ let mut call = Call::default();
59+ let mut iter = args.iter().peekable();
60+ while let Some(arg) = iter.next() {
61+ if call.command.is_none() {
62+ if GLOBAL_VALUES.contains(&arg.as_str()) {
63+ iter.next();
64+ } else if !arg.starts_with('-') {
65+ call.command = Some(arg.clone());
66+ }
67+ continue;
68+ }
69+ let (flag, inline) = match arg.split_once('=') {
70+ Some((flag, value)) if flag.starts_with("--") => (flag, Some(value.to_owned())),
71+ _ => (arg.as_str(), None),
72+ };
73+ let mut value = || inline.clone().or_else(|| iter.next().cloned());
74+ match flag {
75+ "--bundle" | "-b" => call.bundle = value(),
76+ "--process" | "-p" => call.process = value(),
77+ _ => {}
78+ }
79+ }
80+ call
81+}
82+
83+/// Whether a hook entry is libnetwork's, which joins a container to a
84+/// network the Engine set up.
85+fn libnetwork_hook(hook: &Value) -> bool {
86+ hook.get("args")
87+ .and_then(Value::as_array)
88+ .is_some_and(|args| args.iter().any(|a| a.as_str().is_some_and(|a| a.contains("libnetwork-setkey"))))
89+}
90+
91+/// Adds the certificates and their variables to a process's environment.
92+fn add_variables(process: &mut Value) -> bool {
93+ let Some(env) = process.get_mut("env").and_then(Value::as_array_mut) else { return false };
94+ let mut changed = false;
95+ for (name, value) in ca_variables() {
96+ let prefix = format!("{name}=");
97+ if !env.iter().any(|e| e.as_str().is_some_and(|e| e.starts_with(&prefix))) {
98+ env.push(json!(format!("{name}={value}")));
99+ changed = true;
100+ }
101+ }
102+ changed
103+}
104+
105+/// Changes a container's `config.json`. `ca_dir`: the folder of
106+/// certificates to give it, in a guarded job. Returns whether it changed.
107+pub(crate) fn patch_config(config: &mut Value, bundle: &str, ca_dir: Option<&str>) -> bool {
108+ let mut changed = false;
109+ // A BuildKit step bound for a bridge network: the job's network instead.
110+ if bundle.contains("/buildkit/") {
111+ let mut bridged = false;
112+ if let Some(hooks) = config.get_mut("hooks").and_then(Value::as_object_mut) {
113+ for list in hooks.values_mut() {
114+ if let Some(entries) = list.as_array_mut() {
115+ let before = entries.len();
116+ entries.retain(|hook| !libnetwork_hook(hook));
117+ bridged |= entries.len() != before;
118+ }
119+ }
120+ }
121+ if bridged && let Some(namespaces) = config.pointer_mut("/linux/namespaces").and_then(Value::as_array_mut) {
122+ namespaces.retain(|ns| ns.get("type").and_then(Value::as_str) != Some("network"));
123+ changed = true;
124+ }
125+ }
126+ if let Some(dir) = ca_dir {
127+ if let Some(mounts) = config.get_mut("mounts").and_then(Value::as_array_mut)
128+ && !mounts.iter().any(|m| m.get("destination").and_then(Value::as_str) == Some(CA_MOUNT))
129+ {
130+ mounts.push(json!({
131+ "destination": CA_MOUNT,
132+ "type": "bind",
133+ "source": dir,
134+ "options": ["rbind", "ro", "nosuid", "nodev", "noexec"],
135+ }));
136+ changed = true;
137+ }
138+ if let Some(process) = config.get_mut("process") {
139+ changed |= add_variables(process);
140+ }
141+ }
142+ changed
143+}
144+
145+/// Changes the process of a `runc exec` (`docker exec`), which has an
146+/// environment of its own.
147+pub(crate) fn patch_process(process: &mut Value, ca_dir: Option<&str>) -> bool {
148+ ca_dir.is_some() && add_variables(process)
149+}
150+
151+/// Whether this program was started as `runc`.
152+pub(crate) fn invoked_as_runc() -> bool {
153+ std::env::args_os()
154+ .next()
155+ .and_then(|arg| std::path::Path::new(&arg).file_name().map(|name| name == "runc"))
156+ .unwrap_or(false)
157+}
158+
159+/// Rewrites a JSON file in place, if `change` changes it.
160+#[cfg(unix)]
161+fn rewrite(path: &std::path::Path, change: impl FnOnce(&mut Value) -> bool) {
162+ let Ok(text) = std::fs::read(path) else { return };
163+ let Ok(mut value) = serde_json::from_slice::<Value>(&text) else { return };
164+ if change(&mut value)
165+ && let Ok(out) = serde_json::to_vec(&value)
166+ {
167+ let staged = path.with_extension("g1t");
168+ if std::fs::write(&staged, out).is_ok() {
169+ let _ = std::fs::rename(&staged, path);
170+ }
171+ }
172+}
173+
174+/// `runc …`: changes the container's config, then becomes the real runc.
175+#[cfg(unix)]
176+pub(crate) fn main() -> ! {
177+ use std::os::unix::process::CommandExt;
178+ let args: Vec<String> = std::env::args().skip(1).collect();
179+ let call = parse(&args);
180+ let ca_dir = std::env::var("G1T_DOCKER_CA_DIR").ok().filter(|dir| std::path::Path::new(dir).is_dir());
181+ match (call.command.as_deref(), &call.bundle, &call.process) {
182+ (Some("create" | "run"), Some(bundle), _) => {
183+ rewrite(&std::path::Path::new(bundle).join("config.json"), |config| patch_config(config, bundle, ca_dir.as_deref()));
184+ }
185+ (Some("exec"), _, Some(process)) => rewrite(std::path::Path::new(process), |process| patch_process(process, ca_dir.as_deref())),
186+ _ => {}
187+ }
188+ let real = std::env::var("G1T_REAL_RUNC").unwrap_or_else(|_| "/usr/bin/runc".into());
189+ let error = std::process::Command::new(&real).arg0("runc").args(&args).exec();
190+ eprintln!("g1t-runner: could not run {real}: {error}");
191+ std::process::exit(127)
192+}
193+
194+#[cfg(not(unix))]
195+pub(crate) fn main() -> ! {
196+ eprintln!("g1t-runner: runc runs on Linux only");
197+ std::process::exit(127)
198+}
199+
200+#[cfg(test)]
201+mod tests {
202+ use super::*;
203+
204+ fn args(text: &str) -> Vec<String> {
205+ text.split_whitespace().map(str::to_owned).collect()
206+ }
207+
208+ #[test]
209+ fn calls_are_read_as_containerd_and_buildkit_make_them() {
210+ let call = parse(&args(
211+ "--root /var/run/docker/runtime-runc/moby --log /x/log.json --log-format json create --bundle /var/run/docker/containerd/daemon/io.containerd.runtime.v2.task/moby/abc --pid-file /x/init.pid abc",
212+ ));
213+ assert_eq!(call.command.as_deref(), Some("create"));
214+ assert_eq!(call.bundle.as_deref(), Some("/var/run/docker/containerd/daemon/io.containerd.runtime.v2.task/moby/abc"));
215+ let call = parse(&args("--log /var/lib/docker/buildkit/executor/runc-log.json --log-format json run --bundle /var/lib/docker/buildkit/executor/x1 --keep x1"));
216+ assert_eq!((call.command.as_deref(), call.bundle.as_deref()), (Some("run"), Some("/var/lib/docker/buildkit/executor/x1")));
217+ let call = parse(&args("--root /r exec --process /tmp/runc-process1 --detach --pid-file /p abc"));
218+ assert_eq!((call.command.as_deref(), call.process.as_deref()), (Some("exec"), Some("/tmp/runc-process1")));
219+ assert_eq!(parse(&args("--root=/r start abc")).command.as_deref(), Some("start"));
220+ assert_eq!(parse(&args("create --bundle=/b x")).bundle.as_deref(), Some("/b"));
221+ assert_eq!(parse(&args("--version")), Call::default());
222+ }
223+
224+ fn build_step(hooked: bool) -> Value {
225+ let hooks = if hooked {
226+ json!({ "prestart": [{ "path": "/proc/21/exe", "args": ["libnetwork-setkey", "-exec-root=/var/run/docker", "abc", "def"] }] })
227+ } else {
228+ json!({})
229+ };
230+ json!({
231+ "hostname": "buildkitsandbox",
232+ "process": { "env": ["PATH=/usr/bin", "SSL_CERT_FILE=/mine.pem"] },
233+ "mounts": [{ "destination": "/proc" }, { "destination": "/dev", "type": "tmpfs" }],
234+ "linux": { "namespaces": [{ "type": "pid" }, { "type": "network" }, { "type": "mount" }] },
235+ "hooks": hooks,
236+ })
237+ }
238+
239+ #[test]
240+ fn build_steps_bound_for_a_bridge_join_the_jobs_network() {
241+ let mut config = build_step(true);
242+ assert!(patch_config(&mut config, "/var/lib/docker/buildkit/executor/x1", None));
243+ let namespaces: Vec<&str> = config["linux"]["namespaces"].as_array().unwrap().iter().map(|n| n["type"].as_str().unwrap()).collect();
244+ assert_eq!(namespaces, vec!["pid", "mount"]);
245+ assert!(config["hooks"]["prestart"].as_array().unwrap().is_empty());
246+ // RUN --network=none: no libnetwork hook, so its own empty network.
247+ let mut config = build_step(false);
248+ assert!(!patch_config(&mut config, "/var/lib/docker/buildkit/executor/x2", None));
249+ assert_eq!(config["linux"]["namespaces"].as_array().unwrap().len(), 3);
250+ // A container the Engine runs is the API proxy's business, not this.
251+ let mut config = build_step(true);
252+ assert!(!patch_config(&mut config, "/run/containerd/io.containerd.runtime.v2.task/moby/abc", None));
253+ }
254+
255+ #[test]
256+ fn guarded_containers_get_the_certificates_without_losing_their_own_settings() {
257+ let mut config = build_step(false);
258+ assert!(patch_config(&mut config, "/run/containerd/io.containerd.runtime.v2.task/moby/abc", Some("/run/g1t-docker/certs")));
259+ let mounts = config["mounts"].as_array().unwrap();
260+ let last = mounts.last().unwrap();
261+ assert_eq!(last["destination"], CA_MOUNT);
262+ assert_eq!(last["source"], "/run/g1t-docker/certs");
263+ assert!(last["options"].as_array().unwrap().contains(&json!("ro")));
264+ let env: Vec<&str> = config["process"]["env"].as_array().unwrap().iter().map(|e| e.as_str().unwrap()).collect();
265+ assert!(env.contains(&"SSL_CERT_FILE=/mine.pem"));
266+ assert!(!env.contains(&"SSL_CERT_FILE=/dev/g1t-egress/ca-certificates.crt"));
267+ assert!(env.contains(&"NODE_EXTRA_CA_CERTS=/dev/g1t-egress/egress-ca.crt"));
268+ // Patching twice changes nothing more.
269+ assert!(!patch_config(&mut config, "/run/containerd/io.containerd.runtime.v2.task/moby/abc", Some("/run/g1t-docker/certs")));
270+ let mut process = json!({ "env": ["PATH=/bin"] });
271+ assert!(patch_process(&mut process, Some("/run/g1t-docker/certs")));
272+ assert!(!patch_process(&mut json!({ "env": [] }), None));
273+ }
274+}
+5−0
4040 mod clone;
4141 mod confidence;
4242 mod deploy;
43+mod docker;
4344 mod guard;
4445 mod harness;
4546 mod learned;
206207 }
207208
208209 fn main() {
210+ // The runc the job's Docker Engine starts containers with (docker/oci.rs).
211+ if docker::oci::invoked_as_runc() {
212+ docker::oci::main();
213+ }
209214 // A self-hosted runner's commands; the modes below are what it, and
210215 // g1t's sandboxes, run work with.
211216 let args: Vec<String> = std::env::args().skip(1).collect();
+3−0
140140 let mut command = Command::new("docker");
141141 command.args(["run", "--rm", "--name", &name, "--label", &format!("sh.g1t.runner={}", config.runner)]);
142142 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"]);
143146 for name in env.keys() {
144147 command.args(["-e", name]);
145148 }
+152−5
303303 keeps `runs.gateway_note`, its correction says why, and it raises the
304304 **Unpriced** drift. For agent runs g1t keeps no token rates of its own:
305305 the first figure is Claude Code's, the final one the gateway's. AI Gateway
306−requests are the exception: they are charged from `gateway_models` (one
307−row per model offered), which has to follow the provider's price list by
306+requests are the exception: they are charged from `gateway_models` (the
307+model catalogue, below), which has to follow the provider's price list by
308308 hand until they are settled like runs. It is not part of the price book's
309−`price_versions`: a price change is a migration that updates the row and
310−its `updated_at` (as 0047 did for Sonnet 5.5's cache reads), and the
311−`gateway_models` meter's markup is the only price-book number on it. Open
309+`price_versions`: a new model's prices are confirmed when staff approve it
310+in sudo, and a changed price on a model already offered is a migration that
311+updates the row and its `updated_at` (as 0047 did for Sonnet 5.5's cache
312+reads). The `gateway_models` meter's markup is the only price-book number
313+on it. Open
312314 models need `WORKERS_AI_TOKEN` (a Cloudflare API token with Workers AI on
313315 g1t's account) on the model proxy; without it, and without
314316 `AI_GATEWAY_TOKEN` holding that permission, they are refused with `503`.
344346 Every usage path goes through this: `finish_run`, settling, sandbox time,
345347 features and builds (`charge_feature`), and the month-end meters.
346348
349+## The model catalogue
350+
351+Every model g1t can use is one row of `gateway_models` (migrations 0045,
352+0047 and `0048_model_catalogue.sql`; code in `catalogue.rs`): the agents'
353+tiers, the AI Gateway's Claude and open models, and the embeddings model.
354+Billing keeps it because billing owns prices: the gateway charges from the
355+same row, so there is one price per model, not two that drift. Page: sudo
356+**Agents & models** (`/agents`).
357+
358+| Column | What |
359+| --- | --- |
360+| `model`, `name`, `provider`, `kind` | The provider's id, the name people see, `anthropic` or `workers-ai`, `chat` or `embeddings` |
361+| `aliases` | Other ids the provider lists it by, comma separated (a dated id such as `claude-haiku-4-5-20251001`) |
362+| `family`, `tier_hint` | `haiku`, `sonnet`, `opus`, `fable` (or a Workers AI author); the agent tier it suits: `small`, `large`, `frontier` |
363+| `context_window`, `max_output`, `capabilities`, `dimensions` | From the provider's list where it gives them: `effort`, `thinking`, `tools`, `vision`, `embeddings`; an embeddings model's vector length |
364+| Prices | Per million tokens in millionths: input, output, cache read, five-minute and hour-long cache writes, and for a model priced by prompt length the `threshold` and `over_` prices |
365+| `status` | `available` (routed to and offered), `new` (found, not approved), `deprecated` (the provider stopped listing it), `retired` (staff took it out) |
366+| `priced` | 1 when its prices are known |
367+| `source`, `first_seen_at`, `last_seen_at`, `missing_since`, `approved_by`, `approved_at`, `note` | Where it came from (`discovered` or `staff`) and its history |
368+
369+What each status allows:
370+
371+| Status | Default for a purpose | Offered on the AI Gateway | Charged |
372+| --- | --- | --- | --- |
373+| `available`, priced | Yes | Yes | Yes |
374+| `new`, or unpriced | No | No: refused before it reaches a provider | No |
375+| `deprecated` | No: a default that chose it falls back | Yes, to whoever names it | Yes |
376+| `retired` | No: a default that chose it falls back | No | No |
377+
378+### Discovery
379+
380+The models service lists each provider once a day (`29 5 * * *`, after
381+billing's daily run) and whenever staff press **Check for new models**
382+(`services/models/src/discover.ts`, through its `Discovery` entrypoint,
383+which only sudo binds). Listing models is free; nothing calls a model.
384+
385+| Provider | How it is listed | Credentials (on g1t-models) |
386+| --- | --- | --- |
387+| Anthropic | `GET /v1/models` through g1t's AI Gateway (`…/anthropic/v1/models`, tagged `task: discovery`), or straight to Anthropic with g1t's key when there is no gateway | `AI_GATEWAY_TOKEN` (the gateway holds Anthropic's key), or `ANTHROPIC_API_KEY` |
388+| Workers AI | `GET /accounts/{account}/ai/models/search` | `WORKERS_AI_TOKEN` (Workers AI Read), else `AI_GATEWAY_TOKEN` |
389+
390+Each provider's list goes to billing's `record_discovery`, which compares
391+it with the catalogue:
392+
393+- **An id it has never seen** is added as `new`. Chat and embeddings
394+ models only. Anthropic models are priced from the price table in
395+ `catalogue.rs` (`ANTHROPIC_PRICES`: Anthropic's list prices by model, a
396+ dated id priced as its model) when it has them; Workers AI models from
397+ the price their listing gives, with cached tokens at the input price.
398+ Anything else is added unpriced. Its family and tier hint come from its
399+ id (`claude-haiku-*` is fast, `claude-sonnet-*` standard,
400+ `claude-opus-*` and `claude-fable-*` most capable).
401+- **A dated id of a model it has** (`claude-sonnet-5-5-20261001`) is that
402+ model: the id is added to its aliases.
403+- **A model it has that is listed** gets `last_seen_at`, and its context
404+ window, output limit and capabilities from the list.
405+- **A model it has that is not listed** (available or new) becomes
406+ `deprecated`, with `missing_since`. Listed again, it goes back to
407+ `available` if it was ever approved, else to `new`.
408+- **An empty list or a failed one** changes nothing: it is recorded as a
409+ failed check with the provider's answer (never a key).
410+
411+Every check is kept 90 days in `model_checks` and shown on the page. When a
412+check adds, deprecates or restores anything, it is in the audit log
413+(`models_discovered`, as `schedule` or the staff member) and staff are
414+emailed at `COSTS_ALERT_EMAIL` with a link to Agents & models.
415+
416+When Anthropic publishes a new model's price, add a row to
417+`ANTHROPIC_PRICES`, so the next one of its kind arrives priced. Until then
418+staff enter the prices when they approve it.
419+
420+### Approving, retiring and restoring
421+
422+On Agents & models, **New models** lists every `new` model with its prices
423+filled in where they are known. To approve one:
424+
425+1. Check the name people see and the tier it suits.
426+2. Check or enter its prices per million tokens against the provider's
427+ price page: input, output, cache read, cache writes (five-minute and
428+ hour-long; an hour-long price left empty is the five-minute one), and
429+ under **Priced by prompt length** the threshold and the prices above it.
430+3. Say why (for example where the prices came from), and **Approve**
431+ (`admin_decide_model`, `approve`).
432+
433+It is `available` at once: the AI Gateway offers it within five minutes
434+(the proxy keeps the catalogue that long) and it can be chosen as a
435+default. **Retire** takes a model out of routing and the gateway; any
436+default that chose it falls back to the next suitable model until staff
437+choose another, and the audit line names those defaults. **Restore** puts
438+a retired or deprecated model back: `available` if it was ever approved,
439+else `new`. Each needs a reason and is in the audit log (`model_approved`,
440+`model_retired`, `model_restored`).
441+
442+### Defaults
443+
444+`model_defaults` holds staff's choice per purpose, each with when, who and
445+why (`admin_set_model_default`; the audit log, `model_default`, has the old
446+value, the new one and why):
447+
448+| Purpose | What it chooses | Read by |
449+| --- | --- | --- |
450+| `tier_small`, `tier_large`, `tier_frontier` | The model behind Auto's fast, standard and most capable tiers | The runner |
451+| `background` | The harness's own small tasks in every run on g1t's tiers (`ANTHROPIC_SMALL_FAST_MODEL`, `ANTHROPIC_DEFAULT_HAIKU_MODEL`) | The runner |
452+| `gateway_first` | The Claude listed first by `GET /openai/v1/models` | Billing's `gateway_models` |
453+| `job_implement`, `job_revise`, `job_answer`, `job_review`, `job_update`, `job_plan` | Each kind of job's starting tier (`small`, `large`, `frontier`; a review also `change`, sized by its change) and effort (`low` to `max`, or none for the harness's own) | The runner |
454+
455+A model purpose takes only an available, priced Claude chat model (the
456+harness speaks Anthropic's API). The migration seeded each with what
457+`AGENT_ROUTING` had.
458+
459+Before a default is saved, sudo shows it beside the current one with what
460+a **typical run** would cost on each: 40 requests of 2,000 input, 1,500
461+output, 45,000 cache-read and 4,000 cache-write tokens, each request priced
462+on its own at the catalogue's prices (`catalogue::TYPICAL`; on 2026-10-08,
463+$0.076 on Claude Haiku 5.5, $1.34 on Sonnet 5.5, $2.68 on Opus 5.5). It is
464+an estimate for comparing models, never a charge.
465+
466+**How the runner reads them.** `model_defaults` (the RPC) returns each
467+purpose's model as it applies now, with its prices and capabilities, and
468+each job's tier and effort. The runner reads it at most once a minute per
469+isolate and puts it over `AGENT_ROUTING` (`withDefaults` in
470+`services/runner/src/model-env.ts`), so a change reaches runs within a
471+minute. When billing cannot be read, the runner uses `AGENT_ROUTING` (and
472+`DEFAULT_ROUTING` under it) alone and asks again ten seconds later. The
473+labels, change sizes, `frontierAfter` and learning always come from
474+`AGENT_ROUTING`. Effort is not sent on a model whose catalogue entry lacks
475+`effort` (Claude Haiku 4.5).
476+
477+**Never a retired model.** A chosen model that is deprecated, retired or
478+unpriced is not handed out: the purpose falls back to the first available,
479+priced Claude with the same tier hint (any Claude for `gateway_first`), in
480+the catalogue's order, with a sentence such as *Claude Haiku 5.5 is
481+retired; using Claude Haiku 4.5 instead.* The runner adds that sentence to
482+the run's reason line, and sudo shows it beside the default. With no model
483+left for a purpose, the runner keeps `AGENT_ROUTING`'s.
484+
485+**Not a default:** the context hub's embeddings model
486+(`@cf/baai/bge-base-en-v1.5`, in `services/context`). Its vectors are
487+only comparable with others from the same model, so changing it means a
488+new index, rebuilt; it is pinned in code, and listed in the catalogue with
489+its price. No other g1t service calls a model.
490+
491+Customers never choose among these: they keep **Auto**, or pin a tier per
492+kind of work, and nothing customer-facing names the catalogue.
493+
347494 ## Discounts
348495
349496 An account's terms are standard, or custom: a **discount** from 1 to 100%,
+75−21
198198
199199 | Image | Built from | Holds | Rebuilt |
200200 | --- | --- | --- | --- |
201−| **Base**, `g1t-runner:base-<date>-<inputs>` | `services/runner/base/Dockerfile` | Debian bookworm, Node 24, Python 3.11, Go (from go.dev), Rust stable for the `node` user with rustfmt, clippy and the `wasm32-unknown-unknown` target, build-essential, git, ripgrep, jq, zstd, sudo, and the pinned Claude Code CLI on top | When its folder changes, weekly, or by hand (`build-base`) |
201+| **Base**, `g1t-runner:base-<date>-<inputs>` | `services/runner/base/Dockerfile` | Debian bookworm, Node 24, Python 3.11, Docker (Engine, Buildx, Compose, from Docker's apt repository), Go (from go.dev), Rust stable for the `node` user with rustfmt, clippy and the `wasm32-unknown-unknown` target, build-essential, musl-tools, git, ripgrep, jq, zstd, sudo, and the pinned Claude Code CLI on top | When its folder changes, weekly, or by hand (`build-base`) |
202202 | **Runner**, `g1t-runner:<content hash>` | `services/runner/Dockerfile`: `FROM` the base, plus one file | The g1t runner, a static binary | When the binary or the base changes |
203203
204204 Both are pushed to one repository of Cloudflare's registry,
255255 1. A deploy computes the tag and asks the registry whether it is there
256256 (a `HEAD` of its manifest, with credentials from Wrangler; no Docker).
257257 2. If it is, nothing is built: the deploy uses it.
258−3. If not, and Docker is here, it builds the binary and the image (seconds
259− on a warm machine) and pushes it.
260−4. If not, and Docker is not here (a g1t Actions sandbox), the unit fails
261− saying to run `node scripts/deploy.mjs image` on a machine with Docker;
262− then re-run the workflow.
258+3. If not, it builds the binary and the image (seconds on a warm machine)
259+ and pushes it. In `deploy.yml` that is the `runner-image` job, on
260+ `g1t-4core`, with the job's own Docker Engine (see
261+ [Docker in workflow jobs](#docker-in-workflow-jobs)): it adds the musl
262+ target, builds the binary natively (the base has `musl-gcc`), pulls the
263+ base from Cloudflare's registry, builds, and pushes one layer.
264+4. If Docker does not answer (a machine without it, or jobs with Docker
265+ turned off), the unit fails saying to run `node scripts/deploy.mjs
266+ image` on a machine with Docker; then re-run the workflow.
263267
264268 Then `wrangler deploy` is given the image by reference, from a generated
265269 config (`services/runner/wrangler.deploy.json`, deleted after, ignored by
277281
278282 `.g1t/workflows/runner-base.yml` does the same weekly (and when the
279283 base's folder changes on `main`), and opens a pull request with
280−`base.json`. It needs Docker, so it runs on a self-hosted runner with the
281−`docker` label (`runs-on: [self-hosted, docker]`). Until one is
282−registered, its runs wait for one; run `build-base` by hand instead.
284+`base.json`. It runs on a self-hosted runner with the `docker` label
285+(`runs-on: [self-hosted, docker]`): g1t's own machines have Docker now,
286+but the base's build downloads from Docker's apt repository over HTTPS,
287+which does not trust a guarded job's egress certificate, so it stays on an
288+open network. Until a runner is registered, its runs wait for one; run
289+`build-base` by hand instead.
283290
284291 **Sandboxes start from the image.** Cloudflare pulls an image to a
285292 machine the first time a sandbox lands there, and keeps it. A smaller base
298305 their CPU (see the public billing guide). The account's Containers limits
299306 must allow `standard-4`; Wrangler refuses the deploy otherwise.
300307
308+#### Docker in workflow jobs
309+
310+Workflow jobs on g1t's machines have a Docker Engine of their own
311+(`crates/runner/src/docker/`; the public guide is
312+`apps/docs/src/content/docs/guides/actions.md`, "Docker"). What Cloudflare
313+Containers allow decides how it runs (findings in `docs/PLAN.md`,
314+"Docker in workflow jobs"):
315+
316+| Piece | What it does |
317+| --- | --- |
318+| `dockerd` | Started as root with `sudo`, only when the job first uses Docker or has `services:` or `container:`. Flags: `--iptables=false --ip6tables=false --ip-forward=false` (Containers allow neither), the containerd image store, Docker Hub through `mirror.gcr.io`. Its config, socket and log are in `/run/g1t-docker` (`dockerd.log` is the place to look); its data in `/var/lib/docker`, on overlays when the disk takes them, plain copies (`native`) when not. |
319+| `/var/run/docker.sock` | The runner's API proxy (`docker/api.rs`), which starts the Engine on the first connection. Containers that ask for a bridge network get the job's own (`host`), the names they would have had resolve to 127.0.0.1, and ports published under another number are forwarded. |
320+| `runc` | The Engine finds the runner binary first on its `PATH` as `runc` (`docker/oci.rs`): BuildKit's `RUN` steps join the job's network, and in a guarded job every container gets the egress certificate at `/dev/g1t-egress`. Then the real `/usr/bin/runc` runs. |
321+| cgroups | Before the Engine starts, the sandbox's processes move to a cgroup of their own and every controller is handed down, as Docker's own Docker-in-Docker image does, so `--cpus` and `--memory` work. |
322+
323+- **Turning it off:** set the runner Worker's `DOCKER` var to `off`
324+ (`services/runner/wrangler.jsonc`) and deploy the runner: new jobs get
325+ no Engine (`G1T_DOCKER=off`), and jobs that need one fail saying Docker
326+ does not answer. Anything else is on.
327+- **Network:** containers share the job's network, so the guardrails, the
328+ workflow-only domains and the egress Worker apply to them unchanged. The
329+ public registries are in `BUILD_HOSTS` (`services/runner/src/egress.ts`).
330+- **Isolation:** one Engine per job, inside the job's sandbox (its own
331+ VM), gone with it. The job already had root through `sudo`; Docker adds
332+ no reach beyond the sandbox, and no host socket is ever mounted into one.
333+- **Checked locally** (2026-10-08, Docker Desktop, the base and runner
334+ image built from this tree, a privileged container standing in for a
335+ sandbox, a pretend API): services with health checks, `localhost` and
336+ names, port forwarding, `docker build` with a networked `RUN`, Compose
337+ with a healthy dependency, `docker://` steps, a Dockerfile action, a
338+ `container:` job with a JavaScript action, an Alpine job container, the
339+ egress certificate in `run`, `exec` and build steps and in no layer,
340+ plain-copy storage, and the deploy's own build and push to a registry.
341+ Not yet seen on Cloudflare itself: watch the first runs' logs for the
342+ `Docker: started` line, and `dockerd.log` if it does not come.
343+
301344 ## Build speed
302345
303346 Measured on the development machine (Windows, 32 cores, warm Cargo cache),
366409 - **Build groups:** a stage's units are split so each job shares a build:
367410 Rust workers at most four to a job (each a 4-vCPU `g1t-4core` machine), the
368411 TypeScript Workers together, each site alone, and a unit whose image must
369− be rebuilt alone. `fail-fast: false`, so one failed job does not cut
412+ be rebuilt alone (`image: true` in the matrix, also on `g1t-4core`, where
413+ it builds and pushes the image with the job's own Docker Engine). `fail-fast: false`, so one failed job does not cut
370414 another off mid-upload; the next stage then does not start.
371415 - **Tests:** there is no CI workflow on g1t yet; `main` is kept passing by
372416 the merge queue's checks. `check` runs the deploy tool's own tests. When a
373417 CI workflow is added, make `plan` wait for it (`workflow_run`, or a job in
374418 this file).
375−- **Machines:** Rust jobs run on `g1t-4core` (4 vCPUs, 12 GiB), the
376− others on the standard machine (`runs-on: ${{ matrix.rust && 'g1t-4core' || 'ubuntu-latest' }}`).
419+- **Machines:** Rust jobs and the runner's image run on `g1t-4core` (4 vCPUs,
420+ 12 GiB, 20 GB), the others on the standard machine
421+ (`runs-on: ${{ (matrix.rust || matrix.image) && 'g1t-4core' || 'ubuntu-latest' }}`).
422+ The image job needs the room: the base it builds on is about 3.2 GB
423+ unpacked.
377424 - **Caching** (`actions/cache`: up to 2 GB an entry, 10 GB a repository,
378425 kept until unused for 7 days): the worker-build binary, worker-build's
379426 downloaded tools, `~/.cargo/registry/cache`, and the Cargo target's
395442 ### What the sandbox has
396443
397444 The base image (`services/runner/base/Dockerfile`) has Node 24, npm, git,
398−Go, zstd, and Rust stable for the `node` user with rustfmt, clippy and the
399−`wasm32-unknown-unknown` target, but not worker-build or Docker. The
400−workflow's `rustup target add wasm32-unknown-unknown` is then a no-op, and
401−worker-build is restored from the cache, installed on a miss. worker-build
445+Go, zstd, Docker, musl-tools, and Rust stable for the `node` user with
446+rustfmt, clippy and the `wasm32-unknown-unknown` target, but not
447+worker-build. The workflow's `rustup target add wasm32-unknown-unknown` is
448+then a no-op, and worker-build is restored from the cache, installed on a
449+miss. The image job adds `x86_64-unknown-linux-musl` (about 30 MB from
450+`static.rust-lang.org`) and keeps its Cargo target in the cache. worker-build
402451 fetches wasm-bindgen and wasm-opt from GitHub releases and esbuild from
403452 npm. All of those hosts are on the list every workflow job may reach.
404453
418467 ```
419468
420469 `api.cloudflare.com` is Wrangler's API; `registry.cloudflare.com` is where
421−the deploy asks whether the runner's image is already built (and where
422−`runner-base.yml` pushes). Only `deploy.yml`'s and `runner-base.yml`'s
470+the deploy asks whether the runner's image is already built, and where the
471+`runner-image` job pulls the base from and pushes the runner's image to
472+(as `runner-base.yml` pushes the base). The job's Docker Engine shares the
473+job's network, so these lines are what let it reach the registry. If a pull
474+is refused with `g1t guardrails: <host> is not on this project's allowed
475+domains`, the registry sent the layers from another host: add that host on
476+the same line. Only `deploy.yml`'s and `runner-base.yml`'s
423477 jobs with `environment: production` reach them, which are also the only
424478 jobs that can read `CLOUDFLARE_API_TOKEN`. Each change to the list is in
425479 the workspace's audit log as `update_guardrails`.
436490 | Account | Queues: Edit | Attaching each unit's queue consumers on deploy |
437491 | Account | Workers R2 Storage: Read | Wrangler checks `og`'s bucket binding |
438492 | Account | Account Settings: Read | Wrangler reads the account |
439−| Account | Containers: Edit | The runner's deploy updates its applications (the image reference, the three classes), and gets registry credentials to look for its image. `runner-base.yml` pushes images with it. |
493+| Account | Containers: Edit | The runner's deploy updates its applications (the image reference, the three classes), and gets registry credentials (`wrangler containers registries credentials --push`, one hour) to look for, pull and push its image. `runner-base.yml` pushes images with it. |
440494 | Zone (`g1t.sh`, `g1t.page`) | Workers Routes: Edit | `pages`' zone routes, and custom domains |
441495 | Zone (`g1t.sh`, `g1t.page`) | DNS: Edit | Custom domains (`api`, `mcp`, `og`, `models`, `status`, `sudo`, `docs`, `g1t.sh`, `g1t.page`) keep their DNS records |
442496 | Zone (`g1t.sh`, `g1t.page`) | Zone: Read | Finding the zone a route names |
584638 repository from the git remote on g1t.sh. A report that fails is one line
585639 in the log and never fails the deploy.
586640
587−When CI cannot finish a deploy, for example a runner image that must be
588−built (hosted runners have no Docker), deploy from a machine with Docker.
641+When CI cannot finish a deploy, for example a runner image that will not
642+build there, deploy from a machine with Docker.
589643 The report records it, and production shows the commit that really runs.
590644
591645 ## Rolling back
+122−4
479479 commands declared in `.g1t/checks.yaml`, run in sandboxes on every pull request
480480 and on every combined state in the landing queue.
481481
482+### Docker in workflow jobs (built 2026-10-08)
483+
484+> **2026-10-08:** "Why didn't we give CI docker then? We need GitHub
485+> Actions functionality maxxed baby but with all the good good security
486+> etc." The trigger: `deploy.yml`'s runner-image job needs `docker build`
487+> and `docker push`, and failed on hosted runners.
488+
489+**What Cloudflare Containers allow** (their FAQ, updated 2026-10-05, and
490+the Docker-in-Docker guide and example it links):
491+
492+- Docker runs inside a container: `docker:dind`, with `dockerd` as
493+ **root**. A rootless Engine does not start there.
494+- **No iptables**: `--iptables=false --ip6tables=false`, or the Engine
495+ fails setting up its rules. Containers with the `durable_object`
496+ scheduling policy (ours: each sandbox is a Durable Object's) cannot turn
497+ IP forwarding on either: `--ip-forward=false`, or the Engine exits.
498+- So **a bridge network has no way out**: the guide's answer is
499+ `--network=host` for `docker run` and `docker build`, which gives
500+ containers the outer container's network.
501+- Built images and containers are lost when the sandbox stops.
502+
503+**What was found by trying** (Docker Engine 29.8.2 in a privileged
504+container standing in for a sandbox, with the same flags):
505+
506+- `--bridge=none` makes BuildKit's `RUN` steps fail outright ("network
507+ bridge not found"); with the default bridge they run with no route out.
508+ Containers default to the bridge too. The default bridge is created
509+ fine without iptables, as the FAQ's own example relies on.
510+- The overlay snapshotter does not work on an overlay root filesystem, and
511+ the containerd image store does not fall back by itself: the Engine
512+ starts, then every container fails to mount. Whether a sandbox's disk
513+ takes overlays is not documented, so the runner tries a mount first and
514+ uses `native` (plain copies) when it fails. `vfs` is not a name the
515+ containerd store accepts.
516+- `--cpus` and `--memory` need cgroup v2 controllers handed down from a
517+ cgroup with no processes, which `docker:dind`'s entrypoint does and a
518+ plain `dockerd` does not.
519+- A `runc` earlier on the Engine's `PATH` is used both for containers (via
520+ containerd's shim) and by BuildKit's executor, with the bundle's
521+ `config.json` written before it runs. BuildKit's bridged steps carry
522+ libnetwork's `libnetwork-setkey` prestart hook; `RUN --network=none`
523+ does not.
524+- Buildx skips `type=gha` caches when the job has no GitHub cache service,
525+ and the build succeeds without one.
526+- Registries' layer hosts: Docker Hub sends layers from
527+ `production.cloudfront.docker.com` (and `production.cloudflare.docker.com`),
528+ Quay from `cdn0N.quay.io`, Microsoft's from regional
529+ `*.data.mcr.microsoft.com`, public ECR from a CloudFront host;
530+ `mirror.gcr.io` serves its own.
531+
532+**What was built** (`crates/runner/src/docker`, `actions/containers.rs`):
533+
534+- **One Engine per job, started lazily.** The runner listens on
535+ `/var/run/docker.sock` itself and starts `dockerd` (root, the flags
536+ above, containerd image store, `mirror.gcr.io` first for Docker Hub) on
537+ the first connection, or when the job has `services:` or `container:`.
538+ A log line says it started and how long it took.
539+- **Containers on the job's network.** The socket is an API proxy: a
540+ container that asks for a bridge or user network gets `host`; its names
541+ (container name, aliases, Compose service, links) resolve to 127.0.0.1
542+ in later containers (`ExtraHosts`) and in the job's steps
543+ (`/etc/hosts`); `-p 8080:80` is forwarded; `docker inspect` reports the
544+ ports as published; `network connect` adds aliases. Sharing the job's
545+ network is also what keeps the guardrails on every container: the
546+ egress Worker sees their traffic as the job's.
547+- **A `runc` shim.** The runner binary, as `runc`: BuildKit steps bound for
548+ a bridge lose the network namespace and the libnetwork hook (so they use
549+ the job's network); in a guarded job every container (run, exec, build
550+ step) gets the egress certificate at `/dev/g1t-egress` and the
551+ variables that point tools at it. `/dev` is the container's own tmpfs,
552+ so none of it lands in a layer; checked by saving a built image.
553+- **Job features:** `services:` (pull, credentials, health waits, logs at
554+ the end, `job.services.*`), `container:` (steps and JavaScript actions
555+ through `docker exec`, Node mounted from the runner, Alpine falls back to
556+ running actions beside it), `docker://` steps and Dockerfile actions in
557+ GitHub's `/github/*` layout, `docker/setup-buildx-action` answered
558+ natively (the job's Engine is the builder), sign-in to g1t's registry
559+ with the run's token.
560+- **Kill switch:** the runner Worker's `DOCKER` var (`off`).
561+
562+**Rejected:** rootless Docker or BuildKit (does not start in Containers);
563+Podman or buildah with `vfs` (no better networking, less compatible, slow);
564+a standalone `buildkitd --oci-worker-net=host` as the default builder
565+(images not in the Engine's store, so `FROM` a just-built image and
566+`docker run` of a build fail without `--load` round trips); a `docker` CLI
567+wrapper adding `--network=host` (misses Compose, SDKs and testcontainers,
568+which speak the API).
569+
570+**Not yet:**
571+
572+- Seen on Cloudflare itself: whether a sandbox's disk takes overlays,
573+ `--privileged`, and the first deploy's pull of the base from
574+ `registry.cloudflare.com` (its layer host may need a workflow-only line).
575+- `type=gha` build caches backed by g1t's Actions cache.
576+- Multi-platform builds (QEMU's `binfmt_misc` in a sandbox).
577+- Docker for agents and checks, not just workflow jobs.
578+- `runner-base.yml` on g1t's machines: the base's apt step needs
579+ `Acquire::https::CAInfo` pointed at the egress certificate first.
580+- A deploy that appends the runner binary as a layer through the registry
581+ API, with no Docker and no 3 GB pull.
582+
482583 Agents can also reach integrations directly: an agent definition lists MCP
483584 servers (Sentry, Linear and so on) it may use while working.
484585
11801281 - **Tiers and catalogue.** `small` (Claude Haiku 5.5 since 2026-10-08,
11811282 $0.10/$0.50 per million input/output up to 100k-token prompts, five
11821283 times that above; it was Haiku 4.5 at $1/$5), `large` (Claude Sonnet 5.5, $2/$10) and `frontier`
1183− (Claude Opus 5.5, $4/$20). Models, names and list prices are
1184− configuration (`AGENT_ROUTING`), never code; prices there are for
1185− estimates only, runs are charged what AI Gateway priced them at.
1284+ (Claude Opus 5.5, $4/$20). Models, names and list prices are data,
1285+ never code: since 2026-10-08 billing's model catalogue
1286+ (`gateway_models`, one row per model g1t can use) and staff's defaults
1287+ in sudo, **Agents & models** (`model_defaults`: each tier's model, the
1288+ harness's background model, the AI Gateway's first Claude, each job's
1289+ starting tier and effort), read by the runner once a minute over
1290+ `AGENT_ROUTING`, which is only the fallback when billing cannot be read.
1291+ Catalogue prices are for estimates only; runs are charged what AI
1292+ Gateway priced them at.
1293+- **Keeping up with new models (built 2026-10-08).** The models service
1294+ lists Anthropic's models (through the AI Gateway) and Workers AI's daily
1295+ and on demand; a new id lands in the catalogue as `new`, priced from a
1296+ maintained table of Anthropic's list prices or Workers AI's listing, or
1297+ unpriced, and staff are emailed. Nothing routes to it, offers it or
1298+ charges for it until staff approve it with its prices. A model a provider
1299+ stops listing is `deprecated`; routing never sends work to a deprecated
1300+ or retired model, falling back to the next model of the tier and saying
1301+ so on the run. Customers keep Auto: the catalogue is staff's. See
1302+ [BILLING_OPERATIONS.md](BILLING_OPERATIONS.md#the-model-catalogue).
11861303 - **Starting tier by job.** Catch-up, answering a question, and reviews of
11871304 at most 10 files and 200 lines touching no sensitive path: small.
11881305 Plans: small at high effort (Haiku 5.5 takes an effort level).
12011318 *Used a fast model (Claude Haiku 5.5): small change, 3 files and 80
12021319 lines.* Effort per kind of job (`effort` in `AGENT_ROUTING`: plan high,
12031320 answer medium, update low) is sent as `CLAUDE_CODE_EFFORT_LEVEL` on
1204− g1t's tiers and named in that line.
1321+ g1t's tiers (never on a model the catalogue says takes none) and named
1322+ in that line, with the catalogue's name for the model.
12051323 - **Chosen instead.** `model_routes` rows to g1t's models name `small`,
12061324 `large` or `frontier`, or nothing for Auto (Integrations → Models).
12071325 A workspace's own Anthropic key with no model named is routed by Auto
+155−0
681681 setCostMapping(mapping: CostMappingInput, by: string): Promise<Result<CostMapping>>;
682682 /** Reads Cloudflare's bill and reconciles now, as the daily run does. */
683683 runCosts(by: string): Promise<Result<CostsRun>>;
684+ /** Agents & models: the catalogue, each purpose's default, and the latest checks. */
685+ models(): Promise<AdminModels>;
686+ /** Approve a model (its prices confirmed), retire it, or restore it. Needs a reason. */
687+ decideModel(
688+ model: string,
689+ decision: "approve" | "retire" | "restore",
690+ details: { name?: string | null; tierHint?: string | null; prices?: ModelPrices | null },
691+ reason: string,
692+ by: string,
693+ ): Promise<Result<CatalogueModel>>;
694+ /** One purpose's default: a model, or for a job its tier and effort. Needs a reason. */
695+ setModelDefault(
696+ purpose: string,
697+ value: { model?: string | null; tier?: string | null; effort?: string | null },
698+ reason: string,
699+ by: string,
700+ ): Promise<Result<ModelDefault>>;
684701 }
685702
686703 /** How much a workspace has earned g1t's trust with money. */
923940 */
924941 /** What the AI Gateway offers on g1t's key, with prices per million tokens. */
925942 gatewayModels(): Promise<GatewayModel[]>;
943+ /** Every purpose's default model as it applies now, and each job's tier and effort. */
944+ modelDefaults(): Promise<ModelDefaults>;
945+ /** What one provider lists now, from the models service's discovery. */
946+ recordDiscovery(provider: string, models: ProviderModel[], by: string, error?: string | null): Promise<DiscoveryResult>;
926947 /**
927948 * Whether a workspace's next AI Gateway request may go to g1t's models:
928949 * fails with `payment_required` and what to do when it is over its spend
12081229 overCacheWrite1hMicros?: number;
12091230 };
12101231
1232+// --- The model catalogue ------------------------------------------------------
1233+
1234+/** Where a model stands: only `available` ones are routed to. */
1235+export type ModelStatus = "available" | "new" | "deprecated" | "retired";
1236+
1237+/** One model in g1t's catalogue: its prices and what g1t knows about it. */
1238+export type CatalogueModel = GatewayModel & {
1239+ /** Other ids the provider lists it by, such as a dated one. */
1240+ aliases: string[];
1241+ family: string;
1242+ /** The agent tier it suits: `small`, `large`, `frontier`, or empty. */
1243+ tierHint: string;
1244+ contextWindow: number;
1245+ maxOutput: number;
1246+ /** Any of `effort`, `thinking`, `tools`, `vision`, `embeddings`. */
1247+ capabilities: string[];
1248+ dimensions: number;
1249+ status: ModelStatus;
1250+ /** Its prices are known. An unpriced model is never routed to, offered or charged for. */
1251+ priced: boolean;
1252+ source: "discovered" | "staff";
1253+ firstSeenAt: string | null;
1254+ lastSeenAt: string | null;
1255+ missingSince: string | null;
1256+ approvedBy: string | null;
1257+ approvedAt: string | null;
1258+ note: string;
1259+ /** A typical agent run on it, in millionths of a dollar; 0 when unpriced or embeddings. */
1260+ typicalRunMicros: number;
1261+};
1262+
1263+/** One model as its provider lists it, from the models service's discovery. */
1264+export type ProviderModel = {
1265+ id: string;
1266+ name: string;
1267+ /** `chat`, `embeddings`, or anything else (counted as listed, never added). */
1268+ kind: string;
1269+ contextWindow: number;
1270+ maxOutput: number;
1271+ capabilities: string[];
1272+ /** Workers AI lists a price per million tokens; Anthropic does not. */
1273+ price: { inputMicros: number; outputMicros: number } | null;
1274+};
1275+
1276+/** What one check of a provider found. */
1277+export type DiscoveryResult = {
1278+ provider: string;
1279+ checkedAt: string;
1280+ by: string;
1281+ listed: number;
1282+ added: string[];
1283+ deprecated: string[];
1284+ restored: string[];
1285+ error: string | null;
1286+};
1287+
1288+/** The purposes a default model is chosen for. */
1289+export type ModelPurpose = "tier_small" | "tier_large" | "tier_frontier" | "background" | "gateway_first";
1290+
1291+/** One purpose's default, as staff last set it. */
1292+export type ModelDefault = {
1293+ /** A `ModelPurpose`, or `job_<kind>`. */
1294+ purpose: string;
1295+ model: string | null;
1296+ /** For a job: `small`, `large`, `frontier` or `change`. */
1297+ tier: string | null;
1298+ effort: string | null;
1299+ updatedAt: string;
1300+ updatedBy: string;
1301+ reason: string;
1302+};
1303+
1304+/** A model purpose's default as it applies now. */
1305+export type ResolvedModel = {
1306+ purpose: string;
1307+ chosen: string;
1308+ /** The model to use; null when nothing suits (callers keep their own fallback). */
1309+ model: GatewayModel | null;
1310+ capabilities: string[];
1311+ /** Why it is not the chosen model, in a sentence. */
1312+ note: string | null;
1313+};
1314+
1315+/** One kind of agent job's starting tier and effort. */
1316+export type JobDefault = { kind: string; tier: string; effort: string | null };
1317+
1318+/** Every purpose's model as it applies now, and each job's tier and effort. */
1319+export type ModelDefaults = { models: ResolvedModel[]; jobs: JobDefault[] };
1320+
1321+/** One check of one provider. */
1322+export type ModelCheck = {
1323+ id: string;
1324+ provider: string;
1325+ checkedAt: string;
1326+ by: string;
1327+ listed: number;
1328+ added: string[];
1329+ deprecated: string[];
1330+ error: string | null;
1331+};
1332+
1333+/** The tokens of the typical agent run estimates are priced from. */
1334+export type TypicalRun = { requests: number; input: number; output: number; cacheRead: number; cacheWrite: number };
1335+
1336+/** sudo's Agents & models. */
1337+export type AdminModels = {
1338+ catalogue: CatalogueModel[];
1339+ defaults: ModelDefault[];
1340+ resolved: ModelDefaults;
1341+ checks: ModelCheck[];
1342+ typical: TypicalRun;
1343+};
1344+
1345+/** A model's prices as staff confirm them, per million tokens in millionths of a dollar. */
1346+export type ModelPrices = {
1347+ inputMicros: number;
1348+ outputMicros: number;
1349+ cacheReadMicros: number;
1350+ cacheWriteMicros: number;
1351+ cacheWrite1hMicros: number;
1352+ threshold: number;
1353+ overInputMicros: number;
1354+ overOutputMicros: number;
1355+ overCacheReadMicros: number;
1356+ overCacheWriteMicros: number;
1357+ overCacheWrite1hMicros: number;
1358+};
1359+
1360+/** The models service's `Discovery` entrypoint, for sudo's "Check for new models". */
1361+export interface ModelDiscoveryApi {
1362+ /** Lists every provider's models now and records what changed: one result per provider. */
1363+ check(by: string): Promise<DiscoveryResult[]>;
1364+}
1365+
12111366 /** The format a gateway request was sent in. */
12121367 export type GatewayFormat = "anthropic" | "openai";
12131368
+36−0
487487 call("token_usage", { workspace, viewer, person: options.person ?? null, days: options.days ?? null }),
488488 recordTokens: (usage) => call("record_tokens", usage),
489489 gatewayModels: () => call("gateway_models", {}),
490+ modelDefaults: () => call("model_defaults", {}),
491+ recordDiscovery: (provider, models, by, error = null) => call("record_discovery", { provider, models, by, error }),
490492 gatewayAdmit: (workspace) => call("gateway_admit", { workspace }),
491493 recordGateway: (record) => call("record_gateway", record),
492494 gatewayRequests: (workspace, viewer, options = {}) =>
619621 by,
620622 }),
621623 runCosts: (by) => call("admin_run_costs", { by }),
624+ models: () => call("admin_models", {}),
625+ decideModel: (model, decision, details, reason, by) =>
626+ call("admin_decide_model", {
627+ model,
628+ decision,
629+ name: details.name ?? null,
630+ tier_hint: details.tierHint ?? null,
631+ prices: details.prices
632+ ? {
633+ input_micros: details.prices.inputMicros,
634+ output_micros: details.prices.outputMicros,
635+ cache_read_micros: details.prices.cacheReadMicros,
636+ cache_write_micros: details.prices.cacheWriteMicros,
637+ cache_write_1h_micros: details.prices.cacheWrite1hMicros,
638+ threshold: details.prices.threshold,
639+ over_input_micros: details.prices.overInputMicros,
640+ over_output_micros: details.prices.overOutputMicros,
641+ over_cache_read_micros: details.prices.overCacheReadMicros,
642+ over_cache_write_micros: details.prices.overCacheWriteMicros,
643+ over_cache_write_1h_micros: details.prices.overCacheWrite1hMicros,
644+ }
645+ : null,
646+ reason,
647+ by,
648+ }),
649+ setModelDefault: (purpose, value, reason, by) =>
650+ call("admin_set_model_default", {
651+ purpose,
652+ model: value.model ?? null,
653+ tier: value.tier ?? null,
654+ effort: value.effort ?? null,
655+ reason,
656+ by,
657+ }),
622658 };
623659 }
624660
+1−1
265265 }
266266 if (!docker) {
267267 throw new Error(
268− `its image ${tag} is not in the registry, and Docker is not available here. Build and push it from a machine with Docker (node scripts/deploy.mjs image), then run this again.`,
268+ `its image ${tag} is not in the registry, and Docker does not answer here. Build and push it where Docker runs (a g1t Actions job, or a machine with Docker: node scripts/deploy.mjs image), then run this again.`,
269269 );
270270 }
271271 const started = Date.now();
+6−6
455455 const data = planJson(stack, decisions, { events: { pending: ["0003_x.sql"] }, repos: { pending: [] } }, HEAD);
456456 assert.deepEqual(data.migrations, [{ unit: "events", database: "g1t-events", pending: ["0003_x.sql"] }]);
457457 assert.deepEqual(data.stages.core.jobs, [
458− { group: "rust", units: "events,repos", rust: true },
459− { group: "ts", units: "projects", rust: false },
458+ { group: "rust", units: "events,repos", rust: true, image: false },
459+ { group: "ts", units: "projects", rust: false, image: false },
460460 ]);
461− assert.deepEqual(data.stages.edge.jobs, [{ group: "rust", units: "api", rust: true }]);
461+ assert.deepEqual(data.stages.edge.jobs, [{ group: "rust", units: "api", rust: true, image: false }]);
462462 assert.deepEqual(data.stages.front.jobs, [
463− { group: "web", units: "web", rust: false },
464− { group: "docs", units: "docs", rust: false },
463+ { group: "web", units: "web", rust: false, image: false },
464+ { group: "docs", units: "docs", rust: false, image: false },
465465 ]);
466466 assert.deepEqual(data.stage_order, ["core", "edge", "front"]);
467467 assert.deepEqual(buildGroups([]), []);
469469 const core = stack.units.filter((u) => u.stage === "core");
470470 const jobs = buildGroups(core, ["runner"]);
471471 assert.deepEqual(jobs.filter((j) => j.rust).map((j) => j.units.split(",").length), [4, 4, 3]);
472− assert.ok(jobs.some((j) => j.group === "runner-image" && j.units === "runner"));
472+ assert.ok(jobs.some((j) => j.group === "runner-image" && j.units === "runner" && j.image && !j.rust));
473473 assert.ok(!jobs.find((j) => j.group === "ts").units.includes("runner"));
474474 });
475475
+1−0
215215 'echo "rust=$(rustc --version | cut -d" " -f2)"',
216216 'echo "git=$(git --version | cut -d" " -f3)"',
217217 'echo "claude_code=$(claude --version | cut -d" " -f1)"',
218+ 'echo "docker=$(dockerd --version | cut -d" " -f3 | tr -d ,)"',
218219 'echo "debian=$(cat /etc/debian_version)"',
219220 ].join("; ");
220221 const found = await exec("docker", ["run", "--rm", "--platform", "linux/amd64", "--entrypoint", "bash", ref, "-c", script]);
+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

+0−0

Binary or large file; its contents are not shown.

This change is too large to show in full.