# swarmctl Swarm-level operator CLI. Runs as **root on the host running `swarm-controller`**, and acts on that host directly; or, for the verbs that talk to the controller, from anywhere as your own SSO account (see [Remote use](#remote-use-swarmctl-login)). Distinct from `hivectl`, which drives one hive's `hive-c0re` over its admin socket. This crate doesn't link `swarm-controller`, for the same reason `hivectl` doesn't link `hive-c0re`; the forge and schedule bodies and the agent responses both sides speak come from `swarm-controller-api`, as `hivectl`'s come from `hive-host-sock`. ## Why root, and why no socket The first verb writes authelia's users database. Making that write rootless was examined and rejected — **relocating the file only turns a write problem into a read problem.** Move `users.yml` into a directory the controller owns and the controller can write it, but authelia then has to read it across the same boundary in the other direction. Making _that_ work needs either a hand-pinned gid (the container's uids are allocated inside it, at activation — see the uid-assignment issue) or world-readable password hashes. Both are worse than root. So this binary **serves** no socket, publishes no HTTP route and has no privileged helper. That rules out a way in, not a way out: `agent create` is a _client_ of the controller, because the work it asks for is a job graph only the controller can queue (see below). Nothing here becomes reachable by that. ## One file, two writers `swarmctl` reads and writes `users.yml` — authelia's own users database — directly. `swarm-authelia-bridge` writes agent subjects into the same file. Every verb that writes holds an exclusive `flock` on `users.yml.lock` from before the load until the rename, as the bridge does. ⚠️ The file is round-tripped, so **comments and hand-formatting do not survive a write**. Values do, and so do keys this binary does not model. See `swarm-authelia-bridge/README.md` for what both writers must uphold. ## Configuration The nix deployment sets every path, each derived from an option it already has: `swarm-controller.nix` sets the socket, and `glue-controller-env.nix` sets `SWARMCTL_AUTHELIA_BIN` and `SWARMCTL_AUTHELIA_USERS_FILE`, only where authelia runs on the same host. They are required rather than defaulted — a default would be an address we _hope_ points at something, and one that resolves cleanly to the wrong place is worse than an error. | variable | what | | ------------------------------ | -------------------------------------------------------------------- | | `SWARMCTL_AUTHELIA_BIN` | the **configured** authelia; argon2 params must match the verifier's | | `SWARMCTL_AUTHELIA_USERS_FILE` | host-side path of the users database | | `SWARMCTL_AUTHELIA_MACHINE` | container name, for `systemctl -M` | | `SWARMCTL_AUTHELIA_UNIT` | authelia's unit inside that container | | `SWARM_CONTROLLER_SOCKET` | the controller's unix socket, for `agent` and `forge` verbs | | `SWARMCTL_LOGIN_DEFAULT_SWARM` | `https://` of this host's swarm, for `login` only | | `SWARMCTL_SWARM` | the swarm's domain, bare or `https://`: reach the controller there | | `SWARMCTL_CA_FILE` | a PEM CA to trust beside the system roots, for `SWARMCTL_SWARM` | Every verb and flag: [`docs/tools/swarmctl-cli.md`](../docs/tools/swarmctl-cli.md). The sections below cover why each verb behaves as it does. ## `user add` ```console # swarmctl user add mara --display-name "Mara" --email mara@example.com --group admins ``` Keep both flags: - **`--group admins`** — the swarm UI and other operator surfaces gate on it. - **`--email`** — the forge won't create an account without one. `user add` refuses a username that already exists; fix an existing account with `swarmctl user update mara --add-group admins --email …`. The password is **generated by authelia** (`crypto hash generate argon2 --random`) and printed once. It is never passed on a command line: `/proc//cmdline` is world-readable, so a password in argv is readable by any local process for the lifetime of the call. `user reset-password` generates a new one the same way. ## `agent create` ```console # swarmctl agent create scribe --hive alpha queued: job node 42 agent "scribe" will be deployed to hive "alpha" once the job graph runs; `swarmctl` does not wait for it ``` `POST /api/agents` on the swarm-controller, over its unix socket (`--controller-socket`, else `SWARM_CONTROLLER_SOCKET`, which the nix module sets from the daemon's own `socketPath`). It prints the queued job's node id **and stops there**. It deliberately does not wait. The endpoint queues a DAG — SSO identity, forge user, forge repo, repo membership, config-repo seed, store identity, forge token, matrix account, a `paused` wanted state, then a deploy message — and the last of those _publishes_: the hive's `hive-c0re` picks it up and converges on its own clock, out of the controller's sight. So even a fully settled graph would not mean the agent is up, and there is nothing this CLI could wait for that would let it say so honestly. Watch the swarm UI's job view for the rest. The new agent starts `paused`: it doesn't drive turns until you set it `up` in the swarm UI. No approval gate, for the same reason nothing else here has one: the caller is root on the controller's host, or logged in through the swarm domain's SSO gate. `--hive` is required. It is an _address_ — where the deploy message goes — not an attribute of the agent, so there is no sensible default. The controller checks it against the swarm's hive roster and names the hives that would have worked when it misses. The response shapes come from `swarm-controller-api`. `src/agent.rs` mirrors the request shape: the controller's `CreateAgentRequest` carries types from `swarm-queue-client` and `hive-sh4re`, neither of which `swarm-controller-api` depends on. Two fields out, both ends validating — a drift shows up as a 400 naming the field. ## `agent mint-forge-token` ```console # swarmctl agent mint-forge-token ruth ``` `POST /api/agents/{name}/forge-token` on the swarm-controller. Checks one agent's forge token and mints it if it's missing or stale. The controller already does this for every agent with a store identity at start and every five minutes; this verb skips the wait. Queues and returns. ## `forge make-admin` ```console # swarmctl forge make-admin mara forge: "mara" is now a site admin ``` `POST /api/forge/users/{name}/admin` on the swarm-controller, over the same socket as `agent create`. It never creates an account: the forge makes a person's on their first login through authelia, and until then this fails saying so. Running it on a site admin changes nothing, and an agent's name is refused. The response body comes from `swarm-controller-api`, imported in `src/forge.rs`. ## `schedule` ```console # swarmctl schedule add --name standup --agent scribe --body "post the standup summary" --every 1d # swarmctl schedule list --agent scribe # swarmctl schedule edit kqzpmwab --at 2026-10-05T09:00:00+02:00 --add-agent iris # swarmctl schedule pause kqzpmwab # swarmctl schedule rm kqzpmwab ``` The `/api/schedules` routes on the swarm-controller, over the same socket as `agent create`; each verb waits for the stored result. What a schedule does once stored is in [`docs/swarm/schedules.md`](../docs/swarm/schedules.md). The bodies come from `swarm-controller-api`, imported in `src/schedule.rs`. ## Remote use: `swarmctl login` The `agent`, `schedule` and `forge` verbs also work from any machine, as your own SSO account, through the swarm domain's gateway: ```console $ export SWARMCTL_SWARM=https://swarm.example.com $ swarmctl login Log in to https://swarm.example.com/ in your browser: … logged in to https://swarm.example.com/; login kept in ~/.local/state/swarmctl/swarm.example.com.json $ swarmctl schedule list ``` `--swarm` (before or after the verb) names the swarm, else `SWARMCTL_SWARM`; either takes the bare domain too. With neither, `login` alone falls back to `SWARMCTL_LOGIN_DEFAULT_SWARM`, which the swarm-controller module sets to its host's own swarm. It's not `SWARMCTL_SWARM` there because that would send the host's other verbs through the gateway instead of the socket. `login` opens the swarm's SSO login in a browser and receives the result on a loopback port, so run it on the machine whose browser you use. swarmctl keeps the login, mode 0600, under `$XDG_STATE_HOME/swarmctl/` (default `~/.local/state`) and refreshes it as it's used, and once more if the gateway refuses it. When it can't be refreshed, any verb says to run `swarmctl login` again. To log out, delete the file. A swarm whose gateway serves a certificate from the swarm's own CA needs that CA. On a controller host the swarm-controller module's wrapper sets `SSL_CERT_FILE` to the bundle the daemon itself trusts (the system CAs plus the swarm's root), so nothing needs setting there. Elsewhere, pass the CA with `--ca-file` or `SWARMCTL_CA_FILE`. The `user` verbs edit the local users file and have no remote form.