Skip to content
quarry Documentation
Login

Documentation

Summary

RFC 0029 made each onboarding step something a person or an agent can do alone. On 2026-11-12 a second repository, dmestas/suit, showed that the steps are still too many and that the two that matter most still end in a browser: a credential the owner mints and carries by hand, and a runner built as root from another repository's unit. This RFC replaces both with one act each. An agent the owner already operates asks for "an agent NAME on repository R as ROLE with a credential of KIND"; the owner approves once; the forge creates the agent, grants the role, mints a narrow token and releases it once to the holder of a pickup key, so the value never reaches a screen. A repository admin clicks once for an install code; quarry runner install on the runner's host does the rest. A runner that is stopped hands its jobs back so they run again at once.

Motivation

What happened on 2026-11-12, on zeitforge.app, in order:

  1. A session needed an agent that could push to dmestas/suit. No token may create an agent or mint its token, so the owner was sent to a settings page.
  2. A repository agent took three places: Settings, Agents for the name; /me/agents/NAME for the token; a role elsewhere. The refusals on the way hid that the account did not exist yet.
  3. The owner minted the token by hand. It came out with sync, api and web scopes on every repository, and he typed it into a Keychain prompt.
  4. The first push of existing local history into the empty repository was refused, and the work was redone in a fresh clone.
  5. A runner for suit took, as root: two system users, a sudoers file, directories, a unit copied from another repository, a worker token from a browser-only form, an env file. A scripted client that re-posted on the redirect switched three repository settings off.
  6. The session's first read of CI runs on a public repository was refused.
  7. A second client, the E desktop app, now needs a read-only token for private repositories: another agent, another token, more browser steps.

The owner: "this needs to be smoother take note and get a plan"; on credentials, "it needs something like auto setup and I just approve it"; on runners, "this should be easy for our consumers so if it needs design design".

Where RFC 0029 stands

Read against trunk on 2026-11-12.

# RFC 0029 promised On trunk Where
1 An empty repository adopts the first push's project code Not built quickStartSource in internal/web/web.go tells every reader to clone first and says a store made elsewhere cannot be pushed; ticket #416
2 An agent pushes with a sync-scoped agent token got through its person Built agent.token.new is askable and marks its value secret:"once" (internal/api/agentops.go); ownCredential in internal/api/ask.go lets a repository-limited token ask for its own
3 SessionOnly on ops that refuse a token, printed in the docs, with a test Not built No SessionOnly field exists; refuseToken (internal/api/register.go) is called inside each Run while the op still declares Auth: ops.Token
4 agent.token.new and me.sync.token named apart in their docs Built, differently Both Docs now say minting adds; the one-sync-token rule itself is gone
5 A guide at /docs/start with whoami, walked by a test Partly me.account answers GET /api/v1/me; the agent guide in internal/web/web.go has "A new repository, from nothing to CI"; there is no /docs/start, no walking test, and step 7 still says a site admin starts the runner
6 check: true on create and git-mirror Not built No such field on either input
7 Worker tokens through the API, and a "no runner" notice Partly registerWorkerTokens in internal/api/workertokens.go (ci.worker.token.new, .list, .revoke, at /ci/worker-tokens); the settings page says "No runner has attached"; the CI page lists setup steps; nothing names the task type no runner serves
8 The Cloudflare skip rule, recorded in the deploy notes Not recorded Nothing under docs/ records the rule; it cannot be confirmed from the tree
9 Dotfiles (danmestas/zit#333) Built on the forge side The guide tells the reader to add dotfiles by name
10 A deploy loses no finished verdict Built Runner.finish retries with ResolveRetry (internal/runner/runner.go)
11 The guide shows binding a bucket step by step Partly One line of the guide names the call

Four built, three partly, three not built, one not recorded.

What 2026-11-12 hit, and what covers it

# Friction Covered by State
1 No token may create an agent RFC 0027 and 0028: askable, as a plan the asker composes Partly. The asker chooses every scope, the page shows a sentence per step and no scope, and the result is three separate acts. Agent setup
2a Three places to make a repository agent Landed 2026-11-12: Settings, Agents takes a role with the name; a repository's admins grant its own agents (refuseOutOfScope) Covered for name and role. The token is still a separate page. Agent setup
2b Refusals that hid a missing account Landed 2026-11-12: agentToPutToWork says "not an agent"; the sync front tells an unverified card one sentence and uses LetterlessSecrets only after a card verifies; preflights in internal/api/ask.go refuses at ask time Covered
3 A hand-minted token, too wide, typed into a prompt Nothing Uncovered. Agent setup and Pickup
4 First push of existing history refused RFC 0029 item 1, not built First push
5a Worker token only from a browser form, shown by a flash Landed 2026-11-12: the three worker-token ops; an agent asks and the value is released once; sealed and lapsing (internal/api/signoffsecret.go) Covered for an agent with a person. Uncovered for a person at their own host. Install codes
5b Users, sudoers, directories, unit, env file by hand Nothing Uncovered. A runner by one command
5c A re-posted form switched settings off Landed 2026-11-12: settings forms change only what the request names Covered
6 Reading CI on a public repository refused Nothing Uncovered. Reading CI
7 A read-only client for private repositories Nothing Uncovered. Agent setup, kind read

Three covered by what landed, one by RFC 0029 as written and unbuilt, six uncovered or partly covered and designed below.

One finding beside the table. A lapse revokes only worker tokens: lapseRefOf in internal/api/signoffsecret.go returns nothing for any other op, so an agent token minted on approval and never collected is cleared from the request but stays live. Build step 1 closes that.

Design

Agent setup by one approval

A plan (RFC 0028) can already create an agent, grant it and mint its token. It is the wrong vehicle for setup: the asker picks every scope, the page cannot show what the credential reaches, and a failure at the second step leaves an agent with no role. Setup is therefore one operation with fixed defaults, which the existing request machinery carries unchanged.

The operation. agent.setup, POST /api/v1/agents/setup. Like its siblings it refuses a token (refuseToken), so an agent never runs it; an agent asks for it with Quarry-Ask.

{"name": "suit-push", "kind": "push", "repos": "dmestas/suit", "role": "developer"}
{"name": "e-desktop", "kind": "read", "repos": "dmestas/suit,dmestas/context"}
Field Meaning
name The agent's account name.
kind push or read.
repos Repository addresses, comma-separated. push takes exactly one. read takes one to twenty.
role For push: developer (the default), publisher or maintainer. Refused for read, whose role is always reader. admin is refused for both.
days A fixed life for the token, up to 366. Zero, the default, is no date: the token lapses after 90 days unused (RFC 0026).
belongs_to repository or person. Empty takes the rule below.

What it makes. The credential is fixed by the kind and is not an input.

Kind Token scopes Token repositories Token ceiling
push sync only the one repository the role's letters
read api only the named repositories gjorzh28: the letters every visitor holds on a public repository, and seeing CI

A push token cannot call the API or open a browser session. A read token cannot sync, post, or change anything, whatever role the agent is later given.

Whose agent it is.

Setup makes a new agent only. A name that is already an account, in any case, or that was one, is refused, and nothing the existing account holds is touched.

Who may ask. An agent whose personFor (internal/api/ask.go) names a person: a personal agent, or a forge agent with its answerable admin. A repository's or a team's own agent has no person there and cannot ask, which is what keeps an agent made by setup from asking for more agents. Nobody holding only a code or a link can file one.

mayAsk gains one rule beside ownCredential: a token limited to repositories may ask for agent.setup when every repository named is one it reaches. The person must be able to read every repository named and, for push, hold the role's letters or a there.

agent.setup joins preflights. Refused when asked, in words: a name that is a person or somebody else's agent, a bad kind or role, a wrong count of repositories. Refused as the plain call is refused, saying nothing more: a repository the asker cannot read, or one the person does not hold. Account names are public on the forge, so "that name is taken" tells an asker nothing new.

What the owner sees. The request's row and email read "Set up agent suit-push to push to dmestas/suit". Its page, /me/requests/{id}, shows every line below before the buttons, each with a data hook:

Line Example
Asked by zeitgeist, forge agent. "suit needs a pushing agent for its CI release"
Agent suit-push, new. Belongs to the repository dmestas/suit; its admins manage it.
Role developer on dmestas/suit: checks in, on any branch that is not protected.
Credential scope sync: push and pull. No API, no browser session.
Credential reaches dmestas/suit only.
Credential ceiling developer.
Credential expires No fixed date. Lapses after 90 days unused.
Collected by The holder of pickup code 3f9a 81c2 07de. Or: the asking agent, zeitgeist.
If not collected Revoked 24 hours after you approve.
Check-ins Pushes are recorded as pushed by suit-push. Each check-in keeps the author it names.

Approve and Deny work as for any request: from the page, from the Waiting row, or through the email's button to the page (RFC 0028).

Running it. Approval runs the operation as the approver. The store gains one method, SetupAgent, that inserts the account, writes each grant and mints the token in a single write transaction. The agent, its role and its token exist together or not at all. What follows the commit (the notify subscriber, topic membership, the account-change event) is best-effort, as it is for agent.new and agent.grant today.

Partial failure.

What fails Outcome
The name was taken, or a repository changed, between asking and approving The transaction does not start. The request reads failed with the reason. Nothing was made.
A grant is refused inside the transaction The transaction rolls back. failed, with the grant's own refusal. Nothing was made.
The forge has no secret key to hold the value Checked before the transaction (SecretBox.Ready). failed. Nothing was made.
Sealing fails after the commit The token is revoked. An agent this request created is deleted; an agent that existed keeps its earlier grants and loses the new ones. failed.
A best-effort step after the commit fails The request is approved. The step is logged and retried by its own upkeep.

There is no state in which an agent exists with a grant and no way to get its credential, or with a credential and no grant.

What is logged.

Collecting. The operation's answer has a public half and a secret half (secret:"once", as agent.token.new):

{"agent": "suit-push", "operator": "repo:dmestas/suit", "created": true,
 "grants": [{"repo": "dmestas/suit", "role": "developer"}],
 "token": {"id": "...", "name": "setup 4be19c02", "scopes": ["sync"],
           "repos": ["dmestas/suit"], "ceiling": "...", "expires": ""},
 "remotes": [{"repo": "dmestas/suit", "url": "https://zeitforge.app/r/dmestas/suit",
              "project_code": "..."}]}

The secret half is {"value": "..."}. A request asked without a pickup key releases it as today: GET /api/v1/signoffs/{id} by the asking agent, once. A request asked with a pickup key releases it only through pickup. Either way it is sealed while it waits and lapses 24 hours after approval (SignOffSecretLife), and a lapse now revokes the token.

The author on the timeline. The sync front records the pusher from the verified login and never from the check-in's U card (internal/xfer/xfer.go). So the choice is already the owner's, per checkout: commit as yourself and push as the agent, and the timeline shows your name with the agent as pusher; commit as the agent, and it shows the agent. Setup takes no author field and changes no default user anywhere.

Credential pickup

A value read with signoff.get lands in a JSON answer on the asking session's screen or in its transcript. Pickup removes that step: the program that will use the credential fetches it.

The key. The requester makes 32 random bytes, the pickup key, and keeps them in a file of mode 0600. The pickup code is the lowercase hex SHA-256 of those bytes. The code is not a secret: it goes into the request, onto the owner's page and into logs.

Asking with a key. Any Quarry-Ask call may add Quarry-Pickup: <code>; POST /api/v1/requests takes "pickup": "<code>". The request row stores the code. Asking the same pending call again with another code replaces the code. One code binds one request.

Collecting. POST /api/v1/pickups/collect with Authorization: Pickup <key, base64url> and no other credential.

Answer When
202 {"state": "pending"} The request waits for its person.
200 {"request": ID, "op": NAME, "result": {...}, "secret": {...}} Approved. Returned once; the stored secret is cleared in the same statement that reads it.
410 {"state": S, "error": WORDS} denied, failed, expired, lapsed or collected.
404 No request holds this key's code.

POST /r/{repo}/pickup is the same handler for a client that knows only a sync URL. It answers 409, consuming nothing, when the credential does not reach that repository.

Rules.

A client that is not an agent. E cannot ask: it is neither the owner's agent nor the forge agent. E makes a key and shows its code. The owner's agent asks for agent.setup of kind read with that code. The page shows the code beside "Collected by", so the owner can compare it with what E displays. E polls the collect route and stores the token in the OS keychain itself. The value passes through no agent, no terminal and no person.

Into zit. zit pickup new makes the key and prints the code. zit clone URL DIR --pickup CODE and zit login --pickup CODE collect from URL/pickup, derive the sync secret from the project code, the agent's name and the value, save it as an authenticated push would, and discard the value and the key. Nothing secret is printed or passed on a command line. The proposal for zit is Appendix A. Until it ships, a pushing agent's requester asks without a key and reads the value once into a 0600 file, as the guide says today.

A CI runner by one command

sudo quarry-runner install https://zeitforge.app/r/dmestas/suit --code qri_4mx7k2p9wq3hd5tnb8ya

The same subcommands exist under quarry runner. A first argument that is a URL or a flag runs the runner as today, so existing units keep working.

Install codes

The person installing a runner is often at their own host, with no agent and no token. They cannot be allowed to file a request at an owner, and they should not be sent to copy a long-lived credential out of a browser. An install code is what the repository's admin hands them.

This does not reopen the decision that nobody holding a one-time code may put an agent-setup request in front of the owner. That decision is about who may start a request. An install code is not a request and asks nobody anything: an admin starts it, already signed in, and the code only carries that admin's finished decision to one host. A stranger cannot obtain one, and a code cannot create an account, grant a role or reach anything but one repository's CI queue.

A runner whose token an agent already obtained (by asking for ci.worker.token.new) installs with QUARRY_RUNNER_TOKEN set and no code. The code may also come from QUARRY_INSTALL_CODE or a no-echo prompt.

What install creates

On Linux with systemd, as root. S is the repository's worker group as the redeem answer names it, cut to 20 characters with four hex digits of its hash appended when cut, so every name fits a Unix user name.

What Name Owner and mode
The program /usr/local/lib/quarry-runner/quarry-runner, a copy of the running binary root, 0755
Service user and group qr-S, system, shell nologin, home /var/lib/quarry-runner/S
Job users qj-S-1, qj-S-2 (--jobs N changes the count, which is also --max-tasks), system, no home, each with a group of its own; qr-S is added to each group
Home /var/lib/quarry-runner/S qr-S, 0711
Work directory /var/lib/quarry-runner/S/work qr-S, 0750
Sudo rule /etc/sudoers.d/quarry-runner-S: qr-S ALL=(qj-S-1,qj-S-2) NOPASSWD: ALL, checked with visudo -cf before it is moved into place root, 0440
Env file /etc/quarry-runner/S.env: QUARRY_RUNNER_TOKEN=... root, 0600
Manifest /etc/quarry-runner/S.json: the URL, every user, group and path this install created, the token's id, the version root, 0644
Unit /etc/systemd/system/quarry-runner-S.service root, 0644
[Unit]
Description=quarry runner for https://zeitforge.app/r/dmestas/suit
After=network-online.target
Wants=network-online.target

[Service]
User=qr-S
Group=qr-S
WorkingDirectory=/var/lib/quarry-runner/S
EnvironmentFile=/etc/quarry-runner/S.env
Environment=HOME=/var/lib/quarry-runner/S
ExecStart=/usr/local/lib/quarry-runner/quarry-runner --work /var/lib/quarry-runner/S/work --max-tasks 2 --job-users qj-S-1,qj-S-2 https://zeitforge.app/r/dmestas/suit
Restart=always
RestartSec=5
KillMode=mixed
TimeoutStopSec=90

[Install]
WantedBy=multi-user.target

--checkout zit, --zit PATH, --branch NAME and --tasks LIST pass through to ExecStart.

Order: check the host (root, systemd, useradd, sudo, visudo, the forge reachable); create users, directories, the sudo rule, the unit; redeem the code; write the env file; systemctl enable --now; run doctor. Every step is idempotent, so a second run repairs a first. A failure before the redeem removes what this run created and spends no code. A failure after it leaves the token in the env file and says which doctor check to read. Nothing runs as root after install: the unit runs as qr-S and jobs as the job users.

Doctor

quarry-runner doctor S (or the URL; with one runner installed, no argument). One line a check, each ending ok, warn or FAIL, and --json for the same as data.

# Check Failing is
1 The manifest reads FAIL
2 The program exists; its version against the forge's /runner/SHA256SUMS FAIL if missing, warn if older
3 Every user and group in the manifest exists; qr-S is in each job group FAIL
4 As qr-S, sudo -n -u JOBUSER runs job-reclaim for each job user FAIL
5 Home, work directory, env file and sudo rule have the owners and modes above FAIL
6 The env file holds a token FAIL
7 The unit is enabled and active FAIL
8 The forge answers GET /api/v1 FAIL
9 The bridge accepts the token (/v1/workers/connect) FAIL, with "revoked or wrong repository"
10 Trunk has a workflow in ci/ or a ci/runner.json warn: "nothing to run yet"
11 With --checkout zit, the zit binary runs FAIL
12 The work directory's disk has a gigabyte free warn

Exit 0 when every line is ok, 1 when any is FAIL, 3 when the worst is a warn, 2 for a usage error.

Uninstall

quarry-runner uninstall S stops and disables the unit, lets running jobs be handed back (below), retires the token, then removes the unit, the sudo rule, the env file, the job users and their groups, the service user, the work directory and the manifest. It removes only what the manifest lists. --keep-work leaves the work directory.

Retiring needs one small route, because a worker token cannot call ci.worker.token.revoke: POST /api/v1/r/{repo}/ci/worker-tokens/retire, authenticated by the worker token, revokes that token and no other, and logs revoked by its holder (uninstall on HOST).

Without root

quarry-runner install --user URL --code CODE needs no root and makes no users. It writes a systemd user unit (~/.config/systemd/user/quarry-runner-S.service), an env file (~/.config/quarry-runner/S.env, 0600), a manifest beside it, and works in ~/.cache/quarry-runner/S. It prints the loginctl enable-linger line for a runner that should outlive the login, and does not run it. On macOS it writes a launchd agent (~/Library/LaunchAgents/app.quarry.runner.S.plist) instead; macOS has only this mode.

Such a runner has no job users, so it is never delivered a secret and refuses a job that asks for one, as today (RFC 0019). Install and doctor both say so in one line: "this runner takes no job that uses secrets; install as root for that".

A runner stopped in the middle of a job

Today a stopping runner cancels its context: the job dies, finish returns at once, and the task stays claimed until its heartbeat lapses. Since 2026-11-12 the next start hands workspaces back (handBackWorkspaces); the engine still waits.

Jobs are handed back, not drained. A deploy restarts runners, and a drain would hold a deploy for as long as the longest job.

Reading CI on a public repository

Today a visitor with no session holds the nobody category, which never holds 8; any signed-in account holds it on a public repository through anonymous (Visibilities in internal/oltp/roles.go).

Without 8 the API also blanks every CI fact carried beside other things: a check-in's verdict, a landing's checks, a timeline row's badge (hideCI in internal/api/register.go).

Verdicts open, logs stay shut. A check's name and state (pass, fail, running) become readable by whoever can read the check-in: hideCI keeps them and blanks only the run id and its link. A verdict is a word the forge wrote, not output a program wrote, and a reader judging a public project needs it. Steps and logs keep needing 8, and a visitor with no session does not hold it by default.

An admin may open it. Settings, visibility gets one box under the public presets, "Anyone may see CI runs and logs", which adds 8 to nobody for that repository. The comment in roles.go that nobody never holds 8 becomes "never by default".

The refusal says what to do. With no credential, on a repository the caller can otherwise read: 401, "CI runs and logs on REPO are shown to signed-in accounts. Send any account's API token, or sign in." With a token whose ceiling lacks 8: 403, "this token's ceiling does not include seeing CI on REPO". Both setup kinds above carry 8, so neither client made by this RFC meets either.

Who am I, and what a token read

Who am I. No new route. me.account (GET /api/v1/me) already names the account and what it reaches. It gains two things:

{"name": "e-desktop", "operator": "dmestas", "answerable": "",
 "token": {"id": "...", "name": "setup 9c01d7aa", "scopes": ["api"],
           "repos": ["dmestas/suit", "dmestas/context"], "ceiling": "gjorzh28",
           "expires": "", "last_used": "2026-11-12T18:04:00Z"}}

operator is empty for a person. token is present when the call was made on a token and absent on a session. A client can then show its user exactly what it holds and warn before an expiry, and an agent handed a credential can check it without a probing call.

Read audit: counted, not listed. A row for every read would put a write on the forge's hottest path and would keep a record of what a person browsed, which nobody has asked for. Counts answer the question an owner has about a client: which private repositories did this token touch, and when.

The first push of existing history

This stays with quarry #416 and danmestas/zit#342, and RFC 0029's decision 1 stands. This design assumes only what is true today: the repository exists, its project code is fixed, and the first push comes from a clone of it. The setup answer and the pickup both carry project_code, so a client can tell before it pushes that a store is another project and say so in its own words. When #416 lands, nothing here changes: an adopting push derives its secret from the adopted code, and zit login --pickup saves a login for whichever code the forge then reports.

Build order

Each step lands alone. "Sign-off" marks a step that changes a page somebody sees: the owner approves the real output on real data before it lands.

  1. A lapse revokes agent tokens. lapseRef names an account's token as well as a worker token. Test: an approved agent.token.new left uncollected past the limit has its token revoked and one settings-log row.
  2. Pickup. The two columns, Quarry-Pickup, the pickup field, both collect routes. Test: the key collects once and then gets 410; signoff.get on the same request never returns the secret.
  3. agent.setup. The op, SetupAgent in one transaction, the mayAsk rule, the preflight. Test: an approved request yields an agent, a grant and a token whose scopes are sync and whose repositories are the one named; a forced grant failure leaves no account row.
  4. The setup request's page. Sign-off. Test: the page carries a hook each for scope, repositories, ceiling, expiry and collector, with the values of the stored request.
  5. me.account names the token. Test: a call on a token answers its own id, scopes, repositories and ceiling; a session answers no token.
  6. Token use counts. The table, the flush, agent.token.list, the agent's page. Sign-off for the page. Test: two reads and a push on one token show as reads 2, writes 1 for that repository and day.
  7. CI verdicts, refusals and the visibility box. Sign-off for the box and for the badge a signed-out visitor now sees. Test: with no credential checkin.get carries each check's name and state and no run id, a run's log gets the 401 sentence, and with the box ticked the same call answers the log.
  8. Hand-back on stop and at start. Test: SIGTERM during a job resolves its task with pause and a second runner takes it within ten seconds; a marker left by a killed runner is resolved at start.
  9. Install codes. The op, redeem, retire, and the "Add a runner" button. Sign-off for the button. Test: a code redeems once for a token scoped to its repository's group; a second redemption and a stale code get the one 404; six bad codes close the window.
  10. install, doctor, uninstall as root. Test: in a Linux container with systemd, install against a test forge makes doctor exit 0 and a job with a secret pass; uninstall leaves no user, file or unit from the manifest, and the token reads revoked.
  11. --user and macOS. Test: install without root, doctor exits 0 with the no-secrets line, and a job that asks for a secret is refused.
  12. The guide. RFC 0029's step 6, written around setup, pickup and install, with its walking test. Sign-off. Test: the test follows the guide's calls against a test forge from an empty forge to a green run.
  13. zit. File Appendix A at danmestas/zit; when it ships, the guide's push recipe uses it. Test: zit clone --pickup against a test forge leaves a saved login and prints no secret.

RFC 0029's own unbuilt steps (first push, SessionOnly, check: true) keep their place in its build order.

Decisions

Settled by the owner on 2026-11-12:

  1. The owner approves; the forge does the setup. One request, one approval, from a phone if need be.
  2. Only agents the owner already operates, and the forge agent, may put an agent-setup request in front of him. Not anyone holding a one-time code.
  3. An agent never mints its own credentials directly; it asks.
  4. A credential minted on approval is never shown in a browser or passed through a person's hands. It is released once to the requester, sealed while it waits, and lapses if uncollected.
  5. A pushing agent's default credential is sync scope only, limited to the one repository. A read-only client's is api scope only, named repositories, a read-only ceiling.
  6. The check-in author is the owner's choice per repository; the flow does not force the agent's name onto the timeline.
  7. Setting up a CI runner is easy for consumers: people other than the owner, on their own forge or their own host.

Decided here, reversible:

  1. Setup is one operation, not a plan. Reason: fixed defaults, one transaction, and a page that can state what the credential reaches.
  2. A pushing agent belongs to the repository when the approver administers it, and to the approver otherwise; a reading client always belongs to the approver. Reason: a repository's automation should outlive one person's role there, and a client that reads several repositories is nobody's but its user's.
  3. The read-only ceiling is gjorzh28. Reason: it is exactly what a visitor reads on a public repository, plus CI.
  4. admin cannot be asked for through setup. Reason: an agent that administers a repository can mint runner codes and change who reaches it; that stays a separate, visible grant.
  5. A pickup key is a bearer secret whose hash is the public code, not a key pair. Reason: one hash, no new cryptography, and the forge keeps sealing at rest as it does now.
  6. A request bound to a pickup code never releases its secret through signoff.get. Reason: one path out, so the page's "Collected by" line is true.
  7. A lapse revokes the token and leaves an agent it created in place. Reason: the agent holds no credential and is visible in settings; deleting accounts on a timer is the larger surprise.
  8. An install code lives 15 minutes, redeems once, and may appear on a command line. Reason: it is spent by the command that carries it, and one paste is the point.
  9. Unix names are qr-S and qj-S-N, state is under /var/lib/quarry-runner and /etc/quarry-runner. Reason: one prefix an admin can find and remove by hand.
  10. A stopping runner hands jobs back; it does not drain. Reason: a deploy must not wait for the longest job.
  11. Anonymous visitors read a check's name and state on a public repository, and do not read CI steps or logs unless its admin opens them. Reason: under Reading CI.
  12. No new who-am-I route; me.account names the token. Read audit is daily counts per token and repository, kept 90 days, not a row a read. Reason: under Who am I.
  13. Setup makes a new agent only; a name that is already an account is refused. Reason: naming an existing agent silently replaced its role.

Appendix A: the zit proposal

To be filed at danmestas/zit by the owner or on his word. Not filed.

Title: Save a sync login from a forge's credential pickup, so a password never reaches the terminal

Problem. The only way to give zit a login is --user U with the password in --password, in ZIT_PASSWORD, or typed at a prompt. An agent session setting up a repository therefore handles the password itself: it reads it from an API answer, holds it in a variable or a file, and passes it to zit. On 2026-11-12 that meant a token typed by hand into a Keychain prompt. A private repository adds a second problem: the clone itself needs the login, so there is no checkout to save one in yet.

Proposal. Three small additions.

  1. zit pickup new. Makes 32 random bytes, saves them at $XDG_STATE_HOME/zit/pickup/<code> (directory 0700, file 0600), where <code> is the lowercase hex SHA-256 of the bytes, and prints the code and nothing else. The code is not secret. zit pickup list and zit pickup rm CODE manage leftovers.
  2. zit login --pickup CODE [URL] [--wait DURATION], in a checkout. URL defaults to the saved remote and otherwise becomes it.
  3. zit clone URL DIR --pickup CODE [--wait DURATION], which logs in first and clones authenticated.

The exchange. POST <URL>/pickup with header Authorization: Pickup <key, base64url, unpadded> and an empty body. The server answers:

  • 202, {"state":"pending"}: not approved yet. With --wait (default 15 minutes, 0 for one try) zit asks again every five seconds.
  • 200, {"result":{"agent":USER,"remotes":[{"url":...,"project_code":...}]}, "secret":{"value":PASSWORD}}: zit derives the shared secret from the project code, USER and PASSWORD exactly as an authenticated push does, saves it for the saved remote at 0600, and deletes the key file. It prints logged in to URL as USER. It never prints PASSWORD, never writes it to disk, and never accepts it on argv.
  • 410, {"state":S,"error":WORDS}: denied, failed, lapsed or already collected. zit prints the words, deletes the key file, and exits 1.
  • 404 or 409: no such pickup, or it is for another repository. zit prints that and keeps the key file.

zit refuses the exchange over plain http except to loopback, follows redirects by the rule push already uses, and sends the key to the remote's own host only.

What does not change. The default check-in user. A pickup saves who the checkout pushes as, not who its check-ins are by, so a person can commit under their own name and push through an agent's login.

Exit status. 0 logged in; 1 refused, lapsed, or still pending when the wait ended (the message says which); 2 usage.

Why in zit. The alternative is a wrapper that fetches the value and feeds it to zit push --user, which puts the password back in a process's environment and in whatever wrote the wrapper. The exchange is one POST and reuses the secret derivation and the saved-login file zit already has. Nothing in it is specific to one forge: any server may answer <URL>/pickup.

Server side. quarry RFC 0030, Credential pickup.