Watch
0
0
Fork
You've already forked hyperhive
0

Compare commits

..
43 changed files with 329 additions and 1902 deletions

View file

@ -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. <hive> is the
# name of the hive she runs on.
swarmctl agent mint-identity ruth --hive <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.
<!-- vale write-good.Passive = NO -->
- **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/<name>`) go through
operator approval — agents can't unilaterally rebuild containers, by design.

View file

@ -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/<agent>/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 `<state>/forge-token`,
the file hive-c0re wrote before. hive-c0re no longer creates agent users or
mints agent tokens; the `hyperhive-<unix-seconds>` 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
`<state>/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 `<state>/forge-token` (agent has no forge
- `<state>/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

View file

@ -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 <gateway-ip>` 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

View file

@ -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 <unit>` rather than a
broken switch-to-configuration.

View file

@ -448,7 +448,7 @@ connects to the compositor at `127.0.0.1:<vnc_port>`.
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

View file

@ -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.
<!-- vale write-good.Passive = NO -->
| store path | minter | reader — pulls at runtime, holds in memory | renewal |
| -------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `swarm/agents/<agent>/matrix/<account>` | `swarm-controller` | the agent container itself, under the certificate its hive passed in | must be stated |
| `swarm/agents/<agent>/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/<agent>/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/<agent>/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/<hive>/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/<hive>/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/<hive>/queue/agent` | authelia | the agent container presenting the OIDC client to the swarm queue, under its own certificate | must be stated |
| `swarm/services/<clientId>/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/<agent>/matrix/<account>` | `swarm-controller` | the agent container itself, under the certificate its hive passed in | must be stated |
| `swarm/agents/<agent>/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/<agent>/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/<hive>/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/<hive>/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/<hive>/queue/agent` | authelia | the agent container presenting the OIDC client to the swarm queue, under its own certificate | must be stated |
| `swarm/services/<clientId>/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 |
<!-- vale write-good.Passive = YES -->
@ -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 <agent>
```
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

View file

@ -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.

View file

@ -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 `<name>`
* `create-user` — Create or refresh the Forgejo account + token for `<name>`
* `reconcile-config` — Show + reconcile the divergence between an agent's local applied config checkout and its forge `agent-configs/<agent>` main
## `hivectl forge create-user`
Create or refresh a non-agent Forgejo account + token for `<name>`.
Create or refresh the Forgejo account + token for `<name>`.
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 <agent>`).
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] <NAME>`
###### **Arguments:**
* `<NAME>` — Forgejo username of a human/other account — `mara`, `damocles`, etc
* `<NAME>` — Forgejo username. For agents: the container/agent name (`<n>` in `h-<n>`; manager uses the literal `manager`). For humans: any forgejo username — `mara`, `damocles`, etc
###### **Options:**

View file

@ -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 <agent>` checks
and, if needed, re-mints it.
`create-user` persists the token to `<state>/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.

View file

@ -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] <NAME>`
###### **Arguments:**
* `<NAME>` — Name of an agent that already exists
###### **Options:**
* `--controller-socket <PATH>` — 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

View file

@ -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/<name>/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@<name>.service.d/` drop-in on destroy |
| `DaemonReload` | `systemctl daemon-reload` |
| `RunForgeAdmin` | `nixos-container run hive-forge -- runuser -u forgejo -- forgejo admin <args>` |
| `WriteAgentMatrixToken` | write `0600` credential file into agent state dir |
| `WriteAgentForgeToken` / `WriteAgentMatrixToken` | write `0600` credential file into agent state dir |
| `RestartMatrixDaemon` | `systemctl --machine=h-<name> restart hive-matrix-daemon.service` |
| `ControlInfraContainer` | `systemctl <action> container@<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` |

View file

@ -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

View file

@ -351,12 +351,7 @@ fn agent_links(label: &str, gui_enabled: bool) -> Vec<AgentLink> {
});
}
// 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(),

View file

@ -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<Coordinator>, 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");
}

View file

@ -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> {
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<Forgejo> {
/// 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).

View file

@ -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<String> {
let token_name = format!(
"{TOKEN_NAME_PREFIX}-{}",
@ -349,6 +349,16 @@ async fn mint_token(name: &str, scopes: &str) -> Result<String> {
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/<name>/` 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

View file

@ -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.

View file

@ -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
/// `<agent_state_root>/<agent_name>/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
/// `<state>/matrix-token`; `account: Some(name)` writes

View file

@ -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))
}

View file

@ -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() {

View file

@ -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
/// `<state_dir>/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<std::path::PathBuf> {
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<String> {
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);
}
}

View file

@ -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 `<state dir>/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<String> {
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()))?;

View file

@ -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);
}

View file

@ -316,10 +316,10 @@ pub enum HostRequest {
room: Option<String>,
},
/// 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 `<notes>/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,

View file

@ -468,6 +468,19 @@ pub enum PrivRequest {
},
// --- Agent credential writes ---
/// Write `forge-token` into `AGENT_STATE_ROOT/<agent_name>/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-<name>` 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 {

View file

@ -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/<agent_name>/state/<filename>`,
/// chowns to the agent user (derived from the state dir's existing owner),
/// and chmods 0600. Running as root (hive-priv), so this succeeds

View file

@ -248,15 +248,15 @@ impl ScopeArgs {
#[derive(Subcommand)]
pub enum ForgeCmd {
/// Create or refresh a non-agent Forgejo account + token for `<name>`.
/// Create or refresh the Forgejo account + token for `<name>`.
///
/// 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 <agent>`).
/// 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
/// (`<n>` in `h-<n>`; 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

View file

@ -1,5 +1,5 @@
//! `hivectl forge create-user <name>` — 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};

View file

@ -46,7 +46,6 @@ in
./dashboard-links.nix
./docs.nix
./forge.nix
./forge-token.nix
./frontend.nix
./github.nix
./logs.nix

View file

@ -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/<agent>/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 `<state>/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";
};
};
};
}

View file

@ -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 <this> --token "$(cat $HYPERHIVE_STATE_DIR/forge-token)"`
(= `/agents/<name>/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 <verb>: 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
# `<state>/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/<name>/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

View file

@ -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"
];

View file

@ -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;

View file

@ -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

View file

@ -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`.

View file

@ -1,563 +0,0 @@
//! Each agent's own forge access token: minted here with the admin API, stored
//! at `swarm/agents/<agent>/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-<unix-seconds>` 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<Item = &'a str>) -> BTreeSet<String> {
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<String> {
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<Option<Vec<AccessToken>>> {
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<forge::Credential> = 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::<forge::Credential>(&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<Vec<(String, Observed)>> {
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<Client>, enqueue: impl Fn(Vec<String>) + 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::<Vec<_>>(),
);
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
)));
}
}

View file

@ -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<Arc<forge::Client>>,
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<hive_jobq::scheduler::Scheduler<SwarmNodeKind, SwarmResourceKind>>,
agents: Vec<String>,
) -> Result<Vec<hive_jobq::NodeId>> {
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<AppState>,
Path(name): Path<String>,
) -> Result<Json<MintAgentForgeTokenResponse>, 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<SwarmNodeKind, super::SwarmResourceKind>| {
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<String> = 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

View file

@ -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<Vec<String>, 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:

View file

@ -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<String, Error> {
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", &"<redacted>")
.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-<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}");
}
}

View file

@ -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;

View file

@ -96,23 +96,6 @@ pub fn agent_object_name(agent: &str) -> Result<String, Error> {
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<String> {
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<String> = [
"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<String> = ["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"]);
}
}

View file

@ -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<CreateAgentResponse> {
post(

View file

@ -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<PathBuf>,
}
#[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<PathBuf>,
}
#[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";