16 KiB
gitea
The forge, and the mesh's npm package registry: it claims the git and npm-package-registry seats.
Its tools, its events and the provisioner of registry accounts are the TypeScript bundle (index.ts,
tools/, provisioner/). This README covers the two Go bundles beside it.
What a registry consumer is told (serves.npm-package-registry): where the registry answers on the
private network (scheme, port, npm-path), and its public-root, the address the forge writes every
tarball at: https:// and the forge's route's composed public name, under the npm path
(${bound:route:name-web}, novox/hq ADR 0346). A lock file holds that address, and npm 12 installs a
locked tarball only from the registry it is configured with, so a consumer that installs from lock files
trusts that root. On a machine with no public domain the key is left out, and the plan says so.
Announcing pull requests to the merge check (novox/hq to-be 45 §9, issue 347)
Every new head of an open pull request is announced as pull.updated, once, and the controller has it checked.
- Which repositories a pass looks at. Each that moved since the last pass, less a minute's margin (issue 250); each holding a pull request updated since then, found by the forge's own search — opening, reopening or retargeting a pull request moves its own update time and not its repository's, and a pull request opened more than a minute after its push was never announced (issue 347); and every repository on a sweep, every ten minutes, or when the search fails.
- A pull request nobody checks is said — by the delivery's owner, not here.
gitea_open_pullsanswers every open pull request from the forge itself, with the statuses its base requires and the ones its head has. Themesh-deliverymodule asks it every five minutes, and a head on a base requiringmesh/merge-gateormesh/repo-checkwith neither for ten minutes since it first saw it so is a delivery held past its bound (delivery.<id>.stalled, stateunannounced), which the controller raises like any other (issue 347). Each of the head's statuses comes with its state and when it was set, so a head whose check started and never answered — a required check pending, or never set beside one that is — is said as a stalled line in the stateunansweredonce that has lasted the delivery table's bound for a check asked and not answered, an hour, counted from its first sight and again only when a check is asked anew (issue 438). - How to see it work. A pull request opened more than a minute after its branch was pushed — this fix's own
was one, and the announcer it fixes never saw it — has
mesh/merge-gatepending within a pass of the next sweep at the latest, andmesh-controller.conditionsnames none for it. - A pull request or issue number that is not one is refused before the forge is asked: a tool called with a
misnamed argument sent
pulls/NaN.
An agent's commit and the reads around it (novox/hq issue 375)
An agent that finished a change on a branch had no way to put it on the forge: the module had no commit or
push tool, gitea_api refused the forge's patch endpoint, and its only file write took one whole file
base64-encoded. The Go bundle cmd/forge serves the commit, and the reads an agent otherwise made through
gitea_api as raw JSON, summarised.
| tool | what | |
|---|---|---|
gitea_commit |
a | one commit on a branch of its own, created from base (default the default branch) when absent, carrying either files (each {path, content | content_base64 | delete: true}) patch (a unified diff, as git diff writes it), or patch_file with patch_sha256 (a patch too large to type, see below); answers the commit and its parent |
gitea_commits |
r | a ref's history, newest first, optionally only commits touching a path: sha, author, date, subject |
gitea_compare |
r | what head holds that base does not: its commits and the files they touch |
gitea_pull_reviews |
r | a pull request's reviews — state, the commit reviewed and whether it is the head now, stale, dismissed — with their line comments |
gitea_list_dir |
r | a directory's entries at a ref: name, type, a file's size |
gitea_search_code |
r | the lines of a ref's text files matching a literal string or a regular expression, optionally under a path |
Where a commit may go is the rule gitea_api already holds for a file written (novox/hq ADR 0266):
never the repository's default branch, never a branch a protection rule names. The tools' account may not read the
rules themselves (the forge keeps them for a repository's administrator, novox/hq ADR 0365): for it an existing branch
is judged by the protection the forge says it applies there, and a branch not made yet by the forge itself, which
refuses the commit that would make a branch a rule guards. A rule covers a branch as the
forge itself decides it (Gitea 1.27, novox/hq issue 539): a plain name covers that branch in any case; a glob
(*, **, ?, [abc], [!abc], {a,b}, \ escaping) covers case-sensitively with / as separator; a glob
the forge cannot compile covers its own text. cmd/forge/rules.go and guard.ts hold this one rule. The rules are read before anything is sent; rules that cannot be read refuse the
commit. Nothing about merging changes: a merge still needs an independent review at the head and both
required checks green, through gitea_merge_pull_request.
A patch too large to type (novox/hq issue 852: a feature's diff is megabytes) goes as patch_file, with
patch_sha256. The caller writes the patch into the module's spool directory forge-patches
(/var/lib/mesh/forge-patches, mode 0733 and owned by the machine's account, which the tools run as: anyone writes,
nobody lists; the bundle is given its path as MESH_GITEA_PATCH_DIR) and names it: patch_file is the file's NAME,
32 to 64 characters of 0-9a-f that the caller chooses at random, never a path. The tool then reads that one file,
applies it exactly as it applies patch (same size limit, same answer) and removes it once it has found it, whether
the commit was made or not. A file refused before that (a bad name, a missing hash or spool directory, a name with no
file) stays in the spool. It refuses, sending nothing, a name of another shape, a file that is missing, empty, over
4 MiB, with more than one hard link or not a regular file (a symlink is never followed), and a file whose SHA-256 is not patch_sha256: the directory has no sticky bit
(the manifest allows none), so another writer could replace a file, and the hash is how the caller knows the tool read
the bytes it wrote. It reads nothing outside the directory.
How the commit is made. files become one call to the forge's multi-file contents endpoint: a path the
ref holds is updated, one it does not is created, and an update or delete carries the blob read just
before, so a file someone changed meanwhile is refused by the forge rather than overwritten. The blobs are read at
the commit the branch (or, for a new branch, its base) was read at, never at its name. A patch goes to the
forge's diff endpoint, which applies it; for a small change to a large file this is far shorter than the
file. Neither endpoint takes a commit to build on, only a branch (Gitea 1.27), so the parent answered is the
one the forge says it made the commit on; when the branch moved between the read and the commit, the answer
says so. One commit carries at most 200 files, 4 MiB per file and 16 MiB together, or a patch of 4 MiB. Either way the commit is the forge's own, made as the tools' account, so its sha is not the
local commit's: the merge check judges the head on the forge. After the write the branch is read back, and a
branch not at the commit the forge named is a failure, never a success.
Code search has no endpoint on the forge, so the ref's archive (/archive/{ref}.tar.gz, at most 64 MiB)
is read once and searched in memory; a binary file is passed over, a file over 4 MiB is named under
skipped, and at most max matches (default 100) are answered, with truncated when more were found. A
search that reaches the call's deadline stops there and answers what it found, with stopped_at_deadline.
It reaches the forge through the same HTTP client as the npm-registry bundle (internal/forgehttp), as the
tools' own account (novox/hq ADR 0365 §2, issue 469): the token the TypeScript bundle mints for mesh-tools and
keeps (MESH_GITEA_TOKEN_FILE), sent only when its scopes line names that account, never the admin login. One tool
call takes at most 50 seconds.
go test ./cmd/forge runs it against a fake forge: the files of one commit created, updated and deleted on
a new branch from its base; an existing branch committed on; the default branch, a protected branch and
unreadable rules refused before anything is sent; a patch sent to the forge; a branch read back at another
commit failing; a branch or base that moved under the commit said, with the parent the forge used; a file
changed under the commit refused; a commit over its size limits refused; protection globs matched as the
forge matches them; a code search stopped at its deadline; every malformed ask refused; and each read
summarised.
Each agent's read credential (novox/hq ADR 0365 §1, §3–§5, issue 737)
An agent clones and fetches the repositories it may read as a forge account of its own, agent-<node>. The
module offers the provision gitea-read, whose credential its software issues (ADR 0305) one per holder (issue
749): the Go bundle cmd/agent-token serves gitea_agent_token, which the vault alone asks (ADR 0349 clause 10;
any other caller is refused and nothing is minted). For each holder the vault names that is the claude-code module
on a machine, it
- makes the account
agent-<node>with the administrator's login, when there is none: no administrator, a random password sent once and kept nowhere, and in no team — it reads public repositories until the operator adds it to a team that reads more (§3). An account found made an administrator, inactive or barred from logging in is made a plain account again; - deletes the account's token by the name
mesh-agent-read(by its id, every one of them) and mints one whose one scope isread:repository: whatever team it is put in, the forge refuses it a push, a comment and any token or administration of its own (§5). Every ask mints anew; that is its rotation; - answers git's credential-store lines for it — the forge's public origin, read from
origins(the route's name), and on the forge's own machine its address there too — sealed to the key the vault made for the call. A holder of another module is not answered and said underskipped.
The vault keeps each as provision/<forge's node>/gitea-read/<node>/claude-code and hands it to that holder alone;
the claude-code module places it in the agent's home (ADR 0365 §4). The token is never logged, answered in the clear
or put in an error. go test ./cmd/agent-token runs it against a fake forge.
The agents' read teams (novox/hq ADR 0365 §3, issue 758)
An agent's account reads more than public repositories only through a team, per repository, and only to read. Which
teams exist and who is in them is the operator's, and it is one trusted config value, agent-read-teams:
{"<team>": {"repos": ["<owner>/<repo>", ...], "members": ["<node>", ...]}}, a member <node> being the account
agent-<node>. The module renders it into agent-teams.json and runs cmd/agent-teams when it starts and
whenever that file changes. For each team it makes agent-read-<team> in the organisation of the first repository's
owner, with permission read and no other right, its repositories exactly the listed ones and its members exactly the
listed accounts (an account that does not exist yet is skipped and said). A team named agent-read-* that has left
the value is emptied, never deleted. A team without that prefix is never touched. There are no verbs: a change is a
change of the value. It says each change in one line on stdout, and never a secret. go test ./cmd/agent-teams
runs it against a fake forge.
The package registry's retention (novox/hq ADR 0251 §4, to-be 51)
Every publish adds a version to the registry, and until this nothing removed one. The Go bundle
cmd/npm-registry says which versions each package holds, what keeps each one, and — asked with a why —
deletes the versions nothing keeps.
| tool | what | |
|---|---|---|
npm_packages |
r | every package of the registry's owner, each version newest first with its publish time, its size and why it is kept |
npm_retention |
a | the versions retention would delete, and the bytes. A dry run unless dry_run is false; a real run needs why |
A version is kept when:
- a lockfile on the default branch of any repository on the forge names it —
package-lock.json(every lockfile version),npm-shrinkwrap.json,yarn.lock(classic and berry) andpnpm-lock.yaml(by each resolvedname@versionorname/version), whatever the version's age; - it is the highest published version satisfying a range that a
package.jsonthere names in its dependencies, development, peer or optional dependencies (annpm:alias counts for the package it stands for; a path, a URL, git or a workspace is not a range on this registry). npm itself installs thelatestdist-tag when it satisfies the range; that version is kept by its dist-tag; - a dist-tag names it — and a range that is a dist-tag's name keeps that tag's version;
- it is among the newest
keepof its package, five by default (the artifact store's five builds, ADR 0189 §3), by semantic-version order. Pre-releases count among the newest.
A version that is not a semantic version is never ordered, so it is kept. A range that cannot be read,
or a package whose dist-tags cannot be read, keeps every version of its package, and the answer says
why under keeps_all.
Nothing is deleted on partial knowledge. A repository whose files could not be listed, or a
manifest or lockfile that could not be fetched, is named under repositories_unread in every answer,
and a real run then deletes nothing at all. A file that was fetched but is not readable (broken JSON) is
named under files_not_read, and so is a lockfile entry of a shape the reader does not know (the rest of
that file is still read); what it would have named is not known, so a real run then deletes nothing at all
either. Under node_modules nothing is read: that is what a lockfile already says. An empty
repository, or one without its default branch, holds nothing to read and is not a failure.
A real run deletes each version through the forge's own interface (DELETE /api/v1/packages/{owner}/npm/{name}/{version}), says what it deleted and what the forge refused, and a
version already gone counts as deleted.
How it reaches the forge
Over the forge's HTTP interface on the machine (MESH_GITEA_URL), as the admin account the vault
delivered (MESH_GITEA_ADMIN_USER, the password in MESH_GITEA_ADMIN_PASSWORD_FILE — the same file
the TypeScript bundle mints its token with), by basic authentication: this bundle mints no token and
keeps nothing. The password is read per call and never logged, answered or put in an error. The
registry's owner is MESH_NPM_OWNER, the owner in the seat's npm-path.
Calls to the forge run eight at a time, and one tool call reads for at most 50 seconds, inside a module's 60-second ask.
Why the manifest lists no tools
The TypeScript bundle's tools are served without a tools list, and a claim without serves offers
the module's tools as the seat's verbs — so a list here would make these two tools verbs of both the
git and the npm-package-registry seats. The two names are kept in ToolNames, and a test holds them
to this README and to what the bundle serves.
Tests
go test ./...
Against a fake forge (httptest): what keeps a version (a lockfile whatever its age, the highest
version satisfying a range, a dist-tag, the newest keep); a dry run deleting nothing; a real run
without why refused; a real run deleting only what nothing keeps; an unread repository stopping a real
run before anything is deleted; an unreadable range keeping its whole package; a wrong password failing
without saying the password. And the range matcher against npm's rules — exact, ^, ~, x and *,
comparisons with partial versions, hyphen ranges, ||, and the pre-release rule — and each lockfile
format.