Skip to content
quarry Artifact [36ed17a1b7]
Login

Artifact [36ed17a1b7]

Artifact 36ed17a1b7de847d97515672da8a15279ce6ddcf22ec2222c96847080c25c177:


# Working on this forge as an agent

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/v1` lists every route: op name, method, route, first sentence.
  It also names `/api/v1/openapi.json`, which has every field's schema.
- `docs/api.md` is 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=TEAM` for a team). The answer has `free` and, when it is
  not, `why`. To create it: `POST /api/v1/repos` with
  `{"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.md`
  shows only the floor (`token`, `admin`). The OpenAPI document does: each
  op that needs a capability has an `x-quarry-caps` field, and its summary
  ends "Needs capability L on the repo" ("fleet" for a forge-wide op). Letters
  include `i` check in, `a` repository settings, `8` see 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`.

## 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.

1. 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

2. Ask with the code: add `Quarry-Pickup: $(cat pickup.code)` beside
   `Quarry-Ask`. Without `Quarry-Ask` it 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.
3. Collect, from the program that will use it, with no query string and no
   other credential:

       printf 'Authorization: Pickup %s\n' "$(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":"..."}`) and
     `result` (for setup: `agent` the new agent, `grants`, `token`,
     `remotes` with each repository's push URL). If the call ran on your
     own authority nobody approved it: `parked` is true and `answerable`
     replaces `owner`.
   - **410** `{"state","error"}` afterwards: `collected` (a second read
     finds it gone), or `denied`, `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 setup `result.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 in `result`: it is under `secret`,
   which is the last key. For setup, top-level `repo` is empty and the
   repositories are in `result.grants` and `result.token.repos`, named
   by key (`website`), not by address (`team/website`); the address is
   in `result.remotes[].url`. A plan
   (`request.plan`) answers differently: `result` is a list of steps,
   each with `sentence`, `state` and `answer`, and `secret` holds each
   step's secret fields, so look under `secret` for the `value`.
4. An approved credential nobody collects within 24 hours is revoked.
   `GET /api/v1/signoffs/ID` never releases a pickup-bound secret.
   A token nobody uses for 90 days lapses (`token.lapses` gives the day).

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 with
  `GET /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 `status` is its verdict: `completed` means every job passed,
  `failed` that one did not, `running` that 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, `HOME` included. Treat that as a
  cache, never as something a test may rely on.
- `GET /api/v1/r/REPO/ci/runs/RUNID` and `.../ci/runs/RUNID/log` (query
  `step`, `tail`); `GET /api/v1/r/REPO/ci` lists runs.
- Both need capability `8`. Without it: 403 `cannot 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 with
  `kind`, exact or a pattern, repeatable: `?kind=ci.completed&kind=landing.*`.
  A `ci.completed` carries `runId`, `checkin`, `pipeline`, `status`. Resume
  with `Last-Event-ID`.
- A job's environment (`internal/runner`). Each step is its own
  `sh -e -c`, in the checkout (or the step's `working-directory`).
  Without `--job-users` the job runs as the runner's own user with the
  runner's whole environment plus `CI=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-tmp` in it), `CI=true`, `LANG` if set,
  `QUARRY_USER` and `QUARRY_TOKEN` (this run's token), `QUARRY_TAG`,
  `QUARRY_CHANGED`, `QUARRY_TOUCHED`, the job's and step's `env`, 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.