Ledger · web/device-ladder
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