zuka
zuka/README.md

worklyn / zukapublic

Agent-first git hosting. One Rust binary: git over HTTP and SSH, a REST API, MCP, CI, and multi-tenant isolation.

Get a copy: git clone https://zuka.worklyn.com/worklyn/zuka.git
zuka/README.md
MDREADME.md19.3 KBFormattedDownload
1# zuka
2
3Git hosting for agents. One Rust binary — bare repositories, SSH and HTTP git, a REST
4API, MCP, and CI. No database, no Docker, no external services.
5
6```
7Status Complete. M0–M7 shipped: standalone and multi-tenant both work.
8Tests 253 unit · 59 live (real git, ssh and MCP against a real server)
9Gate cargo fmt --check · clippy -D warnings · cargo test · deno task test:live
10```
11
12Named for the sound of it rather than the meaning: two syllables, a rare opening
13consonant, one plausible spelling after hearing it once. It sits near Swahili
14*kuzuka* — to surface, to emerge — without claiming to be it.
15
16---
17
18## The idea
19
20**Agents already know git. What they can't do is the GitHub part.**
21
22Claude, Cursor and the rest run `git clone`, `commit` and `push` perfectly well. What
23stops a non-developer shipping with them is everything bolted on top: creating the
24repository, orgs and teams, deploy keys, permission matrices, pull requests, review,
25Actions YAML.
26
27So the split is strict:
28
29| | |
30|---|---|
31| **git does the git** | Code moves over the wire protocol, HTTP or SSH. Nothing proprietary. |
32| **the API does the rest** | Create a repo, register a key, read a file, check CI — over REST and MCP. |
33
34**Nothing in the API writes to a repository.** No commit endpoint, no file endpoint,
35no ref-moving endpoint. A second write path would mean two homes for the
36fast-forward rule, two concurrency models and a merge story — git already has all
37three. The write path is `git push`.
38
39What's deliberately absent is the point: no organisations, no teams, no pull
40requests, no review, no issues. The whole model is accounts, repositories, refs, keys
41and runs.
42
43---
44
45## Install onto a host
46
47```sh
48sudo zuka setup --standalone --dry-run # prints every file it would write
49sudo zuka setup --standalone
50```
51
52It writes `/etc/zuka.env` and two systemd units — the service and maintenance
53separately, because a long repack must not stall requests — then prints the remaining
54commands rather than running them. Creating users and enabling units are steps
55someone may reasonably want to do differently.
56
57**`--domain` is optional, and does one thing:** it adds a Caddyfile so Caddy obtains
58and renews TLS for that name, and sets the public URLs advertised in clone URLs.
59Without it you get a service on `127.0.0.1:8790` with no TLS, which is right behind
60an existing proxy or for a local trial.
61
62```sh
63sudo zuka setup --standalone --domain git.example.com
64```
65
66**`--isolated`** installs the multi-tenant shape: a control plane that holds no
67repositories and gives every account its own Incus container. Code, CI and objects
68for one account never touch another's filesystem. `--domain` is optional here too and
69means exactly the same thing — the control plane is the only public listener, so it
70is the only thing a certificate is for.
71
72```sh
73sudo zuka setup --isolated
74curl -X POST localhost:8790/v1/accounts/alice # returns immediately
75```
76
77Set a public URL if you have one: a tenant is only reachable *through* the control
78plane, so left to itself it advertises its own container address and hands callers a
79clone URL they cannot use.
80
81Provisioning never blocks a request. The account records intent and a reconciler
82drives it to `ready`; a request arriving early gets `429` with a `Retry-After`.
83Tenants are stopped when idle and started again on demand.
84
85The tenant binary must be **statically linked** — the control plane pushes it into a
86container whose C library is not the host's:
87
88```sh
89cargo build --release --target x86_64-unknown-linux-musl
90```
91
92## Quickstart
93
94Needs `git` ≥ 2.41 on the host.
95
96```sh
97cargo build --release
98export ZUKA_DATA_DIR=/tmp/zuka && mkdir -p "$ZUKA_DATA_DIR"
99
100TOKEN=$(./target/release/zuka token alice --scopes admin) # shown once
101./target/release/zuka
102# [boot] listening on http://127.0.0.1:8790
103# [ssh] listening on ssh://127.0.0.1:2222
104```
105
106**Create and push**
107
108```sh
109curl -X POST localhost:8790/v1/repos \
110 -H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' \
111 -d '{"name":"site"}'
112
113git clone "http://x-access-token:$TOKEN@127.0.0.1:8790/alice/site.git"
114cd site && echo hello > README.md
115git add . && git commit -m "first" && git push origin HEAD:refs/heads/main
116```
117
118The git username is ignored; the password is your token.
119
120**Use SSH instead**
121
122```sh
123./target/release/zuka key alice ~/.ssh/id_ed25519.pub --title laptop
124git clone ssh://git@127.0.0.1:2222/alice/site.git
125```
126
127`--read-only` makes a key clone-only; `--repos a,b` confines it.
128
129**Read without cloning**
130
131```sh
132A="Authorization: Bearer $TOKEN"; R=localhost:8790/v1/repos/alice/site
133curl -H "$A" $R/refs
134curl -H "$A" "$R/tree/src?ref=refs/heads/main"
135curl -H "$A" "$R/raw/README.md"
136curl -H "$A" "$R/search?q=TODO"
137curl -H "$A" "$R/blame/src/main.rs"
138curl -H "$A" "$R/compare?base=<sha>&head=<sha>"
139```
140
141The ref is always a **query parameter**, never a path segment — `feature/login` and
142`src/main.rs` both contain slashes, so `/raw/{ref}/{path}` has no unique parse.
143
144**Turn on CI**
145
146```sh
147ZUKA_CI_ENABLED=1 ./target/release/zuka
148```
149
150```toml
151# .zuka.toml, in the repository
152[run]
153steps = ["cargo fmt --check", "cargo test"]
154timeout_secs = 600
155branches = ["refs/heads/main"] # optional; omit to run everywhere
156```
157
158Push, then `curl -H "$A" $R/runs` and `$R/runs/{id}/logs`.
159
160---
161
162## For agents — MCP
163
164```
165POST /mcp JSON-RPC 2.0: initialize · ping · tools/list · tools/call
166```
167
168The intended session: **`repo_create` → `key_add` → plain `git clone`/`commit`/`push`
169→ `run_list`**. There is no tool that writes to a repository, and `initialize` says
170so, so a model doesn't go hunting for one.
171
172A tool failure that's the caller's fault comes back as a tool result with
173`isError: true`, not a JSON-RPC error — the model should see it and adapt, not have
174the call look broken.
175
176<details>
177<summary><b>18 tools</b></summary>
178
179**Administration** — the part git can't do
180| Tool | |
181|---|---|
182| `repo_create` | Create a repository. Returns both clone URLs. |
183| `repo_list` `repo_get` `repo_delete` | |
184| `key_add` `key_list` `key_remove` | Register an SSH key so the agent can then use git. |
185
186**Discovery** — read without cloning
187| Tool | |
188|---|---|
189| `ref_list` | Branches, tags, notes with tip commits. |
190| `tree_list` | List a directory. |
191| `file_read` | Read a text file. |
192| `search` | Fixed-string search across the tree. |
193| `commit_log` `commit_get` | History; optionally path-filtered. |
194| `compare` | Diff from the merge base. |
195| `blame` | Who last changed each line. |
196
197**CI**
198| Tool | |
199|---|---|
200| `run_list` `run_get` `run_logs` | Did the build pass, and why not. |
201
202</details>
203
204---
205
206## API
207
208Full contract: [`openapi.json`](openapi.json), also served at `/openapi.json`.
209
210| | |
211|---|---|
212| `GET /healthz` | Mode, version, disk headroom, CI state. `503` below the reserve. |
213| `GET /openapi.json` | This service's OpenAPI 3.1 document. |
214| `GET /v1/account` | Identity, scopes, storage usage and limits. |
215| `GET POST /v1/repos` | List, create (optionally `import_url`). |
216| `GET PATCH DELETE /v1/repos/{account}/{repo}` | Read, update settings, soft-delete. |
217| `GET .../refs?type=` | Branches, tags, notes. |
218| `GET .../tree/{path}?ref=` | Directory listing. |
219| `GET .../raw/{path}?ref=` | File bytes. |
220| `GET .../commits?ref=&path=&limit=` | Log. |
221| `GET .../commits/{sha}` | One commit and its changed paths. |
222| `GET .../compare?base=&head=` | Diff from the merge base. |
223| `GET .../search?q=&path=&ref=` | Content search. |
224| `GET .../blame/{path}?ref=` | Line attribution. |
225| `GET POST .../runs` · `GET PATCH .../runs/{id}` · `GET .../runs/{id}/logs` | CI. |
226| `GET POST /v1/keys` · `DELETE /v1/keys/{id}` | SSH keys. |
227| `POST /mcp` | MCP JSON-RPC. |
228| `/{account}/{repo}.git/*` | Git smart HTTP. Protocol v2. |
229| `ssh://git@host/{account}/{repo}.git` | Git over SSH, plus `git archive --remote`. |
230
231Errors on `/v1` and `/mcp` are RFC 9457 `application/problem+json` with a stable
232`type` URI — discriminate on `type`, never on `detail`. The git paths answer in
233`text/plain` instead, because git prints the body straight at the user.
234
235Lists return `{items, truncated}`. There is no cursor, and none is claimed.
236
237### CLI
238
239```
240zuka serve
241zuka setup --standalone|--isolated install onto this host [--domain d] [--dry-run]
242zuka jobs maintenance daemon (own service unit)
243zuka gc | sweep | fsck [--repair] one-shot maintenance
244zuka token <account> [--scopes s] [--repos r] [--days n] [--no-expiry]
245zuka key <account> <path.pub> [--title t] [--read-only] [--repos r]
246zuka version
247```
248
249Scopes: `repo:read`, `repo:write`, `admin`, `ci`. Tokens expire in 90 days unless you
250pass `--days` or opt out with `--no-expiry`, which warns. Credentials added while the
251server runs take effect without a restart.
252
253`fsck` exits non-zero on an orphan so a timer notices. It reports a repository with no
254metadata but never deletes one — repair only removes records that describe nothing.
255
256---
257
258## Configuration
259
260Everything has a working default; nothing is required.
261
262<details>
263<summary><b>All settings</b></summary>
264
265| Variable | Default | |
266|---|---|---|
267| `ZUKA_BIND` | `127.0.0.1:8790` | HTTP listener. |
268| `ZUKA_SSH_BIND` | `127.0.0.1:2222` | SSH listener; `off` disables. |
269| `ZUKA_DATA_DIR` | `/var/lib/zuka` | Everything on disk. |
270| `ZUKA_PUBLIC_URL` / `_SSH` | derived | Advertised clone URLs. |
271| `ZUKA_PROBLEM_BASE` | `https://zuka.dev/problems` | Point error `type` URIs at your own docs. |
272| **Limits** | | |
273| `ZUKA_MAX_REPOS_PER_ACCOUNT` | `100` | `0` disables. |
274| `ZUKA_MAX_REPO_MB` / `_ACCOUNT_MB` | `2048` / `10240` | `0` disables. |
275| `ZUKA_RATE_PER_MINUTE` | `600` | Per credential, on `/v1` and `/mcp`. `0` disables. |
276| `ZUKA_MAX_PACK_MB` | `512` | Largest push. Also sets `receive.maxInputSize`. |
277| `ZUKA_MAX_BLOB_MB` | `32` | Largest blob served inline. |
278| `ZUKA_MAX_BODY_MB` | `10` | Largest JSON body. |
279| `ZUKA_PAGE_LIMIT` / `_MAX_PAGE_LIMIT` | `50` / `200` | |
280| `ZUKA_MAX_CONCURRENT_GIT` | `8` | Concurrent git subprocesses. |
281| `ZUKA_DISK_RESERVE_MB` | `2048` | Below this, writes return `507`. |
282| **CI** | | |
283| `ZUKA_CI_ENABLED` | `0` | Off by default; see below. |
284| `ZUKA_CI_TIMEOUT_SECS` | `600` | Ceiling. A spec may lower it, never raise it. |
285| `ZUKA_CI_MAX_CONCURRENT` | `2` | |
286| `ZUKA_CI_LOG_MB` | `8` | Output cap per run. |
287| `ZUKA_CI_MEMORY_MB` | `2048` | `RLIMIT_AS`. `0` disables. |
288| `ZUKA_CI_MAX_PROCESSES` | `0` | `RLIMIT_NPROC` — per-UID, so only meaningful with a runner uid. |
289| `ZUKA_CI_KEEP_RUNS` | `50` | Runs retained per repository. |
290| **Maintenance** | | |
291| `ZUKA_GC_INTERVAL_SECS` | `21600` | |
292| `ZUKA_GC_PRUNE_GRACE` | `2.weeks.ago` | Objects younger than this are never pruned. |
293| `ZUKA_DELETED_RETENTION_DAYS` | `7` | |
294| **Import** | | |
295| `ZUKA_IMPORT_ENABLED` | `1` | |
296| `ZUKA_IMPORT_TIMEOUT_SECS` | `300` | |
297| `ZUKA_IMPORT_ALLOW_PRIVATE` | `0` | Permit private/loopback sources. Only on a trusted network. |
298| **Multi-tenant** | | |
299| `ZUKA_MODE` | `standalone` | `standalone` · `control` · `tenant` |
300| `ZUKA_INCUS_IMAGE` | `images:debian/12` | Image for a new tenant. |
301| `ZUKA_INCUS_NETWORK` | `incusbr0` | Network a tenant joins. |
302| `ZUKA_TENANT_BINARY` | this binary | Pushed into each container; must be static. |
303| `ZUKA_TENANT_PORT` | `8790` | Port a tenant listens on inside its container. |
304| `ZUKA_CONTROL_PUBLIC_KEYS` | — | Tenant only. Comma-separated; more than one allows key rotation. |
305| `ZUKA_PROXY_TIMEOUT_SECS` | `300` | Ceiling on one proxied request. |
306
307</details>
308
309`ZUKA_ME_URL` is parsed and then **refuses to boot** — hosted auth isn't
310implemented, and accepting a security-relevant setting while ignoring it is worse
311than not accepting it.
312
313### Maintenance
314
315Run `zuka jobs` as a second service unit — a long repack must not stall request
316handling.
317
318```ini
319[Unit]
320Description=zuka maintenance
321After=zuka.service
322[Service]
323ExecStart=/usr/local/bin/zuka jobs
324EnvironmentFile=/etc/zuka.env
325User=zuka
326Restart=always
327[Install]
328WantedBy=multi-user.target
329```
330
331It garbage-collects (git's own auto-gc is disabled on every repo, so a repack never
332fires *inside* a push), sweeps deleted repositories and old runs, and fscks.
333
334What makes GC safe against a live clone is the **grace window**, not a lock — a lock
335couldn't span the two processes. `upload-pack` can reference an object between
336resolving and streaming it, so nothing younger than `GC_PRUNE_GRACE` is pruned. A
337test clones a 12 MB repo while GC runs and checks the result byte for byte.
338
339Worth stating plainly: **a committed secret stays fetchable by sha until GC prunes
340it.** Force-pushing past it moves the ref, not the object.
341
342---
343
344## Security
345
346Each of these has a test that fails if it regresses. Several are here because an
347earlier version got them wrong.
348
349**Names can't escape the data directory.** Ref names go through `gix-validate`, never
350a hand-rolled check — `../../config` is a command-execution primitive via
351`core.pager` and `core.sshCommand`. Tree paths reject any `.git` component after
352Unicode normalisation, case folding, HFS-ignorable-codepoint stripping and trailing
353dot/space removal.
354
355**Names are case-folded.** `Alice/Site` and `alice/site` are one resource. Comparing
356the raw path segment once let a case-variant account reach another account's
357repository on a case-insensitive filesystem while passing the ownership check.
358
359**An unreadable repository is `404`, not `403`.** `403` enumerates private repos.
360Confinement applies to destruction too: a token scoped to one repo can't delete
361another.
362
363**Rewriting is allowed; protection is opt-in.** `rebase` and `--amend` are ordinary
364git and blocking them protects nobody when there's one agent and no reviewer — git's
365reflog is the recovery path. Set `protected_refs` on the branch something deploys
366from; it's then enforced server-side over HTTP *and* SSH, which matters because
367`--force` bypasses the client check. One Rust function; the hook is a one-line `exec`
368into the binary.
369
370**SSH runs git and nothing else.** No shell, no pty, no subsystem. Three services:
371`upload-pack`, `receive-pack`, `upload-archive`. The exec string is parsed, never
372shelled, so `git-upload-pack '/a/b.git'; id` is a parse error. The account comes from
373*which registered key matched*, never the SSH username. Only `GIT_PROTOCOL` crosses
374into the child.
375
376**Only three endpoints route under `.git`.** The dumb protocol isn't served. A
377duplicated `service` parameter is refused — this server would take the first and the
378CGI the last, so a duplicate would let the authorization decision and the running
379service disagree.
380
381**Import can't reach the local network.** The one place a caller-supplied URL is
382fetched. Checked in two stages: shape (scheme, credentials) then *every address the
383host resolves to* — checking the hostname alone is defeated by a name that resolves
384to `169.254.169.254`.
385
386**Credentials are hashed, scoped, expiring and `0600`.** Token comparison has no
387early return. Public keys match on raw bytes; `authorized_keys` option prefixes like
388`command=` are refused.
389
390**Deletes are recoverable.** Soft-deleted to `tmp/deleted/`, swept on retention.
391
392**Nothing buffers a pack.**
393
394```
395 before after
396200 MB push, server RSS ~360 MB 5 MB
3974 concurrent clones 1508 MB 5 MB
398```
399
400The child is killed by **process group** on disconnect — `kill_on_drop` reaches only
401the direct child, and `pack-objects` is a grandchild that otherwise kept running for
402seconds after the client left.
403
404### Multi-tenant isolation
405
406In `--isolated`, each account gets its own Incus container, unprivileged and with
407nesting off. The control plane holds no repositories: a push through it lands in the
408account's container and nowhere on the host.
409
410A tenant is told who is calling by an **Ed25519 assertion the control plane signs**,
411not a shared secret. That matters because a tenant runs the account's own CI, which
412is arbitrary code — anything symmetric in there would forge every other tenant. The
413private key never leaves the control plane; tenants hold only the public half, which
414forges nothing.
415
416The assertion authenticates one request, not an identity: single-use nonce, 30-second
417window, bound to the method and path. It is deliberately *not* bound to the body,
418because a push is a multi-gigabyte pack the proxy streams — the nonce is what stops
419replay.
420
421Verified on a real host rather than asserted, in
422[`infra/README.md`](../../infra/README.md): CI inside a tenant reports
423`uname -n` = `zuka-alice`, and a second account gets `404` on the first's
424repository and an empty repo list.
425
426### CI is not a security boundary in standalone
427
428Steps run as the service user with rlimits, a scrubbed environment, a process-group
429kill on timeout and a bounded log — but **no container and no network namespace**.
430Anyone who can push can run code as that user. CI is off by default and the boot log
431says so.
432
433A step allowlist was considered and rejected: `sh -c` defeats it in one character.
434Enforcement is at the OS or it isn't enforcement. Multi-tenant isolation is M6 and
435isn't built.
436
437---
438
439## Renaming it
440
441The name lives in exactly one file. `src/brand.rs` derives the env prefix, data
442directory, problem-type URIs, auth realm, git config namespace, CI filename and hook
443shim from `CARGO_PKG_NAME`.
444
445Renaming is one line in `Cargo.toml` plus the `openapi.json` title — verified by
446actually doing it. A unit test walks `src/` and fails if the name appears as a
447literal anywhere outside `brand.rs`; it has already caught a hardcoded auth realm.
448
449---
450
451## Development
452
453```sh
454cargo fmt --check
455cargo clippy --all-targets -- -D warnings
456cargo test # 204 unit tests
457deno task test:live # 59 live tests: spawns the binary, drives real git and ssh
458```
459
460The live suite is the real proof — unit tests cover units, but only a real clone and
461push exercise the wire protocol. It runs in CI rather than self-skipping there.
462
463```
464src/
465├── brand.rs the one place the product name appears
466├── config.rs Config::from_env(); fails boot on a bad value
467├── error.rs Error -> RFC 9457 problem+json
468├── core/ domain operations shared by every facade
469├── git/
470│ ├── exec.rs the only place this service invokes git
471│ ├── validate.rs security controls: names, refs, paths
472│ ├── store.rs repositories, hooks, ref rules
473│ ├── discover.rs the read core, shared by REST and MCP
474│ ├── transport.rs streaming CGI bridge
475│ └── import.rs SSRF-guarded cloning
476├── http/ routing, limits, responses, auth, rate limiting
477├── api/ REST facade
478├── mcp/ MCP facade
479├── ssh/ SSH server and exec-command parser
480├── ci/ spec, run store, runner, executor
481├── control/ accounts, provisioning, reconciler, streaming proxy
482├── setup.rs systemd units, Caddyfile, environment file
483├── jobs/ gc, sweep, fsck
484├── account/ identity, tokens, SSH keys
485└── store/ metadata, quotas, change-aware file cache
486```
487
488Two rules worth knowing before changing anything:
489
490- **git is the only writer.** No endpoint or tool modifies repository contents.
491- **Facades translate; they don't decide.** Authorization lives on `Identity`, git
492 rules in `git/`, shared operations in `core/`. `api/` and `mcp/` reshape payloads
493 and nothing else — they had drifted copies of repo create and delete once, and
494 those copies had already diverged in four ways.
495
496More: [`SPEC.md`](SPEC.md) · [`ROADMAP.md`](ROADMAP.md) · [`AGENTS.md`](AGENTS.md)