ci: first run on the shared runner
Empty on purpose. `.amadan/ci` and `.amadan/ci.d/` have been in this repo since the bootstrap commit, so nothing here needed changing — what was missing was a runner covering carlos/*, which is now granted. The hub enqueues at push time, and only if a runner has already polled the repo within the last seven days. Every commit on this branch so far was pushed before any runner had polled it, so none of them ever queued and none of them will: nothing enqueues retroactively. This is the push that gives the runner its first job.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Intent
Give me the exact command I need to do to add this repo to the CI runner?
Do I need to invite myself to offer? Do we not just need to add this repo to an allowlist on our existing CI stack?
<task-notification> <task-id>ae50389456560cfb5</task-id> <tool-use-id>toolu_019fwhUEJYmpeHPo34XVRmBR</tool-use-id> <output-file>/home/paulca/.cache/tmp/claude-1001/-home-paulca-amadan-net-carlos-contra/ad0bdb88-8c0a-407a-8704-18d9e48389ca/tasks/ae50389456560cfb5.output</output-file> <status>completed</status> <summary>Agent "Find amadan CI runner design" finished</summary> <note>A task-notification fires each time this agent stops with no live background children of its own. The user can send it another message and resume it, so the same task-id may notify more than once.</note> <result>I found the source and have a definitive answer. ## Short answer **No, the handshake is not required when one person owns both sides.** The `offer/request/accept` machinery exists *specifically* because a grant is structurally impossible between two different people. When the same person owns the agent *and* the namespace, the pre-existing one-sided path works and is strictly simpler: ``` amadan agent grant <agent-name> <ns> -role runner ``` Even simpler, if you're happy for the runner to run under your own identity rather than an agent's: just run `amadan runner` with no flags. It serves every repo you can write. Source: `/home/paulca/amadan.net/amadan/amadan` (main branch, `662bffb`). Ignore the `.claude/worktrees/*` copies — everything below is the main tree. --- ## 1. What an offering grants, and the two-hands rule The design spec states the whole reason the handshake exists — `docs/superpowers/specs/2026-08-21-shared-ci-by-invitation-design.md:24-46`: > **And it is not merely manual — for anyone else it is impossible.** Granting an agent access to a namespace requires the caller to satisfy two independent checks: […] Together they mean a grant can only ever be made by someone who owns the agent *and* the namespace. > […] **Neither party can complete the call.** There is no offer, no request, no acceptance. Shared CI is not an unbuilt feature; it is a structurally unreachable state. `internal/cli/ci.go:4-7` says it in the code: > Two hands, deliberately. A grant needs the caller to own the agent AND the namespace, **which between two people is unreachable** — so lending a runner is an ask by the namespace's owner and an answer by the offering's operator, and neither can do the other's half. An offering is **per-agent-bundle**, not per-repo or per-namespace. It names a set of *your own* agents (`internal/hub/ci_offerings_api.go:70-81` resolves each `-agent` by name within the caller's own agents). The namespace is supplied later, by the requester. Accept then writes `ns_members(ns, agent, 'runner')` for every agent in one transaction — `internal/hub/store_ci_offerings.go:263-271`: ```go `INSERT INTO ns_members(ns, ns_id, user, role) VALUES(?, (SELECT id FROM namespaces WHERE slug = ?), ?, 'runner') ON CONFLICT(ns, user) DO UPDATE SET role = excluded.role, ns_id = excluded.ns_id` ``` So the *end state* the handshake produces is nothing more than `ns_members` rows at role `runner`. `internal/hub/store_ci_offerings.go:15-17` is explicit: > Everything this writes to ns_members is role "runner" — the smaller of the two roles the grant whitelist permits. **This file adds a second way to REACH that table, never a new authority over it.** ## 2. The simpler path, and the check that confirms it counts identically `internal/hub/store_ci_offerings.go:330-336` — the discovery query the runner depends on: ```go // Membership at exactly "runner" is the whole answer — no join back to // ci_requests. There is then ONE source of truth for "may this runner // serve this namespace", so a grant made by the older single-operator // path counts identically, and revoking membership is sufficient to cut // a runner off however the grant arrived. ``` That "older single-operator path" is `amadan agent grant <name> <ns> -role runner`: - CLI: `cmd/amadan/main.go:67` — `agent grant <name> <ns> [-role r] give the agent access to a namespace you own` (`-role`: `"agent"`, the default, or `"runner"`) - `internal/cli/agent.go:94` — `var GrantableAgentRoles = []string{"agent", "runner"}` - Handler: `internal/hub/agents_api.go:196-243`, whose two gates are `operatorAgent` (line 202 — the agent must be one *you* own) and `grantGate` (line 236 → `agents_api.go:176-194`, `RequireNsRole(ns, operatorID, roleOwner)`). Both gates are satisfiable by a single person who owns both. `internal/hub/agents_api.go:218-224` documents why `runner` was added there in the first place: > runner exists here because there are only two paths into `ns_members` — namespace creation, which hardcodes owner, and this endpoint. Without it the runner role was enforceable at the CI gates but ungrantable for a public repo […] ## 3. `-agent` flags: what an agent is, and no wildcard An "agent" is a **disclosed agent account** you create with `amadan agent create <name>` — a separate user row with its own keymail `<name>~<operator>` and its own bearer token (`internal/hub/agents_api.go:1-8`, `:20-24`). `ci offer create` requires at least one, in two places: - CLI, `internal/cli/ci.go:24-26`: `"name at least one agent with -agent (an offering that grants nothing would be accepted and do nothing)"` — this is the error you hit - Hub, `internal/hub/ci_offerings_api.go:63-70` - Store, `internal/hub/store_ci_offerings.go:60-62` (`ErrCIOfferingEmpty`) **There is no wildcard, no `--all`, and no allowlist file.** `cmd/amadan/main.go:1633-1658` (`parseCIOfferCreateArgs`) collects literal `-agent` values only; `internal/hub/ci_offerings_api.go:70-81` resolves each by exact name via `GetAgentByName` and 404s on anything unknown. I grepped for `"*"`, `wildcard`, and `-all` across `internal/cli`, `internal/hub`, and `cmd` — the only wildcard in this area is `SENTINEL_REPOS="*"`, which is the sentinel shell script, not the CLI. The bundling is deliberate (`store_ci_offerings.go:5-13`): a shared box is two agents (the worker that drains the queue, the sentinel that wakes the box), and half a grant costs money silently. ## 4. The runner daemon `amadan runner` (`cmd/amadan/main.go:1458`, usage at `main.go:63-64`) — a plain long-polling process, outbound only, installed as a systemd **user** unit by `amadan runner install` (`internal/cli/runnerinstall.go:139-150`). **There is no allowlist config file.** Repo selection is either explicit `-repo ns/name` flags or auto-discovery. The union is `internal/cli/runnertransport.go:1003-1037`: ```go func RunnerRepos(hub Hub, token string) ([]WritableRepo, error) { writable, err := hub.ListWritableRepos(token) // GET /api/v1/repos — gated at WRITER ... granted, err := hub.ListCIRepos(token) // GET /api/v1/ci/repos — gated at exactly RUNNER ``` - `/api/v1/repos` → `internal/hub/repos.go:397-430`, `RequireNsRole(ns, userID, roleWriter)` at line 412 - `/api/v1/ci/repos` → `internal/hub/ci_offerings_api.go:387-410`, calling `ListCIRunnerNamespaces` at line 393 So **a runner authenticated as you (a namespace owner/writer) already sees every repo in your namespaces** with no CI grant, no offering, no request. That's the whole first half of the union. The production box hand-lists repos only for a historical reason now resolved — `hack/cloud-runner-user-data.sh:274-314`: > `amadan runner` with no -repo already means "every repo my token can write", which would be the matching default — except that discovery calls /api/v1/repos/writable, which gates at WRITER (repos.go), and **this runner deliberately holds `runner`**. and then, added later: > The -repo list below is hand-maintained, **and does not have to stay that way**. `amadan runner` now unions /api/v1/repos with /api/v1/ci/repos, so a box can discover its repos from the CI grants it has been given and this list can go away entirely. Note the caveat at `hack/cloud-runner-user-data.sh:305-309` says namespaces must show accepted "not merely that `amadan agent grant ... -role runner` was run." **That comment is wrong / stale relative to the code**: `ListCIRunnerNamespaces` (`store_ci_offerings.go:337-341`) queries `ns_members.role = 'runner'` with no join to `ci_requests`, and its doc comment explicitly says a direct grant "counts identically." ## 5. The "an agent cannot manage agents" gate `internal/hub/agents_api.go:29-44`: ```go func (s *Server) agentAPIGate(w http.ResponseWriter, r *http.Request) (operatorID int64, ok bool) { uid, ok := s.bearerUser(r) ... if isAgent { writeAPIError(w, http.StatusForbidden, "an agent cannot manage agents") ``` Rationale at `agents_api.go:1-7`: "agents don't spawn agents, mint tokens, or grant themselves membership; their consent is their operator's." **`ci request` is genuinely gated — not just the listing verbs.** Every single CI offering/request route calls `agentAPIGate`: `ci_offerings_api.go:46` (offer create), `:96` (offer list), `:131` (**request create**), `:188` (requests list), `:272` (accept), `:333` (decline/end), `:417` (offer delete). Only one route in that file deliberately skips it — `handleCIReposAPI`, `ci_offerings_api.go:384-386`: > Unlike the handshake routes this does NOT use agentAPIGate — the caller here is expected to be an agent, which that gate forbids. So: an agent token can *claim work*, but can do none of the handshake. If you hit that error, you were running the CLI as an agent. The whole handshake must be driven from a human account. Also: `amadan agent grant` runs through the same `agentAPIGate` (`agents_api.go:198`), so the simpler path is also human-only — but you are the human, so that's fine. ## 6. Is CI opt-in per repo? Two conditions, neither involving an offering: 1. **The repo needs a CI script.** `internal/hub/docs/ci.md:62-73`: `.amadan/ci.d/`, then `.amadan/ci`, then `make ci`, "Otherwise the job is reported **skipped**." Implementation: `internal/cli/runnerjob.go:582-610`. 2. **A runner must have polled that repo recently.** `internal/hub/docs/ci.md:44-56`: > A repo with no runners never queues and never nags. The hub queues a job only if a runner has polled **that repo** within the last seven days. `internal/repod/api_ci.go:27-28`: `const runnerWindow = 7 * 24 * 60 * 60`. Beyond that, CI is **automatic** — no per-repo enable switch anywhere. Once a runner covers the namespace and polls the repo, a push enqueues. ## 7. Docs - `docs/superpowers/specs/2026-08-21-shared-ci-by-invitation-design.md` (358 lines) — the authoritative design. §1 the deadlock, §3 the trust boundary, §4 the handshake, §5 discovery, §8 "What does NOT stop a runaway." - `internal/hub/docs/ci.md` — user-facing CI docs (served at `/docs/ci`) - `internal/hub/docs/cli.md:294-345` — "Lending a runner to somebody else"; `:388-395` — `agent grant … -role agent|runner` - `internal/hub/docs/agents.md:41,51` - `docs/ci-cloud-runner.md`, `docs/ci-sentinel.md`, `docs/ci-watchdog.md` — the AWS box `internal/hub/docs/cli.md:296-298` frames it exactly as I've described: > **Normally the runner and the repo belong to the same person.** To let someone *else's* namespace use *your* runner, both sides have to agree — there is no way for either of you to arrange it alone, by design. ## Exact commands for your case If the runner should run as an agent account: ``` amadan agent create ci-box # once, if it doesn't exist amadan agent token create ci-box # once — shown only once amadan agent grant ci-box <ns> -role runner # on the box, with the agent's token configured: amadan runner -isolate bwrap # no -repo needed ``` If the runner runs as you: just `amadan runner` — `RunnerRepos` picks up every repo in namespaces where you hold writer or above, no grant at all. Then in the repo: add an executable `.amadan/ci` (or `.amadan/ci.d/`), start the runner, *then* push. Order matters — the seven-day poll window means a push before the runner's first poll produces nothing at all, silently. ## Two things I did not find - **`amadan ci requires`** does not exist. The verb list is `offer|request|requests|accept|decline|end|status` (`cmd/amadan/main.go:1662`). I grepped `cmd/` and `internal/cli/` for a `requires` subcommand and found only flag-error strings. - **No runner config file of any kind.** No allowlist, no YAML, no `~/.config/amadan/runner.*`. Configuration is argv on the systemd unit's `ExecStart` plus the hub-side membership rows.</result> <usage><subagent_tokens>102538</subagent_tokens><tool_uses>43</tool_uses><duration_ms>196908</duration_ms></usage> </task-notification>
Are you able to do that, or give me a command to copy / paste?
that's done now I think
Go ahead create the first commit
This commit has no diff (an empty commit).