diff --git a/docs/getting-started/setup.md b/docs/getting-started/setup.md index 5a6c685e..22580928 100644 --- a/docs/getting-started/setup.md +++ b/docs/getting-started/setup.md @@ -24,17 +24,30 @@ operator has to place, and where. ### 1 · Forge -```bash -# Provision (or refresh) ruth's own forge account — do this first. Ruth's -# bootstrap bypasses the normal spawn-approval flow ("Spawn sub-agents" -# below), so unlike every other agent it does not get its forge account -# auto-provisioned — this manual step is still load-bearing. -hivectl forge create-user ruth +hive-c0re no longer creates agent forge users or mints agent tokens. +swarm-controller does, for every agent that holds a store identity: a pass +at start and every five minutes creates the forge user if it's missing, +then mints the token into the swarm secret store, where the agent fetches +it under that identity. An agent created with `swarmctl agent create` gets +its identity then. Ruth doesn't: hive-c0re creates her on its own at +startup, so she needs her identity minted by hand, once. -# Sub-agents spawned later (via the approval flow in "Spawn sub-agents") -# get their forge accounts auto-provisioned — nothing to run here for them. +```bash +# On the swarm-controller host: give ruth her store identity. is the +# name of the hive she runs on. +swarmctl agent mint-identity ruth --hive + +# On ruth's hive: re-apply her container config, which is when hive-c0re +# hands the new identity to the container. +hivectl agent ruth rebuild ``` +You don't need to do anything else. The controller's next pass creates +ruth's forge user and mints her token, and her container fetches it within +about ten minutes. `swarmctl agent mint-forge-token ruth` skips the wait for +the pass. A hive without a swarm secret store has no path to a forge token +for ruth at all. + Swarm SSO creates the human operator's own forge account instead of a manual `hivectl` step — see _Swarm SSO_ below (`swarmctl user add`). @@ -267,7 +280,9 @@ See [`tools/hivectl.md`](../tools/hivectl.md) for every `hivectl` verb. - **No forge admin token is stored in any agent state dir.** Agents - hold a regular agent token in their `forge-token` file; sensitive + hold a regular agent token, fetched from the swarm secret store into + `/run/hive-agent-forge-token/token` (or, for an agent without a store + identity, the `forge-token` file hive-c0re wrote before); sensitive creds (the core token) live on the host. - All config changes (forge PRs on `agent-configs/`) go through operator approval — agents can't unilaterally rebuild containers, by design. diff --git a/docs/integrations/forge.md b/docs/integrations/forge.md index 0979081e..83509234 100644 --- a/docs/integrations/forge.md +++ b/docs/integrations/forge.md @@ -17,7 +17,9 @@ each agent on relevant activity. Two scope sets live in `hive-c0re::forge`: -**`TOKEN_SCOPES`** (per-agent tokens): +**`TOKEN_SCOPES`** (per-agent tokens, and `hivectl forge create-user` +accounts). swarm-controller mints agent tokens with a byte-identical copy, +`forge::agent_token::AGENT_TOKEN_SCOPES`, pinned by a test: | Scope | Why | | -------------------- | ------------------------------------------------------------------------------------------------------- | @@ -46,13 +48,20 @@ hive-c0re to re-mint with the new scopes. ## Per-agent forge accounts -Each agent gets its own Forgejo user + access token, provisioned at -boot by `hive-c0re::forge`. The provisioning flow is idempotent: -`hive-c0re::forge` reuses existing accounts + tokens, so container destroy/recreate -doesn't lose forge identity. It writes the token to -`/forge-token` (one line, no trailing newline) inside the -agent container so `hive-forge` CLI + `forge_notify` poller can -read it without touching c0re's host-side credential store. +Each agent gets its own Forgejo user and access token. swarm-controller +creates the user when it creates the agent, and mints one token named +`swarm-agent` with the admin API into the swarm secret store at +`swarm/agents//forge-token`. A pass at start and every five minutes +covers every agent with a store identity: it creates a missing forge user and +re-mints any token that's missing or no longer matches the forge; +a rotation deletes the old `swarm-agent` token first, so each agent holds at +most one. The agent fetches the token under its own store certificate into +`/run/hive-agent-forge-token/token` (`nix/agent-modules/forge-token.nix`), +and `hive-forge`, the git credential helper, the `forge_notify` poller and +the avatar sync read it from there, falling back to `/forge-token`, +the file hive-c0re wrote before. hive-c0re no longer creates agent users or +mints agent tokens; the `hyperhive-` tokens it minted stay on +the forge until removed. Two things live in the `agent-configs` Forgejo organization: @@ -162,7 +171,7 @@ The poller starts disabled and stays that way for any of: - `HIVE_FORGE_URL` not set (no forge configured for this hive), or not parseable as a URL. -- `/forge-token` missing or empty (agent has no forge +- no token in `$HIVE_FORGE_TOKEN_FILE` or `/forge-token` (agent has no forge account — pre-provisioning or destroy-without-purge race). - Initial client construction fails (the typed `forgejo-api` client for the API calls, or the plain reqwest client kept for the diff --git a/docs/networking/network.md b/docs/networking/network.md index a288374c..1efcf70a 100644 --- a/docs/networking/network.md +++ b/docs/networking/network.md @@ -263,7 +263,7 @@ wiring is runtime: - the `hyperhive-isolated-dns` oneshot (`nix/agent-modules/network.nix`), gated on that marker, rewrites `/etc/resolv.conf` to `nameserver ` at boot. It's ordered `before` the harness (`hive-ag3nt`), the matrix daemon, and - `tea-login` so the resolver is correct before the first DNS lookup. + the forge-token fetch so the resolver is correct before the first DNS lookup. **Why isolation is safe**: hive-c0re's control-plane sockets are unix domain sockets bind-mounted into containers, not network listeners — see diff --git a/docs/process/conventions.md b/docs/process/conventions.md index efd6bf10..7db2237e 100644 --- a/docs/process/conventions.md +++ b/docs/process/conventions.md @@ -502,13 +502,9 @@ sparingly) or build just the suspect check, for example `nix build ## Best-effort oneshot services The harness ships a family of one-shot systemd services that -configure agent-side surfaces from values hive-c0re writes into -the state dir at provisioning time: +configure agent-side surfaces from values delivered to the agent at +provisioning time: -- `tea-login` — writes `~/.config/tea/config.yml` from the - `forge-token` written by `hive-c0re::forge::ensure_user_for`, - so `tea repos create` / `tea pulls create` work without - interactive prompts. - `forge-avatar-sync` — uploads `services.hyperhive.agent.icon` SVG to the agent's Forgejo profile, so the icon shows up on commits / PRs / issue comments. @@ -538,13 +534,10 @@ Shape contract — every one of these: by the `.path` watchers that re-fire on token appearance (see `docs/agent-lifecycle/persistence.md::Matrix per-agent daemon`). -The artefact lives under the agent user's home where applicable -(`~/.config/tea/config.yml`) and is chown'd to that user, but the -service itself stays root-owned so the bootstrap ordering doesn't -need a user-existence check before each fire. +The service stays root-owned so the bootstrap ordering doesn't need a +user-existence check before each fire. This pattern keeps the rebuild path resilient: any failure inside -these services degrades the corresponding surface (no tea config, -no avatar) but never blocks the container from coming up. The +these services degrades the corresponding surface (no avatar) but never blocks the container from coming up. The operator notices through `journalctl -u ` rather than a broken switch-to-configuration. diff --git a/docs/process/gotchas.md b/docs/process/gotchas.md index 6401176e..4571624c 100644 --- a/docs/process/gotchas.md +++ b/docs/process/gotchas.md @@ -448,7 +448,7 @@ connects to the compositor at `127.0.0.1:`. must never block on weston signalling readiness. A misconfigured weston degrades to a `Restart=on-failure` loop visible in `journalctl`, it doesn't abort the `nixos-container update`. - Same reasoning as the `tea-login` unit in `nix/agent-modules/forge.nix`. + Same reasoning as the best-effort oneshots in `docs/process/conventions.md`. - **`[core] idle-time=0`**: disables weston's 300-second idle timeout. Without it the VNC desktop fades to black and desktop-shell shows its click-to-unlock screen — useless for an diff --git a/docs/swarm/credentials.md b/docs/swarm/credentials.md index 3a05fb70..f05efba3 100644 --- a/docs/swarm/credentials.md +++ b/docs/swarm/credentials.md @@ -42,9 +42,9 @@ to the store under its own name, pulling what it needs when it needs it. No process reads a secret on another principal's behalf: the principal that needs a value is the principal that authenticates for it. -Two per-agent credential files — the forge token and the github token — sit -outside this page: they're operator-supplied and never pass through the -store, so the table below doesn't govern them. +One per-agent credential file — the github token — sits outside this page: +it's operator-supplied and never passes through the store, so the table +below doesn't govern it. **Per secret, the target specifies minter, reader, and renewal strategy.** Those three are the contract, and the reader is a process pulling a store @@ -54,16 +54,17 @@ strategy for every credential, including the mTLS leaf. -| store path | minter | reader — pulls at runtime, holds in memory | renewal | -| -------------------------------------------- | ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `swarm/agents//matrix/` | `swarm-controller` | the agent container itself, under the certificate its hive passed in | must be stated | -| `swarm/agents//bao-mtls` | `swarm-controller`, at agent creation | `hive-c0re`, under the hive's own certificate, when it writes the agent's container config | must be stated | -| `swarm/agents//queue` | `swarm-controller`, at agent creation | the agent container itself, under its own certificate — the identity it presents to the swarm queue, naming that one agent rather than its hive | none: the secret is fixed for the life of the agent and is revoked by deleting the path. A rotation mechanism is tracked as separate work, because rotating this credential needs a reconnect path — a queue client holding a revoked secret doesn't find out until it reconnects | -| `swarm/hives//matrix/appservice-token` | one minter, on the authelia host | the hive process that presents the token to its homeserver, under the hive's own certificate | must be stated | -| `swarm/hives//matrix/sender-token` | `swarm-matrix-ctl`, in the `hive-matrix` container | `swarm-matrix-ctl` itself, under its own certificate, before it decides whether to mint, and hive-c0re's `stored_sender_token()`, under the hive's own certificate | must be stated | -| `swarm/hives//queue/agent` | authelia | the agent container presenting the OIDC client to the swarm queue, under its own certificate | must be stated | -| `swarm/services//oidc/client` | authelia | the service process that presents the client secret, under the certificate of the host it runs on | must be stated | -| _(not in the store)_ a hive's mTLS leaf | the store's own PKI, or an operator placing it by hand | its own client, off disk — the exception above, because it's what makes every other row's pull possible | must be stated | +| store path | minter | reader — pulls at runtime, holds in memory | renewal | +| -------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `swarm/agents//matrix/` | `swarm-controller` | the agent container itself, under the certificate its hive passed in | must be stated | +| `swarm/agents//bao-mtls` | `swarm-controller`, at agent creation | `hive-c0re`, under the hive's own certificate, when it writes the agent's container config | must be stated | +| `swarm/agents//queue` | `swarm-controller`, at agent creation | the agent container itself, under its own certificate — the identity it presents to the swarm queue, naming that one agent rather than its hive | none: the secret is fixed for the life of the agent and is revoked by deleting the path. A rotation mechanism is tracked as separate work, because rotating this credential needs a reconnect path — a queue client holding a revoked secret doesn't find out until it reconnects | +| `swarm/agents//forge-token` | `swarm-controller`, at agent creation and in a pass every 5 minutes over every agent with a store identity | the agent container itself, under its own certificate, fetched to `/run/hive-agent-forge-token/token` | the controller re-mints when the stored token is missing or no longer matches the forge (last eight characters and scopes); the agent re-fetches on a 10-minute timer | +| `swarm/hives//matrix/appservice-token` | one minter, on the authelia host | the hive process that presents the token to its homeserver, under the hive's own certificate | must be stated | +| `swarm/hives//matrix/sender-token` | `swarm-matrix-ctl`, in the `hive-matrix` container | `swarm-matrix-ctl` itself, under its own certificate, before it decides whether to mint, and hive-c0re's `stored_sender_token()`, under the hive's own certificate | must be stated | +| `swarm/hives//queue/agent` | authelia | the agent container presenting the OIDC client to the swarm queue, under its own certificate | must be stated | +| `swarm/services//oidc/client` | authelia | the service process that presents the client secret, under the certificate of the host it runs on | must be stated | +| _(not in the store)_ a hive's mTLS leaf | the store's own PKI, or an operator placing it by hand | its own client, off disk — the exception above, because it's what makes every other row's pull possible | must be stated | @@ -95,6 +96,19 @@ value it holds, so running this against an already-migrated agent doesn't drop its queue connection. The certificate half isn't: the agent gets a fresh leaf and picks it up on its next boot. +The forge token needs no such step: `swarm-controller` checks every agent +that has a store identity at start and every five minutes. For any whose +stored token is missing or stale it creates the forge user if there isn't one, +then mints the token. To check one agent now: + +```sh +swarmctl agent mint-forge-token +``` + +An agent without a store identity gets no swarm token and keeps using the +`forge-token` file in its state dir, if it has one; run `mint-identity` for it +first. + ⚠️ **Run this for every existing agent before deploying a hive-side change that makes a container require a credential it may not have.** A container whose credential is absent doesn't start — that's deliberate, and it's what diff --git a/docs/tools/forge.md b/docs/tools/forge.md index d84360f7..108dfa2d 100644 --- a/docs/tools/forge.md +++ b/docs/tools/forge.md @@ -14,7 +14,8 @@ markdown-docs > docs/tools/forge-cli.md`. ## Credentials and repo defaults -- Credentials: `$HYPERHIVE_STATE_DIR/forge-token` +- Credentials: `$HIVE_FORGE_TOKEN_FILE` (the token the agent fetched from + the swarm secret store), else `$HYPERHIVE_STATE_DIR/forge-token` - Active repo resolves, highest priority first: global `-r/--repo` flag > the `origin` remote of the cwd's git checkout > `$HIVE_FORGE_REPO` (last-resort override, unset by default) > a hard error. diff --git a/docs/tools/hivectl-cli.md b/docs/tools/hivectl-cli.md index 18881495..cf635f73 100644 --- a/docs/tools/hivectl-cli.md +++ b/docs/tools/hivectl-cli.md @@ -103,22 +103,22 @@ Manual entry point to the same idempotent provisioning c0re runs at boot — for ###### **Subcommands:** -* `create-user` — Create or refresh the Forgejo account + token for `` +* `create-user` — Create or refresh a non-agent Forgejo account + token for `` * `reconcile-config` — Show + reconcile the divergence between an agent's local applied config checkout and its forge `agent-configs/` main ## `hivectl forge create-user` -Create or refresh the Forgejo account + token for ``. +Create or refresh a non-agent Forgejo account + token for ``. -For an existing agent, persists the token to its state dir; for a human/other account, prints the token to stdout. Set a password to enable forge web-UI login (otherwise it uses a random throwaway). +Prints the token to stdout. Set a password to enable forge web-UI login (otherwise it uses a random throwaway). Refused for an existing agent: swarm-controller mints an agent's token (`swarmctl agent mint-forge-token `). **Usage:** `hivectl forge create-user [OPTIONS] ` ###### **Arguments:** -* `` — Forgejo username. For agents: the container/agent name (`` in `h-`; manager uses the literal `manager`). For humans: any forgejo username — `mara`, `damocles`, etc +* `` — Forgejo username of a human/other account — `mara`, `damocles`, etc ###### **Options:** diff --git a/docs/tools/hivectl.md b/docs/tools/hivectl.md index 409d462b..47e56416 100644 --- a/docs/tools/hivectl.md +++ b/docs/tools/hivectl.md @@ -29,7 +29,7 @@ at boot. Useful for recovery, ad-hoc re-provisioning, or fixing a single agent without bouncing the daemon. ```bash -hivectl forge create-user iris # provision (or refresh) forge account for agent `iris` +hivectl forge create-user iris # refused: `iris` is an agent; use `swarmctl agent mint-forge-token iris` hivectl forge create-user mara # create forge account for a human user; prints token to stdout hivectl forge create-user mara --password hunter2 # set a web-login password hivectl forge create-user mara --password-stdin # read password from stdin (safer for scripting) @@ -40,8 +40,9 @@ hivectl forge reconcile-config iris --verbose # include the full diff, not ``` - For **agents** (name has a state dir under `/var/lib/hyperhive/agents/`): - `create-user` persists the token to `/forge-token`. Re-running refreshes the - token (idempotent — scope always matches current `TOKEN_SCOPES`). + `create-user` refuses. swarm-controller mints an agent's token into + the swarm secret store; `swarmctl agent mint-forge-token ` checks + and, if needed, re-mints it. - For **non-agents** (humans): creates the account and prints the token to stdout, creating no state dir. Re-running after account already exists re-mints the token and prints it again — safe for password resets. diff --git a/docs/tools/swarmctl-cli.md b/docs/tools/swarmctl-cli.md index 96d0eb20..1810d02d 100644 --- a/docs/tools/swarmctl-cli.md +++ b/docs/tools/swarmctl-cli.md @@ -8,6 +8,7 @@ This document contains the help content for the `swarmctl` command-line program. * [`swarmctl agent`↴](#swarmctl-agent) * [`swarmctl agent create`↴](#swarmctl-agent-create) * [`swarmctl agent mint-identity`↴](#swarmctl-agent-mint-identity) +* [`swarmctl agent mint-forge-token`↴](#swarmctl-agent-mint-forge-token) * [`swarmctl user`↴](#swarmctl-user) * [`swarmctl user add`↴](#swarmctl-user-add) * [`swarmctl user update`↴](#swarmctl-user-update) @@ -45,6 +46,7 @@ Manage agents across the swarm * `create` — Queue creation of a new agent on a hive in this swarm * `mint-identity` — Queue a re-mint of an existing agent's identity at the swarm's secret store +* `mint-forge-token` — Check one agent's forge token, and mint it if it's missing or stale @@ -104,6 +106,28 @@ Queues and returns, the same way `agent create` does — watch the swarm UI's jo +## `swarmctl agent mint-forge-token` + +Check one agent's forge token, and mint it if it's missing or stale. + +swarm-controller does this for every agent with a store identity at start and every five minutes; this is for when waiting isn't an option. It leaves a current token alone. + +Queues and returns, the same way `agent create` does — watch the swarm UI's job view for the outcome. + +**Usage:** `swarmctl agent mint-forge-token [OPTIONS] ` + +###### **Arguments:** + +* `` — Name of an agent that already exists + +###### **Options:** + +* `--controller-socket ` — swarm-controller's unix socket. + + Supplied by the nix module that installs this binary, from the same `socketPath` option the daemon binds; falls back to `SWARM_CONTROLLER_SOCKET`. + + + ## `swarmctl user` Manage subjects in the swarm's SSO provider diff --git a/docs/trust-boundary/security.md b/docs/trust-boundary/security.md index 78a98465..ca8fe746 100644 --- a/docs/trust-boundary/security.md +++ b/docs/trust-boundary/security.md @@ -236,9 +236,12 @@ token policy bounds file reads; network isolation bounds network reach. this directory at a default mode is world-readable, which isn't hypothetical: `plugins/` and `.last-cleanup` already are. - `$HYPERHIVE_STATE_DIR/forge-token` (= `/agents//state/forge-token`) - — written at mode `0600` and chowned to the per-agent uid:gid (see - `hive-c0re/src/forge/mod.rs`'s module doc for exactly where). nixbld users - can't read it. + — the token hive-c0re wrote at mode `0600`, chowned to the per-agent + uid:gid, before swarm-controller took over minting. Nothing writes it any + more; it stays as the fallback for an agent without a store identity. + nixbld users can't read it. The swarm-minted token lives under + `/run/hive-agent-forge-token/` (`0700` directory, `0400` file), outside + the state dir. **Policy**: all credential files written to agent state directories MUST be mode `0600` or stricter. Don't create world-readable secret files in agent state dirs. @@ -291,7 +294,7 @@ known operations; there is no arbitrary command pass-through: | `RemoveServiceDropin` | remove `container@.service.d/` drop-in on destroy | | `DaemonReload` | `systemctl daemon-reload` | | `RunForgeAdmin` | `nixos-container run hive-forge -- runuser -u forgejo -- forgejo admin ` | -| `WriteAgentForgeToken` / `WriteAgentMatrixToken` | write `0600` credential file into agent state dir | +| `WriteAgentMatrixToken` | write `0600` credential file into agent state dir | | `RestartMatrixDaemon` | `systemctl --machine=h- restart hive-matrix-daemon.service` | | `ControlInfraContainer` | `systemctl container@.service` — the `InfraContainer` enum is the allowlist, and serde rejects unknown names at the wire boundary (`hive-c0re` has no variant, so no request can name it) | | `SyncAgentTmpfiles` | write `/etc/tmpfiles.d/hyperhive-agents.conf` for the agent set, then `systemd-tmpfiles --create` | diff --git a/docs/turn-loop/config.md b/docs/turn-loop/config.md index 50549231..e50213da 100644 --- a/docs/turn-loop/config.md +++ b/docs/turn-loop/config.md @@ -137,11 +137,9 @@ services.hyperhive.agent.forge.url = "http://forge.example:3000"; # default: nu services.hyperhive.agent.matrix.url = "https://matrix.example"; # default: null ``` -**`services.hyperhive.agent.forge.url`** — base URL of the Forgejo instance. Used by -a one-shot boot unit (`tea-login`) that writes `~/.config/tea/config.yml` -directly from the agent's `forge-token`, so `tea` and `hive-forge` -work without an interactive auth step. The unit is a no-op when -`forge-token` is absent. Override when the agent should connect to a +**`services.hyperhive.agent.forge.url`** — base URL of the Forgejo instance. It scopes +the git credential helper to this forge and is where `forge-avatar-sync` +uploads the agent's icon. Override when the agent should connect to a Forgejo on a different host or port (for example a swarm peer's forge). Validated: must be an `http://` or `https://` URL, or `null`. @@ -149,7 +147,7 @@ Validated: must be an `http://` or `https://` URL, or `null`. loopback default would only ever be correct when the forge shares the agent's network namespace, and inside a container `localhost` is the agent itself, so the default was a value that built fine and then talked -to the wrong machine. With `null` the `tea-login` and `forge-avatar-sync` +to the wrong machine. With `null` the credential helper and `forge-avatar-sync` units aren't generated at all: an absent integration rather than a misdirected one. You don't normally set this — hive-c0re renders the host's real forge URL into every agent, and refuses to write a meta diff --git a/hive-agent/src/web_ui/state.rs b/hive-agent/src/web_ui/state.rs index f39e3095..a701e4a5 100644 --- a/hive-agent/src/web_ui/state.rs +++ b/hive-agent/src/web_ui/state.rs @@ -351,7 +351,12 @@ fn agent_links(label: &str, gui_enabled: bool) -> Vec { }); } - if crate::paths::state_dir().join("forge-token").is_file() { + // Either copy of the forge token: the one fetched from the swarm secret + // store (`HIVE_FORGE_TOKEN_FILE`), or the state-dir file the hive used + // to write. + let fetched_token = std::env::var_os("HIVE_FORGE_TOKEN_FILE") + .is_some_and(|p| std::path::Path::new(&p).is_file()); + if fetched_token || crate::paths::state_dir().join("forge-token").is_file() { links.push(AgentLink { url: format!("/{label}"), icon: "🔨".to_owned(), diff --git a/hive-c0re/src/actions.rs b/hive-c0re/src/actions.rs index 23fa8de5..e2140da0 100644 --- a/hive-c0re/src/actions.rs +++ b/hive-c0re/src/actions.rs @@ -527,7 +527,7 @@ pub(crate) async fn resolve_approval_dag( let mut terminal_tag = None; match approval.kind { ApprovalKind::Spawn => { - // Post-spawn forge bookkeeping (user, config repo mirror, meta + // Post-spawn forge bookkeeping (config repo mirror, meta // access) — warn-only, then the resolution events + a rescan so // the dashboard reflects the post-spawn state either way. if result.is_ok() { @@ -605,13 +605,10 @@ fn fetch_approval_for_worker( } /// Forge bookkeeping run once after the very first container spawn: -/// create the per-agent forge user, mirror the applied repo, and grant -/// read access to core/meta. Also rescans containers so the dashboard -/// reflects the post-spawn state. +/// mirror the applied repo and grant read access to core/meta. The +/// agent's forge user and token are swarm-controller's, not this +/// hive's. Also rescans containers so the dashboard reflects the post-spawn state. async fn forge_after_first_spawn(coord: &Arc, agent: &str) { - if let Err(e) = crate::forge::ensure_user_for(agent).await { - tracing::warn!(%agent, error = ?e, "forge: ensure_user after first spawn failed"); - } if let Err(e) = crate::forge::ensure_config_repo(agent).await { tracing::warn!(%agent, error = ?e, "forge: ensure_config_repo after first spawn failed"); } diff --git a/hive-c0re/src/forge/mod.rs b/hive-c0re/src/forge/mod.rs index 89ca413d..e79e1d65 100644 --- a/hive-c0re/src/forge/mod.rs +++ b/hive-c0re/src/forge/mod.rs @@ -1,4 +1,4 @@ -//! Optional Forgejo wiring — per-agent user + token provisioning, +//! Optional Forgejo wiring — per-agent account alignment, //! config-repo mirroring, meta read-access grants. Also seeds //! `internal/docs` — a private repo every agent gets read-only //! collaborator access to for operator-curated shared content. @@ -21,7 +21,7 @@ pub use repos::{ ensure_meta_remote, ensure_repo, ensure_shared_docs_repo, fast_forward_applied_main, fetch_config_main_into_applied, meta_read_access, push_config, push_meta, shared_docs_access, }; -pub use users::{core_token, ensure_user_for, provision_user_token}; +pub use users::{core_token, provision_user_token}; use std::sync::OnceLock; use std::time::{Duration, Instant}; @@ -212,9 +212,9 @@ pub(crate) fn api(token: &str) -> Result { Forgejo::new(Auth::Token(token), url).context("build forgejo api client") } -/// Per-agent forge sync: ensure the agent has a forgejo user + token, -/// a mirrored config repo, read access to `core/meta`, and the `meta` -/// remote in its proposed repo. All operations are idempotent; failures +/// Per-agent forge sync: align the agent's forge account (email, +/// repo-creation lockdown), mirror its config repo, grant read access +/// to `core/meta`, and wire the `meta` remote in its proposed repo. All operations are idempotent; failures /// are logged as warnings but don't abort the caller. /// /// `core_token` is `core_token()` — passed in so callers that already @@ -228,11 +228,10 @@ pub(crate) fn api(token: &str) -> Result { /// dashboard warning (see [`ensure_all`]); the rebuild path ignores it and /// relies on the journal `warn!` lines alone (a rebuild is its own retry). pub async fn sync_agent(name: &str, core_token: Option<&str>) -> bool { + // The agent's forge user and token are swarm-controller's now + // (`MintAgentForgeToken`); this hive no longer creates or re-mints + // either. let mut ok = true; - if let Err(e) = ensure_user_for(name).await { - tracing::warn!(%name, error = ?e, "forge: ensure_user failed"); - ok = false; - } // Align email to match the git user.email set by meta::render_flake // so commits link to the agent's Forgejo profile. Best-effort; // also patches up agents created before this fix (old @hive.local). diff --git a/hive-c0re/src/forge/users.rs b/hive-c0re/src/forge/users.rs index 2bc6be80..fc36a113 100644 --- a/hive-c0re/src/forge/users.rs +++ b/hive-c0re/src/forge/users.rs @@ -313,8 +313,8 @@ pub(super) async fn ensure_repo_creation_disabled(name: &str) { /// a monotonic clock so re-issuing doesn't collide with an existing /// token of the same name in the DB. `scopes` is the scope string /// passed to `forgejo admin user generate-access-token --scopes`; -/// use `TOKEN_SCOPES` for agents, `CORE_TOKEN_SCOPES` for the -/// bootstrap `core` user. +/// use `TOKEN_SCOPES` for `hivectl forge create-user` accounts and +/// `CORE_TOKEN_SCOPES` for the bootstrap `core` user. async fn mint_token(name: &str, scopes: &str) -> Result { let token_name = format!( "{TOKEN_NAME_PREFIX}-{}", @@ -349,16 +349,6 @@ async fn mint_token(name: &str, scopes: &str) -> Result { Ok(token) } -/// Mint a fresh Forgejo access token for an agent and write it to the -/// agent's state dir via hive-priv. hive-c0re runs unprivileged and -/// cannot write to agent-owned (0755) state directories directly. -async fn mint_and_persist_agent_token(name: &str) -> Result<()> { - let token = mint_token(name, TOKEN_SCOPES).await?; - crate::priv_client::write_agent_forge_token(name, &token) - .await - .with_context(|| format!("write forge-token for {name} via hive-priv")) -} - /// Mint a fresh Forgejo access token for the `core` admin user and /// write it directly to `path`. Unlike agent tokens this path is owned /// by hive-c0re itself (under `/var/lib/hyperhive/`), so a direct @@ -376,24 +366,11 @@ async fn mint_and_persist_core_token(path: &Path) -> Result<()> { Ok(()) } -/// Ensure `name` has a forgejo user + token file. Always re-mints the -/// token so the on-disk file always reflects the current `TOKEN_SCOPES`. -/// Safe to call on every spawn and on every hive-c0re startup. -pub async fn ensure_user_for(name: &str) -> Result<()> { - if !is_present().await { - return Ok(()); - } - ensure_user_exists(name, false, None).await?; - ensure_user_email(name).await; - mint_and_persist_agent_token(name).await -} - /// Provision a forgejo user for `name` and return the freshly-minted -/// token. Unlike [`ensure_user_for`], the token is **not** persisted to -/// disk — the caller is responsible for storing it. Used by `hivectl -/// forge create-user` for human (non-agent) accounts so we don't create -/// stray `/var/lib/hyperhive/agents//` directories for users that -/// aren't agents. +/// token, which is **not** persisted to disk — the caller is responsible +/// for storing it. Used by `hivectl forge create-user` for human +/// (non-agent) accounts. Agent tokens are minted by swarm-controller +/// (`swarm-controller/src/forge/agent_token.rs`), not here. /// /// `password` picks the account password. `None` keeps the existing /// random-throwaway shape (caller doesn't need web UI access — token diff --git a/hive-c0re/src/meta.rs b/hive-c0re/src/meta.rs index 3f26dd4b..e510ab40 100644 --- a/hive-c0re/src/meta.rs +++ b/hive-c0re/src/meta.rs @@ -642,7 +642,7 @@ const FORWARDED_VARS: &[&str] = &[ /// Map of forwarded env var -> the agent option carrying the same value. /// /// Both exist because they're consumed at different times: the option is baked -/// into scripts at build time (tea-login bakes `FORGE_URL` from it), the env +/// into scripts at build time (forge-avatar-sync bakes `FORGE_URL` from it), the env /// var is read at runtime. Setting only one leaves the other on its default, /// which is how a hive ends up with two disagreeing answers for one value. /// @@ -1242,7 +1242,7 @@ where # resolve the agent's durable state dir without hard-coding it. # `environment.variables` only writes /etc/environment (login # shells); `systemd.globalEnvironment` is the analogue for - # systemd units so tea-login / forge-avatar-sync / + # systemd units so forge-avatar-sync / # hive-matrix-daemon etc. can read `$HYPERHIVE_STATE_DIR` # without each service having to redeclare it. environment.variables = { @@ -1259,7 +1259,7 @@ where // Forwarded vars (HIVE_FORGE_URL etc.) also go into globalEnvironment, // not just the harness service env below, so EVERY service + shell in // the container inherits them — crucially the bash-task runner (where - // `hive-forge` + `git` actually run), plus the matrix daemon, tea-login + // `hive-forge` + `git` actually run), plus the matrix daemon, forge-avatar-sync // and interactive shells. Scoped to the harness service alone they were // invisible to bash tasks: harmless in shared netns (the localhost // default works) but broken under isolation, where the in-cluster @@ -2081,7 +2081,7 @@ mod tests { fn render_flake_sets_service_url_options_from_forwarded_env() { // The forwarded env vars must ALSO become option assignments, because // the two are consumed at different times: the option is baked into - // scripts at build time (tea-login's FORGE_URL), the env var is read at + // scripts at build time (forge-avatar-sync's FORGE_URL), the env var is read at // runtime. Emitting only the env var leaves the option on its default, // which is how a hive ends up with two disagreeing answers for the same // URL. diff --git a/hive-c0re/src/priv_client.rs b/hive-c0re/src/priv_client.rs index 978c3ad6..4ef99842 100644 --- a/hive-c0re/src/priv_client.rs +++ b/hive-c0re/src/priv_client.rs @@ -361,18 +361,6 @@ pub async fn set_agent_paused(agent_name: &str, paused: bool) -> Result<()> { .await?) } -/// Write the Forgejo access token for `agent_name` to -/// `//state/forge-token` via hive-priv -/// (running as root). The file is written 0600 and chowned to the agent -/// user so it is readable from inside the agent container. -pub async fn write_agent_forge_token(agent_name: &str, token: &str) -> Result<()> { - ok(call(&PrivRequest::WriteAgentForgeToken { - agent_name: agent_name.to_owned(), - token: token.to_owned(), - }) - .await?) -} - /// Write a Matrix access token for `agent_name` via hive-priv (running as /// root). `account: None` writes the hive-internal /// `/matrix-token`; `account: Some(name)` writes diff --git a/hive-c0re/src/server.rs b/hive-c0re/src/server.rs index 0d0e3440..f2c4b389 100644 --- a/hive-c0re/src/server.rs +++ b/hive-c0re/src/server.rs @@ -600,36 +600,30 @@ async fn handle_forge_create_user( "hive-forge container not running — wait for hive-c0re to start it before provisioning forge users" ); } - let mut out = Vec::new(); if agent_exists(name)? { - if password.is_some() { - // Agents authenticate by API token, never by password — refuse - // rather than silently dropping a supplied one. - anyhow::bail!( - "forge create-user: a password is for non-agent (operator) accounts only; '{name}' is an agent which authenticates via API token" - ); - } - crate::forge::ensure_user_for(name.as_str()) - .await - .with_context(|| format!("forge create-user {name}"))?; - let path = Coordinator::agent_notes_dir(name).join("forge-token"); - out.push(format!("forge: provisioned agent user '{name}'")); - out.push(format!("token persisted at: {}", path.display())); + // An agent's forge user and token are swarm-controller's: it mints + // the token into the swarm secret store and the agent fetches it + // from there. A hive-minted token would be one the swarm neither + // tracks nor rotates. + anyhow::bail!( + "forge create-user: '{name}' is an agent; its forge token is minted by \ + swarm-controller — run `swarmctl agent mint-forge-token {name}` on the \ + controller host" + ); + } + let token = crate::forge::provision_user_token(name.as_str(), password) + .await + .with_context(|| format!("forge create-user {name}"))?; + let mut out = vec![ + format!("forge: provisioned user '{name}' (not an agent — token not persisted)"), + format!("token: {token}"), + ]; + if password.is_some() { + out.push("password: set as supplied — use it to log into the forge web UI".to_owned()); } else { - let token = crate::forge::provision_user_token(name.as_str(), password) - .await - .with_context(|| format!("forge create-user {name}"))?; - out.push(format!( - "forge: provisioned user '{name}' (not an agent — token not persisted)" - )); - out.push(format!("token: {token}")); - if password.is_some() { - out.push("password: set as supplied — use it to log into the forge web UI".to_owned()); - } else { - out.push( - "password: random throwaway (not surfaced — pass --password or --password-stdin to set one you can use)".to_owned(), - ); - } + out.push( + "password: random throwaway (not surfaced — pass --password or --password-stdin to set one you can use)".to_owned(), + ); } Ok(HostResponse::messages(out)) } diff --git a/hive-forge-notify/src/bin/hive-forge-notify/main.rs b/hive-forge-notify/src/bin/hive-forge-notify/main.rs index b1786c87..f9717ed6 100644 --- a/hive-forge-notify/src/bin/hive-forge-notify/main.rs +++ b/hive-forge-notify/src/bin/hive-forge-notify/main.rs @@ -37,23 +37,17 @@ async fn forgejo_loop(state_dir: String, socket: std::path::PathBuf) { } }; - let token_path = format!("{state_dir}/forge-token"); - // Retry reading the token to handle races where hive-priv provisions - // it after the harness starts, or where a parent-container chown - // briefly makes the file unreadable. - let token = { + let token_paths = hive_forge_notify::forge_token_paths(&state_dir); + // Retry reading the token to handle races where the agent's fetch of + // it from the swarm secret store lands after this unit starts, or where + // a parent-container chown briefly makes the file unreadable. + let mut token = { let mut attempts = 0u32; loop { - match tokio::fs::read_to_string(&token_path).await { - Ok(t) => { - let t = t.trim().to_owned(); - if !t.is_empty() { - break t; - } - debug!("forge_notify: empty forge token at {token_path}"); - } - Err(e) => debug!("forge_notify: cannot read token at {token_path}: {e}"), + if let Some(t) = hive_forge_notify::read_first_token(&token_paths) { + break t; } + debug!(?token_paths, "forge_notify: no forge token yet"); attempts += 1; if attempts >= TOKEN_RETRY_MAX { debug!( @@ -65,7 +59,7 @@ async fn forgejo_loop(state_dir: String, socket: std::path::PathBuf) { } }; - let Some(source) = ForgejoSource::new(&forge_url, &token) else { + let Some(mut source) = ForgejoSource::new(&forge_url, &token) else { return; }; @@ -111,6 +105,18 @@ async fn forgejo_loop(state_dir: String, socket: std::path::PathBuf) { loop { interval.tick().await; + // The swarm rotates the token when it goes stale, and the agent's + // fetch replaces the file; pick the new one up rather than polling + // with a revoked token for the rest of this process's life. + if let Some(t) = hive_forge_notify::read_first_token(&token_paths) + && t != token + && let Some(s) = ForgejoSource::new(&forge_url, &t) + { + info!("forge_notify: forge token changed on disk; using the new one"); + token = t; + source = s; + own_login.clear(); + } if own_login.is_empty() { own_login = resolve_own_login(&client, &source).await; if !own_login.is_empty() { diff --git a/hive-forge-notify/src/lib.rs b/hive-forge-notify/src/lib.rs index 95f1cf2d..702ba922 100644 --- a/hive-forge-notify/src/lib.rs +++ b/hive-forge-notify/src/lib.rs @@ -70,3 +70,76 @@ pub fn init_tracing() { pub fn state_dir() -> String { std::env::var("HYPERHIVE_STATE_DIR").unwrap_or_default() } + +/// Where this agent's forge token is read from, first match wins: +/// `HIVE_FORGE_TOKEN_FILE` (the copy the agent fetched from the swarm secret +/// store, set by `nix/agent-modules/forge-token.nix`), then +/// `/forge-token` (the file the hive used to write, still the only +/// copy on an agent without a store identity). +#[must_use] +pub fn forge_token_paths(state_dir: &str) -> Vec { + let mut paths = Vec::with_capacity(2); + if let Ok(fetched) = std::env::var("HIVE_FORGE_TOKEN_FILE") + && !fetched.is_empty() + { + paths.push(std::path::PathBuf::from(fetched)); + } + paths.push(std::path::Path::new(state_dir).join("forge-token")); + paths +} + +/// The first non-empty token among `paths`, trimmed. `None` when none of +/// them holds one. +#[must_use] +pub fn read_first_token(paths: &[std::path::PathBuf]) -> Option { + paths.iter().find_map(|p| { + let t = std::fs::read_to_string(p).ok()?; + let t = t.trim(); + (!t.is_empty()).then(|| t.to_owned()) + }) +} + +#[cfg(test)] +mod token_tests { + use super::read_first_token; + + fn scratch(tag: &str) -> std::path::PathBuf { + let ts = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |d| d.as_nanos()); + let dir = std::env::temp_dir().join(format!("hive-forge-notify-token-{tag}-{ts}")); + std::fs::create_dir_all(&dir).expect("create scratch dir"); + dir + } + + #[test] + fn the_fetched_token_wins_over_the_state_file() { + let dir = scratch("wins"); + let (fetched, state) = (dir.join("fetched"), dir.join("forge-token")); + std::fs::write(&fetched, "new\n").expect("write"); + std::fs::write(&state, "old\n").expect("write"); + assert_eq!(read_first_token(&[fetched, state]).as_deref(), Some("new")); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn a_missing_or_empty_fetched_token_falls_back_to_the_state_file() { + let dir = scratch("fallback"); + let (fetched, state) = (dir.join("fetched"), dir.join("forge-token")); + std::fs::write(&state, "old\n").expect("write"); + assert_eq!( + read_first_token(&[fetched.clone(), state.clone()]).as_deref(), + Some("old") + ); + std::fs::write(&fetched, "\n").expect("write"); + assert_eq!(read_first_token(&[fetched, state]).as_deref(), Some("old")); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn no_token_anywhere_is_none() { + let dir = scratch("none"); + assert_eq!(read_first_token(&[dir.join("a"), dir.join("b")]), None); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/hive-forge/src/client.rs b/hive-forge/src/client.rs index a33f87e1..cfe86bdb 100644 --- a/hive-forge/src/client.rs +++ b/hive-forge/src/client.rs @@ -1,5 +1,6 @@ -//! App-level Forgejo client wrapper. Identity is the per-agent token -//! under `${HYPERHIVE_STATE_DIR}/forge-token`. REST calls go through +//! App-level Forgejo client wrapper. Identity is the per-agent token at +//! `$HIVE_FORGE_TOKEN_FILE`, else `${HYPERHIVE_STATE_DIR}/forge-token` +//! (see `read_token`). REST calls go through //! the typed [`forgejo_api::sync::Forgejo`] client (exposed via //! [`Client::api`]); a minimal raw `reqwest` client remains for the //! few *web-router* routes Forgejo does not serve under `/api/v1/` @@ -462,9 +463,20 @@ fn state_dir() -> PathBuf { } } -/// Locate and read the forge token. Falls back to `$PWD/forge-token` -/// when `HYPERHIVE_STATE_DIR` isn't set, matching the bash helper. +/// Locate and read the forge token: `HIVE_FORGE_TOKEN_FILE` first (the +/// copy the agent fetched from the swarm secret store, set by +/// `nix/agent-modules/forge-token.nix`) when it names a non-empty file, +/// otherwise `/forge-token`, the file the hive used to write. +/// The state dir falls back to `$PWD` when `HYPERHIVE_STATE_DIR` isn't +/// set, matching the bash helper. fn read_token() -> Result { + if let Ok(fetched) = std::env::var("HIVE_FORGE_TOKEN_FILE") + && !fetched.is_empty() + && let Ok(raw) = std::fs::read_to_string(&fetched) + && !raw.trim().is_empty() + { + return Ok(raw.trim().to_owned()); + } let path = state_dir().join("forge-token"); let raw = std::fs::read_to_string(&path) .with_context(|| format!("hive-forge: no forge-token at {}", path.display()))?; diff --git a/hive-forge/tests/credential_helper.rs b/hive-forge/tests/credential_helper.rs index e15947a4..77936bb6 100644 --- a/hive-forge/tests/credential_helper.rs +++ b/hive-forge/tests/credential_helper.rs @@ -29,7 +29,23 @@ fn scratch_state_dir(tag: &str) -> PathBuf { /// `state_dir`, feeding `request` as the git credential-protocol /// request body on stdin. Returns `(exit success, stdout, stderr)`. fn run_get(base_url: &str, state_dir: &Path, request: &str) -> (bool, String, String) { - let mut child = Command::new(env!("CARGO_BIN_EXE_hive-forge")) + run_get_with(base_url, state_dir, None, request) +} + +/// [`run_get`], with `HIVE_FORGE_TOKEN_FILE` set to `fetched` when given and +/// removed otherwise, so the caller's own environment never leaks in. +fn run_get_with( + base_url: &str, + state_dir: &Path, + fetched: Option<&Path>, + request: &str, +) -> (bool, String, String) { + let mut cmd = Command::new(env!("CARGO_BIN_EXE_hive-forge")); + match fetched { + Some(p) => cmd.env("HIVE_FORGE_TOKEN_FILE", p), + None => cmd.env_remove("HIVE_FORGE_TOKEN_FILE"), + }; + let mut child = cmd .arg("credential-helper") .arg("get") .env("HIVE_FORGE_URL", base_url) @@ -111,3 +127,46 @@ fn credential_helper_get_still_works_with_no_host_line() { ); let _ = std::fs::remove_dir_all(&dir); } + +/// The token the agent fetched from the swarm secret store wins over the +/// state-dir file the hive used to write, so a rotated token takes effect +/// even while the old file is still on disk. +#[test] +fn the_fetched_token_wins_over_the_state_file() { + let dir = scratch_state_dir("fetched"); + let fetched = dir.join("fetched-token"); + std::fs::write(&fetched, "fetched-token-value\n").expect("seed fetched token"); + let (ok, stdout, stderr) = run_get_with( + "http://forge.internal.example", + &dir, + Some(&fetched), + "protocol=https\nhost=forge.internal.example\n", + ); + assert!(ok, "stderr={stderr}"); + assert!( + stdout.contains("password=fetched-token-value"), + "stdout={stdout}" + ); + assert!(!stdout.contains(TOKEN), "stdout={stdout}"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// An agent whose token was not minted yet has the variable set and no file +/// behind it; it keeps working off the state-dir file. +#[test] +fn a_fetched_token_that_is_not_there_falls_back_to_the_state_file() { + let dir = scratch_state_dir("fallback"); + let missing = dir.join("never-fetched"); + let (ok, stdout, stderr) = run_get_with( + "http://forge.internal.example", + &dir, + Some(&missing), + "protocol=https\nhost=forge.internal.example\n", + ); + assert!(ok, "stderr={stderr}"); + assert!( + stdout.contains(&format!("password={TOKEN}")), + "stdout={stdout}" + ); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/hive-host-sock/src/lib.rs b/hive-host-sock/src/lib.rs index 2e9dcae0..d7263bfb 100644 --- a/hive-host-sock/src/lib.rs +++ b/hive-host-sock/src/lib.rs @@ -316,10 +316,10 @@ pub enum HostRequest { room: Option, }, /// Create or refresh a forge account + API token for `name`. Daemon-side - /// equivalent of `hivectl forge create-user`: for an existing agent it - /// provisions the account and persists the token to `/forge-token`; - /// for a non-agent (operator/human) it mints a user and returns the token - /// in [`HostResponse::messages`]. `password` is resolved client-side + /// equivalent of `hivectl forge create-user`: for a non-agent + /// (operator/human) it mints a user and returns the token in + /// [`HostResponse::messages`]. An existing agent is refused: its token is + /// swarm-controller's to mint. `password` is resolved client-side /// (inline flag or stdin) and only meaningful for non-agent accounts. ForgeCreateUser { name: Ident, diff --git a/hive-priv-sock/src/lib.rs b/hive-priv-sock/src/lib.rs index 60dfdcd2..8fa577c4 100644 --- a/hive-priv-sock/src/lib.rs +++ b/hive-priv-sock/src/lib.rs @@ -468,19 +468,6 @@ pub enum PrivRequest { }, // --- Agent credential writes --- - /// Write `forge-token` into `AGENT_STATE_ROOT//state/forge-token`. - /// - /// hive-priv validates `agent_name`, creates the state dir if absent, - /// writes the file 0600, and chowns it to the state dir's owner so - /// the agent process can read it. Required because hive-c0re runs - /// unprivileged and cannot write to agent-owned state directories. - WriteAgentForgeToken { - /// Logical agent name (validated by `validate_agent_name`). - agent_name: String, - /// Token value. hive-priv appends a trailing newline before writing. - token: String, - }, - /// Write a matrix access token into the agent's state dir. With /// `account: None` it targets the hive-internal `matrix-token`; with /// `account: Some(name)` it targets `matrix-token-` for an extra @@ -488,8 +475,10 @@ pub enum PrivRequest { /// `account` suffix as plain identifiers before building the path, so a /// crafted account name cannot traverse out of the state dir. /// - /// Same write semantics as `WriteAgentForgeToken` — validates names, - /// creates dir, writes 0600, chowns to agent owner. + /// hive-priv validates the names, creates the state dir if absent, + /// writes the file 0600, and chowns it to the state dir's owner so the + /// agent process can read it. Required because hive-c0re runs + /// unprivileged and cannot write to agent-owned state directories. WriteAgentMatrixToken { /// Logical agent name (validated by `validate_agent_name`). agent_name: String, @@ -512,7 +501,7 @@ pub enum PrivRequest { /// The operator-supplied GitHub personal access token (PAT) for the /// agent's GitHub integration (`services.hyperhive.agent.github.enable`). Same write /// semantics as - /// `WriteAgentForgeToken` — validates `agent_name`, creates the state dir + /// `WriteAgentMatrixToken` — validates `agent_name`, creates the state dir /// if absent, writes the file 0600, and chowns it to the agent so the /// `gh` wrapper / git credential helper can read it. No account suffix /// (single GitHub account per agent). @@ -534,7 +523,7 @@ pub enum PrivRequest { /// `label` MUST be validated as a plain identifier (same rule as the /// matrix `account` suffix) before it goes into the filename — a /// crafted label could otherwise traverse out of the state dir. Same - /// write semantics as `WriteAgentForgeToken` — validates `agent_name`, + /// write semantics as `WriteAgentMatrixToken` — validates `agent_name`, /// creates the state dir if absent, writes both files 0600, chowns to /// the agent. WriteAgentExtraForgeAccount { diff --git a/hive-priv/src/main.rs b/hive-priv/src/main.rs index 5a1be8d5..66358cb6 100644 --- a/hive-priv/src/main.rs +++ b/hive-priv/src/main.rs @@ -403,11 +403,6 @@ async fn exec( paused, } => exec_set_agent_paused(agent_name, paused), - PrivRequest::WriteAgentForgeToken { - ref agent_name, - ref token, - } => write_forge_token(agent_name, token), - PrivRequest::WriteAgentMatrixToken { ref agent_name, ref token, @@ -619,12 +614,6 @@ fn exec_set_agent_paused(agent_name: &str, paused: bool) -> Result<(String, Stri set_agent_paused(agent_name, paused) } -/// `WriteAgentForgeToken`. -fn write_forge_token(agent_name: &str, token: &str) -> Result<(String, String)> { - validate_agent_name(agent_name)?; - write_agent_state_file(agent_name, "forge-token", &format!("{token}\n")) -} - /// `WriteAgentGithubToken`. fn write_github_token(agent_name: &str, token: &str) -> Result<(String, String)> { validate_agent_name(agent_name)?; @@ -1441,7 +1430,7 @@ struct ForgeSidecar<'a> { base_url: &'a str, } -/// Shared helper for `WriteAgentForgeToken` and `WriteAgentMatrixToken`. +/// Shared helper for the `WriteAgent*Token` requests. /// Writes `content` to `AGENT_STATE_ROOT//state/`, /// chowns to the agent user (derived from the state dir's existing owner), /// and chmods 0600. Running as root (hive-priv), so this succeeds diff --git a/hivectl/src/cli.rs b/hivectl/src/cli.rs index a4de9784..35bc83d0 100644 --- a/hivectl/src/cli.rs +++ b/hivectl/src/cli.rs @@ -248,15 +248,15 @@ impl ScopeArgs { #[derive(Subcommand)] pub enum ForgeCmd { - /// Create or refresh the Forgejo account + token for ``. + /// Create or refresh a non-agent Forgejo account + token for ``. /// - /// For an existing agent, persists the token to its state dir; for a - /// human/other account, prints the token to stdout. Set a password to - /// enable forge web-UI login (otherwise it uses a random throwaway). + /// Prints the token to stdout. Set a password to enable forge web-UI + /// login (otherwise it uses a random throwaway). Refused for an + /// existing agent: swarm-controller mints an agent's token + /// (`swarmctl agent mint-forge-token `). CreateUser { - /// Forgejo username. For agents: the container/agent name - /// (`` in `h-`; manager uses the literal `manager`). - /// For humans: any forgejo username — `mara`, `damocles`, etc. + /// Forgejo username of a human/other account — `mara`, + /// `damocles`, etc. name: String, /// Set the account password to this string instead of a random /// throwaway. Use this for operator accounts that need to log diff --git a/hivectl/src/forge.rs b/hivectl/src/forge.rs index bebad895..12ca8964 100644 --- a/hivectl/src/forge.rs +++ b/hivectl/src/forge.rs @@ -1,5 +1,5 @@ //! `hivectl forge create-user ` — provision a Forgejo account via the -//! daemon (which owns the forge admin token + account-token persistence); +//! daemon (which owns the forge admin token); //! hivectl just resolves the password client-side and relays the request. use std::io::{self, Write}; diff --git a/nix/agent-modules/default.nix b/nix/agent-modules/default.nix index a244ad02..68a93b9f 100644 --- a/nix/agent-modules/default.nix +++ b/nix/agent-modules/default.nix @@ -46,6 +46,7 @@ in ./dashboard-links.nix ./docs.nix ./forge.nix + ./forge-token.nix ./frontend.nix ./github.nix ./logs.nix diff --git a/nix/agent-modules/forge-token.nix b/nix/agent-modules/forge-token.nix new file mode 100644 index 00000000..58ca6a94 --- /dev/null +++ b/nix/agent-modules/forge-token.nix @@ -0,0 +1,182 @@ +# This agent's own forge access token, fetched from the swarm secret store by +# the agent itself. +# +# `swarm-controller` mints the token with the forge's admin API and writes it +# to `swarm/agents//forge-token` +# (`swarm_secret_client::forge::agent_token_path`); no hive is in that chain. +# This unit logs in to the store with the certificate ./bao.nix already proves +# it can log in with, and reads its own path. The shape is ./queue-identity.nix's, +# for the same reason: a hive handing the token over would be the hive reading +# a secret on the agent's behalf. +# +# Unlike the queue credential this one rotates: the controller replaces the +# token when the forge's copy stops matching the stored one. So the unit is +# re-run by a timer, and it swaps the file in by rename, only when the value +# changed, so a reader never sees half a token and a watcher on the file +# (forge-avatar-sync.path) fires only on a real change. +# +# Consumers read `services.hyperhive.agent.forge.tokenFile` first and fall back +# to `/forge-token`, the file the hive wrote before this existed. +{ + pkgs, + lib, + config, + ... +}: +let + cfg = config.services.hyperhive.agent.bao; + + # The name `swarm-controller` minted the token under — see ./queue-identity.nix. + agentName = config.services.hyperhive.agent.user.name; + + # The same three ids ./bao.nix and ./queue-identity.nix load. + certCredential = "hive-agent-bao-cert"; + keyCredential = "hive-agent-bao-key"; + serverCaCredential = "hive-agent-bao-server-ca"; + + unitName = "hive-agent-forge-token"; + + # The nix half of `swarm_secret_client::forge::agent_token_path` plus + # `path::MOUNT`. + tokenPath = "secret/swarm/agents/${agentName}/forge-token"; + + runtimeDir = unitName; + tokenFile = "/run/${runtimeDir}/token"; + # Beside the token, so the rename that replaces it stays in one directory. + stagingFile = "/run/${runtimeDir}/token.new"; + + # The store's address is the whole switch, as in ./bao.nix and + # ./queue-identity.nix. + configured = cfg.addr != null; +in +{ + options.services.hyperhive.agent.forge.tokenFile = lib.mkOption { + type = lib.types.str; + readOnly = true; + default = tokenFile; + description = '' + Path this agent's own forge token is fetched to. Read-only: it is a + fact about where `${unitName}.service` writes, not a knob. + + 🩸 A PATH and never a value. The file is `0400` to the agent user. + + The file exists only once the swarm has minted a token for this agent + and the agent has a store identity to fetch it with. Until then every + consumer falls back to `$HYPERHIVE_STATE_DIR/forge-token`. + ''; + }; + + config = lib.mkIf configured { + # Every unit and the bash-task runner (where `hive-forge` and `git` run) + # resolve the token through this, the same way they find + # `$HYPERHIVE_STATE_DIR`. A path, never the value. + systemd.globalEnvironment.HIVE_FORGE_TOKEN_FILE = tokenFile; + environment.variables.HIVE_FORGE_TOKEN_FILE = tokenFile; + + systemd.services.${unitName} = { + description = "fetch this agent's own forge token from the secret store"; + after = [ + "network.target" + # Ordering only, for the reason ./queue-identity.nix gives. + "hive-agent-bao-identity.service" + ]; + before = [ "hive-forge-notify.service" ]; + wantedBy = [ "multi-user.target" ]; + path = [ + pkgs.openbao + pkgs.coreutils + pkgs.diffutils + ]; + startLimitBurst = 4; + startLimitIntervalSec = 300; + serviceConfig = { + Type = "oneshot"; + # Not `RemainAfterExit`, unlike the queue fetch: the timer below has to + # be able to start this unit again, and an active unit cannot be + # started. `RuntimeDirectoryPreserve` is what keeps the directory, and + # the token in it, alive between runs instead. + RemainAfterExit = false; + TimeoutStartSec = 30; + Restart = "on-failure"; + RestartSec = 15; + User = agentName; + Group = agentName; + RuntimeDirectory = runtimeDir; + RuntimeDirectoryMode = "0700"; + RuntimeDirectoryPreserve = "yes"; + UMask = "0377"; + LoadCredential = [ + certCredential + keyCredential + serverCaCredential + ]; + }; + environment = { + BAO_ADDR = cfg.addr; + BAO_CLIENT_CERT = "%d/${certCredential}"; + BAO_CLIENT_KEY = "%d/${keyCredential}"; + }; + script = '' + set -euo pipefail + + # No identity delivered: ./bao.nix's check reports that; saying it + # twice adds nothing. + for id in ${lib.escapeShellArg certCredential} ${lib.escapeShellArg keyCredential}; do + if [ ! -s "$CREDENTIALS_DIRECTORY/$id" ]; then + echo "this agent has no store identity, so it cannot fetch its own forge token." >&2 + exit 0 + fi + done + + if [ -s "$CREDENTIALS_DIRECTORY/${serverCaCredential}" ]; then + export BAO_CACERT="$CREDENTIALS_DIRECTORY/${serverCaCredential}" + fi + + err="$(mktemp)" + trap 'rm -f "$err" ${lib.escapeShellArg stagingFile}' EXIT + + if ! BAO_TOKEN="$(bao login -method=cert -token-only 2>"$err")"; then + echo "this agent's certificate was refused by the swarm secret store at $BAO_ADDR." >&2 + if [ -s "$err" ]; then cat "$err" >&2; fi + exit 1 + fi + export BAO_TOKEN + + # 🩸 Degrades rather than fails, for the reason ./queue-identity.nix + # gives: the policy stanza that let the login read `bao-mtls` covers + # this path too, so a refusal here is a token not minted yet. The + # file already in place, if any, is kept: a store that is briefly + # unreachable must not take a working token away. + # + # ⚠️ Written by redirect, never echoed: the field is the secret. The + # staging file is removed first so the redirect creates it — a file + # `UMask=0377` left behind is `0400` and could not be reopened for + # writing. + rm -f ${lib.escapeShellArg stagingFile} + if ! bao kv get -field=value ${lib.escapeShellArg tokenPath} > ${lib.escapeShellArg stagingFile} 2>"$err"; then + echo "no forge token at ${tokenPath} yet; consumers keep using the state-dir token if there is one." >&2 + if [ -s "$err" ]; then cat "$err" >&2; fi + exit 0 + fi + + if cmp -s ${lib.escapeShellArg stagingFile} ${lib.escapeShellArg tokenFile}; then + echo "this agent's forge token at ${tokenPath} is unchanged." + exit 0 + fi + mv -f ${lib.escapeShellArg stagingFile} ${lib.escapeShellArg tokenFile} + echo "fetched this agent's forge token from ${tokenPath}." + ''; + }; + + # The controller re-checks every agent's token every five minutes; this + # picks up a rotation within about ten more. + systemd.timers.${unitName} = { + description = "re-fetch this agent's forge token from the secret store"; + wantedBy = [ "timers.target" ]; + timerConfig = { + OnUnitInactiveSec = "10min"; + RandomizedDelaySec = "1min"; + }; + }; + }; +} diff --git a/nix/agent-modules/forge.nix b/nix/agent-modules/forge.nix index 694b479e..19dc9649 100644 --- a/nix/agent-modules/forge.nix +++ b/nix/agent-modules/forge.nix @@ -1,6 +1,6 @@ -# In-container forge (Forgejo) integration: the `tea` CLI login -# oneshot, the `hive-forge` verb CLI on PATH, and the icon → forge -# avatar sync. +# In-container forge (Forgejo) integration: the `hive-forge` verb CLI on +# PATH, the git credential helper, the notification poller, and the icon → +# forge avatar sync. The token itself is fetched by ./forge-token.nix. { pkgs, lib, @@ -9,7 +9,18 @@ }: let userName = config.services.hyperhive.agent.user.name; - homeDir = "/home/${userName}"; + # The token the agent fetched from the swarm secret store + # (./forge-token.nix), then the state-dir file the hive used to write, + # which is still the only copy for an agent without a store identity. + # Both are PATHS; each reader below takes the first that holds a token. + fetchedTokenFile = config.services.hyperhive.agent.forge.tokenFile; + stateTokenFile = "/agents/${userName}/state/forge-token"; + pickTokenFile = '' + TOKEN_FILE= + for f in ${lib.escapeShellArg fetchedTokenFile} ${lib.escapeShellArg stateTokenFile}; do + if [ -s "$f" ] && [ -r "$f" ]; then TOKEN_FILE="$f"; break; fi + done + ''; # Same 512×512 rasterization of the agent icon the matrix avatar # sync uses (./matrix.nix — identical derivation, same store path). # Only forced when an icon is configured AND a forge is (the avatar-sync @@ -20,7 +31,7 @@ let # git credential helper for the hive forge --- the exact shape # `./github.nix` uses for github.com, for the same two reasons: the token - # is read from the agent's state file AT INVOCATION (so a re-issued token + # is read from its file AT INVOCATION (so a re-issued token # takes effect with no rebuild), and the token PATH is baked in at build # time rather than read from the environment, because claude's Bash tool # runs `bash -c` in a minimal env that never sources `/etc/set-environment`. @@ -32,8 +43,8 @@ let gitCredHelper = pkgs.writeShellScriptBin "git-credential-hive-forge" '' # git credential-helper protocol: only `get` needs an answer. [ "''${1:-}" = "get" ] || exit 0 - TOKEN_FILE="/agents/${userName}/state/forge-token" - [ -r "$TOKEN_FILE" ] || exit 0 + ${pickTokenFile} + [ -n "$TOKEN_FILE" ] || exit 0 printf 'username=%s\n' ${lib.escapeShellArg userName} printf 'password=%s\n' "$(cat "$TOKEN_FILE")" ''; @@ -44,20 +55,16 @@ in default = null; example = "http://forge.internal:3000"; description = '' - Base URL of the hyperhive-managed Forgejo. Used at container - boot by a oneshot systemd unit that calls - `tea login add --url --token "$(cat $HYPERHIVE_STATE_DIR/forge-token)"` - (= `/agents//state/forge-token`) so the agent's claude can - shell out to `tea` without an extra auth dance. No-op when the - forge-token file is missing (i.e. hive-forge isn't running on - the host). + Base URL of the hyperhive-managed Forgejo. Scopes the git + credential helper to this forge, and is where the avatar sync + uploads the agent's icon. **`null` means "no forge", not "guess one".** There is deliberately no loopback default: the forge may run on a different host from the agents, and inside an agent's network namespace `localhost` reaches the agent rather than the forge, so a default would be a value that builds fine and then talks to the wrong machine. - When this is `null` the tea-login and avatar-sync units are not + When this is `null` the credential helper and avatar-sync units are not generated at all --- an absent integration, never a misdirected one. @@ -88,10 +95,6 @@ in ]; environment.systemPackages = [ - # tea: gitea/forgejo CLI client. Configured at boot by the - # tea-login oneshot below if /state/forge-token is present, so - # claude can `tea repos create`, `tea pulls create`, etc. - pkgs.tea # hive-forge : CLI wrapping common Forgejo REST API operations # (view, pr, issue, comment, assign, close, labels, branches, etc.). # The per-bin split package — narrow closure, no hivectl/wireguard. @@ -178,94 +181,22 @@ in }; }; - # One-shot: tea config.yml from the seeded forge token. Shape - # contract (always exit 0, no set -e, skip-silently, re-runnable): - # docs/process/conventions.md::Best-effort oneshot services. - # Not generated at all when no forge is configured: an absent - # integration rather than one pointed at a guessed address. - systemd.services.tea-login = lib.mkIf (config.services.hyperhive.agent.forge.url != null) { - description = "configure tea CLI from hive-forge token (best-effort)"; - wantedBy = [ "multi-user.target" ]; - after = [ "local-fs.target" ]; - serviceConfig = { - Type = "oneshot"; - RemainAfterExit = true; - # Pin the journal identity (else it's the `script` store-path wrapper). - SyslogIdentifier = "tea-login"; - }; - path = [ - pkgs.curl - pkgs.jq - pkgs.coreutils - ]; - environment.HOME_DIR = homeDir; - environment.AGENT_USER = userName; - script = '' - # No `set -e`: best-effort posture (see docs pointer above). - FORGE_URL=${lib.escapeShellArg config.services.hyperhive.agent.forge.url} - # $HYPERHIVE_STATE_DIR is system-wide via the meta flake. - TOKEN_FILE="$HYPERHIVE_STATE_DIR/forge-token" - if [ ! -f "$TOKEN_FILE" ]; then - echo "tea-login: no forge-token at $TOKEN_FILE; skipping" - exit 0 - fi - TOKEN=$(cat "$TOKEN_FILE") - # Resolve the agent username from the forge API. - USER=$(curl -sf --max-time 5 \ - -H "Authorization: token $TOKEN" \ - "$FORGE_URL/api/v1/user" \ - | jq -r '.login // empty' 2>/dev/null || true) - if [ -z "$USER" ]; then - echo "tea-login: could not resolve username from forge API; skipping" - exit 0 - fi - # Config under the agent user's home, chown'd to them; - # service stays root-owned (see docs pointer above). - CONFIG="$HOME_DIR/.config/tea/config.yml" - mkdir -p "$(dirname "$CONFIG")" || true - cat > "$CONFIG" << EOF - logins: - - name: forge - url: $FORGE_URL - token: $TOKEN - default: true - ssh_host: "" - ssh_key: "" - insecure: false - ssh_agent: false - user: $USER - preferences: - editor: false - flag_defaults: - remote: "" - EOF - chown -R "$AGENT_USER:$AGENT_USER" "$HOME_DIR/.config" 2>/dev/null || true - echo "tea-login: configured for $FORGE_URL as $USER (config at $CONFIG)" - ''; - }; - - # Path-trigger sibling: re-fires forge-avatar-sync when - # `/forge-token` is written. On first agent deployment the - # container boots before hive-c0re has provisioned the forge-token, so - # the service fires too early and exits with "no forge-token found". - # Without this path unit, RemainAfterExit=true would prevent systemd - # from ever re-running the service. See - # docs/agent-lifecycle/persistence.md::forge-avatar-sync. + # Path-trigger sibling: re-fires forge-avatar-sync when the fetched + # forge token (./forge-token.nix) appears or is replaced. On first + # agent deployment the container can boot before the swarm has minted + # the token, so the service fires too early and exits with "no + # forge-token found". Without this path unit, nothing would ever + # re-run it. See docs/agent-lifecycle/persistence.md::forge-avatar-sync. # # PathChanged=, not PathExists=: a PathExists= condition that already # holds re-activates the unit immediately every time the path unit # re-arms, and a oneshot re-arms it by deactivating — so it loops until # systemd's start limit stops it. PathChanged= does not fire on an - # already-present path, and hive-priv writes this file in place - # (write_state_file_nofollow: O_TRUNC, no rename), so close-after-write - # still triggers it. Same directive and same reason as - # swarm-controller.nix's queue-credential watcher. + # already-present path, and does fire on the rename the fetch unit + # swaps a new token in with. The fetch renames only when the value + # changed, so its timer does not re-upload the avatar every tick. # - # ⚠️ This agent's own token, not a glob over `/agents/*/`. Every agent's - # state dir is visible from inside every container, so a wildcard here - # watches paths this unit has no business reacting to. - # The service reads `$HYPERHIVE_STATE_DIR/forge-token`; this is the same - # file, spelled the way `tea-login` above already spells it. + # ⚠️ This agent's own token, not a glob. # # ⚠️ Gated on the SAME condition as the service it triggers, not just on # the icon: a `.path` unit whose `Unit=` does not exist is a unit pulled @@ -278,7 +209,7 @@ in { description = "trigger forge-avatar-sync when forge-token appears"; wantedBy = [ "multi-user.target" ]; - pathConfig.PathChanged = "/agents/${userName}/state/forge-token"; + pathConfig.PathChanged = fetchedTokenFile; }; # One-shot: services.hyperhive.agent.icon → Forgejo profile avatar. Shape contract: @@ -295,7 +226,7 @@ in { description = "sync agent icon to Forgejo user avatar (best-effort)"; wantedBy = [ "multi-user.target" ]; - after = [ "tea-login.service" ]; + after = [ "hive-agent-forge-token.service" ]; serviceConfig = { Type = "oneshot"; RemainAfterExit = false; @@ -309,10 +240,8 @@ in ]; script = '' FORGE_URL=${lib.escapeShellArg config.services.hyperhive.agent.forge.url} - # $HYPERHIVE_STATE_DIR is set system-wide by the meta flake - # (systemd.globalEnvironment) to `/agents//state`. - TOKEN_FILE="$HYPERHIVE_STATE_DIR/forge-token" - if [ ! -f "$TOKEN_FILE" ]; then + ${pickTokenFile} + if [ -z "$TOKEN_FILE" ]; then echo "forge-avatar-sync: no forge-token found; skipping" exit 0 fi diff --git a/nix/agent-modules/network.nix b/nix/agent-modules/network.nix index 64adf66b..bf9f9d6a 100644 --- a/nix/agent-modules/network.nix +++ b/nix/agent-modules/network.nix @@ -45,7 +45,7 @@ # `/etc/hyperhive-bridge-dns` (containing the gateway IP) since # isolation is always on; the oneshot reads it and rewrites # resolv.conf on every boot. Ordered before the first DNS consumer - # (tea-login) and the network targets so name resolution works for + # (the forge-token fetch) and the network targets so name resolution works for # the very first turn. systemd.services.hyperhive-isolated-dns = { description = "point resolv.conf at the hive bridge resolver (isolated containers)"; @@ -61,7 +61,7 @@ # is a harmless no-op when matrix is disabled (the unit is absent). before = [ "network-online.target" - "tea-login.service" + "hive-agent-forge-token.service" "hive-agent.service" "hive-matrix-daemon.service" ]; diff --git a/nix/checks.nix b/nix/checks.nix index c5238637..bbb0110b 100644 --- a/nix/checks.nix +++ b/nix/checks.nix @@ -108,6 +108,10 @@ in inherit pkgs self nixosSystem; inherit (pkgs) lib; }; + module-eval-agent-forge-bao = import ./module-eval/agent-forge-bao.nix { + inherit pkgs self nixosSystem; + inherit (pkgs) lib; + }; module-eval-agent-memory = import ./module-eval/agent-memory.nix { inherit pkgs self nixosSystem; inherit (pkgs) lib; diff --git a/nix/module-eval/agent-forge-bao.nix b/nix/module-eval/agent-forge-bao.nix new file mode 100644 index 00000000..ce7ef5c9 --- /dev/null +++ b/nix/module-eval/agent-forge-bao.nix @@ -0,0 +1,169 @@ +# `checks.module-eval-agent-forge-bao` — see ./lib.nix for the shared +# rationale (why this suite exists, naming convention, "evaluates +# not executes"). +# +# The agent side of the swarm-minted forge token: ../agent-modules/forge-token.nix +# fetches it, and ../agent-modules/forge.nix's readers find it. +{ + pkgs, + lib, + self, + nixosSystem, +}: +let + inherit + (import ./lib.nix { + inherit + pkgs + lib + self + nixosSystem + ; + }) + agentWith + runGroup + ; + + forgeUrl = "http://forge.t.local"; + baoAddr = "https://bao.t.local:8200"; + + # A forge and a store: the fetch exists and every reader points at it. + agentForgeBao = agentWith { + services.hyperhive.agent.bao.addr = baoAddr; + services.hyperhive.agent.forge.url = forgeUrl; + services.hyperhive.agent.icon = pkgs.emptyFile; + }; + + # A forge and no store: the absence arm, and what makes the cases above able + # to fail. + agentForgeNoBao = agentWith { + services.hyperhive.agent.forge.url = forgeUrl; + services.hyperhive.agent.icon = pkgs.emptyFile; + }; + + fetchUnit = machine: machine.systemd.services.hive-agent-forge-token; + tokenFile = machine: machine.services.hyperhive.agent.forge.tokenFile; +in +let + cases = [ + { + # The whole switch is the store address, as for the queue credential. + name = "an agent with a store address fetches its forge token"; + ok = + agentForgeBao.systemd.services ? hive-agent-forge-token + && agentForgeBao.systemd.timers ? hive-agent-forge-token; + } + { + name = "an agent told no store address fetches no forge token"; + ok = + !(agentForgeNoBao.systemd.services ? hive-agent-forge-token) + && !(agentForgeNoBao.systemd.timers ? hive-agent-forge-token) + && !(agentForgeNoBao.systemd.globalEnvironment ? HIVE_FORGE_TOKEN_FILE); + } + { + # The nix half of `swarm_secret_client::forge::agent_token_path` plus the + # mount. Spelled out: the Rust test pins `swarm/agents/atlas/forge-token`, + # and a drift between the two is a fetch that 404s forever and says + # "not minted yet". + name = "the fetch reads the agent's own forge-token path"; + ok = + let + name = agentForgeBao.services.hyperhive.agent.user.name; + in + lib.hasInfix "secret/swarm/agents/${name}/forge-token" (fetchUnit agentForgeBao).script; + } + { + # One store identity per agent: the fetch presents the same certificate + # the identity check proves. Every `BAO_*` value is an address or a + # `%d/` path, never a value. + name = "the fetch presents the agent's own store identity, by path"; + ok = + let + u = fetchUnit agentForgeBao; + e = u.environment; + in + builtins.elem "hive-agent-bao-cert" u.serviceConfig.LoadCredential + && builtins.elem "hive-agent-bao-key" u.serviceConfig.LoadCredential + && e.BAO_ADDR == baoAddr + && e.BAO_CLIENT_CERT == "%d/hive-agent-bao-cert" + && e.BAO_CLIENT_KEY == "%d/hive-agent-bao-key"; + } + { + # The field is the secret. It goes to a file by redirect and is never + # echoed. + name = "the fetch writes the token by redirect and never echoes it"; + ok = + let + s = (fetchUnit agentForgeBao).script; + in + lib.hasInfix "-field=value" s + && lib.hasInfix "> ${lib.escapeShellArg "/run/hive-agent-forge-token/token.new"}" s + && !(lib.hasInfix "echo \"$(bao" s) + && !(lib.hasInfix "echo $(bao" s); + } + { + # A timer can only start an inactive unit, so the fetch must not stay + # active; the directory, and the token in it, has to outlive each run. + name = "the fetch can be re-run by its timer without losing the token"; + ok = + let + c = (fetchUnit agentForgeBao).serviceConfig; + in + !c.RemainAfterExit + && c.RuntimeDirectory == "hive-agent-forge-token" + && c.RuntimeDirectoryPreserve == "yes" + && c.UMask == "0377"; + } + { + # Rename in only on a change: a reader never sees half a token, and the + # avatar watcher does not fire on every timer tick. + name = "the fetch swaps the token in by rename, only when it changed"; + ok = + let + s = (fetchUnit agentForgeBao).script; + in + lib.hasInfix "cmp -s" s && lib.hasInfix "mv -f" s; + } + { + # Every unit and the bash-task runner find the token through this. A + # path, never the value. + name = "the fetched token's path is published to every unit"; + ok = + agentForgeBao.systemd.globalEnvironment.HIVE_FORGE_TOKEN_FILE == tokenFile agentForgeBao + && tokenFile agentForgeBao == "/run/hive-agent-forge-token/token"; + } + { + # The avatar sync has to re-fire when the swarm's token lands, which is + # the file the fetch writes, not the state-dir file nothing writes any + # more. + name = "the avatar watcher follows the fetched token"; + ok = + agentForgeBao.systemd.paths.forge-avatar-sync.pathConfig.PathChanged == tokenFile agentForgeBao + && builtins.elem "hive-agent-forge-token.service" agentForgeBao.systemd.services.forge-avatar-sync.after; + } + { + # Both readers take the fetched token first and fall back to the state + # file, which is still the only copy for an agent with no store identity. + name = "the avatar sync reads the fetched token before the state file"; + ok = + let + s = agentForgeBao.systemd.services.forge-avatar-sync.script; + name = agentForgeBao.services.hyperhive.agent.user.name; + fetched = lib.escapeShellArg (tokenFile agentForgeBao); + state = lib.escapeShellArg "/agents/${name}/state/forge-token"; + in + lib.hasInfix "for f in ${fetched} ${state}; do" s; + } + { + # tea-login copied the token into ~/.config/tea/config.yml, which + # docs/swarm/credentials.md forbids for a store secret. Gone, with the + # package it configured. + name = "no tea-login unit and no tea package"; + ok = + !(agentForgeBao.systemd.services ? tea-login) + && !(agentForgeNoBao.systemd.services ? tea-login) + && !(builtins.elem pkgs.tea agentForgeBao.environment.systemPackages); + } + ]; +in +runGroup "agent-forge-bao" cases diff --git a/swarm-controller/src/forge.rs b/swarm-controller/src/forge.rs index bb4af221..2ebf02c9 100644 --- a/swarm-controller/src/forge.rs +++ b/swarm-controller/src/forge.rs @@ -33,6 +33,8 @@ use utoipa::ToSchema; use crate::webhook::DeliveryKind; +pub mod agent_token; + /// An agent's open config-PR, as [`Client::list_open_config_prs`] reports it /// and `GET /api/agents/{name}/config-pr` serves it. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize, ToSchema)] @@ -135,6 +137,10 @@ const TOKEN_FILE_ENV: &str = "SWARM_CONTROLLER_FORGE_TOKEN_FILE"; /// Typed forgejo client, built once at startup from env. pub struct Client { api: Forgejo, + /// The forge's base URL, kept so a client authenticated as someone + /// else can be built against the same forge (see + /// [`agent_token`]'s read-back). + url: url::Url, } impl Client { @@ -168,8 +174,12 @@ impl Client { } let parsed_url = url::Url::parse(&url).with_context(|| format!("parse {URL_ENV} ({url}) as a URL"))?; - let api = Forgejo::new(Auth::Token(token), parsed_url).context("build forgejo client")?; - Ok(Some(Self { api })) + let api = + Forgejo::new(Auth::Token(token), parsed_url.clone()).context("build forgejo client")?; + Ok(Some(Self { + api, + url: parsed_url, + })) } /// Creation options for an empty repo defaulting to `main`. diff --git a/swarm-controller/src/forge/agent_token.rs b/swarm-controller/src/forge/agent_token.rs new file mode 100644 index 00000000..19a322d8 --- /dev/null +++ b/swarm-controller/src/forge/agent_token.rs @@ -0,0 +1,563 @@ +//! Each agent's own forge access token: minted here with the admin API, stored +//! at `swarm/agents//forge-token`, and pulled from there by the agent +//! container itself (`nix/agent-modules/forge-token.nix`). +//! +//! One token per agent, under the fixed name [`AGENT_TOKEN_NAME`]. Forgejo +//! refuses a second token with a name the user already has, so a rotation is a +//! delete then a create, and there is never more than one of these per agent. +//! The `hyperhive-` tokens `hive-c0re` used to mint are never +//! touched here: the name cannot match them. +//! +//! Two callers insert the same `MintAgentForgeToken` job node: agent creation, +//! and [`spawn`]'s pass at start and every [`RECONCILE_INTERVAL`] over every +//! agent that holds a store identity. The decision is pure ([`classify`] and +//! [`plan`]), so the tests pin it; the IO on either side only reads or acts. + +use std::collections::BTreeSet; +use std::sync::Arc; + +use anyhow::{Context, Result, bail}; +use forgejo_api::structs::{AccessToken, CreateAccessTokenOption}; +use forgejo_api::{ApiErrorKind, Auth, Forgejo, ForgejoError}; +use reqwest::StatusCode; +use swarm_secret_client::{client::DEFAULT_CERT_MOUNT, forge, policy}; + +use super::Client; + +/// The forge's name for every agent token this module mints. +/// +/// Deliberately not `hyperhive-…`: that prefix is what `hive-c0re` named each +/// of its re-mints, and a later sweep of those must never match this one. +pub const AGENT_TOKEN_NAME: &str = "swarm-agent"; + +/// The scopes an agent token carries. Byte-identical to `hive-c0re`'s +/// `forge::users::TOKEN_SCOPES`, so the move from hive to swarm changes +/// nothing an agent can do. Narrowing it is a separate decision. +/// +/// ⚠️ Duplicated across the crate boundary until the last `hive-c0re` caller +/// of that constant goes. A test here pins the literal. +pub const AGENT_TOKEN_SCOPES: &str = "read:user,write:user,read:notification,write:notification,write:repository,write:issue,write:organization,write:misc"; + +/// How often [`spawn`] re-checks every agent's token. +const RECONCILE_INTERVAL: std::time::Duration = std::time::Duration::from_mins(5); + +/// Why a token has to be (re)minted. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MintReason { + /// The store holds nothing for this agent. + NotStored, + /// The forge lists no [`AGENT_TOKEN_NAME`] token for this agent. + NotOnForge, + /// The forge's token is not the one the store holds. + LastEightMismatch, + /// The forge's token carries other scopes than [`AGENT_TOKEN_SCOPES`]. + ScopeMismatch, +} + +/// What to do about one agent's token. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Decision { + /// The stored token is the forge's token, with the right scopes. + Keep, + /// Delete any [`AGENT_TOKEN_NAME`] token, create a new one, store it. + Mint(MintReason), + /// The forge lists more than one [`AGENT_TOKEN_NAME`] token. Forgejo + /// refuses a duplicate name, so this is a forge in a state nothing here + /// can reason about; a delete by name would 422 on it anyway. + Conflict, +} + +/// A scope list reduced to what Forgejo would store for it. +/// +/// Forgejo normalises a new token's scopes before storing them +/// (`models/auth/access_token_scope.go`, `Normalize` → `toScope`): a `read:X` +/// beside `write:X` is dropped, because the write bit implies the read one. +/// The admin list then returns the stored form. Comparing +/// [`AGENT_TOKEN_SCOPES`] to that list as written would never match, and every +/// pass would rotate every agent's token. +fn normalized_scopes<'a>(scopes: impl IntoIterator) -> BTreeSet { + let all: BTreeSet<&str> = scopes.into_iter().map(str::trim).collect(); + all.iter() + .filter(|s| { + s.strip_prefix("read:") + .is_none_or(|area| !all.contains(format!("write:{area}").as_str())) + }) + .map(|s| (*s).to_owned()) + .collect() +} + +/// The last eight characters of `token`, as Forgejo reports them for a token +/// it holds (`token_last_eight`). `None` for a token shorter than that, which +/// no Forgejo token is. +fn last_eight(token: &str) -> Option<&str> { + token.len().checked_sub(8).and_then(|at| token.get(at..)) +} + +/// Decide what to do about an agent's token, from what the store holds and +/// what the forge lists for the agent. +/// +/// Every token not named [`AGENT_TOKEN_NAME`] is ignored, so the +/// `hyperhive-*` tokens `hive-c0re` left behind never affect the decision. +pub fn classify(stored: Option<&forge::Credential>, listed: &[AccessToken]) -> Decision { + let ours: Vec<&AccessToken> = listed + .iter() + .filter(|t| t.name.as_deref() == Some(AGENT_TOKEN_NAME)) + .collect(); + let token = match ours[..] { + [] => return Decision::Mint(MintReason::NotOnForge), + [token] => token, + _ => return Decision::Conflict, + }; + let Some(stored) = stored else { + return Decision::Mint(MintReason::NotStored); + }; + if token.token_last_eight.as_deref() != last_eight(&stored.value) { + return Decision::Mint(MintReason::LastEightMismatch); + } + let listed_scopes = normalized_scopes(token.scopes.iter().flatten().map(String::as_str)); + if listed_scopes != normalized_scopes(AGENT_TOKEN_SCOPES.split(',')) { + return Decision::Mint(MintReason::ScopeMismatch); + } + Decision::Keep +} + +/// What one pass found for one agent. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Observed { + /// Both reads worked, and this is what [`classify`] made of them. + Decided(Decision), + /// The forge has no user by this agent's name. Planned like a mint: the + /// queued job creates the user first (`CreateForgeUser`, idempotent), so + /// an agent that holds a store identity but was never given a forge + /// account still ends up with both. + NoForgeUser, + /// A read failed. Nothing is known, so nothing is done. + Unknown, +} + +/// The agents a pass mints for: a [`Decision::Mint`], or an agent with no +/// forge user yet ([`Observed::NoForgeUser`]), whose job creates the user +/// before it mints. +/// +/// [`Observed::Unknown`] in particular never mints: a forge or store outage +/// must not turn into a rotation of every agent's token. +pub fn plan(observed: &[(String, Observed)]) -> Vec { + observed + .iter() + .filter(|(_, o)| { + matches!( + o, + Observed::Decided(Decision::Mint(_)) | Observed::NoForgeUser + ) + }) + .map(|(agent, _)| agent.clone()) + .collect() +} + +/// Whether a forge error is a 404, whichever of its two shapes it came in. +fn is_not_found(e: &ForgejoError) -> bool { + match e { + ForgejoError::ApiError(api) => { + matches!(api.error_kind(), ApiErrorKind::NotFound { .. }) + || matches!(api.error_kind(), ApiErrorKind::Other(s) if *s == StatusCode::NOT_FOUND) + } + ForgejoError::UnexpectedStatusCode(s) => *s == StatusCode::NOT_FOUND, + _ => false, + } +} + +impl Client { + /// Every access token the forge lists for `agent`, or `None` when the + /// forge has no such user. + async fn list_agent_tokens(&self, agent: &str) -> Result>> { + match self.api.admin_list_user_access_tokens(agent).all().await { + Ok(tokens) => Ok(Some(tokens)), + Err(e) if is_not_found(&e) => Ok(None), + Err(e) => Err(e).with_context(|| format!("listing {agent}'s forge tokens")), + } + } + + /// Make sure `agent`'s stored token is a live [`AGENT_TOKEN_NAME`] token + /// with [`AGENT_TOKEN_SCOPES`], and mint one if it is not. The whole job + /// of the `MintAgentForgeToken` node. + /// + /// A rotation deletes the old token first, then creates, stores, and uses + /// the new one: it logs in with it and asks the forge who it is, so the + /// node does not report success on a write nobody has read. A crash + /// between any two steps leaves a state the next run classifies as + /// [`Decision::Mint`] again. + /// + /// # Errors + /// When the store or the forge refuses a step, when the agent has no forge + /// user, or when the new token authenticates as someone else. + pub async fn ensure_agent_forge_token(&self, agent: &str) -> Result<()> { + let path = forge::agent_token_path(agent)?; + let store = crate::store::connect() + .await + .context("logging in to the swarm secret store")?; + let stored: Option = store + .read_optional(&path) + .await + .with_context(|| format!("reading {path}"))?; + let Some(listed) = self.list_agent_tokens(agent).await? else { + bail!( + "the forge has no user {agent:?} even after this job's create_forge_user \ + node, so there is nothing to mint a token for" + ); + }; + let reason = match classify(stored.as_ref(), &listed) { + Decision::Keep => { + tracing::debug!(agent, "agent forge token is current; left as it is"); + return Ok(()); + } + Decision::Conflict => bail!( + "the forge lists more than one {AGENT_TOKEN_NAME:?} token for {agent:?}; \ + delete them by id and re-run" + ), + Decision::Mint(reason) => reason, + }; + + if listed + .iter() + .any(|t| t.name.as_deref() == Some(AGENT_TOKEN_NAME)) + { + match self + .api + .admin_delete_user_access_token(agent, AGENT_TOKEN_NAME) + .await + { + Ok(()) => {} + Err(e) if is_not_found(&e) => {} + Err(e) => { + return Err(e) + .with_context(|| format!("deleting {agent}'s {AGENT_TOKEN_NAME} token")); + } + } + } + let created = self + .api + .admin_create_user_access_token( + agent, + CreateAccessTokenOption { + name: AGENT_TOKEN_NAME.to_owned(), + repositories: None, + scopes: Some(AGENT_TOKEN_SCOPES.split(',').map(str::to_owned).collect()), + }, + ) + .await + .with_context(|| format!("creating {agent}'s {AGENT_TOKEN_NAME} token"))?; + let Some(value) = created.sha1.filter(|v| !v.is_empty()) else { + bail!("the forge created {agent}'s token but returned no value for it"); + }; + let credential = forge::Credential { + value, + name: AGENT_TOKEN_NAME.to_owned(), + }; + store + .write(&path, &credential) + .await + .with_context(|| format!("storing {agent}'s forge token at {path}"))?; + + let as_agent = Forgejo::new(Auth::Token(&credential.value), self.url.clone()) + .context("building a forge client with the new token")?; + let me = as_agent + .user_get_current() + .await + .with_context(|| format!("using {agent}'s new forge token"))?; + if me.login.as_deref() != Some(agent) { + bail!("{agent}'s new forge token authenticates as someone else"); + } + tracing::info!(agent, ?reason, %path, "agent forge token minted and stored"); + Ok(()) + } + + /// What the forge and the store say about `agent`'s token. + async fn observe_agent( + &self, + store: &swarm_secret_client::SecretStore, + agent: &str, + ) -> Observed { + let stored = match forge::agent_token_path(agent) { + Ok(path) => store.read_optional::(&path).await, + Err(e) => Err(e), + }; + let stored = match stored { + Ok(stored) => stored, + Err(e) => { + tracing::warn!(agent, error = %e, "agent forge token: store read failed"); + return Observed::Unknown; + } + }; + match self.list_agent_tokens(agent).await { + Ok(Some(listed)) => Observed::Decided(classify(stored.as_ref(), &listed)), + Ok(None) => Observed::NoForgeUser, + Err(e) => { + tracing::warn!(agent, error = %format!("{e:#}"), "agent forge token: forge read failed"); + Observed::Unknown + } + } + } + + /// One pass: every agent holding a store identity, observed. + async fn observe_all(&self) -> Result> { + let store = crate::store::connect() + .await + .context("logging in to the swarm secret store")?; + let roles = store + .list_cert_roles(DEFAULT_CERT_MOUNT) + .await + .context("listing the store's cert-auth roles")?; + let mut observed = Vec::new(); + for agent in policy::agents_from_role_names(&roles) { + let o = self.observe_agent(&store, &agent).await; + if o == Observed::NoForgeUser { + tracing::info!( + agent, + "agent forge token: no forge user; creating one first" + ); + } + observed.push((agent, o)); + } + Ok(observed) + } +} + +/// Check every agent's token now and every [`RECONCILE_INTERVAL`] after, and +/// hand the agents that need one to `enqueue`, which inserts a +/// `MintAgentForgeToken` node for each. +/// +/// The roster is the store's `hive-agent-*` cert-auth roles: exactly the +/// agents that can read what the node writes. The first tick fires +/// immediately (`tokio::time::interval`'s default). A pass that fails is +/// logged and retried on the next tick; it never stops the daemon. +pub fn spawn(client: Arc, enqueue: impl Fn(Vec) + Send + 'static) { + tokio::spawn(async move { + let mut ticker = tokio::time::interval(RECONCILE_INTERVAL); + loop { + ticker.tick().await; + match client.observe_all().await { + Ok(observed) => { + let agents = plan(&observed); + if agents.is_empty() { + tracing::debug!( + checked = observed.len(), + "agent forge tokens: all current" + ); + } else { + tracing::info!( + checked = observed.len(), + minting = agents.len(), + "agent forge tokens: queueing mints" + ); + enqueue(agents); + } + } + Err(e) => tracing::warn!( + error = %format!("{e:#}"), + retry_in_s = RECONCILE_INTERVAL.as_secs(), + "agent forge tokens: pass failed; retrying next tick" + ), + } + } + }); +} + +#[cfg(test)] +mod tests { + use super::*; + + const VALUE: &str = "0123456789abcdef0123456789abcdef01234567"; + + fn stored() -> forge::Credential { + forge::Credential { + value: VALUE.to_owned(), + name: AGENT_TOKEN_NAME.to_owned(), + } + } + + fn token(name: &str, last_eight: &str, scopes: &[&str]) -> AccessToken { + AccessToken { + created_at: None, + id: Some(1), + name: Some(name.to_owned()), + repositories: None, + scopes: Some(scopes.iter().map(|s| (*s).to_owned()).collect()), + sha1: None, + token_last_eight: Some(last_eight.to_owned()), + } + } + + /// What Forgejo v16 stores and lists for [`AGENT_TOKEN_SCOPES`]: the + /// `toScope` order, with each `read:X` folded into its `write:X`. + const LISTED_SCOPES: [&str; 6] = [ + "write:misc", + "write:notification", + "write:organization", + "write:issue", + "write:repository", + "write:user", + ]; + + fn ours() -> AccessToken { + token(AGENT_TOKEN_NAME, "01234567", &LISTED_SCOPES) + } + + #[test] + fn the_scopes_are_hive_c0res_byte_for_byte() { + assert_eq!( + AGENT_TOKEN_SCOPES, + "read:user,write:user,read:notification,write:notification,write:repository,write:issue,write:organization,write:misc" + ); + } + + #[test] + fn a_current_token_is_kept() { + assert_eq!(classify(Some(&stored()), &[ours()]), Decision::Keep); + } + + #[test] + fn scopes_as_written_are_kept_too() { + // A forge that listed the scopes un-normalised must not rotate either. + let t = token( + AGENT_TOKEN_NAME, + "01234567", + &AGENT_TOKEN_SCOPES.split(',').collect::>(), + ); + assert_eq!(classify(Some(&stored()), &[t]), Decision::Keep); + } + + #[test] + fn nothing_stored_mints() { + assert_eq!( + classify(None, &[ours()]), + Decision::Mint(MintReason::NotStored) + ); + } + + #[test] + fn nothing_on_the_forge_mints() { + assert_eq!( + classify(Some(&stored()), &[]), + Decision::Mint(MintReason::NotOnForge) + ); + assert_eq!(classify(None, &[]), Decision::Mint(MintReason::NotOnForge)); + } + + #[test] + fn a_different_last_eight_mints() { + let t = token(AGENT_TOKEN_NAME, "ffffffff", &LISTED_SCOPES); + assert_eq!( + classify(Some(&stored()), &[t]), + Decision::Mint(MintReason::LastEightMismatch) + ); + } + + #[test] + fn a_missing_scope_mints() { + let t = token(AGENT_TOKEN_NAME, "01234567", &LISTED_SCOPES[1..]); + assert_eq!( + classify(Some(&stored()), &[t]), + Decision::Mint(MintReason::ScopeMismatch) + ); + } + + #[test] + fn an_extra_scope_mints() { + let mut scopes = LISTED_SCOPES.to_vec(); + scopes.push("write:admin"); + let t = token(AGENT_TOKEN_NAME, "01234567", &scopes); + assert_eq!( + classify(Some(&stored()), &[t]), + Decision::Mint(MintReason::ScopeMismatch) + ); + } + + #[test] + fn scope_order_does_not_matter() { + let mut scopes = LISTED_SCOPES.to_vec(); + scopes.reverse(); + let t = token(AGENT_TOKEN_NAME, "01234567", &scopes); + assert_eq!(classify(Some(&stored()), &[t]), Decision::Keep); + } + + #[test] + fn legacy_tokens_are_ignored() { + // A pile of `hyperhive-*` tokens, one of them even matching the + // stored value's last eight, is still "no swarm token". + let legacy = [ + token("hyperhive-1700000000", "01234567", &LISTED_SCOPES), + token("hyperhive-1700000001", "aaaaaaaa", &LISTED_SCOPES), + ]; + assert_eq!( + classify(Some(&stored()), &legacy), + Decision::Mint(MintReason::NotOnForge) + ); + let mut with_ours = legacy.to_vec(); + with_ours.push(ours()); + assert_eq!(classify(Some(&stored()), &with_ours), Decision::Keep); + } + + #[test] + fn two_swarm_tokens_are_a_conflict_not_a_mint() { + assert_eq!( + classify(Some(&stored()), &[ours(), ours()]), + Decision::Conflict + ); + } + + #[test] + fn last_eight_is_the_tail() { + assert_eq!(last_eight(VALUE), Some("01234567")); + assert_eq!(last_eight("short"), None); + } + + #[test] + fn a_mint_or_a_missing_user_is_planned() { + let observed = [ + ("a".to_owned(), Observed::Decided(Decision::Keep)), + ( + "b".to_owned(), + Observed::Decided(Decision::Mint(MintReason::NotStored)), + ), + ("c".to_owned(), Observed::Unknown), + ("d".to_owned(), Observed::NoForgeUser), + ("e".to_owned(), Observed::Decided(Decision::Conflict)), + ( + "f".to_owned(), + Observed::Decided(Decision::Mint(MintReason::ScopeMismatch)), + ), + ]; + assert_eq!(plan(&observed), ["b", "d", "f"]); + } + + /// An agent with a store identity and no forge user used to be skipped + /// on every pass, forever. It is planned now; the job it gets creates + /// the user before it mints (see `queue_forge_token_mints`). + #[test] + fn a_missing_forge_user_is_planned() { + let observed = [("ruth".to_owned(), Observed::NoForgeUser)]; + assert_eq!(plan(&observed), ["ruth"]); + } + + #[test] + fn an_outage_plans_nothing() { + let observed = [ + ("a".to_owned(), Observed::Unknown), + ("b".to_owned(), Observed::Unknown), + ]; + assert!(plan(&observed).is_empty()); + } + + #[test] + fn both_404_shapes_are_not_found() { + assert!(is_not_found(&ForgejoError::UnexpectedStatusCode( + StatusCode::NOT_FOUND + ))); + assert!(is_not_found(&ForgejoError::ApiError( + ApiErrorKind::NotFound { errors: None }.into() + ))); + assert!(!is_not_found(&ForgejoError::UnexpectedStatusCode( + StatusCode::FORBIDDEN + ))); + } +} diff --git a/swarm-controller/src/main.rs b/swarm-controller/src/main.rs index 6ebb4c40..e43fcb0e 100644 --- a/swarm-controller/src/main.rs +++ b/swarm-controller/src/main.rs @@ -105,6 +105,14 @@ enum SwarmNodeKind { /// its hive's shared queue credential, so the document cannot be rendered /// without knowing which hive the agent belongs to. MintAgentIdentity { hive: String, agent: String }, + /// Make sure `agent` holds a live forge access token in the swarm secret + /// store, minting one with the forge's admin API when it does not. See + /// `forge::agent_token` — including why a rotation is a delete then a + /// create. + /// + /// Carries no hive: the token's store path has no hive segment, and the + /// agent pulls it from wherever it runs. + MintAgentForgeToken { agent: String }, /// Declare `agent` on `hive` as `Paused` in the swarm's wanted-state /// store, so a freshly created agent does not start driving turns the /// moment it's deployed — the operator has to explicitly flip it to `Up`. @@ -130,6 +138,7 @@ impl hive_jobq_wire::WireNode for SwarmNodeKind { SwarmNodeKind::AddRepoMember { .. } => "add_repo_member".to_owned(), SwarmNodeKind::InitAgentConfigRepo { .. } => "init_agent_config_repo".to_owned(), SwarmNodeKind::MintAgentIdentity { .. } => "mint_agent_identity".to_owned(), + SwarmNodeKind::MintAgentForgeToken { .. } => "mint_agent_forge_token".to_owned(), SwarmNodeKind::SetAgentWanted { .. } => "set_agent_wanted".to_owned(), SwarmNodeKind::TriggerDeploy { .. } => "trigger_deploy".to_owned(), } @@ -147,7 +156,8 @@ impl hive_jobq_wire::WireNode for SwarmNodeKind { | SwarmNodeKind::CreateRepo { agent } | SwarmNodeKind::CreateForgeUser { agent } | SwarmNodeKind::AddRepoMember { agent } - | SwarmNodeKind::InitAgentConfigRepo { agent } => { + | SwarmNodeKind::InitAgentConfigRepo { agent } + | SwarmNodeKind::MintAgentForgeToken { agent } => { serde_json::json!({ "agent": agent }) } SwarmNodeKind::TriggerDeploy { hive, agent } @@ -308,6 +318,7 @@ async fn run_swarm_node( } } }, + SwarmNodeKind::MintAgentForgeToken { agent } => mint_forge_token(deps.forge, &agent).await, SwarmNodeKind::SetAgentWanted { hive, agent } => match deps.wanted { None => Outcome::Failed( "no swarm queue is configured on this host, so no wanted-state \ @@ -327,6 +338,27 @@ async fn run_swarm_node( (builder, outcome) } +/// The `MintAgentForgeToken` arm, lifted out so `run_swarm_node` stays under +/// `clippy::too_many_lines`. +async fn mint_forge_token( + forge: Option>, + agent: &str, +) -> hive_jobq::scheduler::Outcome { + use hive_jobq::scheduler::Outcome; + + let Some(client) = forge else { + return Outcome::Failed( + "no forge configured on this host (SWARM_CONTROLLER_FORGE_URL / \ + SWARM_CONTROLLER_FORGE_TOKEN_FILE unset), so no agent forge token can be minted" + .to_owned(), + ); + }; + match client.ensure_agent_forge_token(agent).await { + Ok(()) => Outcome::Done, + Err(e) => Outcome::Failed(format!("{e:#}")), + } +} + /// Declare a brand-new agent at [`NEW_AGENT_WANTED_STATE`] — unless it turns /// out not to be new: an agent that already has a declaration (other than /// `Destroyed`, which this treats as reusable) is left alone, so a retried @@ -1470,6 +1502,14 @@ fn declare_agent_job( agent: agent.to_owned(), }) .after_ok(create_identity); + // The agent's own forge token, in the store before the hive deploys + // the container that pulls it. After the forge user, because the token + // is minted for that user. + let mint_forge_token = b + .node(SwarmNodeKind::MintAgentForgeToken { + agent: agent.to_owned(), + }) + .after_ok(create_forge_user); // Declared before the deploy trigger so the pause is visible in the // wanted-state store before the hive brings the container up — see the // node's own doc comment for why "before", not just "eventually". Needs @@ -1491,6 +1531,11 @@ fn declare_agent_job( // create agents exactly as it does today. `after_ok` there would turn an // unconfigured option into an agent nobody runs. // + // The forge-token mint gets `after_any` for the same reason: a host with + // no forge or no store must still create agents; only that node fails, + // by name, and the agent's container fetches the token on its next + // timer tick once one is minted. + // // `after_ok` on the declaration, though: an agent deployed without its // pause landing first is the race this node exists to close, so a // declaration that did not land must not be deployed past. A host with no @@ -1504,6 +1549,7 @@ fn declare_agent_job( }) .after_ok(init_config) .after_any(mint_identity) + .after_any(mint_forge_token) .after_ok(set_wanted); vec![create_identity.guid()] } @@ -1703,6 +1749,85 @@ async fn mint_agent_identity( Ok(Json(MintAgentIdentityResponse { node_id: id.get() })) } +/// Success body of `POST /api/agents/{name}/forge-token`. +#[derive(Clone, Debug, Serialize, ToSchema)] +struct MintAgentForgeTokenResponse { + /// The queued node, so a caller can follow it in the job view. + node_id: u64, +} + +/// Insert one job per agent: `CreateForgeUser`, then `MintAgentForgeToken` +/// `after_ok` it — the same edge [`declare_agent_job`] uses. Returns the mint +/// nodes' ids. +/// +/// The one place that node is queued outside agent creation: both the manual +/// route below and `forge::agent_token::spawn`'s periodic pass come through +/// here, so a backfilled mint is the same node a new agent gets. The user +/// node is what lets the pass reach an agent that has a store identity but +/// no forge account; for one that has an account it is a no-op, because +/// `forge::Client::ensure_agent_user` folds "already exists" into success. +fn queue_forge_token_mints( + sched: &Mutex>, + agents: Vec, +) -> Result> { + let mut sched = sched + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let mut ids = Vec::with_capacity(agents.len()); + for agent in agents { + let queued = sched + .insert_job(None, |b| { + let create_forge_user = b.node(SwarmNodeKind::CreateForgeUser { + agent: agent.clone(), + }); + vec![ + b.node(SwarmNodeKind::MintAgentForgeToken { agent }) + .after_ok(create_forge_user) + .guid(), + ] + }) + .map_err(|e| anyhow::anyhow!("{e}"))?; + ids.extend(queued); + } + Ok(ids) +} + +/// Check an agent's forge token now, and mint one if it is missing or stale. +/// +/// The periodic pass (`forge::agent_token::spawn`) does the same every five +/// minutes for every agent with a store identity; this is for an operator who +/// does not want to wait, or for an agent the pass skipped. Queues the same +/// node agent creation does. Idempotent: a current token is left alone. +#[utoipa::path( + post, + path = "/api/agents/{name}/forge-token", + params(("name" = String, Path, description = "agent name")), + responses( + (status = 200, description = "mint queued", body = MintAgentForgeTokenResponse), + (status = 400, description = "`name` is not a valid identifier (problem+json)", body = String), + (status = 500, description = "the job could not be queued (problem+json)", body = String), + ), + tag = "agents" +)] +async fn mint_agent_forge_token( + State(state): State, + Path(name): Path, +) -> Result, problem_details::ProblemDetails> { + let agent = hive_types::Ident::parse(&name) + .map_err(|reason| error_problem(axum::http::StatusCode::BAD_REQUEST, reason))? + .into_string(); + let ids = queue_forge_token_mints(&state.jobq, vec![agent]).map_err(|e| { + error_problem( + axum::http::StatusCode::INTERNAL_SERVER_ERROR, + &e.to_string(), + ) + })?; + let [id] = ids[..] else { + unreachable!("exactly one handle was asked for"); + }; + Ok(Json(MintAgentForgeTokenResponse { node_id: id.get() })) +} + /// Every agent with an open config PR, in one response — the bulk /// counterpart to [`get_agent_config_pr`]. swarm-ui's config-PR table needs /// every agent's status to render, and fetching them one at a time doesn't @@ -1990,6 +2115,14 @@ async fn main() -> Result<()> { ); } + if let Some(client) = forge_client.clone() { + let sched = Arc::clone(&jobq); + forge::agent_token::spawn(client, move |agents| { + if let Err(e) = queue_forge_token_mints(&sched, agents) { + tracing::warn!(error = %format!("{e:#}"), "agent forge tokens: queueing failed"); + } + }); + } let config_prs = forge_client.clone().map(config_pr::spawn); let state_forge = keep_forge_for_state(forge_client, webhook_secret.clone()); @@ -2041,6 +2174,7 @@ fn build_app(state: AppState) -> axum::Router { .routes(routes!(get_config_prs)) .routes(routes!(create_agent)) .routes(routes!(mint_agent_identity)) + .routes(routes!(mint_agent_forge_token)) .routes(routes!(get_agents)) .routes(routes!(get_agents_status)) .routes(routes!(set_agent_state)) @@ -2842,6 +2976,151 @@ mod tests { ); } + /// The forge-token mint sits between the forge user and the deploy: it + /// needs the user to exist, and the deploy must not overtake it — but a + /// host with no forge or no store must still deploy the agent. + #[tokio::test] + async fn the_forge_token_mint_follows_the_user_and_does_not_block_the_deploy() { + use hive_jobq_wire::WireNode as _; + + let (state, sched) = state_with_roster(); + let _queued = super::create_agent( + axum::extract::State(state), + axum::Json(super::CreateAgentRequest { + name: "atlas".to_owned(), + hive: "pr1ma".to_owned(), + }), + ) + .await + .expect("a hive in the roster must be accepted"); + + let guard = sched + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let graph = guard.graph(); + let id_of = |label: &str| { + graph + .nodes() + .find(|n| n.payload.label() == label) + .unwrap_or_else(|| panic!("the graph holds a {label} node")) + .id + }; + let edge = |from: hive_jobq::NodeId, to: &str| { + graph + .node(id_of(to)) + .expect("just found") + .deps + .iter() + .find_map(|d| match d { + hive_jobq::Dep::Node { id, when } if *id == from => Some(*when), + _ => None, + }) + .unwrap_or_else(|| panic!("{to} waits for node {}", from.get())) + }; + + let user_to_mint = edge(id_of("create_forge_user"), "mint_agent_forge_token"); + assert!( + !user_to_mint.accepts(hive_jobq::TerminalState::Failed), + "a token for a user that was never created cannot be minted; this \ + edge has to be `after_ok`" + ); + let mint_to_deploy = edge(id_of("mint_agent_forge_token"), "trigger_deploy"); + assert!( + mint_to_deploy.accepts(hive_jobq::TerminalState::Failed), + "a host with no forge or store must not cancel the deploy; this edge \ + has to be `after_any`, not `after_ok`" + ); + } + + #[test] + fn a_forge_token_node_renders_the_agent() { + use hive_jobq_wire::WireNode as _; + + let kind = SwarmNodeKind::MintAgentForgeToken { + agent: "atlas".to_owned(), + }; + assert_eq!(kind.label(), "mint_agent_forge_token"); + assert_eq!(kind.data(1)["agent"], "atlas"); + } + + /// The manual route and the periodic pass both go through + /// `queue_forge_token_mints`, so this is the assertion that a backfilled + /// mint is the same pair of nodes agent creation inserts: the forge user, + /// then the mint `after_ok` it, per agent. + #[test] + fn a_queued_mint_creates_the_forge_user_first() { + use hive_jobq_wire::WireNode as _; + + let sched = std::sync::Mutex::new(hive_jobq::scheduler::Scheduler::new( + hive_jobq::Graph::new(), + hive_jobq::resources::ResourceTable::new(), + )); + let ids = super::queue_forge_token_mints(&sched, vec!["a".to_owned(), "b".to_owned()]) + .expect("two jobs insert"); + assert_eq!(ids.len(), 2, "one returned id per agent: its mint node"); + let guard = sched + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let graph = guard.graph(); + let agent_of = |n: &hive_jobq::Node| { + n.payload.data(n.id.get())["agent"] + .as_str() + .expect("agent is a string") + .to_owned() + }; + for agent in ["a", "b"] { + let find = |label: &str| { + graph + .nodes() + .find(|n| n.payload.label() == label && agent_of(n) == agent) + .unwrap_or_else(|| panic!("{agent} has a {label} node")) + }; + let user = find("create_forge_user").id; + let mint = find("mint_agent_forge_token"); + assert!(ids.contains(&mint.id), "the returned id is {agent}'s mint"); + let when = mint + .deps + .iter() + .find_map(|d| match d { + hive_jobq::Dep::Node { id, when } if *id == user => Some(*when), + _ => None, + }) + .unwrap_or_else(|| panic!("{agent}'s mint waits for its own forge user")); + assert!( + !when.accepts(hive_jobq::TerminalState::Failed), + "a token cannot be minted for a user that was never created; this \ + edge has to be `after_ok`" + ); + } + assert_eq!( + graph.nodes().count(), + 4, + "two nodes per agent, nothing else" + ); + } + + /// The backfill end to end, minus the IO: an agent the pass observed with + /// no forge user is planned, and the job it gets creates that user before + /// it mints. + #[test] + fn an_agent_with_no_forge_user_gets_a_user_and_a_mint() { + use crate::forge::agent_token::{Observed, plan}; + use hive_jobq_wire::WireNode as _; + + let agents = plan(&[("ruth".to_owned(), Observed::NoForgeUser)]); + let sched = std::sync::Mutex::new(hive_jobq::scheduler::Scheduler::new( + hive_jobq::Graph::new(), + hive_jobq::resources::ResourceTable::new(), + )); + super::queue_forge_token_mints(&sched, agents).expect("one job inserts"); + let guard = sched + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let mut labels: Vec = guard.graph().nodes().map(|n| n.payload.label()).collect(); + labels.sort(); + assert_eq!(labels, ["create_forge_user", "mint_agent_forge_token"]); + } + /// The socket must not share a directory with anything else, because /// the socket is `0666` and the directory is therefore the only access /// control it has. `/run/hyperhive` in particular holds hive-c0re's diff --git a/swarm-secret-client/src/client.rs b/swarm-secret-client/src/client.rs index 0a8232f3..a0df7d36 100644 --- a/swarm-secret-client/src/client.rs +++ b/swarm-secret-client/src/client.rs @@ -260,6 +260,25 @@ impl SecretStore { .await?; Ok(()) } + + /// The names of every cert-auth role under `mount`, or none when the + /// mount has no roles at all. + /// + /// **Only a 404 is "none".** The store answers a `LIST` on an empty + /// directory with a 404; a denial is a 403 and stays an error, so a caller + /// iterating the result never mistakes "not allowed to look" for "nothing + /// there". + /// + /// # Errors + /// [`Error::Vault`] for anything that is not a 404: a denial, or an + /// unreachable store. + pub async fn list_cert_roles(&self, mount: &str) -> Result, Error> { + match vaultrs::auth::cert::ca_cert_role::list(&self.inner, mount).await { + Ok(listed) => Ok(listed.keys), + Err(vaultrs::error::ClientError::APIError { code: 404, .. }) => Ok(Vec::new()), + Err(e) => Err(e.into()), + } + } } /// Write an ACL policy, spelled out here because [`vaultrs`] does not have it: diff --git a/swarm-secret-client/src/forge.rs b/swarm-secret-client/src/forge.rs new file mode 100644 index 00000000..8a0f68ce --- /dev/null +++ b/swarm-secret-client/src/forge.rs @@ -0,0 +1,137 @@ +//! The forge agreement: where an agent's own forge token lives in the store, +//! and what the object at that path holds. +//! +//! `swarm-controller` mints the token with the forge's admin API and writes it +//! here; the agent container reads it back under its own certificate +//! (`nix/agent-modules/forge-token.nix`). Neither end is senior, so both halves +//! are stated once, here, beside [`crate::matrix`]. + +use serde::{Deserialize, Serialize}; + +use crate::{ + Error, + path::{Kind, principal_prefix}, +}; + +/// The path holding `agent`'s own forge access token. +/// +/// A flat leaf under the agent's prefix, like its controller-minted siblings +/// [`crate::queue::agent_queue_path`] and [`crate::mtls::identity_path`], so +/// the agent's own read stanza ([`crate::policy::render_agent_with_queue`]) +/// already covers it. +/// +/// # Errors +/// [`Error::PathSegment`] when `agent` contains anything but `[A-Za-z0-9_-]`, +/// which is what keeps one agent's name from addressing another agent's secret. +pub fn agent_token_path(agent: &str) -> Result { + let prefix = principal_prefix(Kind::Agent, agent)?; + Ok(format!("{prefix}/forge-token")) +} + +/// What an agent's forge-token path holds. +/// +/// No `Debug` derive: `value` is a live forge credential, and a derived +/// `Debug` is one `{:?}` away from a log line. +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Credential { + /// The token itself. `forge-token.nix` reads the store with + /// `bao kv get -field=value`, so this name is load-bearing for a reader + /// this crate does not control, the same as [`crate::matrix::Credential`]'s. + pub value: String, + + /// The forge's name for the token — not secret. Stored beside the value + /// so whoever rotates it can name the token it replaces. + pub name: String, +} + +impl std::fmt::Debug for Credential { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Credential") + .field("value", &"") + .field("name", &self.name) + .finish() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_token_lands_under_the_agent_prefix() { + // Spelled out rather than rebuilt from the pieces the code uses: the + // nix reader spells the same string, and a rename here that moved it + // would leave that reader fetching nothing. + assert_eq!( + agent_token_path("atlas").expect("a plain name is legal"), + "swarm/agents/atlas/forge-token" + ); + } + + #[test] + fn a_traversal_in_the_agent_name_is_refused() { + for bad in ["../argus", "a/b", "atlas/../argus", "a.b", ""] { + let e = agent_token_path(bad).expect_err("a traversal is not legal"); + assert!(matches!(e, Error::PathSegment { kind: "agent", .. }), "{e}"); + } + } + + #[test] + fn the_legal_charset_is_actually_reachable() { + // The control for the test above: a guard that refused everything + // would pass it for the wrong reason. + assert!(agent_token_path("a-b_C9").is_ok()); + } + + #[test] + fn the_agents_own_read_grant_covers_the_path() { + // The agent reads this path under `hive-agent-`, and that + // policy is rendered elsewhere. If the path ever moved out from under + // it, the agent's fetch would 403 at boot, naming neither. + let path = agent_token_path("atlas").expect("legal"); + let policy = crate::policy::render_agent_with_queue("atlas", "pr1ma").expect("legal"); + let covered = policy.lines().any(|line| { + line.strip_prefix("path \"") + .and_then(|rest| rest.split_once("\" {")) + .and_then(|(p, _)| p.strip_suffix('*')) + .is_some_and(|prefix| format!("secret/data/{path}").starts_with(prefix)) + }); + assert!(covered, "no stanza in\n{policy}\ncovers {path}"); + } + + #[test] + fn another_agents_read_grant_does_not_cover_the_path() { + // Control for the test above: the prefix match must actually be + // discriminating, or it proves nothing. + let path = agent_token_path("atlas").expect("legal"); + let policy = crate::policy::render_agent_with_queue("argus", "pr1ma").expect("legal"); + let covered = policy.lines().any(|line| { + line.strip_prefix("path \"") + .and_then(|rest| rest.split_once("\" {")) + .and_then(|(p, _)| p.strip_suffix('*')) + .is_some_and(|prefix| format!("secret/data/{path}").starts_with(prefix)) + }); + assert!(!covered, "argus's policy must not reach atlas's token"); + } + + #[test] + fn the_value_field_matches_what_the_nix_reader_asks_for() { + let json = serde_json::to_string(&Credential { + value: "t".to_owned(), + name: "swarm-agent".to_owned(), + }) + .expect("two Strings serialise"); + assert_eq!(json, r#"{"value":"t","name":"swarm-agent"}"#); + } + + #[test] + fn debug_never_prints_the_value() { + let c = Credential { + value: "0123456789abcdef".to_owned(), + name: "swarm-agent".to_owned(), + }; + let shown = format!("{c:?}"); + assert!(!shown.contains("0123456789abcdef"), "{shown}"); + assert!(shown.contains("swarm-agent"), "{shown}"); + } +} diff --git a/swarm-secret-client/src/lib.rs b/swarm-secret-client/src/lib.rs index 28fd566c..cb93e056 100644 --- a/swarm-secret-client/src/lib.rs +++ b/swarm-secret-client/src/lib.rs @@ -5,7 +5,7 @@ //! the rules every path obeys ([`path`]), the translation from this //! deployment's environment into a logged-in client ([`client`]), and, per kind //! of secret, the path it lives at together with the fields it holds -//! ([`matrix`], [`queue`], [`mtls`]). Each of those is a thing the controller +//! ([`matrix`], [`queue`], [`mtls`], [`forge`]). Each of those is a thing the controller //! and a hive must say identically, so it is said once here. //! //! [`policy`] is the same kind of agreement seen from the other side: which of @@ -23,6 +23,7 @@ //! value inside it, and its doc explains why that is not circular. pub mod client; +pub mod forge; pub mod matrix; pub mod mtls; pub mod path; diff --git a/swarm-secret-client/src/policy.rs b/swarm-secret-client/src/policy.rs index a04e4e57..72cdfa0c 100644 --- a/swarm-secret-client/src/policy.rs +++ b/swarm-secret-client/src/policy.rs @@ -96,6 +96,23 @@ pub fn agent_object_name(agent: &str) -> Result { Ok(format!("{AGENT_PREFIX}{agent}")) } +/// The agents among `names`, a listing of cert-auth roles: the inverse of +/// [`agent_object_name`], for a caller that needs the set of agents holding a +/// store identity. +/// +/// A name that carries the prefix but whose suffix is not a legal segment is +/// dropped rather than returned: [`agent_object_name`] could not have produced +/// it, so it is not an agent this crate minted. +#[must_use] +pub fn agents_from_role_names(names: &[String]) -> Vec { + names + .iter() + .filter_map(|name| name.strip_prefix(AGENT_PREFIX)) + .filter(|agent| checked_segment("agent", agent).is_ok()) + .map(str::to_owned) + .collect() +} + /// One read stanza. The only shape this module emits, so "read-only" is a /// property of the renderer rather than of each call site. fn read_stanza(path: &str) -> String { @@ -595,4 +612,33 @@ mod tests { let expected_queue_path = crate::queue::agent_client_path("pr1ma").expect("legal"); assert!(p.contains(&expected_queue_path)); } + + #[test] + fn only_agent_roles_come_back_and_without_their_prefix() { + let roles: Vec = [ + "hive-agent-atlas", + "hive-pr1ma", + "swarm-controller", + "hive-agent-argus", + ] + .map(str::to_owned) + .to_vec(); + assert_eq!(agents_from_role_names(&roles), ["atlas", "argus"]); + } + + #[test] + fn a_role_agent_object_name_could_not_have_produced_is_dropped() { + // An empty suffix and one carrying a dot are both names the prefix + // matches and no agent can have. + let roles: Vec = ["hive-agent-", "hive-agent-a.b", "hive-agent-ok"] + .map(str::to_owned) + .to_vec(); + assert_eq!(agents_from_role_names(&roles), ["ok"]); + } + + #[test] + fn the_listing_inverts_agent_object_name() { + let name = agent_object_name("atlas").expect("legal"); + assert_eq!(agents_from_role_names(&[name]), ["atlas"]); + } } diff --git a/swarmctl/src/agent.rs b/swarmctl/src/agent.rs index 7b6811d1..e6b57bf5 100644 --- a/swarmctl/src/agent.rs +++ b/swarmctl/src/agent.rs @@ -1,5 +1,5 @@ -//! `swarmctl agent create` and `swarmctl agent mint-identity` — queue work -//! on the swarm-controller's job graph. +//! `swarmctl agent create`, `swarmctl agent mint-identity` and `swarmctl agent +//! mint-forge-token` — queue work on the swarm-controller's job graph. //! //! Each POSTs and returns as soon as the work is *inserted*. Both verbs //! print the queued node id and stop: creation's last node only *publishes* @@ -64,6 +64,12 @@ struct MintIdentityResponse { node_id: u64, } +/// Success body of `POST /api/agents/{name}/forge-token`, which takes no body. +#[derive(Deserialize)] +struct MintForgeTokenResponse { + node_id: u64, +} + /// Run `swarmctl agent create`. /// /// Synchronous on purpose: every other verb in this crate is, and this is @@ -137,6 +143,33 @@ pub(crate) fn mint_identity(socket: &Path, name: &str, hive: &str) -> Result<()> Ok(()) } +/// Run `swarmctl agent mint-forge-token`. +/// +/// Synchronous for the same reason [`create`] is, and built on the same +/// round trip. No `--hive`: the token's store path has no hive in it. +pub(crate) fn mint_forge_token(socket: &Path, name: &str) -> Result<()> { + let name = parse_ident(name, "agent name")?; + + let rt = tokio::runtime::Builder::new_current_thread() + .enable_io() + .build() + .context("starting a tokio runtime for the controller request")?; + let resp: MintForgeTokenResponse = rt.block_on(post( + socket, + &format!("/api/agents/{name}/forge-token"), + &serde_json::json!({}), + "mint-forge-token", + ))?; + + println!("queued: job node {}", resp.node_id); + println!( + "agent {name:?}'s forge token will be checked once the job graph runs, and minted \ + only if it is missing or stale; `swarmctl` does not wait for it. The agent picks a \ + new token up on its next fetch" + ); + Ok(()) +} + /// One `POST /api/agents` round trip over the controller's unix socket. async fn post_create(socket: &Path, name: &str, hive: &str) -> Result { post( diff --git a/swarmctl/src/main.rs b/swarmctl/src/main.rs index 78eef948..7832e335 100644 --- a/swarmctl/src/main.rs +++ b/swarmctl/src/main.rs @@ -184,6 +184,15 @@ enum AgentVerb { /// Queues and returns, the same way `agent create` does — watch the /// swarm UI's job view for the outcome. MintIdentity(AgentMintIdentityArgs), + /// Check one agent's forge token, and mint it if it's missing or stale. + /// + /// swarm-controller does this for every agent with a store identity at + /// start and every five minutes; this is for when waiting isn't an + /// option. It leaves a current token alone. + /// + /// Queues and returns, the same way `agent create` does — watch the + /// swarm UI's job view for the outcome. + MintForgeToken(AgentMintForgeTokenArgs), } #[derive(Args)] @@ -210,6 +219,19 @@ struct AgentCreateArgs { controller_socket: Option, } +#[derive(Args)] +struct AgentMintForgeTokenArgs { + /// Name of an agent that already exists. + name: String, + /// swarm-controller's unix socket. + /// + /// Supplied by the nix module that installs this binary, from the same + /// `socketPath` option the daemon binds; falls back to + /// `SWARM_CONTROLLER_SOCKET`. + #[arg(long, value_name = "PATH")] + controller_socket: Option, +} + #[derive(Args)] struct AgentMintIdentityArgs { /// Name of an agent that already exists. @@ -308,6 +330,13 @@ fn main() -> Result<()> { let socket = path_from(args.controller_socket, "SWARM_CONTROLLER_SOCKET")?; agent::mint_identity(&socket, &args.name, &args.hive) } + // Same socket-resolution reasoning as `Create` above. + Verb::Agent { + command: AgentVerb::MintForgeToken(args), + } => { + let socket = path_from(args.controller_socket, "SWARM_CONTROLLER_SOCKET")?; + agent::mint_forge_token(&socket, &args.name) + } // Resolved lazily, inside the one arm that actually touches the // deployment env vars — see the `MarkdownDocs` doc comment above // for why an unconditional resolve up front would be wrong. @@ -697,6 +726,31 @@ mod tests { ); } + #[test] + fn the_forge_token_verb_takes_an_agent_and_no_hive() { + let cli = Cli::try_parse_from(["swarmctl", "agent", "mint-forge-token", "scribe"]) + .expect("the minimal form parses"); + let Verb::Agent { + command: AgentVerb::MintForgeToken(args), + } = cli.command + else { + panic!("expected `agent mint-forge-token`"); + }; + assert_eq!(args.name, "scribe"); + assert!( + Cli::try_parse_from([ + "swarmctl", + "agent", + "mint-forge-token", + "scribe", + "--hive", + "a" + ]) + .is_err(), + "the token has no hive, so the verb must not take one" + ); + } + #[test] fn parses_authelia_hash_output() { let out = "Random Password: hunter2\nDigest: $argon2id$v=19$m=65536$abc\n";