diff --git a/docs/getting-started/setup.md b/docs/getting-started/setup.md index 22580928..5a6c685e 100644 --- a/docs/getting-started/setup.md +++ b/docs/getting-started/setup.md @@ -24,30 +24,17 @@ operator has to place, and where. ### 1 · Forge -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. - ```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 +# 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 -# 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 +# Sub-agents spawned later (via the approval flow in "Spawn sub-agents") +# get their forge accounts auto-provisioned — nothing to run here for them. ``` -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`). @@ -280,9 +267,7 @@ 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, 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 + hold a regular agent token in their `forge-token` file; 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 83509234..0979081e 100644 --- a/docs/integrations/forge.md +++ b/docs/integrations/forge.md @@ -17,9 +17,7 @@ each agent on relevant activity. Two scope sets live in `hive-c0re::forge`: -**`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: +**`TOKEN_SCOPES`** (per-agent tokens): | Scope | Why | | -------------------- | ------------------------------------------------------------------------------------------------------- | @@ -48,20 +46,13 @@ hive-c0re to re-mint with the new scopes. ## Per-agent forge accounts -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. +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. Two things live in the `agent-configs` Forgejo organization: @@ -171,7 +162,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. -- no token in `$HIVE_FORGE_TOKEN_FILE` or `/forge-token` (agent has no forge +- `/forge-token` missing or empty (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 1efcf70a..a288374c 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 - the forge-token fetch so the resolver is correct before the first DNS lookup. + `tea-login` 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 7db2237e..efd6bf10 100644 --- a/docs/process/conventions.md +++ b/docs/process/conventions.md @@ -502,9 +502,13 @@ 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 delivered to the agent at -provisioning time: +configure agent-side surfaces from values hive-c0re writes into +the state dir 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. @@ -534,10 +538,13 @@ 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 service stays root-owned so the bootstrap ordering doesn't need a -user-existence check before each fire. +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. This pattern keeps the rebuild path resilient: any failure inside -these services degrades the corresponding surface (no avatar) but never blocks the container from coming up. The +these services degrades the corresponding surface (no tea config, +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 4571624c..6401176e 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 best-effort oneshots in `docs/process/conventions.md`. + Same reasoning as the `tea-login` unit in `nix/agent-modules/forge.nix`. - **`[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 f05efba3..3a05fb70 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. -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. +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. **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,17 +54,16 @@ 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/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 | +| 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 | @@ -96,19 +95,6 @@ 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 108dfa2d..d84360f7 100644 --- a/docs/tools/forge.md +++ b/docs/tools/forge.md @@ -14,8 +14,7 @@ markdown-docs > docs/tools/forge-cli.md`. ## Credentials and repo defaults -- Credentials: `$HIVE_FORGE_TOKEN_FILE` (the token the agent fetched from - the swarm secret store), else `$HYPERHIVE_STATE_DIR/forge-token` +- Credentials: `$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 cf635f73..18881495 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 a non-agent Forgejo account + token for `` +* `create-user` — Create or refresh the 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 a non-agent Forgejo account + token for ``. +Create or refresh the Forgejo account + token for ``. -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 `). +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). **Usage:** `hivectl forge create-user [OPTIONS] ` ###### **Arguments:** -* `` — Forgejo username of a human/other account — `mara`, `damocles`, etc +* `` — Forgejo username. For agents: the container/agent name (`` in `h-`; manager uses the literal `manager`). For humans: any forgejo username — `mara`, `damocles`, etc ###### **Options:** diff --git a/docs/tools/hivectl.md b/docs/tools/hivectl.md index 47e56416..409d462b 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 # refused: `iris` is an agent; use `swarmctl agent mint-forge-token iris` +hivectl forge create-user iris # provision (or refresh) forge account for agent `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,9 +40,8 @@ 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` 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. + `create-user` persists the token to `/forge-token`. Re-running refreshes the + token (idempotent — scope always matches current `TOKEN_SCOPES`). - 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 1810d02d..96d0eb20 100644 --- a/docs/tools/swarmctl-cli.md +++ b/docs/tools/swarmctl-cli.md @@ -8,7 +8,6 @@ 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) @@ -46,7 +45,6 @@ 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 @@ -106,28 +104,6 @@ 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 ca8fe746..78a98465 100644 --- a/docs/trust-boundary/security.md +++ b/docs/trust-boundary/security.md @@ -236,12 +236,9 @@ 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`) - — 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. + — 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. **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. @@ -294,7 +291,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 ` | -| `WriteAgentMatrixToken` | write `0600` credential file into agent state dir | +| `WriteAgentForgeToken` / `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 e50213da..50549231 100644 --- a/docs/turn-loop/config.md +++ b/docs/turn-loop/config.md @@ -137,9 +137,11 @@ 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. 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 +**`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 Forgejo on a different host or port (for example a swarm peer's forge). Validated: must be an `http://` or `https://` URL, or `null`. @@ -147,7 +149,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 credential helper and `forge-avatar-sync` +to the wrong machine. With `null` the `tea-login` 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 a701e4a5..f39e3095 100644 --- a/hive-agent/src/web_ui/state.rs +++ b/hive-agent/src/web_ui/state.rs @@ -351,12 +351,7 @@ fn agent_links(label: &str, gui_enabled: bool) -> Vec { }); } - // 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() { + if 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 e2140da0..23fa8de5 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 (config repo mirror, meta + // Post-spawn forge bookkeeping (user, 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,10 +605,13 @@ fn fetch_approval_for_worker( } /// Forge bookkeeping run once after the very first container spawn: -/// 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. +/// 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. 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 e79e1d65..89ca413d 100644 --- a/hive-c0re/src/forge/mod.rs +++ b/hive-c0re/src/forge/mod.rs @@ -1,4 +1,4 @@ -//! Optional Forgejo wiring — per-agent account alignment, +//! Optional Forgejo wiring — per-agent user + token provisioning, //! 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, provision_user_token}; +pub use users::{core_token, ensure_user_for, 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: 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 +/// 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 /// are logged as warnings but don't abort the caller. /// /// `core_token` is `core_token()` — passed in so callers that already @@ -228,10 +228,11 @@ 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 fc36a113..2bc6be80 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 `hivectl forge create-user` accounts and -/// `CORE_TOKEN_SCOPES` for the bootstrap `core` user. +/// use `TOKEN_SCOPES` for agents, `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,6 +349,16 @@ 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 @@ -366,11 +376,24 @@ 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, 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. +/// 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. /// /// `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 e510ab40..3f26dd4b 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 (forge-avatar-sync bakes `FORGE_URL` from it), the env +/// into scripts at build time (tea-login 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 forge-avatar-sync / + # systemd units so tea-login / 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, forge-avatar-sync + // `hive-forge` + `git` actually run), plus the matrix daemon, tea-login // 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 (forge-avatar-sync's FORGE_URL), the env var is read at + // scripts at build time (tea-login'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 4ef99842..978c3ad6 100644 --- a/hive-c0re/src/priv_client.rs +++ b/hive-c0re/src/priv_client.rs @@ -361,6 +361,18 @@ 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 f2c4b389..0d0e3440 100644 --- a/hive-c0re/src/server.rs +++ b/hive-c0re/src/server.rs @@ -600,30 +600,36 @@ 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)? { - // 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()); + 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())); } else { - out.push( - "password: random throwaway (not surfaced — pass --password or --password-stdin to set one you can use)".to_owned(), - ); + 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(), + ); + } } 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 f9717ed6..b1786c87 100644 --- a/hive-forge-notify/src/bin/hive-forge-notify/main.rs +++ b/hive-forge-notify/src/bin/hive-forge-notify/main.rs @@ -37,17 +37,23 @@ async fn forgejo_loop(state_dir: String, socket: std::path::PathBuf) { } }; - 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 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 mut attempts = 0u32; loop { - if let Some(t) = hive_forge_notify::read_first_token(&token_paths) { - break t; + 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}"), } - debug!(?token_paths, "forge_notify: no forge token yet"); attempts += 1; if attempts >= TOKEN_RETRY_MAX { debug!( @@ -59,7 +65,7 @@ async fn forgejo_loop(state_dir: String, socket: std::path::PathBuf) { } }; - let Some(mut source) = ForgejoSource::new(&forge_url, &token) else { + let Some(source) = ForgejoSource::new(&forge_url, &token) else { return; }; @@ -105,18 +111,6 @@ 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 702ba922..95f1cf2d 100644 --- a/hive-forge-notify/src/lib.rs +++ b/hive-forge-notify/src/lib.rs @@ -70,76 +70,3 @@ 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 cfe86bdb..a33f87e1 100644 --- a/hive-forge/src/client.rs +++ b/hive-forge/src/client.rs @@ -1,6 +1,5 @@ -//! 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 +//! App-level Forgejo client wrapper. Identity is the per-agent token +//! under `${HYPERHIVE_STATE_DIR}/forge-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/` @@ -463,20 +462,9 @@ fn state_dir() -> PathBuf { } } -/// 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. +/// Locate and read the forge token. Falls back to `$PWD/forge-token` +/// 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 77936bb6..e15947a4 100644 --- a/hive-forge/tests/credential_helper.rs +++ b/hive-forge/tests/credential_helper.rs @@ -29,23 +29,7 @@ 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) { - 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 + let mut child = Command::new(env!("CARGO_BIN_EXE_hive-forge")) .arg("credential-helper") .arg("get") .env("HIVE_FORGE_URL", base_url) @@ -127,46 +111,3 @@ 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 d7263bfb..2e9dcae0 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 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 + /// 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 /// (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 8fa577c4..60dfdcd2 100644 --- a/hive-priv-sock/src/lib.rs +++ b/hive-priv-sock/src/lib.rs @@ -468,6 +468,19 @@ 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 @@ -475,10 +488,8 @@ pub enum PrivRequest { /// `account` suffix as plain identifiers before building the path, so a /// crafted account name cannot traverse out of the state dir. /// - /// 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. + /// Same write semantics as `WriteAgentForgeToken` — validates names, + /// creates dir, writes 0600, chowns to agent owner. WriteAgentMatrixToken { /// Logical agent name (validated by `validate_agent_name`). agent_name: String, @@ -501,7 +512,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 - /// `WriteAgentMatrixToken` — validates `agent_name`, creates the state dir + /// `WriteAgentForgeToken` — 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). @@ -523,7 +534,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 `WriteAgentMatrixToken` — validates `agent_name`, + /// write semantics as `WriteAgentForgeToken` — 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 66358cb6..5a1be8d5 100644 --- a/hive-priv/src/main.rs +++ b/hive-priv/src/main.rs @@ -403,6 +403,11 @@ 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, @@ -614,6 +619,12 @@ 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)?; @@ -1430,7 +1441,7 @@ struct ForgeSidecar<'a> { base_url: &'a str, } -/// Shared helper for the `WriteAgent*Token` requests. +/// Shared helper for `WriteAgentForgeToken` and `WriteAgentMatrixToken`. /// 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 35bc83d0..a4de9784 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 a non-agent Forgejo account + token for ``. + /// Create or refresh the Forgejo account + token for ``. /// - /// 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 `). + /// 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). CreateUser { - /// Forgejo username of a human/other account — `mara`, - /// `damocles`, etc. + /// Forgejo username. For agents: the container/agent name + /// (`` in `h-`; manager uses the literal `manager`). + /// For humans: any forgejo username — `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 12ca8964..bebad895 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); +//! daemon (which owns the forge admin token + account-token persistence); //! 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 68a93b9f..a244ad02 100644 --- a/nix/agent-modules/default.nix +++ b/nix/agent-modules/default.nix @@ -46,7 +46,6 @@ 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 deleted file mode 100644 index 58ca6a94..00000000 --- a/nix/agent-modules/forge-token.nix +++ /dev/null @@ -1,182 +0,0 @@ -# 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 19dc9649..694b479e 100644 --- a/nix/agent-modules/forge.nix +++ b/nix/agent-modules/forge.nix @@ -1,6 +1,6 @@ -# 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. +# In-container forge (Forgejo) integration: the `tea` CLI login +# oneshot, the `hive-forge` verb CLI on PATH, and the icon → forge +# avatar sync. { pkgs, lib, @@ -9,18 +9,7 @@ }: let userName = config.services.hyperhive.agent.user.name; - # 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 - ''; + homeDir = "/home/${userName}"; # 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 @@ -31,7 +20,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 its file AT INVOCATION (so a re-issued token + # is read from the agent's state 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`. @@ -43,8 +32,8 @@ let gitCredHelper = pkgs.writeShellScriptBin "git-credential-hive-forge" '' # git credential-helper protocol: only `get` needs an answer. [ "''${1:-}" = "get" ] || exit 0 - ${pickTokenFile} - [ -n "$TOKEN_FILE" ] || exit 0 + TOKEN_FILE="/agents/${userName}/state/forge-token" + [ -r "$TOKEN_FILE" ] || exit 0 printf 'username=%s\n' ${lib.escapeShellArg userName} printf 'password=%s\n' "$(cat "$TOKEN_FILE")" ''; @@ -55,16 +44,20 @@ in default = null; example = "http://forge.internal:3000"; description = '' - 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. + 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). **`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 credential helper and avatar-sync units are not + When this is `null` the tea-login and avatar-sync units are not generated at all --- an absent integration, never a misdirected one. @@ -95,6 +88,10 @@ 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. @@ -181,22 +178,94 @@ in }; }; - # 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. + # 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. # # 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 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. + # 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. # - # ⚠️ This agent's own token, not a glob. + # ⚠️ 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. # # ⚠️ 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 @@ -209,7 +278,7 @@ in { description = "trigger forge-avatar-sync when forge-token appears"; wantedBy = [ "multi-user.target" ]; - pathConfig.PathChanged = fetchedTokenFile; + pathConfig.PathChanged = "/agents/${userName}/state/forge-token"; }; # One-shot: services.hyperhive.agent.icon → Forgejo profile avatar. Shape contract: @@ -226,7 +295,7 @@ in { description = "sync agent icon to Forgejo user avatar (best-effort)"; wantedBy = [ "multi-user.target" ]; - after = [ "hive-agent-forge-token.service" ]; + after = [ "tea-login.service" ]; serviceConfig = { Type = "oneshot"; RemainAfterExit = false; @@ -240,8 +309,10 @@ in ]; script = '' FORGE_URL=${lib.escapeShellArg config.services.hyperhive.agent.forge.url} - ${pickTokenFile} - if [ -z "$TOKEN_FILE" ]; then + # $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 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 bf9f9d6a..64adf66b 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 - # (the forge-token fetch) and the network targets so name resolution works for + # (tea-login) 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" - "hive-agent-forge-token.service" + "tea-login.service" "hive-agent.service" "hive-matrix-daemon.service" ]; diff --git a/nix/checks.nix b/nix/checks.nix index bbb0110b..c5238637 100644 --- a/nix/checks.nix +++ b/nix/checks.nix @@ -108,10 +108,6 @@ 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 deleted file mode 100644 index ce7ef5c9..00000000 --- a/nix/module-eval/agent-forge-bao.nix +++ /dev/null @@ -1,169 +0,0 @@ -# `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 2ebf02c9..bb4af221 100644 --- a/swarm-controller/src/forge.rs +++ b/swarm-controller/src/forge.rs @@ -33,8 +33,6 @@ 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)] @@ -137,10 +135,6 @@ 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 { @@ -174,12 +168,8 @@ 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.clone()).context("build forgejo client")?; - Ok(Some(Self { - api, - url: parsed_url, - })) + let api = Forgejo::new(Auth::Token(token), parsed_url).context("build forgejo client")?; + Ok(Some(Self { api })) } /// 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 deleted file mode 100644 index 19a322d8..00000000 --- a/swarm-controller/src/forge/agent_token.rs +++ /dev/null @@ -1,563 +0,0 @@ -//! 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 e43fcb0e..6ebb4c40 100644 --- a/swarm-controller/src/main.rs +++ b/swarm-controller/src/main.rs @@ -105,14 +105,6 @@ 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`. @@ -138,7 +130,6 @@ 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(), } @@ -156,8 +147,7 @@ impl hive_jobq_wire::WireNode for SwarmNodeKind { | SwarmNodeKind::CreateRepo { agent } | SwarmNodeKind::CreateForgeUser { agent } | SwarmNodeKind::AddRepoMember { agent } - | SwarmNodeKind::InitAgentConfigRepo { agent } - | SwarmNodeKind::MintAgentForgeToken { agent } => { + | SwarmNodeKind::InitAgentConfigRepo { agent } => { serde_json::json!({ "agent": agent }) } SwarmNodeKind::TriggerDeploy { hive, agent } @@ -318,7 +308,6 @@ 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 \ @@ -338,27 +327,6 @@ 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 @@ -1502,14 +1470,6 @@ 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 @@ -1531,11 +1491,6 @@ 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 @@ -1549,7 +1504,6 @@ 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()] } @@ -1749,85 +1703,6 @@ 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 @@ -2115,14 +1990,6 @@ 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()); @@ -2174,7 +2041,6 @@ 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)) @@ -2976,151 +2842,6 @@ 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 a0df7d36..0a8232f3 100644 --- a/swarm-secret-client/src/client.rs +++ b/swarm-secret-client/src/client.rs @@ -260,25 +260,6 @@ 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 deleted file mode 100644 index 8a0f68ce..00000000 --- a/swarm-secret-client/src/forge.rs +++ /dev/null @@ -1,137 +0,0 @@ -//! 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 cb93e056..28fd566c 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`], [`forge`]). Each of those is a thing the controller +//! ([`matrix`], [`queue`], [`mtls`]). 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,7 +23,6 @@ //! 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 72cdfa0c..a04e4e57 100644 --- a/swarm-secret-client/src/policy.rs +++ b/swarm-secret-client/src/policy.rs @@ -96,23 +96,6 @@ 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 { @@ -612,33 +595,4 @@ 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 e6b57bf5..7b6811d1 100644 --- a/swarmctl/src/agent.rs +++ b/swarmctl/src/agent.rs @@ -1,5 +1,5 @@ -//! `swarmctl agent create`, `swarmctl agent mint-identity` and `swarmctl agent -//! mint-forge-token` — queue work on the swarm-controller's job graph. +//! `swarmctl agent create` and `swarmctl agent mint-identity` — 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,12 +64,6 @@ 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 @@ -143,33 +137,6 @@ 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 7832e335..78eef948 100644 --- a/swarmctl/src/main.rs +++ b/swarmctl/src/main.rs @@ -184,15 +184,6 @@ 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)] @@ -219,19 +210,6 @@ 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. @@ -330,13 +308,6 @@ 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. @@ -726,31 +697,6 @@ 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";