Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ee5a960b4 | ||
|
|
22f0acfd6d | ||
|
|
abc942cff3 | ||
|
|
6d0c30ade2 | ||
|
|
b5d07d4df2 | ||
|
|
dd32a395f7 | ||
|
|
52c8c0b0de |
43 changed files with 1902 additions and 329 deletions
|
|
@ -24,17 +24,30 @@ operator has to place, and where.
|
|||
|
||||
### 1 · Forge
|
||||
|
||||
```bash
|
||||
# Provision (or refresh) ruth's own forge account — do this first. Ruth's
|
||||
# bootstrap bypasses the normal spawn-approval flow ("Spawn sub-agents"
|
||||
# below), so unlike every other agent it does not get its forge account
|
||||
# auto-provisioned — this manual step is still load-bearing.
|
||||
hivectl forge create-user ruth
|
||||
hive-c0re no longer creates agent forge users or mints agent tokens.
|
||||
swarm-controller does, for every agent that holds a store identity: a pass
|
||||
at start and every five minutes creates the forge user if it's missing,
|
||||
then mints the token into the swarm secret store, where the agent fetches
|
||||
it under that identity. An agent created with `swarmctl agent create` gets
|
||||
its identity then. Ruth doesn't: hive-c0re creates her on its own at
|
||||
startup, so she needs her identity minted by hand, once.
|
||||
|
||||
# Sub-agents spawned later (via the approval flow in "Spawn sub-agents")
|
||||
# get their forge accounts auto-provisioned — nothing to run here for them.
|
||||
```bash
|
||||
# On the swarm-controller host: give ruth her store identity. <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
|
||||
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 -->
|
||||
|
||||
- **No forge admin token is stored in any agent state dir.** Agents
|
||||
hold a regular agent token in their `forge-token` file; sensitive
|
||||
hold a regular agent token, fetched from the swarm secret store into
|
||||
`/run/hive-agent-forge-token/token` (or, for an agent without a store
|
||||
identity, the `forge-token` file hive-c0re wrote before); sensitive
|
||||
creds (the core token) live on the host.
|
||||
- All config changes (forge PRs on `agent-configs/<name>`) go through
|
||||
operator approval — agents can't unilaterally rebuild containers, by design.
|
||||
|
|
|
|||
|
|
@ -17,7 +17,9 @@ each agent on relevant activity.
|
|||
|
||||
Two scope sets live in `hive-c0re::forge`:
|
||||
|
||||
**`TOKEN_SCOPES`** (per-agent tokens):
|
||||
**`TOKEN_SCOPES`** (per-agent tokens, and `hivectl forge create-user`
|
||||
accounts). swarm-controller mints agent tokens with a byte-identical copy,
|
||||
`forge::agent_token::AGENT_TOKEN_SCOPES`, pinned by a test:
|
||||
|
||||
| Scope | Why |
|
||||
| -------------------- | ------------------------------------------------------------------------------------------------------- |
|
||||
|
|
@ -46,13 +48,20 @@ hive-c0re to re-mint with the new scopes.
|
|||
|
||||
## Per-agent forge accounts
|
||||
|
||||
Each agent gets its own Forgejo user + access token, provisioned at
|
||||
boot by `hive-c0re::forge`. The provisioning flow is idempotent:
|
||||
`hive-c0re::forge` reuses existing accounts + tokens, so container destroy/recreate
|
||||
doesn't lose forge identity. It writes the token to
|
||||
`<state>/forge-token` (one line, no trailing newline) inside the
|
||||
agent container so `hive-forge` CLI + `forge_notify` poller can
|
||||
read it without touching c0re's host-side credential store.
|
||||
Each agent gets its own Forgejo user and access token. swarm-controller
|
||||
creates the user when it creates the agent, and mints one token named
|
||||
`swarm-agent` with the admin API into the swarm secret store at
|
||||
`swarm/agents/<agent>/forge-token`. A pass at start and every five minutes
|
||||
covers every agent with a store identity: it creates a missing forge user and
|
||||
re-mints any token that's missing or no longer matches the forge;
|
||||
a rotation deletes the old `swarm-agent` token first, so each agent holds at
|
||||
most one. The agent fetches the token under its own store certificate into
|
||||
`/run/hive-agent-forge-token/token` (`nix/agent-modules/forge-token.nix`),
|
||||
and `hive-forge`, the git credential helper, the `forge_notify` poller and
|
||||
the avatar sync read it from there, falling back to `<state>/forge-token`,
|
||||
the file hive-c0re wrote before. hive-c0re no longer creates agent users or
|
||||
mints agent tokens; the `hyperhive-<unix-seconds>` tokens it minted stay on
|
||||
the forge until removed.
|
||||
|
||||
Two things live in the `agent-configs` Forgejo organization:
|
||||
|
||||
|
|
@ -162,7 +171,7 @@ The poller starts disabled and stays that way for any of:
|
|||
|
||||
- `HIVE_FORGE_URL` not set (no forge configured for this hive), or
|
||||
not parseable as a URL.
|
||||
- `<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).
|
||||
- Initial client construction fails (the typed `forgejo-api` client
|
||||
for the API calls, or the plain reqwest client kept for the
|
||||
|
|
|
|||
|
|
@ -263,7 +263,7 @@ wiring is runtime:
|
|||
- the `hyperhive-isolated-dns` oneshot (`nix/agent-modules/network.nix`), gated on that
|
||||
marker, rewrites `/etc/resolv.conf` to `nameserver <gateway-ip>` at boot.
|
||||
It's ordered `before` the harness (`hive-ag3nt`), the matrix daemon, and
|
||||
`tea-login` so the resolver is correct before the first DNS lookup.
|
||||
the forge-token fetch so the resolver is correct before the first DNS lookup.
|
||||
|
||||
**Why isolation is safe**: hive-c0re's control-plane sockets are unix
|
||||
domain sockets bind-mounted into containers, not network listeners — see
|
||||
|
|
|
|||
|
|
@ -502,13 +502,9 @@ sparingly) or build just the suspect check, for example `nix build
|
|||
## Best-effort oneshot services
|
||||
|
||||
The harness ships a family of one-shot systemd services that
|
||||
configure agent-side surfaces from values hive-c0re writes into
|
||||
the state dir at provisioning time:
|
||||
configure agent-side surfaces from values delivered to the agent at
|
||||
provisioning time:
|
||||
|
||||
- `tea-login` — writes `~/.config/tea/config.yml` from the
|
||||
`forge-token` written by `hive-c0re::forge::ensure_user_for`,
|
||||
so `tea repos create` / `tea pulls create` work without
|
||||
interactive prompts.
|
||||
- `forge-avatar-sync` — uploads `services.hyperhive.agent.icon` SVG to the
|
||||
agent's Forgejo profile, so the icon shows up on commits / PRs /
|
||||
issue comments.
|
||||
|
|
@ -538,13 +534,10 @@ Shape contract — every one of these:
|
|||
by the `.path` watchers that re-fire on token appearance (see
|
||||
`docs/agent-lifecycle/persistence.md::Matrix per-agent daemon`).
|
||||
|
||||
The artefact lives under the agent user's home where applicable
|
||||
(`~/.config/tea/config.yml`) and is chown'd to that user, but the
|
||||
service itself stays root-owned so the bootstrap ordering doesn't
|
||||
need a user-existence check before each fire.
|
||||
The service stays root-owned so the bootstrap ordering doesn't need a
|
||||
user-existence check before each fire.
|
||||
|
||||
This pattern keeps the rebuild path resilient: any failure inside
|
||||
these services degrades the corresponding surface (no tea config,
|
||||
no avatar) but never blocks the container from coming up. The
|
||||
these services degrades the corresponding surface (no avatar) but never blocks the container from coming up. The
|
||||
operator notices through `journalctl -u <unit>` rather than a
|
||||
broken switch-to-configuration.
|
||||
|
|
|
|||
|
|
@ -448,7 +448,7 @@ connects to the compositor at `127.0.0.1:<vnc_port>`.
|
|||
must never block on weston signalling readiness. A misconfigured
|
||||
weston degrades to a `Restart=on-failure` loop visible in
|
||||
`journalctl`, it doesn't abort the `nixos-container update`.
|
||||
Same reasoning as the `tea-login` unit in `nix/agent-modules/forge.nix`.
|
||||
Same reasoning as the best-effort oneshots in `docs/process/conventions.md`.
|
||||
- **`[core] idle-time=0`**: disables weston's 300-second idle
|
||||
timeout. Without it the VNC desktop fades to black and
|
||||
desktop-shell shows its click-to-unlock screen — useless for an
|
||||
|
|
|
|||
|
|
@ -42,9 +42,9 @@ to the store under its own name, pulling what it needs when it needs it. No
|
|||
process reads a secret on another principal's behalf: the principal that
|
||||
needs a value is the principal that authenticates for it.
|
||||
|
||||
Two per-agent credential files — the forge token and the github token — sit
|
||||
outside this page: they're operator-supplied and never pass through the
|
||||
store, so the table below doesn't govern them.
|
||||
One per-agent credential file — the github token — sits outside this page:
|
||||
it's operator-supplied and never passes through the store, so the table
|
||||
below doesn't govern it.
|
||||
|
||||
**Per secret, the target specifies minter, reader, and renewal strategy.**
|
||||
Those three are the contract, and the reader is a process pulling a store
|
||||
|
|
@ -55,10 +55,11 @@ strategy for every credential, including the mTLS leaf.
|
|||
<!-- vale write-good.Passive = NO -->
|
||||
|
||||
| store path | minter | reader — pulls at runtime, holds in memory | renewal |
|
||||
| -------------------------------------------- | ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| -------------------------------------------- | ---------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `swarm/agents/<agent>/matrix/<account>` | `swarm-controller` | the agent container itself, under the certificate its hive passed in | must be stated |
|
||||
| `swarm/agents/<agent>/bao-mtls` | `swarm-controller`, at agent creation | `hive-c0re`, under the hive's own certificate, when it writes the agent's container config | must be stated |
|
||||
| `swarm/agents/<agent>/queue` | `swarm-controller`, at agent creation | the agent container itself, under its own certificate — the identity it presents to the swarm queue, naming that one agent rather than its hive | none: the secret is fixed for the life of the agent and is revoked by deleting the path. A rotation mechanism is tracked as separate work, because rotating this credential needs a reconnect path — a queue client holding a revoked secret doesn't find out until it reconnects |
|
||||
| `swarm/agents/<agent>/forge-token` | `swarm-controller`, at agent creation and in a pass every 5 minutes over every agent with a store identity | the agent container itself, under its own certificate, fetched to `/run/hive-agent-forge-token/token` | the controller re-mints when the stored token is missing or no longer matches the forge (last eight characters and scopes); the agent re-fetches on a 10-minute timer |
|
||||
| `swarm/hives/<hive>/matrix/appservice-token` | one minter, on the authelia host | the hive process that presents the token to its homeserver, under the hive's own certificate | must be stated |
|
||||
| `swarm/hives/<hive>/matrix/sender-token` | `swarm-matrix-ctl`, in the `hive-matrix` container | `swarm-matrix-ctl` itself, under its own certificate, before it decides whether to mint, and hive-c0re's `stored_sender_token()`, under the hive's own certificate | must be stated |
|
||||
| `swarm/hives/<hive>/queue/agent` | authelia | the agent container presenting the OIDC client to the swarm queue, under its own certificate | must be stated |
|
||||
|
|
@ -95,6 +96,19 @@ value it holds, so running this against an already-migrated agent doesn't drop
|
|||
its queue connection. The certificate half isn't: the agent gets a fresh leaf
|
||||
and picks it up on its next boot.
|
||||
|
||||
The forge token needs no such step: `swarm-controller` checks every agent
|
||||
that has a store identity at start and every five minutes. For any whose
|
||||
stored token is missing or stale it creates the forge user if there isn't one,
|
||||
then mints the token. To check one agent now:
|
||||
|
||||
```sh
|
||||
swarmctl agent mint-forge-token <agent>
|
||||
```
|
||||
|
||||
An agent without a store identity gets no swarm token and keeps using the
|
||||
`forge-token` file in its state dir, if it has one; run `mint-identity` for it
|
||||
first.
|
||||
|
||||
⚠️ **Run this for every existing agent before deploying a hive-side change
|
||||
that makes a container require a credential it may not have.** A container
|
||||
whose credential is absent doesn't start — that's deliberate, and it's what
|
||||
|
|
|
|||
|
|
@ -14,7 +14,8 @@ markdown-docs > docs/tools/forge-cli.md`.
|
|||
|
||||
## Credentials and repo defaults
|
||||
|
||||
- Credentials: `$HYPERHIVE_STATE_DIR/forge-token`
|
||||
- Credentials: `$HIVE_FORGE_TOKEN_FILE` (the token the agent fetched from
|
||||
the swarm secret store), else `$HYPERHIVE_STATE_DIR/forge-token`
|
||||
- Active repo resolves, highest priority first: global `-r/--repo` flag
|
||||
> the `origin` remote of the cwd's git checkout > `$HIVE_FORGE_REPO`
|
||||
(last-resort override, unset by default) > a hard error.
|
||||
|
|
|
|||
|
|
@ -103,22 +103,22 @@ Manual entry point to the same idempotent provisioning c0re runs at boot — for
|
|||
|
||||
###### **Subcommands:**
|
||||
|
||||
* `create-user` — Create or refresh the Forgejo account + token for `<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
|
||||
|
||||
|
||||
|
||||
## `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>`
|
||||
|
||||
###### **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:**
|
||||
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ at boot. Useful for recovery, ad-hoc re-provisioning, or fixing a single
|
|||
agent without bouncing the daemon.
|
||||
|
||||
```bash
|
||||
hivectl forge create-user iris # provision (or refresh) forge account for agent `iris`
|
||||
hivectl forge create-user iris # refused: `iris` is an agent; use `swarmctl agent mint-forge-token iris`
|
||||
hivectl forge create-user mara # create forge account for a human user; prints token to stdout
|
||||
hivectl forge create-user mara --password hunter2 # set a web-login password
|
||||
hivectl forge create-user mara --password-stdin # read password from stdin (safer for scripting)
|
||||
|
|
@ -40,8 +40,9 @@ hivectl forge reconcile-config iris --verbose # include the full diff, not
|
|||
```
|
||||
|
||||
- For **agents** (name has a state dir under `/var/lib/hyperhive/agents/`):
|
||||
`create-user` persists the token to `<state>/forge-token`. Re-running refreshes the
|
||||
token (idempotent — scope always matches current `TOKEN_SCOPES`).
|
||||
`create-user` refuses. swarm-controller mints an agent's token into
|
||||
the swarm secret store; `swarmctl agent mint-forge-token <agent>` checks
|
||||
and, if needed, re-mints it.
|
||||
- For **non-agents** (humans): creates the account and prints the token to
|
||||
stdout, creating no state dir. Re-running after account already exists
|
||||
re-mints the token and prints it again — safe for password resets.
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ This document contains the help content for the `swarmctl` command-line program.
|
|||
* [`swarmctl agent`↴](#swarmctl-agent)
|
||||
* [`swarmctl agent create`↴](#swarmctl-agent-create)
|
||||
* [`swarmctl agent mint-identity`↴](#swarmctl-agent-mint-identity)
|
||||
* [`swarmctl agent mint-forge-token`↴](#swarmctl-agent-mint-forge-token)
|
||||
* [`swarmctl user`↴](#swarmctl-user)
|
||||
* [`swarmctl user add`↴](#swarmctl-user-add)
|
||||
* [`swarmctl user update`↴](#swarmctl-user-update)
|
||||
|
|
@ -45,6 +46,7 @@ Manage agents across the swarm
|
|||
|
||||
* `create` — Queue creation of a new agent on a hive in this swarm
|
||||
* `mint-identity` — Queue a re-mint of an existing agent's identity at the swarm's secret store
|
||||
* `mint-forge-token` — Check one agent's forge token, and mint it if it's missing or stale
|
||||
|
||||
|
||||
|
||||
|
|
@ -104,6 +106,28 @@ Queues and returns, the same way `agent create` does — watch the swarm UI's jo
|
|||
|
||||
|
||||
|
||||
## `swarmctl agent mint-forge-token`
|
||||
|
||||
Check one agent's forge token, and mint it if it's missing or stale.
|
||||
|
||||
swarm-controller does this for every agent with a store identity at start and every five minutes; this is for when waiting isn't an option. It leaves a current token alone.
|
||||
|
||||
Queues and returns, the same way `agent create` does — watch the swarm UI's job view for the outcome.
|
||||
|
||||
**Usage:** `swarmctl agent mint-forge-token [OPTIONS] <NAME>`
|
||||
|
||||
###### **Arguments:**
|
||||
|
||||
* `<NAME>` — Name of an agent that already exists
|
||||
|
||||
###### **Options:**
|
||||
|
||||
* `--controller-socket <PATH>` — swarm-controller's unix socket.
|
||||
|
||||
Supplied by the nix module that installs this binary, from the same `socketPath` option the daemon binds; falls back to `SWARM_CONTROLLER_SOCKET`.
|
||||
|
||||
|
||||
|
||||
## `swarmctl user`
|
||||
|
||||
Manage subjects in the swarm's SSO provider
|
||||
|
|
|
|||
|
|
@ -236,9 +236,12 @@ token policy bounds file reads; network isolation bounds network reach.
|
|||
this directory at a default mode is world-readable, which isn't
|
||||
hypothetical: `plugins/` and `.last-cleanup` already are.
|
||||
- `$HYPERHIVE_STATE_DIR/forge-token` (= `/agents/<name>/state/forge-token`)
|
||||
— written at mode `0600` and chowned to the per-agent uid:gid (see
|
||||
`hive-c0re/src/forge/mod.rs`'s module doc for exactly where). nixbld users
|
||||
can't read it.
|
||||
— the token hive-c0re wrote at mode `0600`, chowned to the per-agent
|
||||
uid:gid, before swarm-controller took over minting. Nothing writes it any
|
||||
more; it stays as the fallback for an agent without a store identity.
|
||||
nixbld users can't read it. The swarm-minted token lives under
|
||||
`/run/hive-agent-forge-token/` (`0700` directory, `0400` file), outside
|
||||
the state dir.
|
||||
|
||||
**Policy**: all credential files written to agent state directories MUST be mode
|
||||
`0600` or stricter. Don't create world-readable secret files in agent state dirs.
|
||||
|
|
@ -291,7 +294,7 @@ known operations; there is no arbitrary command pass-through:
|
|||
| `RemoveServiceDropin` | remove `container@<name>.service.d/` drop-in on destroy |
|
||||
| `DaemonReload` | `systemctl daemon-reload` |
|
||||
| `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` |
|
||||
| `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` |
|
||||
|
|
|
|||
|
|
@ -137,11 +137,9 @@ services.hyperhive.agent.forge.url = "http://forge.example:3000"; # default: nu
|
|||
services.hyperhive.agent.matrix.url = "https://matrix.example"; # default: null
|
||||
```
|
||||
|
||||
**`services.hyperhive.agent.forge.url`** — base URL of the Forgejo instance. Used by
|
||||
a one-shot boot unit (`tea-login`) that writes `~/.config/tea/config.yml`
|
||||
directly from the agent's `forge-token`, so `tea` and `hive-forge`
|
||||
work without an interactive auth step. The unit is a no-op when
|
||||
`forge-token` is absent. Override when the agent should connect to a
|
||||
**`services.hyperhive.agent.forge.url`** — base URL of the Forgejo instance. It scopes
|
||||
the git credential helper to this forge and is where `forge-avatar-sync`
|
||||
uploads the agent's icon. Override when the agent should connect to a
|
||||
Forgejo on a different host or port (for example a swarm peer's forge).
|
||||
Validated: must be an `http://` or `https://` URL, or `null`.
|
||||
|
||||
|
|
@ -149,7 +147,7 @@ Validated: must be an `http://` or `https://` URL, or `null`.
|
|||
loopback default would only ever be correct when the forge shares the
|
||||
agent's network namespace, and inside a container `localhost` is the
|
||||
agent itself, so the default was a value that built fine and then talked
|
||||
to the wrong machine. With `null` the `tea-login` and `forge-avatar-sync`
|
||||
to the wrong machine. With `null` the credential helper and `forge-avatar-sync`
|
||||
units aren't generated at all: an absent integration rather than a
|
||||
misdirected one. You don't normally set this — hive-c0re renders the
|
||||
host's real forge URL into every agent, and refuses to write a meta
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
url: format!("/{label}"),
|
||||
icon: "🔨".to_owned(),
|
||||
|
|
|
|||
|
|
@ -527,7 +527,7 @@ pub(crate) async fn resolve_approval_dag(
|
|||
let mut terminal_tag = None;
|
||||
match approval.kind {
|
||||
ApprovalKind::Spawn => {
|
||||
// Post-spawn forge bookkeeping (user, config repo mirror, meta
|
||||
// Post-spawn forge bookkeeping (config repo mirror, meta
|
||||
// access) — warn-only, then the resolution events + a rescan so
|
||||
// the dashboard reflects the post-spawn state either way.
|
||||
if result.is_ok() {
|
||||
|
|
@ -605,13 +605,10 @@ fn fetch_approval_for_worker(
|
|||
}
|
||||
|
||||
/// Forge bookkeeping run once after the very first container spawn:
|
||||
/// create the per-agent forge user, mirror the applied repo, and grant
|
||||
/// read access to core/meta. Also rescans containers so the dashboard
|
||||
/// reflects the post-spawn state.
|
||||
/// mirror the applied repo and grant read access to core/meta. The
|
||||
/// agent's forge user and token are swarm-controller's, not this
|
||||
/// hive's. Also rescans containers so the dashboard reflects the post-spawn state.
|
||||
async fn forge_after_first_spawn(coord: &Arc<Coordinator>, agent: &str) {
|
||||
if let Err(e) = crate::forge::ensure_user_for(agent).await {
|
||||
tracing::warn!(%agent, error = ?e, "forge: ensure_user after first spawn failed");
|
||||
}
|
||||
if let Err(e) = crate::forge::ensure_config_repo(agent).await {
|
||||
tracing::warn!(%agent, error = ?e, "forge: ensure_config_repo after first spawn failed");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
//! Optional Forgejo wiring — per-agent user + token provisioning,
|
||||
//! Optional Forgejo wiring — per-agent account alignment,
|
||||
//! config-repo mirroring, meta read-access grants. Also seeds
|
||||
//! `internal/docs` — a private repo every agent gets read-only
|
||||
//! collaborator access to for operator-curated shared content.
|
||||
|
|
@ -21,7 +21,7 @@ pub use repos::{
|
|||
ensure_meta_remote, ensure_repo, ensure_shared_docs_repo, fast_forward_applied_main,
|
||||
fetch_config_main_into_applied, meta_read_access, push_config, push_meta, shared_docs_access,
|
||||
};
|
||||
pub use users::{core_token, ensure_user_for, provision_user_token};
|
||||
pub use users::{core_token, provision_user_token};
|
||||
|
||||
use std::sync::OnceLock;
|
||||
use std::time::{Duration, Instant};
|
||||
|
|
@ -212,9 +212,9 @@ pub(crate) fn api(token: &str) -> Result<Forgejo> {
|
|||
Forgejo::new(Auth::Token(token), url).context("build forgejo api client")
|
||||
}
|
||||
|
||||
/// Per-agent forge sync: ensure the agent has a forgejo user + token,
|
||||
/// a mirrored config repo, read access to `core/meta`, and the `meta`
|
||||
/// remote in its proposed repo. All operations are idempotent; failures
|
||||
/// Per-agent forge sync: align the agent's forge account (email,
|
||||
/// repo-creation lockdown), mirror its config repo, grant read access
|
||||
/// to `core/meta`, and wire the `meta` remote in its proposed repo. All operations are idempotent; failures
|
||||
/// are logged as warnings but don't abort the caller.
|
||||
///
|
||||
/// `core_token` is `core_token()` — passed in so callers that already
|
||||
|
|
@ -228,11 +228,10 @@ pub(crate) fn api(token: &str) -> Result<Forgejo> {
|
|||
/// dashboard warning (see [`ensure_all`]); the rebuild path ignores it and
|
||||
/// relies on the journal `warn!` lines alone (a rebuild is its own retry).
|
||||
pub async fn sync_agent(name: &str, core_token: Option<&str>) -> bool {
|
||||
// The agent's forge user and token are swarm-controller's now
|
||||
// (`MintAgentForgeToken`); this hive no longer creates or re-mints
|
||||
// either.
|
||||
let mut ok = true;
|
||||
if let Err(e) = ensure_user_for(name).await {
|
||||
tracing::warn!(%name, error = ?e, "forge: ensure_user failed");
|
||||
ok = false;
|
||||
}
|
||||
// Align email to match the git user.email set by meta::render_flake
|
||||
// so commits link to the agent's Forgejo profile. Best-effort;
|
||||
// also patches up agents created before this fix (old @hive.local).
|
||||
|
|
|
|||
|
|
@ -313,8 +313,8 @@ pub(super) async fn ensure_repo_creation_disabled(name: &str) {
|
|||
/// a monotonic clock so re-issuing doesn't collide with an existing
|
||||
/// token of the same name in the DB. `scopes` is the scope string
|
||||
/// passed to `forgejo admin user generate-access-token --scopes`;
|
||||
/// use `TOKEN_SCOPES` for agents, `CORE_TOKEN_SCOPES` for the
|
||||
/// bootstrap `core` user.
|
||||
/// use `TOKEN_SCOPES` for `hivectl forge create-user` accounts and
|
||||
/// `CORE_TOKEN_SCOPES` for the bootstrap `core` user.
|
||||
async fn mint_token(name: &str, scopes: &str) -> Result<String> {
|
||||
let token_name = format!(
|
||||
"{TOKEN_NAME_PREFIX}-{}",
|
||||
|
|
@ -349,16 +349,6 @@ async fn mint_token(name: &str, scopes: &str) -> Result<String> {
|
|||
Ok(token)
|
||||
}
|
||||
|
||||
/// Mint a fresh Forgejo access token for an agent and write it to the
|
||||
/// agent's state dir via hive-priv. hive-c0re runs unprivileged and
|
||||
/// cannot write to agent-owned (0755) state directories directly.
|
||||
async fn mint_and_persist_agent_token(name: &str) -> Result<()> {
|
||||
let token = mint_token(name, TOKEN_SCOPES).await?;
|
||||
crate::priv_client::write_agent_forge_token(name, &token)
|
||||
.await
|
||||
.with_context(|| format!("write forge-token for {name} via hive-priv"))
|
||||
}
|
||||
|
||||
/// Mint a fresh Forgejo access token for the `core` admin user and
|
||||
/// write it directly to `path`. Unlike agent tokens this path is owned
|
||||
/// by hive-c0re itself (under `/var/lib/hyperhive/`), so a direct
|
||||
|
|
@ -376,24 +366,11 @@ async fn mint_and_persist_core_token(path: &Path) -> Result<()> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// Ensure `name` has a forgejo user + token file. Always re-mints the
|
||||
/// token so the on-disk file always reflects the current `TOKEN_SCOPES`.
|
||||
/// Safe to call on every spawn and on every hive-c0re startup.
|
||||
pub async fn ensure_user_for(name: &str) -> Result<()> {
|
||||
if !is_present().await {
|
||||
return Ok(());
|
||||
}
|
||||
ensure_user_exists(name, false, None).await?;
|
||||
ensure_user_email(name).await;
|
||||
mint_and_persist_agent_token(name).await
|
||||
}
|
||||
|
||||
/// Provision a forgejo user for `name` and return the freshly-minted
|
||||
/// token. Unlike [`ensure_user_for`], the token is **not** persisted to
|
||||
/// disk — the caller is responsible for storing it. Used by `hivectl
|
||||
/// forge create-user` for human (non-agent) accounts so we don't create
|
||||
/// stray `/var/lib/hyperhive/agents/<name>/` directories for users that
|
||||
/// aren't agents.
|
||||
/// token, which is **not** persisted to disk — the caller is responsible
|
||||
/// for storing it. Used by `hivectl forge create-user` for human
|
||||
/// (non-agent) accounts. Agent tokens are minted by swarm-controller
|
||||
/// (`swarm-controller/src/forge/agent_token.rs`), not here.
|
||||
///
|
||||
/// `password` picks the account password. `None` keeps the existing
|
||||
/// random-throwaway shape (caller doesn't need web UI access — token
|
||||
|
|
|
|||
|
|
@ -642,7 +642,7 @@ const FORWARDED_VARS: &[&str] = &[
|
|||
/// Map of forwarded env var -> the agent option carrying the same value.
|
||||
///
|
||||
/// Both exist because they're consumed at different times: the option is baked
|
||||
/// into scripts at build time (tea-login bakes `FORGE_URL` from it), the env
|
||||
/// into scripts at build time (forge-avatar-sync bakes `FORGE_URL` from it), the env
|
||||
/// var is read at runtime. Setting only one leaves the other on its default,
|
||||
/// which is how a hive ends up with two disagreeing answers for one value.
|
||||
///
|
||||
|
|
@ -1242,7 +1242,7 @@ where
|
|||
# resolve the agent's durable state dir without hard-coding it.
|
||||
# `environment.variables` only writes /etc/environment (login
|
||||
# shells); `systemd.globalEnvironment` is the analogue for
|
||||
# systemd units so tea-login / forge-avatar-sync /
|
||||
# systemd units so forge-avatar-sync /
|
||||
# hive-matrix-daemon etc. can read `$HYPERHIVE_STATE_DIR`
|
||||
# without each service having to redeclare it.
|
||||
environment.variables = {
|
||||
|
|
@ -1259,7 +1259,7 @@ where
|
|||
// Forwarded vars (HIVE_FORGE_URL etc.) also go into globalEnvironment,
|
||||
// not just the harness service env below, so EVERY service + shell in
|
||||
// the container inherits them — crucially the bash-task runner (where
|
||||
// `hive-forge` + `git` actually run), plus the matrix daemon, tea-login
|
||||
// `hive-forge` + `git` actually run), plus the matrix daemon, forge-avatar-sync
|
||||
// and interactive shells. Scoped to the harness service alone they were
|
||||
// invisible to bash tasks: harmless in shared netns (the localhost
|
||||
// default works) but broken under isolation, where the in-cluster
|
||||
|
|
@ -2081,7 +2081,7 @@ mod tests {
|
|||
fn render_flake_sets_service_url_options_from_forwarded_env() {
|
||||
// The forwarded env vars must ALSO become option assignments, because
|
||||
// the two are consumed at different times: the option is baked into
|
||||
// scripts at build time (tea-login's FORGE_URL), the env var is read at
|
||||
// scripts at build time (forge-avatar-sync's FORGE_URL), the env var is read at
|
||||
// runtime. Emitting only the env var leaves the option on its default,
|
||||
// which is how a hive ends up with two disagreeing answers for the same
|
||||
// URL.
|
||||
|
|
|
|||
|
|
@ -361,18 +361,6 @@ pub async fn set_agent_paused(agent_name: &str, paused: bool) -> Result<()> {
|
|||
.await?)
|
||||
}
|
||||
|
||||
/// Write the Forgejo access token for `agent_name` to
|
||||
/// `<agent_state_root>/<agent_name>/state/forge-token` via hive-priv
|
||||
/// (running as root). The file is written 0600 and chowned to the agent
|
||||
/// user so it is readable from inside the agent container.
|
||||
pub async fn write_agent_forge_token(agent_name: &str, token: &str) -> Result<()> {
|
||||
ok(call(&PrivRequest::WriteAgentForgeToken {
|
||||
agent_name: agent_name.to_owned(),
|
||||
token: token.to_owned(),
|
||||
})
|
||||
.await?)
|
||||
}
|
||||
|
||||
/// Write a Matrix access token for `agent_name` via hive-priv (running as
|
||||
/// root). `account: None` writes the hive-internal
|
||||
/// `<state>/matrix-token`; `account: Some(name)` writes
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
if agent_exists(name)? {
|
||||
if password.is_some() {
|
||||
// Agents authenticate by API token, never by password — refuse
|
||||
// rather than silently dropping a supplied one.
|
||||
// An agent's forge user and token are swarm-controller's: it mints
|
||||
// the token into the swarm secret store and the agent fetches it
|
||||
// from there. A hive-minted token would be one the swarm neither
|
||||
// tracks nor rotates.
|
||||
anyhow::bail!(
|
||||
"forge create-user: 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)
|
||||
.await
|
||||
.with_context(|| format!("forge create-user {name}"))?;
|
||||
out.push(format!(
|
||||
"forge: provisioned user '{name}' (not an agent — token not persisted)"
|
||||
));
|
||||
out.push(format!("token: {token}"));
|
||||
let mut out = vec![
|
||||
format!("forge: provisioned user '{name}' (not an agent — token not persisted)"),
|
||||
format!("token: {token}"),
|
||||
];
|
||||
if password.is_some() {
|
||||
out.push("password: set as supplied — use it to log into the forge web UI".to_owned());
|
||||
} else {
|
||||
|
|
@ -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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
Ok(HostResponse::messages(out))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -37,23 +37,17 @@ async fn forgejo_loop(state_dir: String, socket: std::path::PathBuf) {
|
|||
}
|
||||
};
|
||||
|
||||
let token_path = format!("{state_dir}/forge-token");
|
||||
// Retry reading the token to handle races where hive-priv provisions
|
||||
// it after the harness starts, or where a parent-container chown
|
||||
// briefly makes the file unreadable.
|
||||
let token = {
|
||||
let token_paths = hive_forge_notify::forge_token_paths(&state_dir);
|
||||
// Retry reading the token to handle races where the agent's fetch of
|
||||
// it from the swarm secret store lands after this unit starts, or where
|
||||
// a parent-container chown briefly makes the file unreadable.
|
||||
let mut token = {
|
||||
let mut attempts = 0u32;
|
||||
loop {
|
||||
match tokio::fs::read_to_string(&token_path).await {
|
||||
Ok(t) => {
|
||||
let t = t.trim().to_owned();
|
||||
if !t.is_empty() {
|
||||
if let Some(t) = hive_forge_notify::read_first_token(&token_paths) {
|
||||
break t;
|
||||
}
|
||||
debug!("forge_notify: empty forge token at {token_path}");
|
||||
}
|
||||
Err(e) => debug!("forge_notify: cannot read token at {token_path}: {e}"),
|
||||
}
|
||||
debug!(?token_paths, "forge_notify: no forge token yet");
|
||||
attempts += 1;
|
||||
if attempts >= TOKEN_RETRY_MAX {
|
||||
debug!(
|
||||
|
|
@ -65,7 +59,7 @@ async fn forgejo_loop(state_dir: String, socket: std::path::PathBuf) {
|
|||
}
|
||||
};
|
||||
|
||||
let Some(source) = ForgejoSource::new(&forge_url, &token) else {
|
||||
let Some(mut source) = ForgejoSource::new(&forge_url, &token) else {
|
||||
return;
|
||||
};
|
||||
|
||||
|
|
@ -111,6 +105,18 @@ async fn forgejo_loop(state_dir: String, socket: std::path::PathBuf) {
|
|||
|
||||
loop {
|
||||
interval.tick().await;
|
||||
// The swarm rotates the token when it goes stale, and the agent's
|
||||
// fetch replaces the file; pick the new one up rather than polling
|
||||
// with a revoked token for the rest of this process's life.
|
||||
if let Some(t) = hive_forge_notify::read_first_token(&token_paths)
|
||||
&& t != token
|
||||
&& let Some(s) = ForgejoSource::new(&forge_url, &t)
|
||||
{
|
||||
info!("forge_notify: forge token changed on disk; using the new one");
|
||||
token = t;
|
||||
source = s;
|
||||
own_login.clear();
|
||||
}
|
||||
if own_login.is_empty() {
|
||||
own_login = resolve_own_login(&client, &source).await;
|
||||
if !own_login.is_empty() {
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue