You are an agent session with no one to ask. This guide is what you can
find out and do yourself, and what only the owner can give you. Every
route and field named here is in GET /api/v1 and docs/api.md; trust
those over this page if they differ.
FORGE=https://zeitforge.app # the example host; use your own
Never put a token on a command line, in a URL or in a file you commit. Keep it in a keychain and hand curl the header on its standard input:
printf 'Authorization: Bearer %s\n' "$TOKEN" |
curl -s -H @- "$FORGE/api/v1/me"
1. Find things out yourself
GET /api/v1lists every route: op name, method, route, first sentence. It also names/api/v1/openapi.json, which has every field's schema.docs/api.mdis the same reference as prose: one section per op, input rules, output fields.- Is a repository name free?
GET /api/v1/repos/name?name=REPO(add&namespace=TEAMfor a team). The answer hasfreeand, when it is not,why. To create it:POST /api/v1/reposwith{"name":"REPO","create":true}; it is then owned by the agent's person. - Which role does an op need? The index does not say, and
docs/api.mdshows only the floor (token,admin). The OpenAPI document does: each op that needs a capability has anx-quarry-capsfield, and its summary ends "Needs capability L on the repo" ("fleet" for a forge-wide op). Letters includeicheck in,arepository settings,8see CI runs and logs. - A call you may not make is refused 403 with a sentence naming the
letter and who grants it:
AGENT cannot see CI runs, checks and logs on REPO: that needs capability "8", which an admin of the repository grants. A repository you cannot see answers 404 as if it did not exist (section 9).
2. What needs the owner, and how to ask
Send the same call again with one more header:
Quarry-Ask: <one line: why you need it>
If the call was refused 403 and your person may make it, the answer is
202 with {"signoff":{"id","agent","owner","op","repo","summary",
"state":"pending","requested","reason","pickup"}}. Your person is told by inbox,
push and email and approves or denies on one page,
/me/requests/ID. Nothing runs until then. Approval runs exactly the call
you sent, as them, under their permissions at that moment. Read the state
with GET /api/v1/signoffs/ID (request.state: pending, approved,
denied, failed; result once it ran). A request waits seven days; you may
have 20 waiting.
Who may ask (internal/api/ask.go): an agent its person operates, or a
forge agent with an answerable admin. A repository's or team's own agent
has nobody to ask. You may ask only for what your person holds, on
repositories you can see, inside your own token's reach (one exception: a
new token or a wider reach for yourself). The person may switch asking
off for you; the call then says they take no requests from you. When the
ask is not admitted you get the plain refusal, never more.
Quarry-Ask goes on the call itself. A multi-step job is one request:
POST /api/v1/requests with {"title","reason","steps":[...]}.
3. A credential by one approval: agent.setup
POST /api/v1/agents/setup, sent with Quarry-Ask. It is never run
directly. It makes a new agent (a name that is or was an account is
refused), grants it, and mints its token, all or nothing.
| field | meaning |
|---|---|
name |
the new agent's name |
kind |
push: one repository, sync scope. read: one to twenty repositories, api scope, reader plus CI |
repos |
repository names, comma separated |
role |
push only: developer (default), publisher, maintainer. Never admin |
days |
token life up to 366; without it the token lapses after 90 days unused |
belongs_to |
repository or person; empty takes the rule in RFC 0030 |
Do not send ceiling: the forge fixes it from kind and role, and a
request that sends it is refused. The owner sees the page before the
buttons: who asked and why, the new agent, its role, the credential's
scope, reach, ceiling and expiry, who collects it, and that an
uncollected credential is revoked 24 hours after approval. The agent made
stays even if the token lapses. Details: docs/rfcs/0030-setup-by-approval.md.
A client with no credential
If you hold no credential at all, you cannot send Quarry-Ask. Start the
request yourself and let your person claim it.
- Make a pickup key and code as in section 4.
POST /api/v1/setup/startwith no authorization and the body{"name":"NAME","kind":"read","pickup":"CODE"}(kindisreadorpush). Send nothing else: repositories, accounts and reasons are refused, because your person chooses the repositories. The answer is{"code":"XXXX-XXXX","link":"https://FORGE/setup","expires":"..."}. Past five starts a minute from one address (an IPv6 /64 is one), three unclaimed at once from it, or when the forge holds too many, it answers 429 withRetry-After. It answers the same for a name that is already an account; your person is told at the claim.- Show your person the code and the link, and nothing else of yours. They
sign in, open the link, type the code, check it matches, pick the
repositories (and for
pushthe role) and press Continue. That files an ordinaryagent.setuprequest, asked by "client of THEM", which they approve on its page. The code lapses in ten minutes. - Poll
POST /api/v1/pickups/collectwith your key as in section 4. It answers 404 until the person has claimed the code, and for a code that lapsed or was discarded, the same as for a key that never existed; then 202 while the request waits, then 200 once. In the 200,agentis "client of THEM" andopisagent.setup: check the agent inresult.agentis the name you asked for.
4. Pickup: no secret crosses between sessions
The token's value is shown once. Bind it to a key only the collecting program holds, so the value never passes through the owner or a chat.
Make 32 random bytes. The code is the lowercase hex SHA-256 of the raw bytes. The common mistake is hashing the text of the key; hash the bytes. A code that is not 64 lowercase hex digits is refused.
umask 077 python3 - > pickup.code <<'PY' import os, hashlib, base64 key = os.urandom(32) open("pickup.key", "w").write(base64.urlsafe_b64encode(key).rstrip(b"=").decode()) print(hashlib.sha256(key).hexdigest()) PY
Ask with the code: add
Quarry-Pickup: $(cat pickup.code)besideQuarry-Ask. WithoutQuarry-Askit is a 400. A call that leaves no credential, or one a lapse cannot revoke (a sync token, an invite), refuses a code. The code is public; the key is not.Collect, from the program that will use it, with no query string and no other credential:
printf 'Authorization: Pickup %sn' "$(cat pickup.key)" curl -s -H @- -X POST "$FORGE/api/v1/pickups/collect" > collected.json
(An unpadded base64url key; padding is tolerated.) The answers:
- 202
{"state":"pending"}while the owner has not answered. Poll no faster than every 10 seconds. - 200 once:
request,op,agent(who asked: you),repo,owner(who approved),secret(for setup{"value":"..."}) andresult(for setup:agentthe new agent,grants,token,remoteswith each repository's push URL). If the call ran on your own authority nobody approved it:parkedis true andanswerablereplacesowner. - 410
{"state","error"}afterwards:collected(a second read finds it gone), ordenied,failed,expired,lapsed. - 404
{"error":"no such pickup"}: one identical answer for an unknown code, a wrong key and a key of the wrong shape. - 429 with
Retry-After: misses are counted per address and held with a doubling wait, and a right key may call six times a minute. Check who asked and for what in the 200 (agent,op,repo,owner, and for setupresult.agent) against what you asked for before using the value: a code you showed before asking could be bound first by someone else. The 200 comes once. Keep the whole answer until the value is stored and a push has worked; a client that reads the wrong field has lost the credential. The value is never inresult: it is undersecret, which is the last key. For setup, top-levelrepois empty and the repositories are inresult.grantsandresult.token.repos, named by key (website), not by address (team/website); the address is inresult.remotes[].url. A plan (request.plan) answers differently:resultis a list of steps, each withsentence,stateandanswer, andsecretholds each step's secret fields, so look undersecretfor thevalue.- An approved credential nobody collects within 24 hours is revoked.
GET /api/v1/signoffs/IDnever releases a pickup-bound secret. A token nobody uses for 90 days lapses (token.lapsesgives the day).
- An approved credential nobody collects within 24 hours is revoked.
Store the value without a command line (ME is the agent you ask as, AGENT
the new one). macOS (security -i reads its
commands from standard input; -w with no value needs a terminal):
python3 -c 'import json,sys
r=json.load(sys.stdin)
assert r["agent"]=="ME" and r["op"]=="agent.setup"
assert r["result"]["agent"]=="AGENT"
print("add-generic-password -a AGENT -s forge-AGENT -w",
r["secret"]["value"])' \
< collected.json | security -i
rm collected.json
Linux: the same script, but print only r["secret"]["value"] and pipe it
to secret-tool store --label="forge AGENT" service forge account AGENT,
which reads the secret from standard input.
5. Push as the agent, author as a person
zit remote URL # result.remotes[].url from section 4
zit push --user AGENT # the password prompt, or ZIT_PASSWORD
The password is the agent's sync token, never an account password; zit
reads --password, else ZIT_PASSWORD, else a prompt. Put it in the
environment from the keychain, not on the line. After one good push zit
keeps a derived secret for that remote and later pushes need no password.
The author of a check-in is zit commit --user LOGIN, separate from who
pushed: the forge records the agent as the pusher and each check-in keeps
the author it names.
A repository with no check-in takes the project of the first push it
receives, history and project code included. That first contact must be a
push: a pull or sync of it is refused. To start empty, zit clone it
before the first commit.
6. Tokens that fail
- A refused token (wrong, expired, revoked, wrong scope) on a route that
needs a sign-in answers 401
authentication required. Check withGET /api/v1/me/repos. - On a route open to anonymous callers, a refused token is not an error: the call proceeds as anonymous. A 200 on a public repository proves nothing about the token. A private repository answers 404.
- Every refused token is a miss at every door, against the account it
names and the address it came from: five free, then a hold of 30
seconds that doubles (a minute, two, up to about two hours). A held
account gets 429 with
Retry-After, even with a right token. A success clears the count (LoginLimiter,cmd/quarry/tokendoor.go).
On a 401, stop presenting that token. Retrying it digs the hole deeper for you and for every other session that shares the account or address.
7. Never do this
Never revoke push credentials you did not make. POST
/api/v1/me/sync-token/revoke with neither id nor repo ends every
push token of the account, including other sessions'. Minting
(POST /api/v1/me/sync-token) adds a token and leaves the others
working (RFC 0021), and neither call can be made with a token: they need
the account itself signed in. So never ask for a person's or the forge
agent's push token. Get your own through section 3, and do not reuse
another session's credential.
8. CI
A workflow is a YAML file in
ci/, in GitHub Actions' shape (docs/running.md, "CI: workflows, runners and secrets"):on: [push, landing] # a check-in on a branch; a landing's merge jobs: test: timeout-minutes: 30 steps: - run: make test
Keys: on (push and landing with branches or branches-ignore,
tag, workflow_dispatch, schedule), jobs, needs, environment,
timeout-minutes, env, steps, name, run, working-directory.
There is no if:, no expression but ${{ secrets.NAME }}, and any
other Actions key (uses:, runs-on:, strategy:, container:) is
refused by name: the runner checks out for you, and tools must be on
its host. Check a file before pushing it:
POST /api/v1/r/REPO/ci/pipeline with {"path","content","validate":true}
(needs check-in rights); no diagnostics means it is accepted.
- A run's
statusis its verdict:completedmeans every job passed,failedthat one did not,runningthat it has not ended. - A secret reaches a job only through an environment it names, on a branch that environment admits (protected branches unless it lists others), and never in a landing's merge run.
- A workspace is reused between runs: the checkout is updated in place
and untracked files a job wrote stay,
HOMEincluded. Treat that as a cache, never as something a test may rely on. GET /api/v1/r/REPO/ci/runs/RUNIDand.../ci/runs/RUNID/log(querystep,tail);GET /api/v1/r/REPO/cilists runs.- Both need capability
8. Without it: 403cannot see CI runs, checks and logs on REPO: that needs capability "8". A reader role holds it, an anonymous caller never does. - Do not poll.
GET /api/v1/r/REPO/events/stream(all repositories you read:/api/v1/events/stream) is Server-Sent Events. Filter withkind, exact or a pattern, repeatable:?kind=ci.completed&kind=landing.*. Aci.completedcarriesrunId,checkin,pipeline,status. Resume withLast-Event-ID. - A job's environment (
internal/runner). Each step is its ownsh -e -c, in the checkout (or the step'sworking-directory). Without--job-usersthe job runs as the runner's own user with the runner's whole environment plusCI=true, gets no run token and may not use secrets. With job users it runs as one of the pool's users through sudo with umask 007 and only:PATH(the runner's),HOME(the workspace),TMPDIR(.quarry-tmpin it),CI=true,LANGif set,QUARRY_USERandQUARRY_TOKEN(this run's token),QUARRY_TAG,QUARRY_CHANGED,QUARRY_TOUCHED, the job's and step'senv, and the secrets it names. Which compilers and tools exist depends on the runner's host. Print it from a step:id; umask; env | sort; command -v cc fossil zit.
9. A private repository, from outside
It is answered as a missing one, so its existence is not told. The page
and the API answer 404 no repository "REPO". Sync to a caller who sent
no credential is challenged 401 (a missing repository is challenged the
same), and with a credential that may not read it, 404. If you get 404 on
a repository you were told exists, you may lack a role on it: ask your
person (section 2), do not retry.