Page kind: Operate
Serve over ssh#
After this page people and CI reach a store with stock git alone: clone, fetch, pull and push over
ssh, through swarmops serve as the forced command of an authorized_keys line.
You need: SwarmOps 0.7.0 or newer on Linux (swarmops --version); Stores; git
2.39 or newer on the server (git --version); an OpenSSH server (ssh -V).
Commands and outputs here follow the conventions of the operations guide; the client outputs come from a run with git 2.43.0. Over HTTPS instead: Serve over HTTP, which reuses this page's access file and refusals.
Before you start#
swarmopsand git 2.39 or later on the server (Install and upgrade), an OpenSSH server, and any git on the clients.- A store under one directory, here
/srv/so, owned by the account that serves it (Stores). A store's name is its directory's name: one segment of[a-z0-9][a-z0-9._-]{0,62}, hereacme. - A
FORMAT 2store for branches beyondmainandrelease/<name>, and for tags (swarmops init --format 2, orswarmops upgrade). - A filesystem that lets the serving account run files: git runs the hooks
servewrites in the store (git/serve-hooks/pre-receiveandgit/serve-hooks/proc-receive), so a store mountednoexecrefuses every push.
How it works#
git (client) ──ssh──▶ sshd ──forced command──▶ swarmops serve --root /srv/so --as person/ada
│ 1 the caller: --as alone
│ 2 the store named by the client's path
│ 3 access.toml: may the caller read?
│ 4 git/serve.git refreshed from the store,
│ unless git/serve.state shows nothing moved
▼
git upload-pack | git receive-pack on serve.git
│ a fetch: swarmops serve --hook
│ pack-objects sends the pack, from
│ git/serve-cache/ when an identical
│ request was answered before
│ a push: git checks the pack in its
│ quarantine, then runs
▼
swarmops serve --hook pre-receive: per ref, access,
forward or force, import; one refused ref refuses the
push, and git drops the quarantine
▼
swarmops serve --hook proc-receive: per ref, the same,
then compare-and-swap of the store ref, or a landserve runs once per connection and ends with it; there is no daemon. The pack protocol is the
host git's, so every git client that talks to GitHub talks to it. What git advertises is the
export of each store ref's committed head, made again before every connection, so a client never
fetches what a crash could take back. git/serve.state names the store ref, the changeset and
the commit of each export; a connection whose store refs and serve.git refs both match it
advertises serve.git as it is, taking neither the store's writer lock nor git/serve.lock, so a
fetch with nothing new does not wait behind the agents' lands. Anything else (a ref moved, the file
missing or damaged, a serve.git ref moved by hand) refreshes first, as before.
Refreshes coalesce. git/serve.state (version 2) also says covered: how far into the store's
journal the last full refresh read. A connection that started before that point finds serve.git
current even when the store has moved since, because that refresh already holds every land
acknowledged before the connection started; a refresh that finds such a newer export once it has
the lock writes nothing. A refresh lets git/serve.lock go while it waits for the store's writer
lock, and a preparation lets it go between its rounds, so connections and pushes do not wait
behind the agents' lands. A state of version 1 (an older swarmops) is read as not current: the
next connection refreshes and writes version 2.
git hands each fetch's pack to serve --hook pack-objects (its uploadpack.packObjectsHook,
which serve sets in git's environment). It sends only commits a served ref is or reaches: a
want of anything else, such as the commit of a refused push that git kept in serve.git, is
refused before any byte, with either protocol. Identical requests (the same wants, haves, depth
and options, the same git) share one pack in git/serve-cache/: the first produces it, and
the others are sent it, after its header's BLAKE3 digest is checked against its bytes; an entry
that fails the check is removed and the pack produced again. The cache keeps at most 4 GiB a
store, the least recently used removed first.
git/serve.git, git/SERVE, git/serve.lock, git/serve.state, git/serve-hooks/ and
git/serve-cache/ in the store are a cache of that export: deleted, the next connection makes
them again, and a backup may leave them out. git/serve-records.jsonl, its older file and
git/serve-split/ are records of the requests served, which a backup may leave out
too.
The serving account#
One Unix account runs serve for every key: the account that owns the stores, since their files
are readable by their owner alone. Its environment does not name the caller: serve ignores
SWARMOPS_AGENT and SWARMOPS_RUN_ID, so a key line without --as is refused rather than given
the account's identity.
Its state directory. The account keeps one memo key per store,
$XDG_STATE_HOME/swarmops/memo-keys/<repo_id>.key (~/.local/state without XDG_STATE_HOME;
SWARMOPS_MEMO_KEY_DIR moves the directory), made at its first connection. With it, the refresh
before each connection stops its walk of the history at what it exported before; without a home
directory it stays correct but walks the history every time. Give the account a home directory it
can write (Backup and restore says what the key is).
Its first connection exports the store's whole history into serve.git, as loose objects that
git gc --auto packs later (known limitations).
Prepare a large store before people clone it, as the serving account:
swarmops --repo /srv/so/acme serve --prepare --seed-from /srv/import/acme.git--seed-from names the repository git import read: serve --prepare fetches the imported
commits' objects from it into serve.git, exports (finding every commit there, so writing none),
and packs the whole history with a bitmap. Without --seed-from it exports what serve.git
lacks. It needs no --root; it waits for git/serve.lock as a connection does (--prepare-wait zero gives up at once and marks the lock's holder to run again), and exits 0 when it finds
nothing to do. It helps only when it runs as the account that serves the store and that the agents
run as: it writes serve.git and uses that account's memo key. Without the memo key, on a store
whose main head is no imported commit, it exports nothing and says so in warnings; the next
connection then refreshes as before.
After every ref move of a store that holds access.toml (land, commit --advance, ref set, ref delete, op undo, sync, fetch, git import and ws land, from the command line
or as an MCP tool) swarmops starts swarmops serve --prepare --prepare-wait zero for that store
once its response is out (after git import, with --seed-from the repository it read). That
run is detached: its own process group, no stdin, stdout or stderr, and nobody waits for it; the
MCP server reaps the ones it started. When another refresh holds git/serve.lock it leaves
git/serve.dirty for that one and exits, so a burst of lands costs one preparation running at a
time. A preparation that fails changes nothing the operation returned; the next connection
refreshes as before. It helps only when the agents run as the same account as serve.
Write access.toml#
The store grants nothing until its root holds an access.toml, read on every connection. Write it
as the serving account, beside FORMAT and REPO; no command writes it, and a change applies to
the next connection. A push to refs/land/<target> also reads it before each land attempt: a
land grant removed while a land retries after a lost race stops it before its next attempt,
with the refusal the connection would get and nothing written. An attempt already checked is not
stopped by the check: it can still land, lose its race or conflict.
# Who may do what through `swarmops serve`.
# Read on every connection and before each land attempt.
version = 1
[[grant]]
who = ["person/ada", "ci/*"] # an identity, or <family>/* for every instance of a family
read = true # clone, fetch, pull
push = ["release/*", "feature/**", "tags/**"] # refs a push may create and fast-forward
force = ["feature/**"] # of those, refs a push may also move backwards or delete
land = ["main", "release/*"] # targets a push to refs/land/<target> may land onto
[[grant]]
who = ["person/bob"]
read = true- Names are store ref names:
main,release/1.4,feature/x,tags/v1.4.0. A pattern is an exact name,*for one segment without/, or a trailing**for every depth below. - Grants add up, and there are no deny rules: what any grant naming the caller allows, the caller may do, whatever their order.
push,forceandlandeach implyread.forceacts only on refspushalso covers, and a deletion needsforce.mainnever moves backwards and is never deleted: aforcethat coversmainmakes the file invalid. The example lets people changemainonly by a land; a grant withpush = ["main"]lets a push move it forward along first parents as well.- The file holds at most 1 MiB (Access file).
| The file | Every caller gets |
|---|---|
| Missing | E_AUTH, details.reason no_access_file |
Not TOML, an unknown key, version other than 1, a bad pattern, a force covering main, more than 1 MiB | E_POLICY_DENIED, details.reason access_file_invalid, with the line |
fatal: remote error: E_POLICY_DENIED: access.toml has force "**", which covers main (line 22)Add a key line#
In ~/.ssh/authorized_keys of the serving account, one line per person or tool, each naming its
own identity with --as (Identities):
command="swarmops serve --root /srv/so --as person/ada",restrict ssh-ed25519 AAAA… ada@laptop
command="swarmops serve --root /srv/so --as ci/runner-1",restrict ssh-ed25519 AAAA… cirestrict turns off forwarding, the pty and ~/.ssh/rc; the forced command runs whatever the
client asks, and serve reads the request from SSH_ORIGINAL_COMMAND without a shell. It runs
git-upload-pack and git-receive-pack (or git upload-pack, git receive-pack) on a path that
names one store under --root, and refuses anything else. sshd starts the command through the
account's login shell, so swarmops must be on that shell's PATH, or named by its full path.
Let protocol v2 through#
git sends the protocol it wants in GIT_PROTOCOL, which sshd drops unless it accepts it. In
sshd_config, then reload sshd:
AcceptEnv GIT_PROTOCOLWithout it, clients fall back to protocol v0, which serve also serves; v2 sends only the refs a
fetch asks for.
What a client types#
Nothing but stock git, with the store's name as the repository (.git is optional):
git clone ssh://so-host/acme.git # main, the branches and the tags
git pull --rebase # fetch, then rebase onto main
git push origin HEAD:refs/land/main # land: squashed onto main, as an agent lands
git push origin feature/x # a branch of its own (FORMAT 2)
git push --force origin feature/x # with force granted
git push origin v1.4.0 # a lightweight tag (FORMAT 2)
git push origin main # with push granted on main: forward onlyA clone's commits keep their ids: the store's history imported from git exports back as itself.
A land squashes the pushed commits into one changeset on main, as swarmops land does, and
names the commit it made; nothing is left under refs/land/:
remote: landed onto main as 211c210a39dba59a1f0897b5205bd65678ef79b8: git fetch brings it in
To ssh://so-host/acme.git
* [new reference] HEAD -> refs/land/mainThe landed commit is new to the client, whose author is the pushed commit's and whose committer
is the caller; git pull --rebase drops the local commit it replaces:
warning: skipped previously applied commit e6abb43
…
Successfully rebased and updated refs/heads/main.The same push again (a dropped connection, a CI retry) carries the same land key, finds the land
on main and writes nothing (remote: landed already onto main as 211c210…). The same commit
pushed by another identity is a land of its own: a change of its own, or a refusal with
E_NOTHING_TO_LAND when main holds its changes already.
A conflict writes nothing; the report comes on git's remote: lines, at most 50 paths
(Land conflict lines):
remote: E_CONFLICT: 1 path conflicts with main at b62a907
remote: README.md: both changed lines 1; main's change by unknown/unknown
remote: resolve it on top of main, then push again
To ssh://so-host/acme.git
! [remote rejected] HEAD -> refs/land/main (conflict)unknown/unknown names a change that came in from git, which carries no agent; a change an agent
committed in the store names that agent. Fetch, rebase onto origin/main, resolve, push again.
Refusals a client sees#
A refusal before git starts is one line, fatal: remote error: <CODE>: <message>, and the
envelope after it, which ssh passes on from serve's stderr:
fatal: remote error: E_AUTH: the store has no access.toml: serve grants nothing without one
{"ok":false,"op":"serve","api":1,"error":{"code":"E_AUTH","message":"the store has no access.toml: serve grants nothing without one","retryable":false,"exit":5,"details":{"reason":"no_access_file"},"hint":"the operator writes access.toml in the store's root: version = 1, then one [[grant]] per set of callers (who, read, push, force, land)"},"meta":{"elapsed_ms":1,"resolved":{},"auth":"trust"}}A push with one refused ref is refused whole, before any of its objects enters serve.git: every
ref is ! [remote rejected] <ref> (pre-receive hook declined), and a remote: line per ref gives
the reason, <ref> <CODE>: <message> (a ref refused only with the others reads accepted alone).
Push refs that must land independently in separate pushes. A ref that loses a race after that
check, to another push or a land conflict, is refused alone: ! [remote rejected] <ref> (<reason>). No text names a path of the server.
| The client sees | Why | What to do |
|---|---|---|
E_AUTH: serve names no caller: --as is not given, … | The key line has no --as | Operator: add --as to the line |
E_AUTH: no grant of access.toml names person/eve | No grant names the caller | Operator: add a grant, or the caller uses its own key |
E_AUTH: the store has no access.toml: … | Write access.toml | Operator |
E_POLICY_DENIED: access.toml … (line N) | The file is invalid | Operator: fix line N |
E_POLICY_DENIED: person/bob may not push feature/b | The caller's grants do not cover the action and ref | Push to a ref it covers, or land; or the operator widens the grant |
E_INVALID_ARG: the path names no store | The URL's path is not one store name | ssh://<host>/<name>.git |
E_NOT_A_REPO: no store named nope is served | No such store under --root | Check the name with the operator |
E_INVALID_ARG: serve runs git-upload-pack and git-receive-pack alone | ssh so-host <command>, or git archive --remote | Clone, fetch, pull or push with git |
E_BUSY: … | Another connection's refresh held git/serve.lock for 10 s | Connect again |
remote: {"ok":false,…"E_POLICY_DENIED",…"not_served"…}, then git's bad pack header | A fetch by id of an object no served ref reaches | Fetch a branch or a tag |
non-fast-forward: main moves along first parents; push to refs/land/main | A push to main that is not forward along first parents, such as the merge git pull --no-rebase makes | git pull --rebase, or push to refs/land/main |
! [rejected] … (non-fast-forward) | A branch moved backwards without --force | --force, if force covers the branch |
stale: refs/heads/feature/y moved; fetch first | Another push moved the ref after this client's fetch | Fetch, then push again |
E_REF_PROTECTED: ref "main" is protected: it is never deleted | A deletion of main | None: main stays |
E_TAG_IMMUTABLE: tag "tags/v0.1.0" cannot move: … | A tag pushed again at another commit | Delete it (with force), then push it again |
E_INVALID_ARG: … is an annotated tag, which the store does not hold: … | git tag -a | Push a lightweight tag |
E_REF_NAME_INVALID: refs/notes/x is not served: … | A ref outside refs/heads/, refs/tags/ and refs/land/ | Push a branch or a tag |
E_REF_NAME_INVALID: … names a ref the store reserves | A branch under remote/ | Another name |
E_FORMAT_REQUIRED: refs/heads/feature/z needs a store of format 2: … | A branch or tag in a FORMAT 1 store | Operator: swarmops upgrade |
E_GIT_IMPORT_LOSSY: … | The pushed history holds what the store cannot keep, such as a submodule | Push a history without it |
Records#
serve appends one JSON line to the store's git/serve-records.jsonl for each request it served
from the store on: a clone, a fetch, a push, or a refusal after the store was found. Each line is a
ServeRecord (swarmops schema ServeRecord):
tail -n 1 /srv/so/acme/git/serve-records.jsonlat is when the connection started (Unix milliseconds) and identity the caller serve
resolved. service is clone for a fetch that excludes nothing (no have, no excluded commit, no
shallow client), else fetch (a fetch with nothing new too), or push. For a fetch,
prepare_ms runs to the pack's first byte (the refresh, the negotiation, the pack produced or
found in the cache) and send_ms from there to the end, so a slow client shows as send_ms; for
a push, send_ms runs to the start of the push's check (receiving it and git's own checks) and
prepare_ms from there to the end (judging, importing, landing, exporting). A request that sent
no pack, or a push that never reached its check, is all prepare_ms. bytes counts the pack sent
or received, exit is git's (128 + signal for a git a signal ended) or the refusal's, and
code names a refusal's error code, a push's first refused ref among them.
A push the hooks refuse records the refusal's code and that code's exit, though git exits 0:
E_REF_PROTECTED for a move of main off its first parents, E_CAS_MISMATCH for a stale push,
E_CONFLICT for a land that conflicts.
The hooks pass their split to the parent serve in git/serve-split/<connection>, a line per
hook run (a second pack on one connection adds its bytes), which it removes after reading; a
split older than a day, which a serve that died left, is removed by the next connection.
Lines are not synced: the records are diagnostics, a reader skips a torn
last line, and nothing reads them for correctness. A record that cannot be written is a warning:
line on stderr; the request stands. Records hold no path and nothing of the serving account's
environment. Past 64 MiB the file is renamed serve-records.1.jsonl, replacing the one older
file.
Limits#
- A push sends at most 2 GiB (
receive.maxInputSize, Push size); git refuses a bigger pack beforeservedecides anything. - A push names at most 1,000 refs (Push commands); past
that, every ref is refused with
too_many_commands. - The serve lock waits 10 s, the store's writer lock wait; a refresh moves each served ref at most 8 times against a concurrent writer before the connection is refused.
- The pack cache holds at most 4 GiB a store (Serve pack cache); a larger pack is sent and not kept. A fetch waits at most 10 s for another connection producing the same pack, then produces its own.
- The records of a store take at most 64 MiB before they move to the one older file (Serve records).
Not served#
Partial clone filters (the client warns and gets everything), git push --atomic, push options,
signed pushes, annotated tag objects, SHA-256 repositories, git LFS, submodules, server-side user
hooks, git daemon, a web interface, and swarmops serve --stdio, the store-to-store sync, which
is refused with not_served. A push to main moves it; it does not land: land through
refs/land/main.
Next#
Next: Serve over HTTP: the same store over HTTPS, with the same access file.
- Where serve answers differently from git: each difference a client may see.