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 ### 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 ```bash
# On the swarm-controller host: give ruth her store identity. <hive> is the # Provision (or refresh) ruth's own forge account — do this first. Ruth's
# name of the hive she runs on. # bootstrap bypasses the normal spawn-approval flow ("Spawn sub-agents"
swarmctl agent mint-identity ruth --hive <hive> # 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 # Sub-agents spawned later (via the approval flow in "Spawn sub-agents")
# hands the new identity to the container. # get their forge accounts auto-provisioned — nothing to run here for them.
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`).
@ -280,9 +267,7 @@ 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, fetched from the swarm secret store into hold a regular agent token in their `forge-token` file; sensitive
`/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,9 +17,7 @@ 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, and `hivectl forge create-user` **`TOKEN_SCOPES`** (per-agent tokens):
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 |
| -------------------- | ------------------------------------------------------------------------------------------------------- | | -------------------- | ------------------------------------------------------------------------------------------------------- |
@ -48,20 +46,13 @@ hive-c0re to re-mint with the new scopes.
## Per-agent forge accounts ## Per-agent forge accounts
Each agent gets its own Forgejo user and access token. swarm-controller Each agent gets its own Forgejo user + access token, provisioned at
creates the user when it creates the agent, and mints one token named boot by `hive-c0re::forge`. The provisioning flow is idempotent:
`swarm-agent` with the admin API into the swarm secret store at `hive-c0re::forge` reuses existing accounts + tokens, so container destroy/recreate
`swarm/agents/<agent>/forge-token`. A pass at start and every five minutes doesn't lose forge identity. It writes the token to
covers every agent with a store identity: it creates a missing forge user and `<state>/forge-token` (one line, no trailing newline) inside the
re-mints any token that's missing or no longer matches the forge; agent container so `hive-forge` CLI + `forge_notify` poller can
a rotation deletes the old `swarm-agent` token first, so each agent holds at read it without touching c0re's host-side credential store.
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:
@ -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 - `HIVE_FORGE_URL` not set (no forge configured for this hive), or
not parseable as a URL. 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). 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
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 **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,9 +502,13 @@ 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 delivered to the agent at configure agent-side surfaces from values hive-c0re writes into
provisioning time: 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 - `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.
@ -534,10 +538,13 @@ 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 service stays root-owned so the bootstrap ordering doesn't need a The artefact lives under the agent user's home where applicable
user-existence check before each fire. (`~/.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 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 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 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 - **`[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.
One per-agent credential file — the github token — sits outside this page: Two per-agent credential files — the forge token and the github token — sit
it's operator-supplied and never passes through the store, so the table outside this page: they're operator-supplied and never pass through the
below doesn't govern it. store, so the table below doesn't govern them.
**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,11 +55,10 @@ 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 |
@ -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 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,8 +14,7 @@ markdown-docs > docs/tools/forge-cli.md`.
## Credentials and repo defaults ## Credentials and repo defaults
- Credentials: `$HIVE_FORGE_TOKEN_FILE` (the token the agent fetched from - Credentials: `$HYPERHIVE_STATE_DIR/forge-token`
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 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 * `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 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>` **Usage:** `hivectl forge create-user [OPTIONS] <NAME>`
###### **Arguments:** ###### **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:** ###### **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 # 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 # 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,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/`): - For **agents** (name has a state dir under `/var/lib/hyperhive/agents/`):
`create-user` refuses. swarm-controller mints an agent's token into `create-user` persists the token to `<state>/forge-token`. Re-running refreshes the
the swarm secret store; `swarmctl agent mint-forge-token <agent>` checks token (idempotent — scope always matches current `TOKEN_SCOPES`).
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,7 +8,6 @@ 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)
@ -46,7 +45,6 @@ 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
@ -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` ## `swarmctl user`
Manage subjects in the swarm's SSO provider 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 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`)
— the token hive-c0re wrote at mode `0600`, chowned to the per-agent — written at mode `0600` and chowned to the per-agent uid:gid (see
uid:gid, before swarm-controller took over minting. Nothing writes it any `hive-c0re/src/forge/mod.rs`'s module doc for exactly where). nixbld users
more; it stays as the fallback for an agent without a store identity. can't read it.
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.
@ -294,7 +291,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>` |
| `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` | | `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,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.matrix.url = "https://matrix.example"; # default: null
``` ```
**`services.hyperhive.agent.forge.url`** — base URL of the Forgejo instance. It scopes **`services.hyperhive.agent.forge.url`** — base URL of the Forgejo instance. Used by
the git credential helper to this forge and is where `forge-avatar-sync` a one-shot boot unit (`tea-login`) that writes `~/.config/tea/config.yml`
uploads the agent's icon. Override when the agent should connect to a 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). 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`.
@ -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 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 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 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,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 if crate::paths::state_dir().join("forge-token").is_file() {
// 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 (config repo mirror, meta // Post-spawn forge bookkeeping (user, 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,10 +605,13 @@ 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:
/// mirror the applied repo and grant read access to core/meta. The /// create the per-agent forge user, mirror the applied repo, and grant
/// agent's forge user and token are swarm-controller's, not this /// read access to core/meta. Also rescans containers so the dashboard
/// hive's. Also rescans containers so the dashboard reflects the post-spawn state. /// 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 account alignment, //! Optional Forgejo wiring — per-agent user + token provisioning,
//! 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, provision_user_token}; pub use users::{core_token, ensure_user_for, 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: align the agent's forge account (email, /// Per-agent forge sync: ensure the agent has a forgejo user + token,
/// repo-creation lockdown), mirror its config repo, grant read access /// a mirrored config repo, read access to `core/meta`, and the `meta`
/// to `core/meta`, and wire the `meta` remote in its proposed repo. All operations are idempotent; failures /// 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,10 +228,11 @@ 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 `hivectl forge create-user` accounts and /// use `TOKEN_SCOPES` for agents, `CORE_TOKEN_SCOPES` for the
/// `CORE_TOKEN_SCOPES` for the bootstrap `core` user. /// 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,6 +349,16 @@ 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
@ -366,11 +376,24 @@ 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, which is **not** persisted to disk — the caller is responsible /// token. Unlike [`ensure_user_for`], the token is **not** persisted to
/// for storing it. Used by `hivectl forge create-user` for human /// disk — the caller is responsible for storing it. Used by `hivectl
/// (non-agent) accounts. Agent tokens are minted by swarm-controller /// forge create-user` for human (non-agent) accounts so we don't create
/// (`swarm-controller/src/forge/agent_token.rs`), not here. /// stray `/var/lib/hyperhive/agents/<name>/` directories for users that
/// 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 (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, /// 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 forge-avatar-sync / # systemd units so tea-login / 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, 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 // 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 (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, // 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,6 +361,18 @@ 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,24 +600,29 @@ 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)? {
// An agent's forge user and token are swarm-controller's: it mints if password.is_some() {
// the token into the swarm secret store and the agent fetches it // Agents authenticate by API token, never by password — refuse
// from there. A hive-minted token would be one the swarm neither // rather than silently dropping a supplied one.
// tracks nor rotates.
anyhow::bail!( anyhow::bail!(
"forge create-user: '{name}' is an agent; its forge token is minted by \ "forge create-user: a password is for non-agent (operator) accounts only; '{name}' is an agent which authenticates via API token"
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}"))?;
let mut out = vec![ out.push(format!(
format!("forge: provisioned user '{name}' (not an agent — token not persisted)"), "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 {
@ -625,6 +630,7 @@ 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,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); let token_path = format!("{state_dir}/forge-token");
// Retry reading the token to handle races where the agent's fetch of // Retry reading the token to handle races where hive-priv provisions
// it from the swarm secret store lands after this unit starts, or where // it after the harness starts, or where a parent-container chown
// a parent-container chown briefly makes the file unreadable. // briefly makes the file unreadable.
let mut token = { let token = {
let mut attempts = 0u32; let mut attempts = 0u32;
loop { loop {
if let Some(t) = hive_forge_notify::read_first_token(&token_paths) { match tokio::fs::read_to_string(&token_path).await {
Ok(t) => {
let t = t.trim().to_owned();
if !t.is_empty() {
break t; break t;
} }
debug!(?token_paths, "forge_notify: no forge token yet"); debug!("forge_notify: empty forge token at {token_path}");
}
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!(
@ -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; return;
}; };
@ -105,18 +111,6 @@ 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