Watch
0
0
Fork
You've already forked hyperhive
0

Compare commits

...
Author SHA1 Message Date
atlas
3ee5a960b4 docs/setup: ruth needs a store identity and a rebuild, not a hand-made forge user
The rewritten ruth step skipped her store identity, without which
neither the backfill nor her container's fetch can reach her token. With
the backfill now creating a missing forge user, two commands cover her:
swarmctl agent mint-identity ruth --hive <hive>, then hivectl agent ruth
rebuild so hive-c0re hands the identity to her container.

Refs #3782
2026-09-24 17:48:53 +02:00
atlas
22f0acfd6d swarm-controller: the forge-token backfill creates a missing forge user
An agent with a hive-agent-* store identity but no forge user was
observed as NoForgeUser and dropped by plan(), so it never got a token.
plan() now keeps it, and queue_forge_token_mints inserts CreateForgeUser
ahead of MintAgentForgeToken with after_ok, the edge declare_agent_job
already uses. ensure_agent_user folds an existing user into success, so
the extra node is a no-op for agents that have one.

Refs #3782
2026-09-24 17:48:53 +02:00
atlas
abc942cff3 docs: the swarm mints agent forge tokens; hive-c0re and tea-login no longer do
credentials.md gains the forge-token row and drops the claim that the
forge token never passes through the store. setup.md says plainly that
an agent spawned on the hive alone, ruth's bootstrap included, now gets
no forge user from anything. CLI references regenerated.

Refs #3782
2026-09-24 17:48:53 +02:00
atlas
6d0c30ade2 module-eval: pin the agent forge-token fetch and tea-login's removal
Refs #3782
2026-09-24 17:48:53 +02:00
atlas
b5d07d4df2 hive-c0re: stop minting agent forge tokens
Delete ensure_user_for and mint_and_persist_agent_token, the user step
of sync_agent (the per-rebuild re-mint, #4644) and of
forge_after_first_spawn, and the hive-priv WriteAgentForgeToken request
that wrote the token into the agent's state dir. hivectl forge
create-user now refuses an agent and points at swarmctl agent
mint-forge-token. mint_token, ensure_user_exists and TOKEN_SCOPES stay:
provision_user_token and the core bootstrap still call them.

Refs #3782
2026-09-24 17:48:53 +02:00
atlas
dd32a395f7 agents: pull the forge token from bao; drop tea-login
forge-token.nix fetches swarm/agents/<agent>/forge-token under the
agent's own store identity into /run/hive-agent-forge-token/token, and
re-fetches on a timer so a rotation lands. hive-forge, the git
credential helper, hive-forge-notify, forge-avatar-sync and the web UI
read that file first and fall back to <state>/forge-token.

tea-login is deleted: it copied the token into ~/.config/tea, which
docs/swarm/credentials.md forbids for a store secret. hive-forge covers
the same verbs. swarmctl gains agent mint-forge-token.

Refs #3782
2026-09-24 17:48:53 +02:00
atlas
52c8c0b0de swarm-controller: mint each agent's forge token and store it in bao
A MintAgentForgeToken node mints a fixed-name swarm-agent token with the
admin API, keeps it when the stored value's last eight and the normalised
scopes match the forge's list, and otherwise deletes and re-creates it.
The token is stored at swarm/agents/<agent>/forge-token. Agent creation
inserts the node, and a pass at start and every five minutes inserts it
for every agent holding a store identity whose token is missing or stale.

Refs #3782
2026-09-24 17:48:53 +02:00
43 changed files with 1902 additions and 329 deletions

View file

@ -24,17 +24,30 @@ operator has to place, and where.
### 1 · Forge ### 1 · Forge
```bash hive-c0re no longer creates agent forge users or mints agent tokens.
# Provision (or refresh) ruth's own forge account — do this first. Ruth's swarm-controller does, for every agent that holds a store identity: a pass
# bootstrap bypasses the normal spawn-approval flow ("Spawn sub-agents" at start and every five minutes creates the forge user if it's missing,
# below), so unlike every other agent it does not get its forge account then mints the token into the swarm secret store, where the agent fetches
# auto-provisioned — this manual step is still load-bearing. it under that identity. An agent created with `swarmctl agent create` gets
hivectl forge create-user ruth its identity then. Ruth doesn't: hive-c0re creates her on its own at
startup, so she needs her identity minted by hand, once.
# Sub-agents spawned later (via the approval flow in "Spawn sub-agents") ```bash
# get their forge accounts auto-provisioned — nothing to run here for them. # 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>
# On ruth's hive: re-apply her container config, which is when hive-c0re
# hands the new identity to the container.
hivectl agent ruth rebuild
``` ```
You don't need to do anything else. The controller's next pass creates
ruth's forge user and mints her token, and her container fetches it within
about ten minutes. `swarmctl agent mint-forge-token ruth` skips the wait for
the pass. A hive without a swarm secret store has no path to a forge token
for ruth at all.
Swarm SSO creates the human operator's own forge account instead of Swarm SSO creates the human operator's own forge account instead of
a manual `hivectl` step — see _Swarm SSO_ below (`swarmctl user add`). a manual `hivectl` step — see _Swarm SSO_ below (`swarmctl user add`).
@ -267,7 +280,9 @@ See [`tools/hivectl.md`](../tools/hivectl.md) for every `hivectl` verb.
<!-- vale write-good.Passive = NO --> <!-- vale write-good.Passive = NO -->
- **No forge admin token is stored in any agent state dir.** Agents - **No forge admin token is stored in any agent state dir.** Agents
hold a regular agent token in their `forge-token` file; sensitive hold a regular agent token, fetched from the swarm secret store into
`/run/hive-agent-forge-token/token` (or, for an agent without a store
identity, the `forge-token` file hive-c0re wrote before); sensitive
creds (the core token) live on the host. creds (the core token) live on the host.
- All config changes (forge PRs on `agent-configs/<name>`) go through - All config changes (forge PRs on `agent-configs/<name>`) go through
operator approval — agents can't unilaterally rebuild containers, by design. operator approval — agents can't unilaterally rebuild containers, by design.

View file

@ -17,7 +17,9 @@ each agent on relevant activity.
Two scope sets live in `hive-c0re::forge`: Two scope sets live in `hive-c0re::forge`:
**`TOKEN_SCOPES`** (per-agent tokens): **`TOKEN_SCOPES`** (per-agent tokens, and `hivectl forge create-user`
accounts). swarm-controller mints agent tokens with a byte-identical copy,
`forge::agent_token::AGENT_TOKEN_SCOPES`, pinned by a test:
| Scope | Why | | Scope | Why |
| -------------------- | ------------------------------------------------------------------------------------------------------- | | -------------------- | ------------------------------------------------------------------------------------------------------- |
@ -46,13 +48,20 @@ hive-c0re to re-mint with the new scopes.
## Per-agent forge accounts ## Per-agent forge accounts
Each agent gets its own Forgejo user + access token, provisioned at Each agent gets its own Forgejo user and access token. swarm-controller
boot by `hive-c0re::forge`. The provisioning flow is idempotent: creates the user when it creates the agent, and mints one token named
`hive-c0re::forge` reuses existing accounts + tokens, so container destroy/recreate `swarm-agent` with the admin API into the swarm secret store at
doesn't lose forge identity. It writes the token to `swarm/agents/<agent>/forge-token`. A pass at start and every five minutes
`<state>/forge-token` (one line, no trailing newline) inside the covers every agent with a store identity: it creates a missing forge user and
agent container so `hive-forge` CLI + `forge_notify` poller can re-mints any token that's missing or no longer matches the forge;
read it without touching c0re's host-side credential store. 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.
Two things live in the `agent-configs` Forgejo organization: Two things live in the `agent-configs` Forgejo organization:
@ -162,7 +171,7 @@ The poller starts disabled and stays that way for any of:
- `HIVE_FORGE_URL` not set (no forge configured for this hive), or - `HIVE_FORGE_URL` not set (no forge configured for this hive), or
not parseable as a URL. not parseable as a URL.
- `<state>/forge-token` missing or empty (agent has no forge - no token in `$HIVE_FORGE_TOKEN_FILE` or `<state>/forge-token` (agent has no forge
account — pre-provisioning or destroy-without-purge race). account — pre-provisioning or destroy-without-purge race).
- Initial client construction fails (the typed `forgejo-api` client - Initial client construction fails (the typed `forgejo-api` client
for the API calls, or the plain reqwest client kept for the 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 - the `hyperhive-isolated-dns` oneshot (`nix/agent-modules/network.nix`), gated on that
marker, rewrites `/etc/resolv.conf` to `nameserver <gateway-ip>` at boot. marker, rewrites `/etc/resolv.conf` to `nameserver <gateway-ip>` at boot.
It's ordered `before` the harness (`hive-ag3nt`), the matrix daemon, and It's ordered `before` the harness (`hive-ag3nt`), the matrix daemon, and
`tea-login` so the resolver is correct before the first DNS lookup. the forge-token fetch so the resolver is correct before the first DNS lookup.
**Why isolation is safe**: hive-c0re's control-plane sockets are unix **Why isolation is safe**: hive-c0re's control-plane sockets are unix
domain sockets bind-mounted into containers, not network listeners — see domain sockets bind-mounted into containers, not network listeners — see

View file

@ -502,13 +502,9 @@ sparingly) or build just the suspect check, for example `nix build
## Best-effort oneshot services ## Best-effort oneshot services
The harness ships a family of one-shot systemd services that The harness ships a family of one-shot systemd services that
configure agent-side surfaces from values hive-c0re writes into configure agent-side surfaces from values delivered to the agent at
the state dir at provisioning time: 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 - `forge-avatar-sync` — uploads `services.hyperhive.agent.icon` SVG to the
agent's Forgejo profile, so the icon shows up on commits / PRs / agent's Forgejo profile, so the icon shows up on commits / PRs /
issue comments. issue comments.
@ -538,13 +534,10 @@ Shape contract — every one of these:
by the `.path` watchers that re-fire on token appearance (see by the `.path` watchers that re-fire on token appearance (see
`docs/agent-lifecycle/persistence.md::Matrix per-agent daemon`). `docs/agent-lifecycle/persistence.md::Matrix per-agent daemon`).
The artefact lives under the agent user's home where applicable The service stays root-owned so the bootstrap ordering doesn't need a
(`~/.config/tea/config.yml`) and is chown'd to that user, but the user-existence check before each fire.
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 This pattern keeps the rebuild path resilient: any failure inside
these services degrades the corresponding surface (no tea config, these services degrades the corresponding surface (no avatar) but never blocks the container from coming up. The
no avatar) but never blocks the container from coming up. The
operator notices through `journalctl -u <unit>` rather than a operator notices through `journalctl -u <unit>` rather than a
broken switch-to-configuration. 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 must never block on weston signalling readiness. A misconfigured
weston degrades to a `Restart=on-failure` loop visible in weston degrades to a `Restart=on-failure` loop visible in
`journalctl`, it doesn't abort the `nixos-container update`. `journalctl`, it doesn't abort the `nixos-container update`.
Same reasoning as the `tea-login` unit in `nix/agent-modules/forge.nix`. Same reasoning as the best-effort oneshots in `docs/process/conventions.md`.
- **`[core] idle-time=0`**: disables weston's 300-second idle - **`[core] idle-time=0`**: disables weston's 300-second idle
timeout. Without it the VNC desktop fades to black and timeout. Without it the VNC desktop fades to black and
desktop-shell shows its click-to-unlock screen — useless for an 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 process reads a secret on another principal's behalf: the principal that
needs a value is the principal that authenticates for it. needs a value is the principal that authenticates for it.
Two per-agent credential files — the forge token and the github token — sit One per-agent credential file — the github token — sits outside this page:
outside this page: they're operator-supplied and never pass through the it's operator-supplied and never passes through the store, so the table
store, so the table below doesn't govern them. below doesn't govern it.
**Per secret, the target specifies minter, reader, and renewal strategy.** **Per secret, the target specifies minter, reader, and renewal strategy.**
Those three are the contract, and the reader is a process pulling a store Those three are the contract, and the reader is a process pulling a store
@ -55,10 +55,11 @@ strategy for every credential, including the mTLS leaf.
<!-- vale write-good.Passive = NO --> <!-- vale write-good.Passive = NO -->
| store path | minter | reader — pulls at runtime, holds in memory | renewal | | 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>/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>/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>/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/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>/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/hives/<hive>/queue/agent` | authelia | the agent container presenting the OIDC client to the swarm queue, under its own certificate | must be stated |
@ -95,6 +96,19 @@ value it holds, so running this against an already-migrated agent doesn't drop
its queue connection. The certificate half isn't: the agent gets a fresh leaf its queue connection. The certificate half isn't: the agent gets a fresh leaf
and picks it up on its next boot. 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 ⚠️ **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 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 whose credential is absent doesn't start — that's deliberate, and it's what

View file

@ -14,7 +14,8 @@ markdown-docs > docs/tools/forge-cli.md`.
## Credentials and repo defaults ## Credentials and repo defaults
- Credentials: `$HYPERHIVE_STATE_DIR/forge-token` - Credentials: `$HIVE_FORGE_TOKEN_FILE` (the token the agent fetched from
the swarm secret store), else `$HYPERHIVE_STATE_DIR/forge-token`
- Active repo resolves, highest priority first: global `-r/--repo` flag - Active repo resolves, highest priority first: global `-r/--repo` flag
> the `origin` remote of the cwd's git checkout > `$HIVE_FORGE_REPO` > the `origin` remote of the cwd's git checkout > `$HIVE_FORGE_REPO`
(last-resort override, unset by default) > a hard error. (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:** ###### **Subcommands:**
* `create-user` — Create or refresh the Forgejo account + token for `<name>` * `create-user` — Create or refresh a non-agent 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 * `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` ## `hivectl forge create-user`
Create or refresh the Forgejo account + token for `<name>`. Create or refresh a non-agent Forgejo account + token for `<name>`.
For an existing agent, persists the token to its state dir; for a human/other account, prints the token to stdout. Set a password to enable forge web-UI login (otherwise it uses a random throwaway). Prints the token to stdout. Set a password to enable forge web-UI login (otherwise it uses a random throwaway). Refused for an existing agent: swarm-controller mints an agent's token (`swarmctl agent mint-forge-token <agent>`).
**Usage:** `hivectl forge create-user [OPTIONS] <NAME>` **Usage:** `hivectl forge create-user [OPTIONS] <NAME>`
###### **Arguments:** ###### **Arguments:**
* `<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 * `<NAME>` — Forgejo username of a human/other account — `mara`, `damocles`, etc
###### **Options:** ###### **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. agent without bouncing the daemon.
```bash ```bash
hivectl forge create-user iris # provision (or refresh) forge account for agent `iris` hivectl forge create-user iris # refused: `iris` is an agent; use `swarmctl agent mint-forge-token iris`
hivectl forge create-user mara # create forge account for a human user; prints token to stdout hivectl forge create-user mara # 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 hunter2 # set a web-login password
hivectl forge create-user mara --password-stdin # read password from stdin (safer for scripting) hivectl forge create-user mara --password-stdin # read password from stdin (safer for scripting)
@ -40,8 +40,9 @@ hivectl forge reconcile-config iris --verbose # include the full diff, not
``` ```
- For **agents** (name has a state dir under `/var/lib/hyperhive/agents/`): - For **agents** (name has a state dir under `/var/lib/hyperhive/agents/`):
`create-user` persists the token to `<state>/forge-token`. Re-running refreshes the `create-user` refuses. swarm-controller mints an agent's token into
token (idempotent — scope always matches current `TOKEN_SCOPES`). the swarm secret store; `swarmctl agent mint-forge-token <agent>` checks
and, if needed, re-mints it.
- For **non-agents** (humans): creates the account and prints the token to - For **non-agents** (humans): creates the account and prints the token to
stdout, creating no state dir. Re-running after account already exists stdout, creating no state dir. Re-running after account already exists
re-mints the token and prints it again — safe for password resets. re-mints the token and prints it again — safe for password resets.

View file

@ -8,6 +8,7 @@ This document contains the help content for the `swarmctl` command-line program.
* [`swarmctl agent`↴](#swarmctl-agent) * [`swarmctl agent`↴](#swarmctl-agent)
* [`swarmctl agent create`↴](#swarmctl-agent-create) * [`swarmctl agent create`↴](#swarmctl-agent-create)
* [`swarmctl agent mint-identity`↴](#swarmctl-agent-mint-identity) * [`swarmctl agent mint-identity`↴](#swarmctl-agent-mint-identity)
* [`swarmctl agent mint-forge-token`↴](#swarmctl-agent-mint-forge-token)
* [`swarmctl user`↴](#swarmctl-user) * [`swarmctl user`↴](#swarmctl-user)
* [`swarmctl user add`↴](#swarmctl-user-add) * [`swarmctl user add`↴](#swarmctl-user-add)
* [`swarmctl user update`↴](#swarmctl-user-update) * [`swarmctl user update`↴](#swarmctl-user-update)
@ -45,6 +46,7 @@ Manage agents across the swarm
* `create` — Queue creation of a new agent on a hive in this swarm * `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-identity` — Queue a re-mint of an existing agent's identity at the swarm's secret store
* `mint-forge-token` — Check one agent's forge token, and mint it if it's missing or stale
@ -104,6 +106,28 @@ Queues and returns, the same way `agent create` does — watch the swarm UI's jo
## `swarmctl agent mint-forge-token`
Check one agent's forge token, and mint it if it's missing or stale.
swarm-controller does this for every agent with a store identity at start and every five minutes; this is for when waiting isn't an option. It leaves a current token alone.
Queues and returns, the same way `agent create` does — watch the swarm UI's job view for the outcome.
**Usage:** `swarmctl agent mint-forge-token [OPTIONS] <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` ## `swarmctl user`
Manage subjects in the swarm's SSO provider Manage subjects in the swarm's SSO provider

View file

@ -236,9 +236,12 @@ token policy bounds file reads; network isolation bounds network reach.
this directory at a default mode is world-readable, which isn't this directory at a default mode is world-readable, which isn't
hypothetical: `plugins/` and `.last-cleanup` already are. hypothetical: `plugins/` and `.last-cleanup` already are.
- `$HYPERHIVE_STATE_DIR/forge-token` (= `/agents/<name>/state/forge-token`) - `$HYPERHIVE_STATE_DIR/forge-token` (= `/agents/<name>/state/forge-token`)
— written at mode `0600` and chowned to the per-agent uid:gid (see — the token hive-c0re wrote at mode `0600`, chowned to the per-agent
`hive-c0re/src/forge/mod.rs`'s module doc for exactly where). nixbld users uid:gid, before swarm-controller took over minting. Nothing writes it any
can't read it. more; it stays as the fallback for an agent without a store identity.
nixbld users can't read it. The swarm-minted token lives under
`/run/hive-agent-forge-token/` (`0700` directory, `0400` file), outside
the state dir.
**Policy**: all credential files written to agent state directories MUST be mode **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. `0600` or stricter. Don't create world-readable secret files in agent state dirs.
@ -291,7 +294,7 @@ known operations; there is no arbitrary command pass-through:
| `RemoveServiceDropin` | remove `container@<name>.service.d/` drop-in on destroy | | `RemoveServiceDropin` | remove `container@<name>.service.d/` drop-in on destroy |
| `DaemonReload` | `systemctl daemon-reload` | | `DaemonReload` | `systemctl daemon-reload` |
| `RunForgeAdmin` | `nixos-container run hive-forge -- runuser -u forgejo -- forgejo admin <args>` | | `RunForgeAdmin` | `nixos-container run hive-forge -- runuser -u forgejo -- forgejo admin <args>` |
| `WriteAgentForgeToken` / `WriteAgentMatrixToken` | write `0600` credential file into agent state dir | | `WriteAgentMatrixToken` | write `0600` credential file into agent state dir |
| `RestartMatrixDaemon` | `systemctl --machine=h-<name> restart hive-matrix-daemon.service` | | `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) | | `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` | | `SyncAgentTmpfiles` | write `/etc/tmpfiles.d/hyperhive-agents.conf` for the agent set, then `systemd-tmpfiles --create` |

View file

@ -137,11 +137,9 @@ services.hyperhive.agent.forge.url = "http://forge.example:3000"; # default: nu
services.hyperhive.agent.matrix.url = "https://matrix.example"; # default: null services.hyperhive.agent.matrix.url = "https://matrix.example"; # default: null
``` ```
**`services.hyperhive.agent.forge.url`** — base URL of the Forgejo instance. Used by **`services.hyperhive.agent.forge.url`** — base URL of the Forgejo instance. It scopes
a one-shot boot unit (`tea-login`) that writes `~/.config/tea/config.yml` the git credential helper to this forge and is where `forge-avatar-sync`
directly from the agent's `forge-token`, so `tea` and `hive-forge` uploads the agent's icon. Override when the agent should connect to a
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). Forgejo on a different host or port (for example a swarm peer's forge).
Validated: must be an `http://` or `https://` URL, or `null`. Validated: must be an `http://` or `https://` URL, or `null`.
@ -149,7 +147,7 @@ Validated: must be an `http://` or `https://` URL, or `null`.
loopback default would only ever be correct when the forge shares the loopback default would only ever be correct when the forge shares the
agent's network namespace, and inside a container `localhost` is 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 agent itself, so the default was a value that built fine and then talked
to the wrong machine. With `null` the `tea-login` and `forge-avatar-sync` to the wrong machine. With `null` the credential helper and `forge-avatar-sync`
units aren't generated at all: an absent integration rather than a units aren't generated at all: an absent integration rather than a
misdirected one. You don't normally set this — hive-c0re renders the 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 host's real forge URL into every agent, and refuses to write a meta

View file

@ -351,7 +351,12 @@ fn agent_links(label: &str, gui_enabled: bool) -> Vec<AgentLink> {
}); });
} }
if crate::paths::state_dir().join("forge-token").is_file() { // Either copy of the forge token: the one fetched from the swarm secret
// store (`HIVE_FORGE_TOKEN_FILE`), or the state-dir file the hive used
// to write.
let fetched_token = std::env::var_os("HIVE_FORGE_TOKEN_FILE")
.is_some_and(|p| std::path::Path::new(&p).is_file());
if fetched_token || crate::paths::state_dir().join("forge-token").is_file() {
links.push(AgentLink { links.push(AgentLink {
url: format!("/{label}"), url: format!("/{label}"),
icon: "🔨".to_owned(), icon: "🔨".to_owned(),

View file

@ -527,7 +527,7 @@ pub(crate) async fn resolve_approval_dag(
let mut terminal_tag = None; let mut terminal_tag = None;
match approval.kind { match approval.kind {
ApprovalKind::Spawn => { ApprovalKind::Spawn => {
// Post-spawn forge bookkeeping (user, config repo mirror, meta // Post-spawn forge bookkeeping (config repo mirror, meta
// access) — warn-only, then the resolution events + a rescan so // access) — warn-only, then the resolution events + a rescan so
// the dashboard reflects the post-spawn state either way. // the dashboard reflects the post-spawn state either way.
if result.is_ok() { if result.is_ok() {
@ -605,13 +605,10 @@ fn fetch_approval_for_worker(
} }
/// Forge bookkeeping run once after the very first container spawn: /// Forge bookkeeping run once after the very first container spawn:
/// create the per-agent forge user, mirror the applied repo, and grant /// mirror the applied repo and grant read access to core/meta. The
/// read access to core/meta. Also rescans containers so the dashboard /// agent's forge user and token are swarm-controller's, not this
/// reflects the post-spawn state. /// hive's. Also rescans containers so the dashboard reflects the post-spawn state.
async fn forge_after_first_spawn(coord: &Arc<Coordinator>, agent: &str) { 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 { if let Err(e) = crate::forge::ensure_config_repo(agent).await {
tracing::warn!(%agent, error = ?e, "forge: ensure_config_repo after first spawn failed"); tracing::warn!(%agent, error = ?e, "forge: ensure_config_repo after first spawn failed");
} }

View file

@ -1,4 +1,4 @@
//! Optional Forgejo wiring — per-agent user + token provisioning, //! Optional Forgejo wiring — per-agent account alignment,
//! config-repo mirroring, meta read-access grants. Also seeds //! config-repo mirroring, meta read-access grants. Also seeds
//! `internal/docs` — a private repo every agent gets read-only //! `internal/docs` — a private repo every agent gets read-only
//! collaborator access to for operator-curated shared content. //! 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, 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, fetch_config_main_into_applied, meta_read_access, push_config, push_meta, shared_docs_access,
}; };
pub use users::{core_token, ensure_user_for, provision_user_token}; pub use users::{core_token, provision_user_token};
use std::sync::OnceLock; use std::sync::OnceLock;
use std::time::{Duration, Instant}; 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") Forgejo::new(Auth::Token(token), url).context("build forgejo api client")
} }
/// Per-agent forge sync: ensure the agent has a forgejo user + token, /// Per-agent forge sync: align the agent's forge account (email,
/// a mirrored config repo, read access to `core/meta`, and the `meta` /// repo-creation lockdown), mirror its config repo, grant read access
/// remote in its proposed repo. All operations are idempotent; failures /// to `core/meta`, and wire the `meta` remote in its proposed repo. All operations are idempotent; failures
/// are logged as warnings but don't abort the caller. /// are logged as warnings but don't abort the caller.
/// ///
/// `core_token` is `core_token()` — passed in so callers that already /// `core_token` is `core_token()` — passed in so callers that already
@ -228,11 +228,10 @@ pub(crate) fn api(token: &str) -> Result<Forgejo> {
/// dashboard warning (see [`ensure_all`]); the rebuild path ignores it and /// dashboard warning (see [`ensure_all`]); the rebuild path ignores it and
/// relies on the journal `warn!` lines alone (a rebuild is its own retry). /// 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 { 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; 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 // Align email to match the git user.email set by meta::render_flake
// so commits link to the agent's Forgejo profile. Best-effort; // so commits link to the agent's Forgejo profile. Best-effort;
// also patches up agents created before this fix (old @hive.local). // 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 /// 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 /// token of the same name in the DB. `scopes` is the scope string
/// passed to `forgejo admin user generate-access-token --scopes`; /// passed to `forgejo admin user generate-access-token --scopes`;
/// use `TOKEN_SCOPES` for agents, `CORE_TOKEN_SCOPES` for the /// use `TOKEN_SCOPES` for `hivectl forge create-user` accounts and
/// bootstrap `core` user. /// `CORE_TOKEN_SCOPES` for the bootstrap `core` user.
async fn mint_token(name: &str, scopes: &str) -> Result<String> { async fn mint_token(name: &str, scopes: &str) -> Result<String> {
let token_name = format!( let token_name = format!(
"{TOKEN_NAME_PREFIX}-{}", "{TOKEN_NAME_PREFIX}-{}",
@ -349,16 +349,6 @@ async fn mint_token(name: &str, scopes: &str) -> Result<String> {
Ok(token) 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 /// Mint a fresh Forgejo access token for the `core` admin user and
/// write it directly to `path`. Unlike agent tokens this path is owned /// write it directly to `path`. Unlike agent tokens this path is owned
/// by hive-c0re itself (under `/var/lib/hyperhive/`), so a direct /// by hive-c0re itself (under `/var/lib/hyperhive/`), so a direct
@ -376,24 +366,11 @@ async fn mint_and_persist_core_token(path: &Path) -> Result<()> {
Ok(()) 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 /// Provision a forgejo user for `name` and return the freshly-minted
/// token. Unlike [`ensure_user_for`], the token is **not** persisted to /// token, which is **not** persisted to disk — the caller is responsible
/// disk — the caller is responsible for storing it. Used by `hivectl /// for storing it. Used by `hivectl forge create-user` for human
/// forge create-user` for human (non-agent) accounts so we don't create /// (non-agent) accounts. Agent tokens are minted by swarm-controller
/// stray `/var/lib/hyperhive/agents/<name>/` directories for users that /// (`swarm-controller/src/forge/agent_token.rs`), not here.
/// aren't agents.
/// ///
/// `password` picks the account password. `None` keeps the existing /// `password` picks the account password. `None` keeps the existing
/// random-throwaway shape (caller doesn't need web UI access — token /// 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. /// 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 /// Both exist because they're consumed at different times: the option is baked
/// into scripts at build time (tea-login bakes `FORGE_URL` from it), the env /// into scripts at build time (forge-avatar-sync bakes `FORGE_URL` from it), the env
/// var is read at runtime. Setting only one leaves the other on its default, /// 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. /// 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. # resolve the agent's durable state dir without hard-coding it.
# `environment.variables` only writes /etc/environment (login # `environment.variables` only writes /etc/environment (login
# shells); `systemd.globalEnvironment` is the analogue for # shells); `systemd.globalEnvironment` is the analogue for
# systemd units so tea-login / forge-avatar-sync / # systemd units so forge-avatar-sync /
# hive-matrix-daemon etc. can read `$HYPERHIVE_STATE_DIR` # hive-matrix-daemon etc. can read `$HYPERHIVE_STATE_DIR`
# without each service having to redeclare it. # without each service having to redeclare it.
environment.variables = { environment.variables = {
@ -1259,7 +1259,7 @@ where
// Forwarded vars (HIVE_FORGE_URL etc.) also go into globalEnvironment, // Forwarded vars (HIVE_FORGE_URL etc.) also go into globalEnvironment,
// not just the harness service env below, so EVERY service + shell in // not just the harness service env below, so EVERY service + shell in
// the container inherits them — crucially the bash-task runner (where // the container inherits them — crucially the bash-task runner (where
// `hive-forge` + `git` actually run), plus the matrix daemon, tea-login // `hive-forge` + `git` actually run), plus the matrix daemon, forge-avatar-sync
// and interactive shells. Scoped to the harness service alone they were // and interactive shells. Scoped to the harness service alone they were
// invisible to bash tasks: harmless in shared netns (the localhost // invisible to bash tasks: harmless in shared netns (the localhost
// default works) but broken under isolation, where the in-cluster // 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() { fn render_flake_sets_service_url_options_from_forwarded_env() {
// The forwarded env vars must ALSO become option assignments, because // The forwarded env vars must ALSO become option assignments, because
// the two are consumed at different times: the option is baked into // the two are consumed at different times: the option is baked into
// scripts at build time (tea-login's FORGE_URL), the env var is read at // scripts at build time (forge-avatar-sync's FORGE_URL), the env var is read at
// runtime. Emitting only the env var leaves the option on its default, // 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 // which is how a hive ends up with two disagreeing answers for the same
// URL. // URL.

View file

@ -361,18 +361,6 @@ pub async fn set_agent_paused(agent_name: &str, paused: bool) -> Result<()> {
.await?) .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 /// Write a Matrix access token for `agent_name` via hive-priv (running as
/// root). `account: None` writes the hive-internal /// root). `account: None` writes the hive-internal
/// `<state>/matrix-token`; `account: Some(name)` writes /// `<state>/matrix-token`; `account: Some(name)` writes

View file

@ -600,29 +600,24 @@ async fn handle_forge_create_user(
"hive-forge container not running — wait for hive-c0re to start it before provisioning forge users" "hive-forge container not running — wait for hive-c0re to start it before provisioning forge users"
); );
} }
let mut out = Vec::new();
if agent_exists(name)? { if agent_exists(name)? {
if password.is_some() { // An agent's forge user and token are swarm-controller's: it mints
// Agents authenticate by API token, never by password — refuse // the token into the swarm secret store and the agent fetches it
// rather than silently dropping a supplied one. // from there. A hive-minted token would be one the swarm neither
// tracks nor rotates.
anyhow::bail!( anyhow::bail!(
"forge create-user: a password is for non-agent (operator) accounts only; '{name}' is an agent which authenticates via API token" "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"
); );
} }
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 {
let token = crate::forge::provision_user_token(name.as_str(), password) let token = crate::forge::provision_user_token(name.as_str(), password)
.await .await
.with_context(|| format!("forge create-user {name}"))?; .with_context(|| format!("forge create-user {name}"))?;
out.push(format!( let mut out = vec![
"forge: provisioned user '{name}' (not an agent — token not persisted)" format!("forge: provisioned user '{name}' (not an agent — token not persisted)"),
)); format!("token: {token}"),
out.push(format!("token: {token}")); ];
if password.is_some() { if password.is_some() {
out.push("password: set as supplied — use it to log into the forge web UI".to_owned()); out.push("password: set as supplied — use it to log into the forge web UI".to_owned());
} else { } else {
@ -630,7 +625,6 @@ async fn handle_forge_create_user(
"password: random throwaway (not surfaced — pass --password or --password-stdin to set one you can use)".to_owned(), "password: random throwaway (not surfaced — pass --password or --password-stdin to set one you can use)".to_owned(),
); );
} }
}
Ok(HostResponse::messages(out)) Ok(HostResponse::messages(out))
} }

View file

@ -37,23 +37,17 @@ async fn forgejo_loop(state_dir: String, socket: std::path::PathBuf) {
} }
}; };
let token_path = format!("{state_dir}/forge-token"); let token_paths = hive_forge_notify::forge_token_paths(&state_dir);
// Retry reading the token to handle races where hive-priv provisions // Retry reading the token to handle races where the agent's fetch of
// it after the harness starts, or where a parent-container chown // it from the swarm secret store lands after this unit starts, or where
// briefly makes the file unreadable. // a parent-container chown briefly makes the file unreadable.
let token = { let mut token = {
let mut attempts = 0u32; let mut attempts = 0u32;
loop { loop {
match tokio::fs::read_to_string(&token_path).await { if let Some(t) = hive_forge_notify::read_first_token(&token_paths) {
Ok(t) => {
let t = t.trim().to_owned();
if !t.is_empty() {
break t; break t;
} }
debug!("forge_notify: empty forge token at {token_path}"); debug!(?token_paths, "forge_notify: no forge token yet");
}
Err(e) => debug!("forge_notify: cannot read token at {token_path}: {e}"),
}
attempts += 1; attempts += 1;
if attempts >= TOKEN_RETRY_MAX { if attempts >= TOKEN_RETRY_MAX {
debug!( debug!(
@ -65,7 +59,7 @@ async fn forgejo_loop(state_dir: String, socket: std::path::PathBuf) {
} }
}; };
let Some(source) = ForgejoSource::new(&forge_url, &token) else { let Some(mut source) = ForgejoSource::new(&forge_url, &token) else {
return; return;
}; };
@ -111,6 +105,18 @@ async fn forgejo_loop(state_dir: String, socket: std::path::PathBuf) {
loop { loop {
interval.tick().await; 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() { if own_login.is_empty() {
own_login = resolve_own_login(&client, &source).await; own_login = resolve_own_login(&client, &source).await;
if !own_login.is_empty() { if !own_login.is_empty() {

Some files were not shown because too many files have changed in this diff Show more