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
| 1 | # zuka — technical specification |
| 2 | |
| 3 | Status: scoped, not started. Milestones, risks and open decisions live in |
| 4 | [`ROADMAP.md`](ROADMAP.md); this document is the durable technical reference. Where |
| 5 | the two disagree, this one wins on *how* and the roadmap wins on *when*. |
| 6 | |
| 7 | Product framing, positioning and non-goals: [`README.md`](README.md). |
| 8 | |
| 9 | --- |
| 10 | |
| 11 | ## 1. Architecture |
| 12 | |
| 13 | ### 1.1 Three modes, one binary |
| 14 | |
| 15 | ``` |
| 16 | standalone Engine only. Repos on local disk, tokens from a local file. |
| 17 | No outbound calls. This is what a self-hoster runs. Default. |
| 18 | |
| 19 | control Control plane. Owns accounts, provisioning, routing. Holds no |
| 20 | repositories. Proxies data-plane requests to tenant containers. |
| 21 | |
| 22 | tenant Engine inside an Incus container behind a control plane. Trusts a |
| 23 | signed identity assertion instead of verifying tokens itself. |
| 24 | ``` |
| 25 | |
| 26 | `standalone` and `tenant` share the request-handling path exactly. They differ in |
| 27 | boot configuration and in the CI isolation model (§6.3) — that is the honest claim; |
| 28 | "identical but for one function" would not survive §6.3 or §9. |
| 29 | |
| 30 | ``` |
| 31 | agent ──MCP───►┌──────────────────────────────┐ |
| 32 | app ──REST──►│ facades: mcp/ api/ │ |
| 33 | └──────────────┬───────────────┘ |
| 34 | │ same core calls |
| 35 | ┌──────────────▼───────────────┐ |
| 36 | │ core: git/ ci/ account/ │ |
| 37 | └───┬──────────────────────┬───┘ |
| 38 | git CLI ──────►git/transport.rs │ |
| 39 | │ │ |
| 40 | ┌──────▼──────┐ ┌──────▼──────┐ |
| 41 | │ filesystem │ │ KV │ |
| 42 | │ bare repos │ │ metadata │ |
| 43 | └─────────────┘ └─────────────┘ |
| 44 | ``` |
| 45 | |
| 46 | **Facades carry wire-shape translation and client ergonomics. They never carry |
| 47 | authorization or concurrency rules.** Defaults and argument-filling are allowed in |
| 48 | `mcp/` (§5.2); access checks and ref preconditions are not, and never appear twice. |
| 49 | |
| 50 | ### 1.2 Git access |
| 51 | |
| 52 | Everything goes through `git` subprocesses, funnelled through `git::exec::Git` — |
| 53 | the single place this service invokes git. Before that module existed there were five |
| 54 | wrappers and nine ad-hoc spawns with three different environment policies, so |
| 55 | `/etc/gitconfig` applied to `git init`, `git config`, `merge-base --is-ancestor` (the |
| 56 | ref rule) and `count-objects` (quota) but not to the wire protocol. Half a control is |
| 57 | not a control; `Git` clears the environment and sets `GIT_CONFIG_NOSYSTEM` on every |
| 58 | invocation. |
| 59 | |
| 60 | `gix` is not used. A subprocess per read is a real cost, mitigated by running them on |
| 61 | a blocking pool rather than a runtime worker; migrating the read path is a |
| 62 | measurement-driven decision and no measurement has been taken. |
| 63 | |
| 64 | **Error mapping matters here.** `Git::query` treats exit 1 as an answer ("no such |
| 65 | object", "no matches") and anything higher as a fault. An earlier version collapsed |
| 66 | every non-zero exit into `404`, which meant a corrupt repository reported as empty |
| 67 | and nothing paged anyone. Existence is gated on `rev-parse --verify --quiet`, which |
| 68 | exits 1; `cat-file` exits 128 for the same condition and cannot be used for it. |
| 69 | |
| 70 | #### Wire protocol |
| 71 | |
| 72 | - **Reads and API writes** use `gix`. Refs, trees, blobs, log, and building a |
| 73 | tree+commit in-process. No working copy, no subprocess. |
| 74 | - **The wire protocol** delegates to **`git http-backend`**, the reference CGI. Not |
| 75 | `upload-pack --stateless-rpc` directly — `http-backend` already handles gzipped |
| 76 | request bodies, `GIT_PROTOCOL` negotiation, framing, and the exact content types |
| 77 | git expects. Reimplementing those is the failure this choice exists to avoid. |
| 78 | |
| 79 | Naming the binary matters: the two options differ by roughly everything in |
| 80 | [`ROADMAP.md`](ROADMAP.md) R1. |
| 81 | |
| 82 | Required around the CGI regardless: kill the child when the client disconnects (hyper |
| 83 | drops the body future and the process otherwise spins), set `receive.fsckObjects=true` |
| 84 | and `receive.maxInputSize`, and hold a semaphore across concurrent `pack-objects` so N |
| 85 | simultaneous clones cannot exhaust host memory. |
| 86 | |
| 87 | `git` becomes a runtime dependency, minimum **2.41** (for `--filter` and protocol v2 |
| 88 | behaviour we rely on). The README states it; "it is on every Linux host" is not a |
| 89 | version. |
| 90 | |
| 91 | ### 1.3 Git is the only writer |
| 92 | |
| 93 | **Nothing but `git` writes to a repository.** No API creates commits, moves refs or |
| 94 | edits files. The wire protocol is the write path; the REST and MCP surfaces are |
| 95 | administration and read-only inspection (§4). |
| 96 | |
| 97 | This is the single most load-bearing decision in the design, and it deletes a large |
| 98 | amount of machinery that an earlier draft of this document specified: no in-process |
| 99 | commit building, no `base_sha` optimistic-concurrency protocol, no ref |
| 100 | compare-and-swap against a concurrent `receive-pack`, no idempotency keys on writes, |
| 101 | no merge story. There is one writer, `receive-pack`, and it already does its own |
| 102 | locking correctly. |
| 103 | |
| 104 | What remains is the ref *policy*: `git::store::check_ref_move` decides whether a move |
| 105 | is allowed. It has one caller, the `update` hook, which is a one-line `exec` back |
| 106 | into this binary so the rule is written in Rust rather than shell. Reflogs come from |
| 107 | git itself (`core.logAllRefUpdates`), not from us. |
| 108 | |
| 109 | ### 1.4 Stack |
| 110 | |
| 111 | `hyper` 1.x + `tokio`, no web framework. `anyhow` internally, a typed `Error` at the |
| 112 | boundary (§8.3). `serde` on the wire. Structured single-line logs (§10). `edition |
| 113 | 2021`, `opt-level = 3`, `lto = "thin"`, committed `Cargo.lock`. |
| 114 | |
| 115 | Beyond that: `gix`, `gix-validate`, `ed25519-dalek`, `sha2`, `hex`, `uuid`, `toml`, |
| 116 | `base64`, `tokio-util`. |
| 117 | |
| 118 | --- |
| 119 | |
| 120 | ## 2. Identity, auth, validation |
| 121 | |
| 122 | ### 2.1 Flat model |
| 123 | |
| 124 | ``` |
| 125 | account the unit of ownership. Has repos, tokens. |
| 126 | token a scoped, expiring credential belonging to one account. |
| 127 | grant (repo, account, capability) — how a second account gets access. |
| 128 | capability read | write | admin |
| 129 | ``` |
| 130 | |
| 131 | No orgs, no teams, no nesting. `admin` implies `write` implies `read`. `admin` may |
| 132 | manage grants but **may not** delete the repo or grant `admin`; both are owner-only. |
| 133 | |
| 134 | **`write` implies code execution** in the repo owner's CI container, because a writer |
| 135 | can author `.zuka.toml` (§6). This is stated here because it materially changes |
| 136 | what granting `write` means, and it is repeated at the grant endpoint. |
| 137 | |
| 138 | ### 2.2 Tokens |
| 139 | |
| 140 | `POST /v1/tokens {name, scopes[], repos[]?, expires_in}`. Scopes: `repo:read`, |
| 141 | `repo:write`, `admin`, `ci`. `expires_in` has a non-infinite default (90 days). |
| 142 | |
| 143 | Stored as SHA-256; plaintext returned once, at creation. `TokenRecord` carries |
| 144 | `last_used_at` so revocation triage is possible. |
| 145 | |
| 146 | A single unscoped forever-token is total account compromise, and the same secret gets |
| 147 | pasted into `git` (landing in `~/.git-credentials` in plaintext), an MCP client |
| 148 | config, and potentially a CI environment. Scopes and expiry are not optional. |
| 149 | |
| 150 | **CI never receives an account token.** A run gets a single-repo, `ci`-scoped, |
| 151 | run-lifetime credential that cannot mint tokens. |
| 152 | |
| 153 | ### 2.3 Resolving `Identity` |
| 154 | |
| 155 | One function, three implementations, with the failure discipline borrowed from |
| 156 | `yangu/atlas`: a rejected credential is `401`, an unreachable auth service is `502`, |
| 157 | and there is never a silent fallback to a lesser identity. |
| 158 | |
| 159 | | Mode | Source | |
| 160 | |---|---| |
| 161 | | `standalone` (default) | `$DATA_DIR/tokens.json`, SHA-256 compared. No network. | |
| 162 | | `standalone` (hosted) | `GET {ZUKA_ME_URL}/verify`, opt-in. Positive results cached 60s — otherwise every read blocks on a cross-service round trip. | |
| 163 | | `tenant` | `X-Zuka-Identity`, Ed25519-verified (§2.4). | |
| 164 | |
| 165 | ### 2.4 The tenant identity assertion |
| 166 | |
| 167 | A shared symmetric key would be readable from inside a tenant container by the CI |
| 168 | code running there, and would then forge any account against any tenant. So: |
| 169 | |
| 170 | - **Ed25519.** Control signs with a private key it never distributes; tenants hold |
| 171 | only the public key. Two public keys accepted during rotation overlap. |
| 172 | - **Signed payload**, canonically serialized (field order fixed, no whitespace): |
| 173 | `{account, issued_at, expires_at, nonce, method, path, body_sha256}`. |
| 174 | - `expires_at - issued_at <= 60s`. Clock skew tolerance ±30s, stated not implied. |
| 175 | - Nonce cached until expiry; replays rejected. |
| 176 | - Binding to `method`/`path`/`body_sha256` means a captured assertion authorizes one |
| 177 | request, not an identity. |
| 178 | - Tenants bind to a private interface and reject connections not originating from the |
| 179 | control plane. |
| 180 | |
| 181 | ### 2.5 Validation is normative, not a test case |
| 182 | |
| 183 | These are the rules, not suggestions to be covered by a unit test later. |
| 184 | |
| 185 | **Account and repo names** — `^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$`, no `..`, no `.lock` |
| 186 | suffix, unique **case-insensitively** (the KV key is case-sensitive and APFS is not; |
| 187 | without this, disk and metadata silently disagree). Reserved: `.`, `..`, `.git`. |
| 188 | |
| 189 | **Ref names** — delegated to `gix-validate`'s reference-name check. Never hand-rolled. |
| 190 | Must resolve under `refs/heads/` or `refs/tags/`; after canonicalization the resolved |
| 191 | filesystem path must be a prefix-child of `$GIT_DIR/refs/`. An unvalidated ref name is |
| 192 | remote code execution: `../../config` writes git config, and `core.fsmonitor`, |
| 193 | `core.sshCommand` and `core.pager` are all command-execution keys. |
| 194 | |
| 195 | **Tree paths** — reject absolute, empty, `.`, `..`, and any component equal to `.git` |
| 196 | after Unicode normalization (NFC and NFD) and case folding. Reject `.gitmodules` |
| 197 | entries whose submodule path escapes (the CVE-2018-11235 family). Cap component count |
| 198 | and total path length. |
| 199 | |
| 200 | **File modes** — `100644` and `100755` only. `120000` (symlink) is rejected on write, |
| 201 | and `GET .../raw` never follows one. `160000` (gitlink) and `040000` are not |
| 202 | constructible through the API. |
| 203 | |
| 204 | **Cursors** — opaque and HMAC'd. A client-supplied cursor must never become a raw KV |
| 205 | start-key; denokv's key escaping makes that a silent-correctness risk rather than a |
| 206 | loud failure. |
| 207 | |
| 208 | --- |
| 209 | |
| 210 | ## 3. Path shape |
| 211 | |
| 212 | **Decided: `/v1/repos/{account}/{repo}`.** The account is in the path. |
| 213 | |
| 214 | The short form `/v1/repos/{repo}` was rejected because it breaks four things at once: |
| 215 | the control plane cannot route to the *owner's* container without it; grants become |
| 216 | unaddressable (a grantee has no way to name someone else's repo); the KV keys |
| 217 | `grant`/`run`/`run_index` collide across accounts; and it disagrees with the git path |
| 218 | `/{account}/{repo}.git`, so an agent holding a clone URL cannot derive the REST URL. |
| 219 | |
| 220 | --- |
| 221 | |
| 222 | ## 4. REST |
| 223 | |
| 224 | Base `/v1`. JSON in, JSON out. `{a}` = account, `{r}` = repo. |
| 225 | |
| 226 | **This surface administers repositories; it does not modify their contents.** Create, |
| 227 | delete, list and configure a repository, manage credentials, read what is in a |
| 228 | repository, watch CI. To change code you use `git`, over HTTP or SSH, exactly as you |
| 229 | would anywhere else. |
| 230 | |
| 231 | The reason is that an agent already knows git. What it cannot do without help is the |
| 232 | part GitHub bolted on top — provisioning, keys, access, CI. That is what this API is |
| 233 | for, and keeping writes out of it means there is no second write path to keep |
| 234 | consistent with the first. |
| 235 | |
| 236 | ### 4.1 Rules |
| 237 | |
| 238 | - `POST` to a collection creates → `201` + `Location`. |
| 239 | - `DELETE` of an absent resource → `404`. Idempotency is about the effect on the |
| 240 | server (RFC 9110 §9.2.2), not the status code; an agent that typos a repo name must |
| 241 | not be told it deleted something. |
| 242 | - `PATCH` is `application/merge-patch+json` (RFC 7396); `null` clears. |
| 243 | - `Idempotency-Key` accepted on `POST /repos`; `(key → response)` stored 24h and |
| 244 | replayed, so an agent whose response is lost can retry a create safely. |
| 245 | - ETags are quoted strong entity-tags: `ETag: "<sha>"`. `If-None-Match` → `304`. |
| 246 | - Lists return `{items, truncated}`. **There is no cursor.** An earlier draft |
| 247 | specified one and emitted `next_cursor: null` on every list including truncated |
| 248 | ones, which told a client the list had ended when it had not. `truncated` says what |
| 249 | is true; a real cursor can be added later without having lied in the meantime. |
| 250 | Default `limit` 50, max 200, both configurable. |
| 251 | - Errors are RFC 9457 `application/problem+json` with a real `type` URI from |
| 252 | `https://<host>/problems/{slug}` — `ref-moved`, `precondition-required`, |
| 253 | `invalid-path`, `invalid-ref`, `quota-exceeded`, `rate-limited`, `payload-too-large`, |
| 254 | `storage-full`, `not-found`, `forbidden`. Clients discriminate on `type` and read |
| 255 | extension members; never on `detail`. |
| 256 | - Rate limits per token and per IP. `429` + `Retry-After`. |
| 257 | |
| 258 | ### 4.2 Repositories |
| 259 | |
| 260 | | Method | Path | Notes | |
| 261 | |---|---|---| |
| 262 | | `GET` | `/v1/repos` | Owned + granted. `?role=owner\|grantee`. | |
| 263 | | `POST` | `/v1/repos` | `{name, default_branch, description}` → `201`. Duplicate → `409`. | |
| 264 | | `GET` | `/v1/repos/{a}/{r}` | Unreadable repo → `404`, not `403` (no existence leak). | |
| 265 | | `PATCH` | `/v1/repos/{a}/{r}` | `default_branch` must reference an existing branch → else `409`. Updates `HEAD`. | |
| 266 | | `DELETE` | `/v1/repos/{a}/{r}` | `204`. Disk move first, then KV (§9.2). | |
| 267 | |
| 268 | `visibility` is **not** in v1. "Public" would mean anonymous REST reads, anonymous |
| 269 | clone, or both, and each implies an anonymous principal in §2.3 and an open DoS |
| 270 | surface. Everything is private until that is designed. See [`ROADMAP.md`](ROADMAP.md) |
| 271 | D3. |
| 272 | |
| 273 | ### 4.3 Refs — read only |
| 274 | |
| 275 | Full names (`refs/heads/main`), so branches, tags and `refs/notes/*` are all |
| 276 | addressable. |
| 277 | |
| 278 | | Method | Path | Notes | |
| 279 | |---|---|---| |
| 280 | | `GET` | `/v1/repos/{a}/{r}/refs` | Paginated. `?type=branch\|tag\|note` filters. | |
| 281 | | `GET` | `/v1/repos/{a}/{r}/refs/{name}` | `ETag: "<sha>"`. | |
| 282 | |
| 283 | Refs are moved with `git push`. There is no `PUT` or `DELETE` here: a second way to |
| 284 | move a ref is a second place for the fast-forward rule to be wrong, and §1.3 exists |
| 285 | to avoid exactly that. |
| 286 | |
| 287 | ### 4.4 Content — read only |
| 288 | |
| 289 | Ref is a **query parameter**, never a path segment. `feature/login` and `src/main.rs` |
| 290 | both contain slashes, so `/raw/{ref}/{path}` has no unique parse — and percent-encoding |
| 291 | does not save it, because most reverse proxies normalize `%2F` before the handler sees |
| 292 | it. |
| 293 | |
| 294 | | Method | Path | Notes | |
| 295 | |---|---|---| |
| 296 | | `GET` | `/v1/repos/{a}/{r}/tree/{path}?ref=` | Paginated. | |
| 297 | | `GET` | `/v1/repos/{a}/{r}/raw/{path}?ref=` | `HEAD` and `Range` supported. | |
| 298 | | `GET` | `/v1/repos/{a}/{r}/commits?ref=&path=&cursor=` | | |
| 299 | | `GET` | `/v1/repos/{a}/{r}/commits/{sha}` | | |
| 300 | | `GET` | `/v1/repos/{a}/{r}/compare/{base}...{head}` | Diff. | |
| 301 | | `GET` | `/v1/repos/{a}/{r}/search?q=&ref=&path=` | Bounded content search. | |
| 302 | |
| 303 | These exist so a caller can answer "what is in here" without a clone — a dashboard |
| 304 | rendering a file, an agent checking whether a path exists before it bothers fetching. |
| 305 | They are a convenience over the git transport, not a substitute for it, and they are |
| 306 | strictly read-only. |
| 307 | |
| 308 | `GET /commits?path=` walks the DAG to find matching commits and is a trivial DoS |
| 309 | (`?path=nonexistent` walks all history). It carries a walk budget and returns a |
| 310 | `truncated` flag rather than running unbounded. |
| 311 | |
| 312 | ### 4.6 Runs, tokens, grants, account |
| 313 | |
| 314 | | Method | Path | Notes | |
| 315 | |---|---|---| |
| 316 | | `GET` | `/v1/repos/{a}/{r}/runs` | Newest first. `?status=`. | |
| 317 | | `POST` | `/v1/repos/{a}/{r}/runs` | `{ref}` → `201`. | |
| 318 | | `GET` | `/v1/repos/{a}/{r}/runs/{id}` | | |
| 319 | | `PATCH` | `/v1/repos/{a}/{r}/runs/{id}` | `{status:"cancelled"}`. Terminal → `409`. Not `DELETE` — the run still exists after. | |
| 320 | | `GET` | `/v1/repos/{a}/{r}/runs/{id}/logs` | `text/plain`, complete-so-far, `Range`-capable. | |
| 321 | | `GET` | `/v1/repos/{a}/{r}/runs/{id}/logs/stream` | SSE. Honours `Last-Event-ID`; event ids are byte offsets, so a dropped connection resumes. | |
| 322 | | `GET` `POST` | `/v1/tokens` | §2.2. Never returns a secret after creation. | |
| 323 | | `DELETE` | `/v1/tokens/{id}` | | |
| 324 | | `GET` | `/v1/repos/{a}/{r}/grants` | | |
| 325 | | `PUT` `DELETE` | `/v1/repos/{a}/{r}/grants/{account}` | Owner-only. `write` ⇒ code execution (§2.1). | |
| 326 | | `GET` | `/v1/account` | Identity, scopes, quota usage. | |
| 327 | | `DELETE` | `/v1/account` | Cascades repos, tokens, grants, container teardown. | |
| 328 | |
| 329 | Log content type is fixed per endpoint. Negotiating on *run state* — SSE while |
| 330 | running, plain text once finished — makes an identical request's content type depend |
| 331 | on a race with the runner. That is not content negotiation. |
| 332 | |
| 333 | ### 4.7 Non-REST endpoints |
| 334 | |
| 335 | Shape dictated by an external spec, so deliberately outside `/v1`: |
| 336 | |
| 337 | ``` |
| 338 | GET /{account}/{repo}.git/info/refs?service=… |
| 339 | POST /{account}/{repo}.git/git-upload-pack |
| 340 | POST /{account}/{repo}.git/git-receive-pack |
| 341 | POST /mcp |
| 342 | GET /mcp SSE channel, per MCP transport |
| 343 | GET /healthz includes free-disk check |
| 344 | ``` |
| 345 | |
| 346 | **The git paths have their own error contract and RFC 9457 does not apply to them.** |
| 347 | `401` must carry `WWW-Authenticate: Basic realm="zuka"` or git never invokes its |
| 348 | credential helper and the clone fails instead of prompting. Responses use |
| 349 | `application/x-git-*-result`; `info/refs` sets `Cache-Control: no-cache, max-age=0, |
| 350 | must-revalidate`. `problem+json` here is noise git will render at the user. |
| 351 | |
| 352 | ### 4.8 `/raw` is a hostile-content endpoint |
| 353 | |
| 354 | Always `application/octet-stream`, `Content-Disposition: attachment`, |
| 355 | `X-Content-Type-Options: nosniff`, `Content-Security-Policy: sandbox`. Never sniffed, |
| 356 | never caller-specified. GitHub runs `raw.githubusercontent.com` on a separate origin |
| 357 | for exactly this reason. |
| 358 | |
| 359 | CORS is **disabled by default** — §3 of the README says there is no web UI here, so |
| 360 | there is no first-party browser origin to serve. Where enabled it is an explicit |
| 361 | allowlist; `Origin` is never reflected. CORS plus bearer auth plus attacker-controlled |
| 362 | bytes is the standard cross-origin theft recipe. |
| 363 | |
| 364 | Cache: `public, max-age=31536000, immutable` when `ref` is a full sha, `no-cache` |
| 365 | otherwise. |
| 366 | |
| 367 | ### 4.9 Limits |
| 368 | |
| 369 | Stated, not implied: max request body 10 MiB; max `changes[]` length 1,000; max blob |
| 370 | 32 MiB via API (pushes are bounded by `receive.maxInputSize`); tree and ref pages |
| 371 | paginated. Over-quota → `413` or `quota-exceeded`. Free disk below the reserve |
| 372 | threshold → `507` **before** the disk fills, because `receive-pack` interrupted by |
| 373 | ENOSPC leaves a partial pack. |
| 374 | |
| 375 | --- |
| 376 | |
| 377 | ## 5. MCP |
| 378 | |
| 379 | `POST /mcp` JSON-RPC 2.0 plus the `GET` SSE channel and session identification the |
| 380 | current transport revision requires. Auth is a bearer token; whether that satisfies |
| 381 | the target client's authorization expectations is verified in M4, not assumed. |
| 382 | |
| 383 | ### 5.1 Tools |
| 384 | |
| 385 | Administration and inspection. There is no tool that writes to a repository — an |
| 386 | agent that wants to change code runs `git`, which it already knows how to do. |
| 387 | |
| 388 | | Tool | What it is for | |
| 389 | |---|---| |
| 390 | | `repo_create` `repo_list` `repo_get` `repo_delete` | Provisioning. `repo_create` returns both clone URLs and may seed from `import_url`. | |
| 391 | | `key_add` `key_list` `key_remove` | Register the agent's own SSH key so it can then use git. | |
| 392 | | `ref_list` `tree_list` `file_read` `search` `commit_log` `commit_get` `compare` `blame` | Read without cloning. | |
| 393 | | `run_list` `run_get` `run_logs` | Watch CI after a push. | |
| 394 | |
| 395 | |
| 396 | Each maps onto the same core call the REST handler uses. |
| 397 | |
| 398 | ### 5.2 What the facade may and may not do |
| 399 | |
| 400 | Facades may fill defaults and reshape payloads — resolving `ref` to the repository's |
| 401 | default branch, for instance. They may not carry authorization rules; those live on |
| 402 | `Identity` so REST, MCP and SSH cannot drift (§1.1). |
| 403 | |
| 404 | The intended shape of an agent session is: `repo_create` → `key_add` → then plain |
| 405 | `git clone`, `git commit`, `git push` in the agent's own workspace → `run_status` to |
| 406 | see whether CI passed. The MCP surface removes the GitHub-shaped work; git does the |
| 407 | git-shaped work. |
| 408 | |
| 409 | --- |
| 410 | |
| 411 | ## 6. CI |
| 412 | |
| 413 | ### 6.1 Trigger and hooks |
| 414 | |
| 415 | Hooks are **symlinks** into `$DATA_DIR/hooks/`, installed via `init.templateDir` at |
| 416 | repo creation. Symlinks, not copies, so upgrading a hook is atomic across every |
| 417 | existing repo rather than leaving pre-change repos on the old version forever. |
| 418 | |
| 419 | | Hook | Job | |
| 420 | |---|---| |
| 421 | | `update` | Calls `git::store::check_ref_move`, the single implementation of the ref rule. | |
| 422 | | `post-receive` | Writes a queued run record. Never blocks the push. | |
| 423 | |
| 424 | The queue **is** the run records (`ci::run::RunStore::queued`), not a separate |
| 425 | channel: a push that lands while the server is down is still picked up when it |
| 426 | returns, and the API and the executor cannot disagree about what is pending. The cost |
| 427 | is latency — the executor polls, so a run waits up to `POLL` before starting. A socket |
| 428 | would remove that; durability across a restart mattered more. |
| 429 | |
| 430 | Hooks receive `ZUKA_DATA_DIR` and `ZUKA_CI_ENABLED` through |
| 431 | `Config::hook_env`. Both git spawn sites clear the environment deliberately, so a |
| 432 | hook would otherwise inherit nothing and resolve the default data directory. |
| 433 | |
| 434 | `pre-receive` commit-signature enforcement is **cut from v1**: it needs a keyring, a |
| 435 | trust model and an API surface, none of which exist. Branch protection beyond |
| 436 | fast-forward is cut for the same reason. Asserting a feature into existence with a |
| 437 | table row is how specs lie. |
| 438 | |
| 439 | ### 6.2 Spec |
| 440 | |
| 441 | `.zuka.toml` at the repo root: |
| 442 | |
| 443 | ```toml |
| 444 | [run] |
| 445 | steps = ["cargo test", "cargo build --release"] |
| 446 | timeout_secs = 600 |
| 447 | branches = ["refs/heads/main"] |
| 448 | ``` |
| 449 | |
| 450 | **There is no `runtime` key.** An earlier draft specified one, validated it against a |
| 451 | list of toolchains, and then ignored it — every step ran under `/bin/sh` regardless. |
| 452 | A knob that appears to select a toolchain and does not is worse than no knob. Steps |
| 453 | run with whatever is on the host `PATH`; provisioning toolchains is the operator's |
| 454 | job. |
| 455 | |
| 456 | A repository may lower `timeout_secs` but never raise it past the host ceiling, |
| 457 | otherwise one repository can hold a runner slot indefinitely. |
| 458 | |
| 459 | ### 6.3 Isolation |
| 460 | |
| 461 | **`tenant` mode.** The container is the boundary between accounts. It is *not* a |
| 462 | boundary between untrusted CI code and the service process sharing that container, so: |
| 463 | |
| 464 | - The runner has its own uid. `RLIMIT_NPROC` is per-uid — sharing one with the service |
| 465 | means a fork bomb in CI takes down the service. |
| 466 | - The environment is scrubbed (`env -i`). The service's credentials must not be |
| 467 | readable from `/proc/self/environ` or the unit file. |
| 468 | - The tenant holds a **per-tenant KV credential**, not a shared one. A shared |
| 469 | `DENO_KV_ACCESS_TOKEN` inside a container running arbitrary code means every tenant |
| 470 | can read every other account's rows — and would make the isolation milestone's proof |
| 471 | false. |
| 472 | - Limits: `RLIMIT_CPU`, `RLIMIT_AS`, `RLIMIT_FSIZE`, `RLIMIT_NOFILE`, wall clock and |
| 473 | an output cap. **`RLIMIT_NPROC` is off by default** and this is not timidity: it is |
| 474 | per-UID, so with the runner sharing a uid with the service it counts processes we |
| 475 | do not control — a useful-looking value makes `fork` fail on the first step. It |
| 476 | becomes meaningful only alongside a dedicated runner uid, which is also what makes |
| 477 | it safe. |
| 478 | - Timeout kills the **cgroup**, not the shell — killing the shell orphans the tree. |
| 479 | - No network: a network namespace with no route out, stated as the mechanism rather |
| 480 | than as an intention. |
| 481 | |
| 482 | **`standalone` mode.** Same uid separation and rlimits, no container. **This is not a |
| 483 | security boundary**, CI defaults to **off**, and the README says so in those words. |
| 484 | |
| 485 | A step allowlist was considered and rejected as theatre — `sh -c` defeats it in one |
| 486 | character. Enforcement is at the OS or it is not enforcement. That principle then has |
| 487 | to be followed through, which is what the bullets above are. |
| 488 | |
| 489 | --- |
| 490 | |
| 491 | ## 6A. SSH |
| 492 | |
| 493 | Shipped in M1 (pulled forward from M7). A `russh` server in-process — not the host's |
| 494 | `sshd`, which would need per-host configuration and break the single-binary promise. |
| 495 | |
| 496 | **Authentication is public-key only.** Password and keyboard-interactive are rejected |
| 497 | outright. The offered key is matched against stored keys by raw bytes and the account |
| 498 | falls out of the match; the SSH username is never read. |
| 499 | |
| 500 | **A session may exec exactly three commands.** `git-upload-pack`, |
| 501 | `git-receive-pack` and `git-upload-archive`, against one repository. There is no |
| 502 | shell, no pty and no subsystem. The exec string is parsed into a fixed service plus a |
| 503 | validated repository and is never handed to a shell, so `git-upload-pack '/a/b.git'; |
| 504 | id` is a parse error rather than an execution. |
| 505 | |
| 506 | `upload-archive` was refused in an earlier draft on the grounds that it exposes tree |
| 507 | contents through a separate code path. That was wrong: it exposes exactly what |
| 508 | `upload-pack` already exposes to a caller who has read access, so refusing it withheld |
| 509 | a normal git capability without withholding any data. It requires read, not write. |
| 510 | |
| 511 | **One environment variable crosses the boundary.** `GIT_PROTOCOL`, capped at 64 |
| 512 | bytes. Forwarding arbitrary client environment into a subprocess is an injection |
| 513 | primitive: `GIT_CONFIG_*`, `GIT_ALTERNATE_OBJECT_DIRECTORIES` and `LD_PRELOAD` all |
| 514 | change what the child does. |
| 515 | |
| 516 | **Keys carry their own scope.** `read_only` makes a key clone-and-fetch only; |
| 517 | `repos[]` confines it to named repositories. A key is a credential with the reach of |
| 518 | a `repo:write` token and is treated as one. |
| 519 | |
| 520 | **The host key is generated once** and reused, so clients are not trained to accept a |
| 521 | changed fingerprint. |
| 522 | |
| 523 | Concurrency, the disconnect kill and the ref rule are shared with the HTTP path: the |
| 524 | same semaphore bounds both, the same process-group kill applies, and a forced |
| 525 | non-fast-forward is refused by `check_ref_move` regardless of which transport carried |
| 526 | it. |
| 527 | |
| 528 | ## 7. Control plane |
| 529 | |
| 530 | A tenant never learns its own public address. It sits on a private bridge behind the |
| 531 | control plane, so the control plane passes `PUBLIC_URL` and `PUBLIC_SSH` down when it |
| 532 | provisions one. Without that a tenant advertises its bind address and every clone URL |
| 533 | it returns points at a container the caller cannot reach — which is what it did until |
| 534 | it was tested on real infrastructure. |
| 535 | |
| 536 | ### 7.1 Provisioning |
| 537 | |
| 538 | Account creation writes state `provisioning` and returns immediately. A reconciler |
| 539 | drives it to `ready` or `failed`. **Never a synchronous wait on container boot** — the |
| 540 | non-durable `queueMicrotask` in `yangu/roadmap/mailbox-provisioning.md` is the |
| 541 | specific mistake not to repeat: no due index, no retry, no failure surface. |
| 542 | |
| 543 | Containers stop when idle and start on demand, with a stated cold-start latency |
| 544 | budget. Always-on would mean one permanently running container per account for users |
| 545 | who push twice a year. |
| 546 | |
| 547 | ### 7.2 Proxying |
| 548 | |
| 549 | The control plane proxies long-lived, bidirectional, sideband-multiplexed, |
| 550 | multi-gigabyte git streams, plus SSE. Backpressure must be preserved end to end and |
| 551 | SSE must not be buffered. Shelling out to `git` at the engine does not help at the |
| 552 | proxy hop — this is its own risk ([`ROADMAP.md`](ROADMAP.md) R6), not a free |
| 553 | consequence of §1.2. |
| 554 | |
| 555 | --- |
| 556 | |
| 557 | ## 8. Source layout |
| 558 | |
| 559 | ``` |
| 560 | zuka/ |
| 561 | ├── Cargo.toml Cargo.lock README.md openapi.json |
| 562 | ├── src/ |
| 563 | │ ├── main.rs boot, CLI, git hook entry point |
| 564 | │ ├── brand.rs the one place the product name appears |
| 565 | │ ├── config.rs Config::from_env(); fails boot on a bad value |
| 566 | │ ├── error.rs Error -> problem+json |
| 567 | │ ├── core/ domain operations shared by every facade |
| 568 | │ ├── git/ |
| 569 | │ │ ├── exec.rs the only place this service invokes git |
| 570 | │ │ ├── validate.rs names, refs, tree paths, blob modes |
| 571 | │ │ ├── store.rs repositories, hooks, ref rules, protection |
| 572 | │ │ ├── discover.rs the read core, shared by REST and MCP |
| 573 | │ │ ├── transport.rs streaming CGI bridge |
| 574 | │ │ └── import.rs SSRF-guarded cloning |
| 575 | │ ├── http/ routing, limits, responses, auth, rate limiting |
| 576 | │ ├── api/ REST facade + the OpenAPI document |
| 577 | │ ├── mcp/ MCP facade |
| 578 | │ ├── ssh/ SSH server and exec-command parser |
| 579 | │ ├── ci/ spec, run store, runner, executor |
| 580 | │ ├── jobs/ gc, sweep, fsck |
| 581 | │ ├── account/ identity, tokens, SSH keys |
| 582 | │ └── store/ metadata, quotas, change-aware file cache |
| 583 | └── test/live/ spawned-binary suite driven by real git and ssh |
| 584 | ``` |
| 585 | |
| 586 | ### 8.1 The core layer |
| 587 | |
| 588 | `core/` exists because REST and MCP each grew their own copy of repository create and |
| 589 | delete, and the copies drifted: the MCP one had no rollback (so a failed `git init` |
| 590 | wedged the name with a permanent `Creating` record), did not support protected refs or |
| 591 | import, and left run records behind on delete — which meant re-creating a name |
| 592 | resurrected the previous repository's CI history. |
| 593 | |
| 594 | `AppState::open_repo` is the same lesson one layer down: the sequence "resolve names, |
| 595 | check access, confirm ready, get path" appeared seven times, three of them subtly |
| 596 | different. `Access::Write` also enforces the disk reserve and the storage quota, so |
| 597 | no write path can forget them. |
| 598 | |
| 599 | ### 8.2 Why nested rather than flat |
| 600 | |
| 601 | `yangu/atlas` keeps everything in a 1,158-line `main.rs`; `yangu/kazi`'s is 3,050. |
| 602 | That is a judgment call about **four protocol surfaces** — REST, MCP, git wire, |
| 603 | control-plane RPC — against atlas's one, not a claim that the flat pattern has been |
| 604 | measured to fail. Stating it as proven would apply a lower evidentiary standard than |
| 605 | the convention it deviates from demands. |
| 606 | |
| 607 | ### 8.3 Scheduled work has its own unit |
| 608 | |
| 609 | `jobs/` runs in a separate `zuka-jobs` systemd unit, not the web entrypoint: GC and |
| 610 | repack, the deleted-repo sweep, the provisioning reconciler, and quota recomputation. |
| 611 | Running them in the server process would violate the same rule for the same reason it |
| 612 | exists. |
| 613 | |
| 614 | ### 8.4 Errors |
| 615 | |
| 616 | `anyhow` internally; one enum at the boundary. |
| 617 | |
| 618 | ```rust |
| 619 | enum Error { |
| 620 | NotFound(&'static str), |
| 621 | Conflict { slug: &'static str, detail: String }, |
| 622 | Unauthorized, Forbidden, |
| 623 | Invalid { slug: &'static str, detail: String }, |
| 624 | Unprocessable(String), |
| 625 | MethodNotAllowed, |
| 626 | PayloadTooLarge { limit: u64 }, |
| 627 | QuotaExceeded { limit: u64, used: u64 }, |
| 628 | RateLimited { retry_after: u32 }, |
| 629 | StorageFull, |
| 630 | Internal(anyhow::Error), |
| 631 | } |
| 632 | ``` |
| 633 | |
| 634 | `StorageFull` → `507`, `Internal` → `500` with the detail logged and never returned. |
| 635 | There is no `Upstream` variant: nothing makes an upstream call, because hosted auth |
| 636 | refuses to boot rather than being half-implemented. |
| 637 | |
| 638 | --- |
| 639 | |
| 640 | ## 9. Data |
| 641 | |
| 642 | ### 9.1 Disk |
| 643 | |
| 644 | ``` |
| 645 | $ZUKA_DATA_DIR/ |
| 646 | ├── git/{account}/{repo}.git/ |
| 647 | ├── hooks/ templateDir; symlink targets |
| 648 | ├── runs/{account}/{repo}/ one JSON record and one log per run |
| 649 | └── tmp/deleted/ soft-deleted repos, 7-day sweep |
| 650 | ``` |
| 651 | |
| 652 | ### 9.2 KV |
| 653 | |
| 654 | | Key | Value | |
| 655 | |---|---| |
| 656 | | `["zuka","account",account]` | `AccountRecord` incl. provisioning state | |
| 657 | | `["zuka","container",account]` | container id + endpoint (control) | |
| 658 | | `["zuka","repo",account,repo]` | `RepoRecord` | |
| 659 | | `["zuka","repo_index",account,repo]` | `repo` | |
| 660 | | `["zuka","token",tokenHash]` | `TokenRecord` | |
| 661 | | `["zuka","token_id",account,tokenId]` | `tokenHash` | |
| 662 | | `["zuka","grant",account,repo,grantee]` | `Capability` | |
| 663 | | `["zuka","run",account,repo,runId]` | `RunRecord` | |
| 664 | | `["zuka","run_index",account,repo,ts,runId]` | `runId` | |
| 665 | | `["zuka","idem",tokenId,key]` | cached response, 24h TTL | |
| 666 | | `["zuka","audit",account,ts,rand]` | `AuditRow`, 90-day TTL | |
| 667 | |
| 668 | `token_id → tokenHash` exists so `DELETE /v1/tokens/{id}` can find the canonical row; |
| 669 | without it the delete has no path to the record. The audit key carries a random suffix |
| 670 | because two operations in the same millisecond are ordinary for git and would |
| 671 | otherwise overwrite each other. |
| 672 | |
| 673 | Audit writes are fire-and-forget — a failed log row must never block the operation it |
| 674 | describes. |
| 675 | |
| 676 | **Delete ordering: disk move first, then KV.** A crash between them leaves a repo in |
| 677 | `tmp/deleted/` with live KV rows, which `zuka fsck` reports and can roll back. The |
| 678 | reverse leaves an unreachable repo and a `500`. |
| 679 | |
| 680 | ### 9.3 Backup and reconciliation |
| 681 | |
| 682 | Repos live on a local disk and metadata lives in a remote KV; they are never |
| 683 | snapshotted together, so a restore reconciles two stores with different recovery |
| 684 | points. `zuka fsck` lists orphans in both directions. **Disk is authoritative for |
| 685 | repository existence; KV is authoritative for access control.** Documented because the |
| 686 | system enters a split state deliberately on every delete. |
| 687 | |
| 688 | ### 9.4 Garbage collection |
| 689 | |
| 690 | Every API commit writes loose objects; every push leaves a pack; `packed-refs` is |
| 691 | never rewritten. Without GC, inode count and disk grow without bound. |
| 692 | |
| 693 | `jobs/gc` runs `git repack -Ad` + `git prune --expire` with `gc.auto=0`, holding a |
| 694 | lock coordinated with `git/transport.rs` — unsynchronized GC concurrent with a live |
| 695 | `upload-pack` hands the client a corrupt clone. |
| 696 | |
| 697 | The support case this creates is worth stating: *"the agent committed my API key."* |
| 698 | Force-moving the ref leaves the blob fetchable by sha until the next GC. Product docs |
| 699 | must say so rather than implying deletion is immediate. |
| 700 | |
| 701 | --- |
| 702 | |
| 703 | ## 10. Config and observability |
| 704 | |
| 705 | `std::env::var` with defaults, gathered into `Config::from_env() -> Result<Config>` |
| 706 | that fails boot loudly. Prefix `ZUKA_` — including `ZUKA_BIND`; only genuinely |
| 707 | shared infra names (`KV_URL`) go unprefixed. |
| 708 | |
| 709 | | Var | Default | Meaning | |
| 710 | |---|---|---| |
| 711 | | `ZUKA_BIND` | `127.0.0.1:8790` | Listen address | |
| 712 | | `ZUKA_MODE` | `standalone` | `standalone` \| `control` \| `tenant` | |
| 713 | | `ZUKA_DATA_DIR` | `/var/lib/zuka` | | |
| 714 | | `ZUKA_ME_URL` | *unset* | Opt-in hosted auth. Unset → local token file. | |
| 715 | | `ZUKA_CI_ENABLED` | `0` | Must be set explicitly in every mode (§6.3) | |
| 716 | | `ZUKA_CONTROL_PUBKEY` | — | Ed25519 public key; required in `tenant` | |
| 717 | | `ZUKA_DISK_RESERVE_MB` | `2048` | Below this, writes → `507` | |
| 718 | | `KV_URL` | — | Per-tenant credential in `tenant` mode | |
| 719 | |
| 720 | `ZUKA_ME_URL` deliberately has **no default**. Defaulting it to `worklyn.me` would |
| 721 | mean a self-hoster who configures nothing gets a binary that phones Worklyn to |
| 722 | authenticate and returns `502` when it cannot — for the mode described as what a |
| 723 | self-hoster runs. |
| 724 | |
| 725 | **Observability**: structured single-line logs carrying method, route, status, |
| 726 | duration, account and request id. Counters for repo bytes, object counts, CI queue |
| 727 | depth and run outcomes — the queue-depth counter is what makes the overflow policy in |
| 728 | [`ROADMAP.md`](ROADMAP.md) D5 decidable instead of guessed. Whether this emits OTLP to |
| 729 | `product/observability/` is D6. |