zuka
zuka/main

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
ZMake a repository's front page its README, rendered as a sitezuka · 27 min ago
All files
.github
deploy
src
test
·.gitignore8 B
TOML.zuka.toml3.7 KB
MDAGENTS.md6.9 KB
RSbuild.rs3.2 KB
LOCKCargo.lock76.0 KB
TOMLCargo.toml1.1 KB
JSONdeno.json81 B
LOCKdeno.lock441 B
JSONopenapi.json63.3 KB
MDREADME.md24.2 KB
MDROADMAP.md15.4 KB
MDSPEC.md38.8 KB
README.md

zuka

Git hosting your agent can actually drive.

One small Rust binary — bare repositories, SSH and HTTP git, a REST API, MCP, and CI. No database, no Docker, no drama.

See it running Five-minute tour Quickstart

Named for the sound of it rather than the meaning: two syllables, a rare opening consonant, one plausible spelling after hearing it once. It sits near Swahili kuzuka — to surface, to emerge — without claiming to be it.


The idea

Agents already know git. What they can't do is the GitHub part.

Claude, Cursor and the rest run git clone, commit and push perfectly well. What stops a non-developer shipping with them is everything bolted on top: creating the repository, orgs and teams, deploy keys, permission matrices, pull requests, review, Actions YAML.

So the split is strict:

git does the git

Code moves over the wire protocol, HTTP or SSH. Nothing proprietary.

the API does the rest

Create a repo, register a key, read a file, check CI — over REST and MCP.

Nothing in the API writes to a repository. No commit endpoint, no file endpoint, no ref-moving endpoint. A second write path would mean two homes for the fast-forward rule, two concurrency models and a merge story — git already has all three. The write path is git push.

What's deliberately absent is the point: no organisations, no teams, no pull requests, no review, no issues. The whole model is accounts, repositories, refs, keys and runs.


Your README is the website

Open a repository in a browser and you don't get a wall of filenames — you get the README, rendered as a page, the way Markdoc turns Stripe's doc files into stripe.com/docs. The file listing is one tab away, not the thing a visitor has to squint at first. This page you're reading is that website.

The viewer is built for people who have never used a code host: the tabs say Overview · Files · History · Branches instead of tree, log and refs; every tab remembers which branch you were on; history reads like a chat feed with a coloured initial for each author; statuses say Added, Changed, Removed. A wrong link gets a sentence and a way home, not machine JSON.

Plain markdown already works. For readmes that want to be front pages, there's a small dialect — a subset of Markdoc's syntax. A tag sits alone on its own line; everything between tags is ordinary markdown:

{% hero %}
# Big friendly title
One-line pitch under it.

[Primary button](docs/start.md) [Second button](#below)
{% /hero %}

{% callout type="tip" title="Good to know" %}
Callouts come as note, tip, warn and danger.
{% /callout %}

{% cards %}
{% card title="Left" %}
Feature one.
{% /card %}
{% card title="Right" %}
Feature two.
{% /card %}
{% /cards %}

{% details summary="The long table nobody reads twice" %}
…hidden until tapped, no JavaScript involved…
{% /details %}

The rules, all of them:

  • A tag line starts at column zero. Indented or fenced tags are shown, not run — which is how this README quotes them.
  • Unknown tags vanish and their content stays, so a document written for a richer engine degrades to its words, never to tag soup.
  • An unclosed tag is closed for you at the end. A typo can't eat the page.
  • Relative links work: [guide](docs/guide.md) opens that file rendered as a page, ![shot](docs/shot.png) serves the image, [src](src/) opens the listing. A folder of markdown becomes a small site with zero configuration.
  • Headings get anchor ids, so [jump](#the-idea) works — this page uses them.
  • Any .md file renders as a document; add ?plain=1 to see the raw text.

And because a README is written by anyone who can push — on a public repository, a stranger — the whole surface stays paranoid: raw HTML is dropped, link schemes pass an allow-list, every interpolated value is escaped, and the page ships a CSP with no script source at all. The dialect adds wrappers, not execution.


Install onto a host

sudo zuka setup --standalone --dry-run    # prints every file it would write
sudo zuka setup --standalone

It writes /etc/zuka.env and two systemd units — the service and maintenance separately, because a long repack must not stall requests — then prints the remaining commands rather than running them. Creating users and enabling units are steps someone may reasonably want to do differently.

--domain is optional, and does one thing: it adds a Caddyfile so Caddy obtains and renews TLS for that name, and sets the public URLs advertised in clone URLs. Without it you get a service on 127.0.0.1:8790 with no TLS, which is right behind an existing proxy or for a local trial.

sudo zuka setup --standalone --domain git.example.com

--isolated installs the multi-tenant shape: a control plane that holds no repositories and gives every account its own Incus container. Code, CI and objects for one account never touch another's filesystem. --domain is optional here too and means exactly the same thing — the control plane is the only public listener, so it is the only thing a certificate is for.

sudo zuka setup --isolated
curl -X POST localhost:8790/v1/accounts/alice     # returns immediately

Set a public URL if you have one: a tenant is only reachable through the control plane, so left to itself it advertises its own container address and hands callers a clone URL they cannot use.

Provisioning never blocks a request. The account records intent and a reconciler drives it to ready; a request arriving early gets 429 with a Retry-After. Tenants are stopped when idle and started again on demand.

Quickstart

Needs git ≥ 2.41 on the host.

cargo build --release
export ZUKA_DATA_DIR=/tmp/zuka && mkdir -p "$ZUKA_DATA_DIR"

TOKEN=$(./target/release/zuka token alice --scopes admin)   # shown once
./target/release/zuka
# [boot] listening on http://127.0.0.1:8790
# [ssh]  listening on ssh://127.0.0.1:2222

Create and push

curl -X POST localhost:8790/v1/repos \
  -H "Authorization: Bearer $TOKEN" -H 'content-type: application/json' \
  -d '{"name":"site","visibility":"public"}'

git clone "http://x-access-token:$TOKEN@127.0.0.1:8790/alice/site.git"
cd site && echo hello > README.md
git add . && git commit -m "first" && git push origin HEAD:refs/heads/main

The git username is ignored; the password is your token. visibility defaults to private; a public repository is clonable and browsable by anyone, writable by no one anonymous. Open http://127.0.0.1:8790/alice/site and there's your website.

Use SSH instead

./target/release/zuka key alice ~/.ssh/id_ed25519.pub --title laptop
git clone ssh://git@127.0.0.1:2222/alice/site.git

--read-only makes a key clone-only; --repos a,b confines it.

Read without cloning

A="Authorization: Bearer $TOKEN"; R=localhost:8790/v1/repos/alice/site
curl -H "$A" $R/refs
curl -H "$A" "$R/tree/src?ref=refs/heads/main"
curl -H "$A" "$R/raw/README.md"
curl -H "$A" "$R/search?q=TODO"
curl -H "$A" "$R/blame/src/main.rs"
curl -H "$A" "$R/compare?base=<sha>&head=<sha>"

On /v1 the ref is always a query parameter, never a path segment — feature/login and src/main.rs both contain slashes, so /raw/{ref}/{path} has no unique parse. The browser URLs accept the ambiguity for the sake of readable links; the API does not.

Turn on CI

ZUKA_CI_ENABLED=1 ./target/release/zuka
# .zuka.toml, in the repository
[run]
steps = ["cargo fmt --check", "cargo test"]
timeout_secs = 600
branches = ["refs/heads/main"]   # optional; omit to run everywhere

Push, then curl -H "$A" $R/runs and $R/runs/{id}/logs.


For agents — MCP

POST /mcp     JSON-RPC 2.0: initialize · ping · tools/list · tools/call

The intended session: repo_createkey_add → plain git clone/commit/pushrun_list. There is no tool that writes to a repository, and initialize says so, so a model doesn't go hunting for one.

A tool failure that's the caller's fault comes back as a tool result with isError: true, not a JSON-RPC error — the model should see it and adapt, not have the call look broken.

The 18 tools

Administration — the part git can't do

Tool
repo_createCreate a repository. Returns both clone URLs.
repo_list repo_get repo_delete
key_add key_list key_removeRegister an SSH key so the agent can then use git.

Discovery — read without cloning

Tool
ref_listBranches, tags, notes with tip commits.
tree_listList a directory.
file_readRead a text file.
searchFixed-string search across the tree.
commit_log commit_getHistory; optionally path-filtered.
compareDiff from the merge base.
blameWho last changed each line.

CI

Tool
run_list run_get run_logsDid the build pass, and why not.

API

Full contract: openapi.json, also served at /openapi.json.

GET /healthzMode, version, disk headroom, CI state. 503 below the reserve.
GET /openapi.jsonThis service's OpenAPI 3.1 document.
GET /v1/accountIdentity, scopes, storage usage and limits.
GET POST /v1/reposList, create (optionally import_url, visibility).
GET PATCH DELETE /v1/repos/{account}/{repo}Read, update settings, soft-delete.
GET .../refs?type=Branches, tags, notes.
GET .../tree/{path}?ref=Directory listing.
GET .../raw/{path}?ref=File bytes.
GET .../commits?ref=&path=&limit=Log.
GET .../commits/{sha}One commit and its changed paths.
GET .../compare?base=&head=Diff from the merge base.
GET .../search?q=&path=&ref=Content search.
GET .../blame/{path}?ref=Line attribution.
GET POST .../runs · GET PATCH .../runs/{id} · GET .../runs/{id}/logsCI.
GET POST /v1/keys · DELETE /v1/keys/{id}SSH keys.
POST /mcpMCP JSON-RPC.
/{account}/{repo}.git/*Git smart HTTP. Protocol v2.
ssh://git@host/{account}/{repo}.gitGit over SSH, plus git archive --remote.

The browser lives on the same origin, read-only, no credential required for public repositories: /{account}/{repo} (the clone URL minus .git), plus /tree/{ref}/{path}, /blob/{ref}/{path} (?plain=1 for raw text), /raw/{ref}/{path} (download), /commits/{ref} (?from={sha} pages older), /commit/{sha} and /refs. / redirects to the repository named by ZUKA_HOME_REPO.

Errors on /v1 and /mcp are RFC 9457 application/problem+json with a stable type URI — discriminate on type, never on detail. The git paths answer in text/plain, because git prints the body straight at the user. The browser pages answer a missing page in HTML, because a person tapped that link.

Lists return {items, truncated}. There is no cursor, and none is claimed.

CLI

zuka                                   serve
zuka setup --standalone|--isolated     install onto this host [--domain d] [--dry-run]
zuka jobs                              maintenance daemon (own service unit)
zuka gc | sweep | fsck [--repair]      one-shot maintenance
zuka token <account> [--scopes s] [--repos r] [--days n] [--no-expiry]
zuka key   <account> <path.pub> [--title t] [--read-only] [--repos r]
zuka version

Scopes: repo:read, repo:write, admin, ci. Tokens expire in 90 days unless you pass --days or opt out with --no-expiry, which warns. Credentials added while the server runs take effect without a restart.

fsck exits non-zero on an orphan so a timer notices. It reports a repository with no metadata but never deletes one — repair only removes records that describe nothing.


Configuration

Everything has a working default; nothing is required.

All settings
VariableDefault
ZUKA_BIND127.0.0.1:8790HTTP listener.
ZUKA_SSH_BIND127.0.0.1:2222SSH listener; off disables.
ZUKA_DATA_DIR/var/lib/zukaEverything on disk.
ZUKA_PUBLIC_URL / _SSHderivedAdvertised clone URLs.
ZUKA_HOME_REPOaccount/repo featured at / in the browser.
ZUKA_PROBLEM_BASEhttps://zuka.dev/problemsPoint error type URIs at your own docs.
Limits
ZUKA_MAX_REPOS_PER_ACCOUNT1000 disables.
ZUKA_MAX_REPO_MB / _ACCOUNT_MB2048 / 102400 disables.
ZUKA_RATE_PER_MINUTE600Per credential, on /v1 and /mcp. 0 disables.
ZUKA_MAX_PACK_MB512Largest push. Also sets receive.maxInputSize.
ZUKA_MAX_BLOB_MB32Largest blob served inline.
ZUKA_MAX_BODY_MB10Largest JSON body.
ZUKA_PAGE_LIMIT / _MAX_PAGE_LIMIT50 / 200
ZUKA_MAX_CONCURRENT_GIT8Concurrent git subprocesses.
ZUKA_DISK_RESERVE_MB2048Below this, writes return 507.
CI
ZUKA_CI_ENABLED0Off by default; see below.
ZUKA_CI_TIMEOUT_SECS600Ceiling. A spec may lower it, never raise it.
ZUKA_CI_MAX_CONCURRENT2
ZUKA_CI_LOG_MB8Output cap per run.
ZUKA_CI_MEMORY_MB2048RLIMIT_AS. 0 disables.
ZUKA_CI_FILE_MB2048RLIMIT_FSIZE. Independent of the memory limit.
ZUKA_CI_MAX_PROCESSES0RLIMIT_NPROC — per-UID, so only meaningful with a runner uid.
ZUKA_CI_KEEP_RUNS50Runs retained per repository.
Maintenance
ZUKA_GC_INTERVAL_SECS21600
ZUKA_GC_PRUNE_GRACE2.weeks.agoObjects younger than this are never pruned.
ZUKA_DELETED_RETENTION_DAYS7
Import
ZUKA_IMPORT_ENABLED1
ZUKA_IMPORT_TIMEOUT_SECS300
ZUKA_IMPORT_ALLOW_PRIVATE0Permit private/loopback sources. Only on a trusted network.
Multi-tenant
ZUKA_MODEstandalonestandalone · control · tenant
ZUKA_OPERATORAccount allowed to manage accounts. Unset ⇒ refused for all.
ZUKA_INCUS_IMAGEimages:debian/12Image for a new tenant.
ZUKA_INCUS_NETWORKincusbr0Network a tenant joins.
ZUKA_TENANT_BINARYthis binaryPushed into each container; must be static.
ZUKA_TENANT_PORT8790Port a tenant listens on inside its container.
ZUKA_CONTROL_PUBLIC_KEYSTenant only. Comma-separated; more than one allows key rotation.
ZUKA_PROXY_TIMEOUT_SECS300Ceiling on one proxied request.

ZUKA_ME_URL is parsed and then refuses to boot — hosted auth isn't implemented, and accepting a security-relevant setting while ignoring it is worse than not accepting it.

Maintenance

Run zuka jobs as a second service unit — a long repack must not stall request handling.

[Unit]
Description=zuka maintenance
After=zuka.service
[Service]
ExecStart=/usr/local/bin/zuka jobs
EnvironmentFile=/etc/zuka.env
User=zuka
Restart=always
[Install]
WantedBy=multi-user.target

It garbage-collects (git's own auto-gc is disabled on every repo, so a repack never fires inside a push), sweeps deleted repositories and old runs, and fscks.

What makes GC safe against a live clone is the grace window, not a lock — a lock couldn't span the two processes. upload-pack can reference an object between resolving and streaming it, so nothing younger than GC_PRUNE_GRACE is pruned. A test clones a 12 MB repo while GC runs and checks the result byte for byte.


Security

Each of these has a test that fails if it regresses. Several are here because an earlier version got them wrong.

Names can't escape the data directory. Ref names go through gix-validate, never a hand-rolled check — ../../config is a command-execution primitive via core.pager and core.sshCommand. Tree paths reject any .git component after Unicode normalisation, case folding, HFS-ignorable-codepoint stripping and trailing dot/space removal.

Names are case-folded. Alice/Site and alice/site are one resource. Comparing the raw path segment once let a case-variant account reach another account's repository on a case-insensitive filesystem while passing the ownership check.

An unreadable repository is 404, not 403. 403 enumerates private repos. Confinement applies to destruction too: a token scoped to one repo can't delete another.

Anonymous means read, and only through one door. Exactly one function grants anonymous access, it can only read, and a presented-but-rejected credential stays rejected — an expired token never quietly degrades into an anonymous visitor. The browser answers a private repository with the same page as an absent one; the git wire answers both with a 401 challenge, so neither surface can be used to enumerate names.

The viewer renders hostile input inert. Every value is escaped, markdown drops raw HTML and rewrites link schemes against an allow-list, and pages carry a CSP with no script source — so anything that survived the escaping would still have nowhere to execute. Raw file bytes are served as an attachment in a CSP sandbox, never rendered on the origin.

Rewriting is allowed; protection is opt-in. rebase and --amend are ordinary git and blocking them protects nobody when there's one agent and no reviewer — git's reflog is the recovery path. Set protected_refs on the branch something deploys from; it's then enforced server-side over HTTP and SSH, which matters because --force bypasses the client check. One Rust function; the hook is a one-line exec into the binary.

SSH runs git and nothing else. No shell, no pty, no subsystem. Three services: upload-pack, receive-pack, upload-archive. The exec string is parsed, never shelled, so git-upload-pack '/a/b.git'; id is a parse error. The account comes from which registered key matched, never the SSH username. Only GIT_PROTOCOL crosses into the child.

Only three endpoints route under .git. The dumb protocol isn't served. A duplicated service parameter is refused — this server would take the first and the CGI the last, so a duplicate would let the authorization decision and the running service disagree.

Import can't reach the local network. The one place a caller-supplied URL is fetched. Checked in two stages: shape (scheme, credentials) then every address the host resolves to — checking the hostname alone is defeated by a name that resolves to 169.254.169.254.

Credentials are hashed, scoped, expiring and 0600. Token comparison has no early return. Public keys match on raw bytes; authorized_keys option prefixes like command= are refused.

Deletes are recoverable. Soft-deleted to tmp/deleted/, swept on retention.

Nothing buffers a pack.

                          before      after
200 MB push, server RSS   ~360 MB      5 MB
4 concurrent clones       1508 MB      5 MB

The child is killed by process group on disconnect — kill_on_drop reaches only the direct child, and pack-objects is a grandchild that otherwise kept running for seconds after the client left.

Multi-tenant isolation

In --isolated, each account gets its own Incus container, unprivileged and with nesting off. The control plane holds no repositories: a push through it lands in the account's container and nowhere on the host.

A tenant is told who is calling by an Ed25519 assertion the control plane signs, not a shared secret. That matters because a tenant runs the account's own CI, which is arbitrary code — anything symmetric in there would forge every other tenant. The private key never leaves the control plane; tenants hold only the public half, which forges nothing.

The assertion authenticates one request, not an identity: single-use nonce, 30-second window, bound to the method and path. It is deliberately not bound to the body, because a push is a multi-gigabyte pack the proxy streams — the nonce is what stops replay.

Verified on a real host rather than asserted, in infra/README.md: CI inside a tenant reports uname -n = zuka-alice, and a second account gets 404 on the first's repository and an empty repo list.

CI is not a security boundary in standalone

Steps run as the service user with rlimits, a scrubbed environment, a process-group kill on timeout and a bounded log — but no container and no network namespace. Anyone who can push can run code as that user. CI is off by default and the boot log says so.

A step allowlist was considered and rejected: sh -c defeats it in one character. Enforcement is at the OS or it isn't enforcement. Multi-tenant isolation is what --isolated is for.


Renaming it

The name lives in exactly one file. src/brand.rs derives the env prefix, data directory, problem-type URIs, auth realm, git config namespace, CI filename and hook shim from CARGO_PKG_NAME.

Renaming is one line in Cargo.toml plus the openapi.json title — verified by actually doing it. A unit test walks src/ and fails if the name appears as a literal anywhere outside brand.rs; it has already caught a hardcoded auth realm, and it is the reason even the browser viewer's stylesheet URL is derived.


Development

cargo fmt --check
cargo clippy --all-targets -- -D warnings
cargo test                 # 301 unit tests
deno task test:live        # 69 live tests: spawns the binary, drives real git and ssh

The live suite is the real proof — unit tests cover units, but only a real clone and push exercise the wire protocol. It runs in CI rather than self-skipping there.

src/
├── brand.rs       the one place the product name appears
├── config.rs      Config::from_env(); fails boot on a bad value
├── error.rs       Error -> RFC 9457 problem+json
├── core/          domain operations shared by every facade
├── git/
│   ├── exec.rs      the only place this service invokes git
│   ├── validate.rs  security controls: names, refs, paths
│   ├── store.rs     repositories, hooks, ref rules
│   ├── discover.rs  the read core, shared by REST, MCP and the browser
│   ├── transport.rs streaming CGI bridge
│   └── import.rs    SSRF-guarded cloning
├── http/          routing, limits, responses, auth, rate limiting
├── api/           REST facade
├── mcp/           MCP facade
├── web/           the browser viewer: escaping, the tag dialect, the pages
├── ssh/           SSH server and exec-command parser
├── ci/            spec, run store, runner, executor
├── control/       accounts, provisioning, reconciler, streaming proxy
├── setup.rs       systemd units, Caddyfile, environment file
├── jobs/          gc, sweep, fsck
├── account/       identity, tokens, SSH keys
└── store/         metadata, quotas, change-aware file cache

Two rules worth knowing before changing anything:

  • git is the only writer. No endpoint or tool modifies repository contents.
  • Facades translate; they don't decide. Authorization lives on Identity, git rules in git/, shared operations in core/. api/ and mcp/ reshape payloads and nothing else — they had drifted copies of repo create and delete once, and those copies had already diverged in four ways.

More: SPEC.md · ROADMAP.md · AGENTS.md