Merge remote-tracking branch 'forge/main' into docs/networking-scheduler-pass
# Conflicts: # docs/networking/gateway.md
This commit is contained in:
commit
f63ab954c9
57 changed files with 1042 additions and 1070 deletions
|
|
@ -5,9 +5,9 @@ HTTPS, both authenticated by an operator-supplied personal access token
|
|||
(PAT) — so it can run GitHub API calls and push commits without any manual
|
||||
`gh auth login`.
|
||||
|
||||
Provisioning is UI-driven: paste a PAT into the agent's credentials tab
|
||||
and it works. No per-agent nix declaration, no rebuild — hive-c0re
|
||||
injects the token into the agent's state dir out of band.
|
||||
Provisioning is UI-driven: link a PAT to the agent in the swarm UI and it
|
||||
works. No per-agent nix declaration, no rebuild — the agent fetches the token
|
||||
from the swarm secret store itself.
|
||||
|
||||
## Enabling
|
||||
|
||||
|
|
@ -15,8 +15,7 @@ injects the token into the agent's state dir out of band.
|
|||
|
||||
The integration is **on by default** for every agent (`services.hyperhive.agent.github.enable
|
||||
= true`), inert until the operator provisions a PAT. No per-agent declaration is
|
||||
needed — an agent gains GitHub by having a PAT written to its token
|
||||
file.
|
||||
needed — an agent gains GitHub by having a PAT stored for it.
|
||||
|
||||
<!-- vale write-good.Passive = YES -->
|
||||
|
||||
|
|
@ -27,29 +26,31 @@ services.hyperhive.github.enable = false;
|
|||
```
|
||||
|
||||
hive-c0re's meta-flake renderer then injects `services.hyperhive.agent.github.enable = false`
|
||||
into every agent, so no agent ships the `gh` wrapper or credential helper.
|
||||
into every agent, so no agent ships the `gh` wrapper, the credential helper
|
||||
or the token fetch.
|
||||
(`services.hyperhive.agent.github.enable` also exists per-agent for completeness, but the
|
||||
hive-wide host switch is the intended control.)
|
||||
|
||||
github.com only. The token **value** never touches nix — it's written to
|
||||
`<state>/github-token` separately (see [Provisioning](#provisioning)).
|
||||
github.com only. The token **value** never touches nix — the agent writes it
|
||||
to `<state>/github-token` at runtime (see [Provisioning](#provisioning)).
|
||||
|
||||
## Provisioning
|
||||
|
||||
The PAT is operator-supplied. The primary path is the **dashboard
|
||||
credentials tab** (github sub-tab): paste the PAT for an agent and submit
|
||||
(`POST /api/github-account`). A CLI path also exists for
|
||||
recovery/scripting:
|
||||
The PAT is operator-supplied. In the [swarm UI](../swarm/ui.md#linking-external-accounts),
|
||||
open the agent on `/agents`, choose **link github account** and paste the PAT
|
||||
(`PUT /api/hives/{hive}/agents/{agent}/github-account`). swarm-controller
|
||||
stores it at `swarm/agents/<agent>/github-token` in the swarm secret store;
|
||||
no hive writes it. One token per agent: linking again replaces it,
|
||||
and no route hands it back.
|
||||
|
||||
```sh
|
||||
hivectl github set-token <agent> --token-stdin # paste the PAT on stdin (preferred)
|
||||
hivectl github set-token <agent> --token <pat> # inline (visible in shell history)
|
||||
```
|
||||
|
||||
Either path has hive-c0re delegate the write to hive-priv, which stores the
|
||||
file `0600` owned by the agent (so the container can read it) — the same
|
||||
credential-injection path as forge/matrix tokens. See
|
||||
[hivectl → GitHub](../tools/hivectl.md#github).
|
||||
The agent's `hive-agent-github-token` unit reads that path under the
|
||||
agent's own store certificate and writes `<state>/github-token` (`0600`,
|
||||
owned by the agent), on boot and every two minutes, replacing the file only
|
||||
when the token changed. It needs a store identity
|
||||
(`services.hyperhive.agent.bao.addr`); an agent without one gets no token.
|
||||
The unit never deletes the file: a `github-token` already in place stays
|
||||
when the store holds none or doesn't answer. Where the token lives and who
|
||||
reads it: [credentials.md](../swarm/credentials.md).
|
||||
|
||||
## Security
|
||||
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ You rarely switch it on yourself. `gateway.enable` defaults to off, and every mo
|
|||
| --- | --- | --- |
|
||||
| `<swarm>/` | swarm-ui dist (static), behind an authelia subrequest | `swarm-ui.nix`, `deploy.swarm-ui.enable` |
|
||||
| `auth.<swarm>/` | authelia (`9091`) | `swarm-authelia.nix`, `deploy.authelia.enable` |
|
||||
| `forge.<swarm>/` | forgejo (`3000`) | `hive-forge/`, `deploy.forgejo.behindGateway` |
|
||||
| `forge.<swarm>/` | forgejo (`3000`) | `hive-forge/`, `deploy.forgejo.enable` |
|
||||
| `chat.<swarm>/_matrix/*` | tuwunel (`8008`) | `hive-matrix.nix`, `swarm.matrix.gatewayHost != null` |
|
||||
| `chat.<swarm>/` | fluffychat-web static (404 with the GUI off) | `hive-matrix.nix`, `deploy.matrix.gui.enable` |
|
||||
| `chat.<swarm>/config.json` | inline JSON (FluffyChat boot config) | `hive-matrix.nix`, `deploy.matrix.gui.enable` |
|
||||
|
|
@ -170,7 +170,7 @@ services.hyperhive.deploy.forgejo.openFirewall = false; # default
|
|||
|
||||
`sshPort` serves `git clone/push/pull` over SSH (`git@<domain>:owner/repo.git` with `-p 2222`). SSH goes straight to Forgejo, not through nginx.
|
||||
|
||||
`openFirewall` (default `false`) opens `httpPort` and `sshPort` on the host. Agents don't need it — they come through the gateway. Set it for a browser reaching `http://<host>:<httpPort>/` directly, or for external git clients pushing over SSH. The forge vhost behind the gateway (`deploy.forgejo.behindGateway`, default `true`) needs only the gateway's own `openFirewall`.
|
||||
`openFirewall` (default `false`) opens `httpPort` and `sshPort` on the host. Agents don't need it — they come through the gateway. Set it for a browser reaching `http://<host>:<httpPort>/` directly, or for external git clients pushing over SSH. Forge's gateway vhost doesn't need `openFirewall` — the gateway's own `openFirewall` option covers that path.
|
||||
|
||||
### `rootUrl` override
|
||||
|
||||
|
|
@ -178,14 +178,7 @@ services.hyperhive.deploy.forgejo.openFirewall = false; # default
|
|||
services.hyperhive.swarm.forge.rootUrl = "https://forge.example.com/";
|
||||
```
|
||||
|
||||
`rootUrl` (default `null`) overrides the Forgejo `ROOT_URL` derived from `forge.domain` and the gateway:
|
||||
|
||||
| Shape | Derived `ROOT_URL` |
|
||||
|---|---|
|
||||
| `deploy.forgejo.behindGateway = true` | `https://<forge.domain>/` (no port suffix when `gateway.httpsPort == 443`) |
|
||||
| `deploy.forgejo.behindGateway = false` | `http://<forge.domain>:<httpPort>/` |
|
||||
|
||||
Set it when `forge.domain` differs from the public URL, or for a bespoke shape such as an external reverse proxy on another host or path. It must end with `/` (an assertion enforces this).
|
||||
`rootUrl` (default `null`) overrides the Forgejo `ROOT_URL` that's autoderived as `https://<forge.domain>/`, with `:<gateway.httpsPort>` appended when that port isn't 443. The gateway always terminates TLS, so the forge is always advertised over `https://`. Set `rootUrl` explicitly when `forge.domain` differs from the public URL, or for a bespoke shape such as an external reverse proxy on another host or path. It must end with `/` (an assertion enforces this).
|
||||
|
||||
## Security headers
|
||||
|
||||
|
|
@ -218,7 +211,7 @@ When enabled, every vhost adds `Strict-Transport-Security: max-age=…[; include
|
|||
`services.hyperhive.gateway.localHostsEntry = true` maps to `127.0.0.1` in the host's `/etc/hosts`:
|
||||
|
||||
- the hive domain;
|
||||
- every name a module on this host contributes to `gateway.localNames` — each swarm service this host runs adds its own (`forge.<swarm>` when behind the gateway, `chat.<swarm>`, `auth.<swarm>`, the swarm UI's apex, …).
|
||||
- every name a module on this host contributes to `gateway.localNames` — each swarm service this host runs adds its own (`forge.<swarm>`, `chat.<swarm>`, `auth.<swarm>`, the swarm UI's apex, …).
|
||||
|
||||
`services.hyperhive.deploy.singleHostSwarm` turns it on. Leave it off with real DNS.
|
||||
|
||||
|
|
|
|||
|
|
@ -119,7 +119,7 @@ build can't hold the runner's single slot indefinitely).
|
|||
|
||||
## Container design
|
||||
|
||||
- **Private netns, bridge-attached**: the container runs in its own network namespace (`privateNetwork = true`, `hostBridge`) and reaches hive-forge through the gateway at `http://<forge.domain>` (resolved to the bridge IP via `networking.extraHosts`). It can't reach host-loopback services — the core dashboard at `127.0.0.1:7000` and the raw forge port are unreachable from CI. Requires `deploy.forgejo.behindGateway = true`.
|
||||
- **Private netns, bridge-attached**: the container runs in its own network namespace (`privateNetwork = true`, `hostBridge`) and reaches hive-forge through the gateway at `http://<forge.domain>` (resolved to the bridge IP via `networking.extraHosts`). It can't reach host-loopback services — the core dashboard at `127.0.0.1:7000` and the raw forge port are unreachable from CI.
|
||||
- **Non-ephemeral**: runner credentials persist across restarts (written to container's stateDir on first registration, reused thereafter).
|
||||
- **Sandbox fallback**: nspawn containers can't create user-namespaces, so nix's sandboxing would always fail. Module sets `nix.settings.sandbox-fallback = true` in the container — nix builds run unsandboxed (safe because the container is already isolated). See `docs/process/gotchas.md`.
|
||||
- **Credential isolation**: the forge admin token (`forge-core-token`) never enters the container. hive-c0re holds it and performs all forge API calls (runner validation + registration-token mint, in `forge/ci_runner.rs`); via hive-priv it writes only the runner registration token to the host env-file `/run/hive-ci/runner-token`, which the container bind-mounts read-only.
|
||||
|
|
|
|||
|
|
@ -42,10 +42,6 @@ to the store under its own name, pulling what it needs when it needs it. No
|
|||
process reads a secret on another principal's behalf: the principal that
|
||||
needs a value is the principal that authenticates for it.
|
||||
|
||||
One per-agent credential file — the github token — sits outside this page:
|
||||
it's operator-supplied and never passes through the store, so the table
|
||||
below doesn't govern it.
|
||||
|
||||
**Per secret, the target specifies minter, reader, and renewal strategy.**
|
||||
Those three are the contract, and the reader is a process pulling a store
|
||||
path at runtime — not a path on disk, and not a unit whose job is to turn a
|
||||
|
|
@ -69,6 +65,7 @@ of the cell says how.
|
|||
| `swarm/agents/<agent>/queue` | `swarm-controller`, at agent creation | `hive-agent` in the agent container, under the agent's own certificate, held in memory — the identity it presents to the swarm queue, naming that one agent rather than its hive | ✅ `swarm-controller`'s five-minute pass re-mints a live agent's secret once it's 45 days old by `minted_at` on the stored object; a secret with no `minted_at` gets one stamped, value unchanged. The pass skips agents declared `Destroyed` — declaring an agent destroyed deletes every version of the path instead, the undo of the mint rather than another one | ✅ `hive-agent` reads the path before its first connect and again on every reconnect attempt, so a reconnect after a re-mint presents the new secret. An open connection keeps the secret it connected with; after a revocation the agent keeps retrying under the queue client's backoff |
|
||||
| `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/agents/<agent>/forge/<label>` | `swarm-controller`, when an operator links an external forge account in the swarm UI | `hive-agent-forge-accounts` in the agent container, under the agent's own certificate, into `<state>/forge-<label>-token` and `forge-<label>.json` | ❌ an operator's token; replaced only by linking the label again | ✅ the agent re-fetches on a 2-minute timer |
|
||||
| `swarm/agents/<agent>/github-token` | `swarm-controller`, when an operator links a GitHub account in the swarm UI | `hive-agent-github-token` in the agent container, under the agent's own certificate, into `<state>/github-token` | ❌ an operator's token; replaced only by linking it again | ✅ the agent re-fetches on a 2-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 | must be stated |
|
||||
| `swarm/hives/<hive>/matrix/sender-token` | `swarm-controller`, with the swarm's appservice token, for every hive in its directory in a five-minute pass | `swarm-controller` under its own certificate, before it decides whether to mint, and hive-c0re's `stored_sender_token()`, under the hive's own certificate | ✅ the controller's pass re-mints when the stored token is missing, unknown to the homeserver, or someone else's | ✅ hive-c0re's matrix sweep reads the store every run and overwrites its token file when the store's token differs |
|
||||
| `swarm/hives/<hive>/queue/agent` | authelia | `swarm-bao-queue-agent` on the hive's host, under its own per-hive certificate; no agent's policy reaches it | must be stated | must be stated |
|
||||
|
|
|
|||
|
|
@ -7,13 +7,13 @@ domain, covers host-level detail for one hive.
|
|||
|
||||
## What it shows
|
||||
|
||||
| route | what |
|
||||
| ------------------------- | ------------------------------------------------------------------------------------------- |
|
||||
| `/` | the hive directory, each hive with its last reported status |
|
||||
| `/agents` | every agent: status, config PR, wanted state; create agents, link forge and matrix accounts |
|
||||
| `/agents/<name>/terminal` | one agent's live terminal |
|
||||
| `/jobs` | the controller's job graph — where agent creation and credential mints show progress |
|
||||
| `/issues` | a cross-repo issue report |
|
||||
| route | what |
|
||||
| ------------------------- | --------------------------------------------------------------------------------------------------- |
|
||||
| `/` | the hive directory, each hive with its last reported status |
|
||||
| `/agents` | every agent: status, config PR, wanted state; create agents, link forge, matrix and GitHub accounts |
|
||||
| `/agents/<name>/terminal` | one agent's live terminal |
|
||||
| `/jobs` | the controller's job graph — where agent creation and credential mints show progress |
|
||||
| `/issues` | a cross-repo issue report |
|
||||
|
||||
Everything it shows comes from [`swarm-controller`](../../swarm-controller/README.md).
|
||||
An agent created here or with `swarmctl agent create` starts `paused`; set it
|
||||
|
|
@ -21,8 +21,8 @@ An agent created here or with `swarmctl agent create` starts `paused`; set it
|
|||
|
||||
### Linking external accounts
|
||||
|
||||
Each agent on `/agents` opens two dialogs that write a credential for it
|
||||
into the swarm secret store through swarm-controller. Both are blind
|
||||
Each agent on `/agents` opens three dialogs that write a credential for it
|
||||
into the swarm secret store through swarm-controller. All three are blind
|
||||
set/update actions: no route lists linked accounts or hands a token back.
|
||||
|
||||
- **link a matrix account** — `PUT /api/hives/{hive}/agents/{agent}/matrix-accounts/{account}`,
|
||||
|
|
@ -36,6 +36,14 @@ set/update actions: no route lists linked accounts or hands a token back.
|
|||
`hive-forge -f <label>` reads. The unit never deletes a pair: linking
|
||||
the same label again overwrites both files, and a pair whose label the
|
||||
store doesn't list stays untouched.
|
||||
- **link a github account** — `PUT /api/hives/{hive}/agents/{agent}/github-account`
|
||||
with a personal access token, stored at `swarm/agents/<agent>/github-token`.
|
||||
One token per agent: linking again replaces it. The agent's
|
||||
`hive-agent-github-token` unit fetches it into `<state>/github-token`,
|
||||
the file its `gh` wrapper, git credential helper and GitHub notification
|
||||
poller read. A `github-token` already in place stays when the store holds
|
||||
none. What the token needs and how the agent uses it:
|
||||
[GitHub accounts](../integrations/github.md).
|
||||
|
||||
Where each credential lives and who reads it:
|
||||
[`credentials.md`](credentials.md).
|
||||
|
|
|
|||
|
|
@ -9,8 +9,6 @@ This document contains the help content for the `hivectl` command-line program.
|
|||
* [`hivectl forge reconcile-config`↴](#hivectl-forge-reconcile-config)
|
||||
* [`hivectl matrix`↴](#hivectl-matrix)
|
||||
* [`hivectl matrix invite`↴](#hivectl-matrix-invite)
|
||||
* [`hivectl github`↴](#hivectl-github)
|
||||
* [`hivectl github set-token`↴](#hivectl-github-set-token)
|
||||
* [`hivectl gateway`↴](#hivectl-gateway)
|
||||
* [`hivectl gateway create-user`↴](#hivectl-gateway-create-user)
|
||||
* [`hivectl gateway delete-user`↴](#hivectl-gateway-delete-user)
|
||||
|
|
@ -64,7 +62,6 @@ Sibling to the `hive-c0re` daemon binary. Covers host-side admin operations that
|
|||
|
||||
* `forge` — Reconcile an agent's config between this hive and the forge
|
||||
* `matrix` — matrix-tuwunel invites
|
||||
* `github` — GitHub account provisioning
|
||||
* `gateway` — Gateway htpasswd user management
|
||||
* `agent` — Lifecycle actions on ONE managed agent container. Needs the hive-c0re daemon running
|
||||
* `list-agents` — Show all managed agents with their status and technical state
|
||||
|
|
@ -156,39 +153,6 @@ Invite a matrix user to the hive Space, or a specific room with `--room`. Idempo
|
|||
|
||||
|
||||
|
||||
## `hivectl github`
|
||||
|
||||
GitHub account provisioning.
|
||||
|
||||
Store an operator-supplied personal access token (PAT) for an agent so its `gh` and git can authenticate. Creates no account — the PAT is for an existing GitHub account.
|
||||
|
||||
**Usage:** `hivectl github <COMMAND>`
|
||||
|
||||
###### **Subcommands:**
|
||||
|
||||
* `set-token` — Store a GitHub PAT for `<agent>` so its `gh` and git can authenticate
|
||||
|
||||
|
||||
|
||||
## `hivectl github set-token`
|
||||
|
||||
Store a GitHub PAT for `<agent>` so its `gh` and git can authenticate.
|
||||
|
||||
Prefer `--token-stdin` — an inline `--token` is visible in shell history.
|
||||
|
||||
**Usage:** `hivectl github set-token [OPTIONS] <AGENT>`
|
||||
|
||||
###### **Arguments:**
|
||||
|
||||
* `<AGENT>` — Logical agent name (the container/agent name)
|
||||
|
||||
###### **Options:**
|
||||
|
||||
* `--token <TOKEN>` — The PAT value inline. Mutually exclusive with `--token-stdin`
|
||||
* `--token-stdin` — Read the PAT from stdin (trailing newline stripped). Mutually exclusive with `--token`
|
||||
|
||||
|
||||
|
||||
## `hivectl gateway`
|
||||
|
||||
Gateway htpasswd user management.
|
||||
|
|
|
|||
|
|
@ -65,30 +65,6 @@ group; nobody has built that sync yet.
|
|||
invite power (it owns the hive Space, so that case always works).
|
||||
Idempotent — already-member / already-invited is a no-op.
|
||||
|
||||
## GitHub
|
||||
|
||||
<!-- vale write-good.Passive = NO -->
|
||||
|
||||
Write an operator-supplied GitHub personal access token (PAT) into an
|
||||
agent's token file so its `gh` wrapper + git credential helper can act as
|
||||
the bot account. Unlike matrix there is no account creation — the PAT
|
||||
is for an existing GitHub account. A CLI alternative to the dashboard
|
||||
credentials tab; the [GitHub integration](../integrations/github.md) is on by default
|
||||
(`services.hyperhive.agent.github.enable`), so no per-agent config is needed.
|
||||
|
||||
<!-- vale write-good.Passive = YES -->
|
||||
|
||||
```bash
|
||||
hivectl github set-token damocles --token-stdin # paste the PAT on stdin (preferred)
|
||||
hivectl github set-token damocles --token <pat> # inline (visible in shell history)
|
||||
```
|
||||
|
||||
- `set-token`: writes `<state>/github-token` (`0600`, agent-owned) via
|
||||
hive-priv — the same credential-injection path as forge/matrix tokens.
|
||||
The `gh` wrapper / git credential helper read it live, so a freshly set
|
||||
or rotated PAT takes effect with no rebuild or restart. Refuses an empty
|
||||
token. See [github.md](../integrations/github.md) for the full flow + security notes.
|
||||
|
||||
## Gateway
|
||||
|
||||
Manage users in the gateway's HTTP Basic auth htpasswd file
|
||||
|
|
@ -296,6 +272,6 @@ note, not an error.
|
|||
|
||||
A surface has no URL when it isn't browser-reachable: `home` needs
|
||||
`services.hyperhive.domain`; `forge` needs
|
||||
`services.hyperhive.deploy.forgejo.behindGateway = true`; `matrix` needs
|
||||
`services.hyperhive.swarm.forge.publicUrl` (set by default); `matrix` needs
|
||||
`services.hyperhive.deploy.matrix.gui.enable = true`. In those cases the command
|
||||
exits with a hint naming the option to set.
|
||||
|
|
|
|||
|
|
@ -295,7 +295,6 @@ known operations; there is no arbitrary command pass-through:
|
|||
| `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` | unlink `/etc/tmpfiles.d/hyperhive-agents.conf` and return `Ok` |
|
||||
| `SetAgentPaused` | create / remove the `<state>/<name>/harness/paused` marker that parks an agent's turn loop |
|
||||
| `WriteAgentGithubToken` | write `0600` `github-token` into agent state dir |
|
||||
| `RegisterCiRunner` | write `/run/hive-ci/runner-token` (host path, root-owned) then `systemctl --machine=hive-ci restart gitea-runner-hive.service`. Only the registration token crosses; the forge admin token never enters the container |
|
||||
| `EnsureAgentSubvolume` | `btrfs subvolume create <state>/<name>` for a new agent — no-op when the path exists or the filesystem isn't btrfs |
|
||||
| `UpgradeAgentSubvolume` | migrate an existing plain state dir into a subvolume: create, `cp -a --reflink=auto`, atomic swap. Operator opt-in, and the caller stops the agent first |
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ what you actually do here.
|
|||
|
||||
Everything starts at the **H0M3 hub**, served at `/` — a grid of tiles
|
||||
linking to every surface (Dashboard, Flow, Logs, Builds, Stats,
|
||||
Settings, Core, Credentials). Every page links back to H0M3, so you're
|
||||
Settings, Core). Every page links back to H0M3, so you're
|
||||
never more than one step from the hub.
|
||||
|
||||
The **dashboard** itself (`/dashboard.html`) is where you'll spend most
|
||||
|
|
@ -29,11 +29,8 @@ Everything else lives on its own page instead, all reachable from the
|
|||
H0M3 hub: **Flow** (`/flow.html`, the raw live message stream across
|
||||
the whole swarm), **Logs** (`/logs.html`, per-agent and host
|
||||
journals), **Stats** (`/stats.html`, swarm-wide usage stats),
|
||||
**Builds** (`/builds.html`, the rebuild queue and build history),
|
||||
**Core** (`/core.html`, tombstones and container resource use), and
|
||||
**Credentials** (`/credentials.html`, pasting a GitHub PAT for an
|
||||
agent's dedicated bot account — matrix and external-forge accounts are
|
||||
swarm identities, linked from the swarm UI). Your local
|
||||
**Builds** (`/builds.html`, the rebuild queue and build history), and
|
||||
**Core** (`/core.html`, tombstones and container resource use). Your local
|
||||
browser preferences (notifications) live in the dashboard's Y3R C4LL
|
||||
tab.
|
||||
|
||||
|
|
@ -83,11 +80,6 @@ journald view for any agent + service; the per-agent `⋮` menu's
|
|||
recurring or one-shot prompts you schedule for one or more agents, and
|
||||
reminders agents have set for themselves.
|
||||
|
||||
**Set an agent's GitHub token.** The Credentials page pastes a PAT for
|
||||
the agent's dedicated bot account, without editing that agent's config
|
||||
repo. Link matrix and external-forge accounts from the swarm UI
|
||||
instead.
|
||||
|
||||
## More depth
|
||||
|
||||
Both the dashboard and the per-agent pages are SPAs sharing one
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ from the dashboard tab strip.
|
|||
— sits below the tab strip.
|
||||
- **Server-warnings banner** — a generic, sticky top-of-page strip shown
|
||||
on **every** page (dashboard + every stand-alone page — FL0W, L0GS,
|
||||
H0M3, C0R3, BU1LDS, CR3D3NTIALS, ST4TS), injected at the top
|
||||
H0M3, C0R3, BU1LDS, ST4TS), injected at the top
|
||||
of `<body>` by `renderServerWarnings` in `common.js`. Driven by
|
||||
`state.server_warnings` — a list of `{ kind, level, message }` — and
|
||||
coloured by `level` (`warn` amber / `crit` red). hive-c0re owns the
|
||||
|
|
@ -283,35 +283,6 @@ badge with a ticking elapsed-time chip; expanding streams output via
|
|||
(suspends on manual scroll-up). Deep-link: `?id=N#buildlogs` opens the
|
||||
entry with that id pre-expanded.
|
||||
|
||||
## CR3D3NTIALS page (`/credentials.html`)
|
||||
|
||||
Operator surface to provision per-agent credentials without editing the
|
||||
agent's config repo. Standalone page reached from the **Credentials** tile
|
||||
on the H0M3 hub, same minimal chrome as `/logs.html` (a `← home` back-link
|
||||
+ a sub-tab strip, via the shared `@hive/shared/tabs.js` tab strip) rather
|
||||
than `/core.html`'s plain title. Its own esbuild bundle
|
||||
(`credentials.js`); no SSE — it reads `/api/state` once for the (shared)
|
||||
agent picker and otherwise works off purpose-built endpoints per tab.
|
||||
One sub-tab, GITHUB.
|
||||
|
||||
### GITHUB tab
|
||||
|
||||
Provision a single per-agent GitHub personal access token (see
|
||||
[`docs/integrations/github.md`](../integrations/github.md) for the injection + `gh`/git-push
|
||||
mechanics). No login flow — the operator pastes an existing PAT for a
|
||||
dedicated bot account, with a security-warning banner (dedicated account +
|
||||
minimally scoped token) and a link to
|
||||
[github.com/settings/tokens](https://github.com/settings/tokens).
|
||||
|
||||
Status reads `GET /api/github-account?agent=<name>` →
|
||||
`{ present: bool }` — whether the agent's `github-token` file exists.
|
||||
There's no live/heartbeat concept for a static PAT, so this is just a
|
||||
"token stored ✓" / "not set" line.
|
||||
Provisioning posts `POST /api/github-account` (form-encoded `agent`,
|
||||
`token`) → `200 { ok: true }` on success; failures come back as RFC 9457
|
||||
`application/problem+json` with the message in `detail`. The token is never
|
||||
echoed back in either direction.
|
||||
|
||||
## P3RM1SS10NS tab
|
||||
|
||||
Per-agent permission configuration. Two sections, each rendered as a
|
||||
|
|
@ -575,7 +546,7 @@ re-renders the terminal row. The operator addresses the root agent as `@root`.
|
|||
|
||||
The H0M3 hub is the primary landing page (served at `/` by default). A
|
||||
responsive grid of link tiles — Dashboard, Flow, Logs, Builds, Stats,
|
||||
Core, Credentials, API — each pointing to their respective
|
||||
Core, API — each pointing to their respective
|
||||
surfaces, all unconditionally shown (no gating). The page is a pure
|
||||
portal with no tab-bar or SSE subscriptions. Typography + colours inherit
|
||||
from the shared theme (Catppuccin Mocha via `common.css` + `theme.css`).
|
||||
|
|
|
|||
|
|
@ -20,8 +20,8 @@
|
|||
CSS bundle per page (`colors.css` + `theme.css` + `common.css`,
|
||||
loaded by every page, plus a page-specific bundle —
|
||||
`dashboard.css` / `flow.css` / `logs.css` / `home.css` /
|
||||
`stats.css` / `core.css` / `builds.css` /
|
||||
`credentials.css`); `common.css` inlines `@hive/shared`'s
|
||||
`stats.css` / `core.css` / `builds.css`);
|
||||
`common.css` inlines `@hive/shared`'s
|
||||
`base.css` + `terminal.css` + `tabs.css` + `chrome.css` +
|
||||
`pill.css` via esbuild's `@import` resolution.
|
||||
`terminal.js` exports `{ create, linkify }` as ES module
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
// esbuild build for @hive/dashboard: one JS+CSS bundle per page (H0M3 at
|
||||
// `/`, dashboard/flow/logs/core/builds/credentials at their own
|
||||
// `/`, dashboard/flow/logs/core/builds at their own
|
||||
// `.html`), each named after its own entry point below — see the
|
||||
// `entryPoints`/`for` lists for the exact map, not repeated here to
|
||||
// avoid this comment drifting out of sync with the real build steps.
|
||||
|
|
@ -40,7 +40,6 @@ await build({
|
|||
src("stats.js"),
|
||||
src("core.js"),
|
||||
src("builds.js"),
|
||||
src("credentials.js"),
|
||||
],
|
||||
outdir: staticDir(""),
|
||||
bundle: true,
|
||||
|
|
@ -107,7 +106,6 @@ for (const entry of [
|
|||
"stats.css",
|
||||
"core.css",
|
||||
"builds.css",
|
||||
"credentials.css",
|
||||
]) {
|
||||
await build({
|
||||
entryPoints: [src(entry)],
|
||||
|
|
@ -126,7 +124,6 @@ for (const html of [
|
|||
"stats.html",
|
||||
"core.html",
|
||||
"builds.html",
|
||||
"credentials.html",
|
||||
]) {
|
||||
copyFileSync(src(html), dist(html));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,94 +0,0 @@
|
|||
/* CR3D3NTIALS page (/credentials.html) only. Page chrome
|
||||
(.page-header / .page-back / .page-title) comes from the shared
|
||||
chrome.css imported by common.css; base tab styling lives in
|
||||
@hive/shared/tabs.css (.hive-tab*) same as /logs.html. This file holds
|
||||
the account-list + provision-form styling specific to this surface
|
||||
(`.ma-*` classes) plus the tab-strip layout delta + github-tab
|
||||
additions (`.cred-*`). */
|
||||
|
||||
body.cred-shell {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* Same layout delta as .logs-tabbar: fill the header row next to the
|
||||
← home back-link, and let the flex:1 nav shrink below its intrinsic
|
||||
width instead of wrapping onto its own row. */
|
||||
.cred-tabbar {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.cred-main {
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
padding: 1.2em 1.25rem 3rem;
|
||||
}
|
||||
|
||||
.cred-pane[hidden] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.gh-status {
|
||||
margin: 0.5rem 0 1.2rem;
|
||||
}
|
||||
.gh-status-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
}
|
||||
.gh-dot {
|
||||
width: 0.6rem;
|
||||
height: 0.6rem;
|
||||
border-radius: 50%;
|
||||
flex: none;
|
||||
}
|
||||
.gh-dot.present {
|
||||
background: var(--green);
|
||||
}
|
||||
.gh-dot.absent {
|
||||
background: var(--muted);
|
||||
}
|
||||
.gh-status-text.present {
|
||||
color: var(--green);
|
||||
}
|
||||
.gh-status-text.absent {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.ma-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
margin: 0.55rem 0;
|
||||
}
|
||||
.ma-field > span {
|
||||
font-size: 0.8rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
.ma-field input,
|
||||
.ma-field select {
|
||||
padding: 0.4rem 0.5rem;
|
||||
background: var(--bg-elev);
|
||||
color: var(--fg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 4px;
|
||||
font: inherit;
|
||||
}
|
||||
.ma-field input:focus,
|
||||
.ma-field select:focus {
|
||||
outline: none;
|
||||
border-color: var(--purple);
|
||||
}
|
||||
|
||||
.ma-result {
|
||||
margin-top: 0.7rem;
|
||||
font-size: 0.9rem;
|
||||
min-height: 1.2em;
|
||||
}
|
||||
.ma-result.ok {
|
||||
color: var(--green);
|
||||
}
|
||||
.ma-result.err {
|
||||
color: var(--red);
|
||||
}
|
||||
|
|
@ -1,87 +0,0 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>hyperhive // CR3D3NTIALS</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="stylesheet" href="/static/colors.css" />
|
||||
<link rel="stylesheet" href="/static/theme.css" />
|
||||
<link rel="stylesheet" href="/static/common.css" />
|
||||
<link rel="stylesheet" href="/static/credentials.css" />
|
||||
</head>
|
||||
<body class="cred-shell">
|
||||
<!-- Minimal chrome: back link + sub-tab strip, same pattern as
|
||||
logs.html (GITHUB instead of AGENT/INFRA/SYSTEM). Back
|
||||
link points to the H0M3 hub (served at /). -->
|
||||
<header class="page-header">
|
||||
<a class="page-back" href="/">← home</a>
|
||||
<hive-tab-strip
|
||||
class="hive-tabbar cred-tabbar"
|
||||
id="cred-tabbar"
|
||||
prefix="cred"
|
||||
role="tablist"
|
||||
></hive-tab-strip>
|
||||
</header>
|
||||
|
||||
<main class="cred-main">
|
||||
<!-- Agent picker: the selected agent drives the github status. -->
|
||||
<h3>◇ agent</h3>
|
||||
<label class="ma-field">
|
||||
<span>agent</span>
|
||||
<select id="ma-agent"></select>
|
||||
</label>
|
||||
|
||||
<!-- GITHUB tab: single-account PAT paste. No login flow — the
|
||||
operator pastes an existing PAT for a dedicated bot account.
|
||||
Security-warning banner + a link to generate a PAT. -->
|
||||
<section
|
||||
class="cred-pane"
|
||||
id="cred-pane-github"
|
||||
data-tab-pane="github"
|
||||
role="tabpanel"
|
||||
aria-labelledby="cred-tab-github"
|
||||
>
|
||||
<hive-warn level="warning">
|
||||
⚠ use a <strong>dedicated bot account</strong>, not a human's —
|
||||
and a <strong>minimally-scoped</strong> personal access token (only
|
||||
the repos/scopes the agent actually needs, e.g. <code>repo</code> +
|
||||
<code>workflow</code>). the container boundary is the enforcement:
|
||||
anything within the token's scopes is reachable if the agent is ever
|
||||
compromised. the token is injected into the agent's state dir and is
|
||||
<strong>never displayed back</strong> on this page.
|
||||
</hive-warn>
|
||||
|
||||
<h3>◇ status</h3>
|
||||
<div id="gh-status" class="gh-status">
|
||||
<p class="meta">
|
||||
select an agent to see its github credential status.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<h3>◇ provision</h3>
|
||||
<p class="meta">
|
||||
generate a token at
|
||||
<a
|
||||
href="https://github.com/settings/tokens"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
>github.com/settings/tokens</a
|
||||
>
|
||||
and paste it below. one account per agent — pasting a new token
|
||||
replaces the stored one.
|
||||
</p>
|
||||
<form id="gh-form" class="ma-form" autocomplete="off">
|
||||
<label class="ma-field">
|
||||
<span>personal access token</span>
|
||||
<input type="password" name="token" autocomplete="off" required />
|
||||
</label>
|
||||
<button type="submit" class="btn btn-spawn">store token</button>
|
||||
<p id="gh-result" class="ma-result" aria-live="polite"></p>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script type="module" src="/static/credentials.js" defer></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -1,202 +0,0 @@
|
|||
// CR3D3NTIALS page entry (/credentials.html).
|
||||
//
|
||||
// Operator surface to provision per-agent credentials without editing the
|
||||
// agent's config repo. One sub-tab and an agent picker:
|
||||
// GITHUB — single-account PAT paste against /api/github-account
|
||||
// (GET -> {present}, POST form-encoded {agent, token} ->
|
||||
// {ok:true}; error_response shape on failure).
|
||||
// No account name / homeserver / login mode, and no
|
||||
// live/heartbeat concept for a static PAT — just present/absent.
|
||||
// Per-tab detail comments live next to their section below.
|
||||
|
||||
import { $, esc, renderServerWarnings } from "./common.js";
|
||||
import { el } from "@hive/shared/dom.js";
|
||||
import "@hive/shared/hive-tab-strip.js";
|
||||
import { readApiError, problemMessage } from "@hive/shared/api-error.js";
|
||||
|
||||
let agents = [];
|
||||
|
||||
async function loadState() {
|
||||
try {
|
||||
const resp = await fetch("/api/state");
|
||||
if (!resp.ok) return;
|
||||
const s = await resp.json();
|
||||
renderServerWarnings(s.server_warnings);
|
||||
// `/api/state` exposes the live roster under `containers` (each entry an
|
||||
// object carrying `.name` + `.running`); there is no top-level `agents`
|
||||
// field, so the picker stays compatible with both string + object shapes.
|
||||
const containers = (s.containers || [])
|
||||
.map((a) => (typeof a === "string" ? { name: a } : a))
|
||||
.filter((c) => c && c.name);
|
||||
agents = containers.map((c) => c.name).sort();
|
||||
} catch {
|
||||
// best-effort: on a failed state read the picker renders empty
|
||||
// ("— no agents —") and the submit guard blocks until an agent is
|
||||
// selected, rather than guessing a roster.
|
||||
}
|
||||
}
|
||||
|
||||
function renderAgentPicker() {
|
||||
const sel = $("ma-agent");
|
||||
sel.replaceChildren();
|
||||
if (!agents.length) {
|
||||
sel.append(el("option", { value: "" }, "— no agents —"));
|
||||
return;
|
||||
}
|
||||
sel.append(el("option", { value: "" }, "— select agent —"));
|
||||
for (const a of agents) sel.append(el("option", { value: a }, a));
|
||||
}
|
||||
|
||||
// Shape-agnostic error-body parsing (shared by both tabs' submit handlers)
|
||||
// lives in `@hive/shared/api-error.js` now — `readApiError` +
|
||||
// `problemMessage` (this page only needs the one-line message, not the
|
||||
// full `ApiErrorPanel`; its result lines are single-line `aria-live`
|
||||
// regions, not a swap-in-a-panel context). Was a local function here
|
||||
// originally; promoted so swarm-ui shares the same
|
||||
// shape-agnostic reader instead of each side maintaining its own copy.
|
||||
|
||||
function clearSecrets(formEl) {
|
||||
formEl
|
||||
.querySelectorAll('input[type="password"], input[name="token"]')
|
||||
.forEach((i) => {
|
||||
i.value = "";
|
||||
});
|
||||
}
|
||||
|
||||
// ─── GITHUB tab ─────────────────────────────────────────────────────────
|
||||
|
||||
async function loadGithubStatus(agent) {
|
||||
const status = $("gh-status");
|
||||
if (!agent) {
|
||||
status.replaceChildren(
|
||||
el(
|
||||
"p",
|
||||
{ class: "meta" },
|
||||
"select an agent to see its github credential status.",
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
status.replaceChildren(el("p", { class: "meta" }, "loading…"));
|
||||
let data;
|
||||
try {
|
||||
const resp = await fetch(
|
||||
"/api/github-account?agent=" + encodeURIComponent(agent),
|
||||
);
|
||||
if (!resp.ok) throw new Error("HTTP " + resp.status);
|
||||
data = await resp.json();
|
||||
} catch (err) {
|
||||
status.replaceChildren(
|
||||
el(
|
||||
"p",
|
||||
{ class: "err" },
|
||||
"could not load status: " +
|
||||
esc(String(err)) +
|
||||
" (the backend endpoint may not be deployed yet).",
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const present = !!data.present;
|
||||
status.replaceChildren(
|
||||
el(
|
||||
"div",
|
||||
{ class: "gh-status-line" },
|
||||
el("span", { class: "gh-dot " + (present ? "present" : "absent") }),
|
||||
el(
|
||||
"span",
|
||||
{ class: "gh-status-text " + (present ? "present" : "absent") },
|
||||
present ? "token stored ✓" : "not set",
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async function submitGithub(e) {
|
||||
e.preventDefault();
|
||||
const formEl = e.target;
|
||||
const out = $("gh-result");
|
||||
out.className = "ma-result";
|
||||
out.textContent = "";
|
||||
|
||||
const agent = $("ma-agent").value;
|
||||
if (!agent) {
|
||||
out.className = "ma-result err";
|
||||
out.textContent = "select an agent first.";
|
||||
return;
|
||||
}
|
||||
|
||||
const fd = new FormData(formEl);
|
||||
fd.set("agent", agent);
|
||||
|
||||
const btn = formEl.querySelector('button[type="submit"]');
|
||||
const orig = btn.textContent;
|
||||
btn.disabled = true;
|
||||
btn.textContent = "storing…";
|
||||
|
||||
try {
|
||||
const resp = await fetch("/api/github-account", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams(fd),
|
||||
});
|
||||
|
||||
if (resp.ok) {
|
||||
let body = {};
|
||||
try {
|
||||
body = await resp.json();
|
||||
} catch {
|
||||
/* tolerate odd 2xx body */
|
||||
}
|
||||
if (body.ok) {
|
||||
out.className = "ma-result ok";
|
||||
out.textContent = "✓ token stored.";
|
||||
clearSecrets(formEl);
|
||||
loadGithubStatus(agent);
|
||||
} else {
|
||||
out.className = "ma-result err";
|
||||
out.textContent = "✗ store failed (unexpected response).";
|
||||
clearSecrets(formEl);
|
||||
}
|
||||
} else {
|
||||
const msg = problemMessage(await readApiError(resp));
|
||||
out.className = "ma-result err";
|
||||
out.textContent =
|
||||
"✗ " + (msg || "store failed (HTTP " + resp.status + ")");
|
||||
clearSecrets(formEl);
|
||||
}
|
||||
} catch (err) {
|
||||
out.className = "ma-result err";
|
||||
out.textContent =
|
||||
"✗ request failed: " +
|
||||
String(err) +
|
||||
" (the backend endpoint may not be deployed yet).";
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = orig;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── init ─────────────────────────────────────────────────────────────
|
||||
|
||||
async function onAgentChange(agent) {
|
||||
loadGithubStatus(agent);
|
||||
}
|
||||
|
||||
async function init() {
|
||||
await loadState();
|
||||
renderAgentPicker();
|
||||
$("ma-agent").addEventListener("change", (e) =>
|
||||
onAgentChange(e.target.value),
|
||||
);
|
||||
$("gh-form").addEventListener("submit", submitGithub);
|
||||
|
||||
document.getElementById("cred-tabbar").configure({
|
||||
tabs: [{ id: "github", label: "GITHUB" }],
|
||||
defaultId: "github",
|
||||
});
|
||||
|
||||
onAgentChange("");
|
||||
}
|
||||
|
||||
init();
|
||||
|
|
@ -90,16 +90,6 @@
|
|||
<span class="home-tile-desc">kept state · container load</span>
|
||||
</a>
|
||||
|
||||
<a class="home-tile" href="/credentials.html">
|
||||
<span class="home-tile-head">
|
||||
<span class="home-tile-icon" aria-hidden="true">🔑</span>
|
||||
<span class="home-tile-label">Credentials</span>
|
||||
</span>
|
||||
<span class="home-tile-desc"
|
||||
>provision per-agent github + forge accounts</span
|
||||
>
|
||||
</a>
|
||||
|
||||
<!-- API tile: the OpenAPI spec + Swagger UI are always served by
|
||||
hive-c0re itself (docs/web-ui/dashboard.md::Dashboard
|
||||
endpoints), so this tile is never gated/hidden. -->
|
||||
|
|
|
|||
|
|
@ -1,11 +1,10 @@
|
|||
// api-error.ts — reads a failed fetch `Response` into a `ProblemDetails`
|
||||
// (RFC 9457, `application/problem+json`) object, shape-agnostically.
|
||||
//
|
||||
// Promoted from `dashboard/src/credentials.js`'s original `readErrorBody`,
|
||||
// which returned a flat message string. This returns the structured object
|
||||
// instead so a caller — chiefly `ApiErrorPanel` (./api-error-panel/) — can
|
||||
// render title/status/detail separately and build a useful copy-button
|
||||
// payload, rather than re-parsing a pre-squashed string.
|
||||
// Returns the structured object (rather than a flat message string) so a
|
||||
// caller — chiefly `ApiErrorPanel` (./api-error-panel/) — can render
|
||||
// title/status/detail separately and build a useful copy-button payload,
|
||||
// rather than re-parsing a pre-squashed string.
|
||||
//
|
||||
// RFC 9457 is this hive's committed error-body contract for first-party
|
||||
// APIs (mara: "any api of our own responding with error that is not rfc
|
||||
|
|
|
|||
|
|
@ -1,10 +1,9 @@
|
|||
// hive-warn.js — <hive-warn>, the shared inline warning-banner
|
||||
// component. Consolidates three independently-written instances of the
|
||||
// same thing: `.cred-warning` (credentials.html, static
|
||||
// markup), `.tombstone-warn` (core.js, JS-built), `.port-conflict`
|
||||
// (swarm.js, JS-built) — the first two differed only by an
|
||||
// component. Consolidates what were independently-written instances of
|
||||
// the same thing, among them `.tombstone-warn` (core.js, JS-built) and
|
||||
// `.port-conflict` (swarm.js, JS-built) — some differed only by an
|
||||
// undeliberate 10% vs 8% tint, the strongest evidence this was drift,
|
||||
// not three genuinely different needs.
|
||||
// not genuinely different needs.
|
||||
//
|
||||
// Purely presentational — no internal state, no lifecycle beyond
|
||||
// attaching its shadow root once. `level` ('info' | 'warning' | 'error',
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
// hive-tab-strip.js — <hive-tab-strip>, the markup-owning tab-strip custom
|
||||
// element behind the logs/credentials/core/builds sub-page tabbars. Owns
|
||||
// element behind the logs/core/builds sub-page tabbars. Owns
|
||||
// rendering the <a class="hive-tab"> markup from a declarative `tabs` list
|
||||
// instead of every page hand-writing the same <nav><a>...</a></nav>
|
||||
// boilerplate, then wires the existing createTabStrip() behaviour
|
||||
|
|
|
|||
109
frontend/packages/swarm-ui/src/pages/LinkGithubAccountForm.tsx
Normal file
109
frontend/packages/swarm-ui/src/pages/LinkGithubAccountForm.tsx
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
// <LinkGithubAccountForm> — writes a GitHub personal access token for one
|
||||
// agent into the swarm secret store.
|
||||
// PUTs `/api/hives/{hive}/agents/{agent}/github-account` — 204 on success,
|
||||
// 400/500 as `problem+json`, shown via `ApiErrorPanel` like
|
||||
// `LinkForgeAccountForm`.
|
||||
//
|
||||
// One token per agent. A blind set/update: no route says whether a token is
|
||||
// stored, and none hands one back.
|
||||
import { useState } from "preact/hooks";
|
||||
import { ApiErrorPanel } from "@hive/shared/api-error-panel.js";
|
||||
import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js";
|
||||
import { Panel } from "../ui/panel/Panel.js";
|
||||
import { TextField } from "../ui/text-field/TextField.js";
|
||||
import { Button } from "../ui/button/Button.js";
|
||||
import "./LinkMatrixAccountForm.css";
|
||||
|
||||
type SubmitState =
|
||||
| { status: "idle" }
|
||||
| { status: "submitting" }
|
||||
| { status: "done" }
|
||||
| { status: "error"; problem: ProblemDetails };
|
||||
|
||||
export function LinkGithubAccountForm({
|
||||
hive,
|
||||
agent,
|
||||
onClose,
|
||||
}: {
|
||||
hive: string;
|
||||
agent: string;
|
||||
onClose?: () => void;
|
||||
}) {
|
||||
const [token, setToken] = useState("");
|
||||
const [result, setResult] = useState<SubmitState>({ status: "idle" });
|
||||
|
||||
async function submit(e: Event) {
|
||||
e.preventDefault();
|
||||
setResult({ status: "submitting" });
|
||||
try {
|
||||
const r = await fetch(
|
||||
`/api/hives/${encodeURIComponent(hive)}/agents/${encodeURIComponent(agent)}/github-account`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ token }),
|
||||
},
|
||||
);
|
||||
if (!r.ok) {
|
||||
setResult({ status: "error", problem: await readApiError(r) });
|
||||
return;
|
||||
}
|
||||
setResult({ status: "done" });
|
||||
// The store holds the token; nothing here needs it.
|
||||
setToken("");
|
||||
} catch (err) {
|
||||
setResult({ status: "error", problem: { detail: String(err) } });
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Panel
|
||||
title={`link a github account — ${agent}`}
|
||||
icon="🔗"
|
||||
onClose={onClose}
|
||||
>
|
||||
<p>
|
||||
Writes the token to the swarm secret store. The agent fetches it within
|
||||
two minutes, and its <code>gh</code> and <code>git push</code> to
|
||||
github.com then authenticate with it. Use a dedicated bot account and a
|
||||
minimally scoped token, created at{" "}
|
||||
<a
|
||||
href="https://github.com/settings/tokens"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
github.com/settings/tokens
|
||||
</a>
|
||||
; GitHub notifications also need the <code>notifications</code> scope.
|
||||
</p>
|
||||
<form class="link-matrix-account-form" onSubmit={submit}>
|
||||
<TextField
|
||||
id="github-account-token"
|
||||
label="personal access token"
|
||||
type="password"
|
||||
value={token}
|
||||
required
|
||||
onInput={setToken}
|
||||
/>
|
||||
<Button
|
||||
variant="primary"
|
||||
type="submit"
|
||||
disabled={result.status === "submitting"}
|
||||
>
|
||||
{result.status === "submitting" ? "linking…" : "link account"}
|
||||
</Button>
|
||||
</form>
|
||||
{result.status === "done" && (
|
||||
<p class="link-matrix-account-result-ok">
|
||||
stored a github token for <strong>{agent}</strong>
|
||||
</p>
|
||||
)}
|
||||
{result.status === "error" && (
|
||||
<ApiErrorPanel
|
||||
context="failed to link the account"
|
||||
problem={result.problem}
|
||||
/>
|
||||
)}
|
||||
</Panel>
|
||||
);
|
||||
}
|
||||
|
|
@ -49,6 +49,7 @@ import { type TableColumn } from "../../ui/table/Table.js";
|
|||
import { CreateAgentForm } from "../CreateAgentForm.js";
|
||||
import { LinkMatrixAccountForm } from "../LinkMatrixAccountForm.js";
|
||||
import { LinkForgeAccountForm } from "../LinkForgeAccountForm.js";
|
||||
import { LinkGithubAccountForm } from "../LinkGithubAccountForm.js";
|
||||
import { WantedMenu } from "./WantedMenu.js";
|
||||
import "./AgentsPage.css";
|
||||
|
||||
|
|
@ -98,6 +99,8 @@ const VIEW_MODE_KEY = "swarm-ui:agents:view-mode";
|
|||
|
||||
export function AgentsPage() {
|
||||
const [rows, setRows] = useState<AgentRow[] | null>(null);
|
||||
// The row showing the "link a github account" dialog, same shape.
|
||||
const [githubTarget, setGithubTarget] = useState<AgentRow | null>(null);
|
||||
const [error, setError] = useState<ProblemDetails | null>(null);
|
||||
const [intervalMs, setIntervalMs] =
|
||||
useState<RefreshIntervalMs>(DEFAULT_INTERVAL_MS);
|
||||
|
|
@ -600,6 +603,22 @@ export function AgentsPage() {
|
|||
: "no hive on record for this agent — nothing to link against"
|
||||
}
|
||||
/>
|
||||
<Badge
|
||||
variant="quiet"
|
||||
icon={<LinkIcon />}
|
||||
value="link github account"
|
||||
onClick={
|
||||
detailTarget.hive
|
||||
? () => setGithubTarget(detailTarget)
|
||||
: undefined
|
||||
}
|
||||
disabled={!detailTarget.hive}
|
||||
title={
|
||||
detailTarget.hive
|
||||
? `link a github account to ${detailTarget.name}`
|
||||
: "no hive on record for this agent — nothing to link against"
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{/* MVP scope per mara's own ruling: a small read-only
|
||||
preview, no header/no input — the full terminal
|
||||
|
|
@ -651,6 +670,20 @@ export function AgentsPage() {
|
|||
/>
|
||||
) : null}
|
||||
</Dialog>
|
||||
<Dialog
|
||||
open={githubTarget !== null}
|
||||
onClose={() => setGithubTarget(null)}
|
||||
label="link a github account"
|
||||
plain
|
||||
>
|
||||
{githubTarget?.hive ? (
|
||||
<LinkGithubAccountForm
|
||||
hive={githubTarget.hive}
|
||||
agent={githubTarget.name}
|
||||
onClose={() => setGithubTarget(null)}
|
||||
/>
|
||||
) : null}
|
||||
</Dialog>
|
||||
<ConfirmDialog
|
||||
open={confirmTarget !== null}
|
||||
label={confirmTarget ? CONFIRM_COPY[confirmTarget.state].label : ""}
|
||||
|
|
|
|||
|
|
@ -1,100 +0,0 @@
|
|||
//! Per-agent GitHub PAT provisioning for the dashboard's GITHUB tab:
|
||||
//! `POST /api/github-account` stores it, `GET /api/github-account` reports
|
||||
//! whether one is stored.
|
||||
|
||||
use axum::extract::{Form, Query};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::{IntoParams, ToSchema};
|
||||
|
||||
use super::{Ident, error_response};
|
||||
use crate::coordinator::Coordinator;
|
||||
|
||||
/// Form body for `POST /api/github-account` (urlencoded, the dashboard's
|
||||
/// mutation convention). Writes the operator-supplied PAT to the agent's
|
||||
/// `github-token` file. No account creation and no login modes — the
|
||||
/// operator pastes a PAT for an existing account.
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub(super) struct GithubAccountForm {
|
||||
agent: String,
|
||||
token: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
struct GithubAccountResult {
|
||||
ok: bool,
|
||||
}
|
||||
|
||||
/// Provision (or refresh) an agent's GitHub PAT from the dashboard
|
||||
/// credentials tab.
|
||||
///
|
||||
/// Validates the agent name, then writes the PAT to
|
||||
/// `<state>/github-token` (`0600`, agent-owned) via hive-priv. No account
|
||||
/// creation and no daemon to kick — the agent's `gh` wrapper / git credential
|
||||
/// helper read the file live, so the new token takes effect immediately.
|
||||
/// Operator-authenticated (dashboard). Never echoes the token back — only
|
||||
/// `{ ok: true }`.
|
||||
#[utoipa::path(
|
||||
post,
|
||||
path = "/api/github-account",
|
||||
request_body(content = GithubAccountForm, content_type = "application/x-www-form-urlencoded"),
|
||||
responses(
|
||||
(status = 200, description = "PAT provisioned", body = GithubAccountResult),
|
||||
(status = 500, description = "invalid agent name, empty token, or the write failed"),
|
||||
),
|
||||
tag = "matrix_accounts"
|
||||
)]
|
||||
pub(super) async fn post_github_account(Form(f): Form<GithubAccountForm>) -> Response {
|
||||
let agent = f.agent.trim();
|
||||
let token = f.token.trim();
|
||||
let Ok(agent) = Ident::parse(agent) else {
|
||||
return error_response(&format!("github-account: invalid agent {agent:?}"));
|
||||
};
|
||||
if token.is_empty() {
|
||||
return error_response("github-account: token is required");
|
||||
}
|
||||
if let Err(e) = crate::priv_client::write_agent_github_token(agent.as_str(), token).await {
|
||||
return error_response(&format!("github-account: write token failed: {e:#}"));
|
||||
}
|
||||
tracing::info!(%agent, "github-account: provisioned github PAT");
|
||||
axum::Json(GithubAccountResult { ok: true }).into_response()
|
||||
}
|
||||
|
||||
#[derive(Deserialize, IntoParams)]
|
||||
pub(super) struct GithubAccountQuery {
|
||||
agent: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
struct GithubAccountStatus {
|
||||
/// A `github-token` file exists in the agent's state dir (a PAT has been
|
||||
/// provisioned). A static PAT has no live/heartbeat concept, so this is
|
||||
/// the only status the credentials tab needs.
|
||||
present: bool,
|
||||
}
|
||||
|
||||
/// Whether the agent has a GitHub
|
||||
/// PAT provisioned (its `github-token` file exists).
|
||||
///
|
||||
/// Lets the credentials tab show "token stored" vs "not set" instead of a
|
||||
/// black-hole paste field. Never returns the token itself.
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/github-account",
|
||||
params(GithubAccountQuery),
|
||||
responses(
|
||||
(status = 200, description = "whether a github PAT is provisioned", body = GithubAccountStatus),
|
||||
(status = 500, description = "invalid agent name"),
|
||||
),
|
||||
tag = "matrix_accounts"
|
||||
)]
|
||||
pub(super) async fn get_github_account(Query(q): Query<GithubAccountQuery>) -> Response {
|
||||
let agent = q.agent.trim();
|
||||
let Ok(agent) = Ident::parse(agent) else {
|
||||
return error_response(&format!("github-account: invalid agent {agent:?}"));
|
||||
};
|
||||
let present = Coordinator::agent_notes_dir(&agent)
|
||||
.join("github-token")
|
||||
.exists();
|
||||
axum::Json(GithubAccountStatus { present }).into_response()
|
||||
}
|
||||
|
|
@ -38,7 +38,6 @@ use crate::lifecycle;
|
|||
(name = "approvals", description = "approve/deny pending approval rows"),
|
||||
(name = "build_logs", description = "build log headers, full rows, and raw text downloads"),
|
||||
(name = "lifecycle_ops", description = "agent container lifecycle: rebuild/restart/start/stop/pause/limits"),
|
||||
(name = "matrix_accounts", description = "github account provisioning for agents"),
|
||||
(name = "meta_inputs", description = "bulk flake-input update for the meta flake"),
|
||||
(name = "misc_api", description = "operator inbox, compose, spawn-request, hive stats"),
|
||||
(name = "permissions", description = "tool-group + capability assignment for agents"),
|
||||
|
|
@ -61,7 +60,6 @@ pub(crate) use hive_types::Ident;
|
|||
mod health;
|
||||
mod journal;
|
||||
mod lifecycle_ops;
|
||||
mod matrix_accounts;
|
||||
mod meta_inputs;
|
||||
mod misc_api;
|
||||
pub(crate) mod permissions;
|
||||
|
|
@ -126,8 +124,7 @@ pub async fn serve(
|
|||
// call below is therefore scoped to exactly one path — two handlers
|
||||
// in the same call only when they genuinely share a path with
|
||||
// different methods (`schedules::api_schedules`/`post_schedule_new`
|
||||
// on `/api/schedules`, `matrix_accounts::get_github_account`/
|
||||
// `post_github_account` on `/api/github-account`) — chained via
|
||||
// on `/api/schedules`) — chained via
|
||||
// repeated `.routes(...)` calls instead of one giant `routes!(...)`
|
||||
// with everything in it.
|
||||
let (router, api) = OpenApiRouter::<AppState>::with_openapi(ApiDoc::openapi())
|
||||
|
|
@ -137,10 +134,6 @@ pub async fn serve(
|
|||
.routes(routes!(journal::get_journal_host))
|
||||
.routes(routes!(state_snapshot::api_state))
|
||||
.routes(routes!(state_files::get_state_file))
|
||||
.routes(routes!(
|
||||
matrix_accounts::post_github_account,
|
||||
matrix_accounts::get_github_account
|
||||
))
|
||||
.routes(routes!(misc_api::api_operator_inbox))
|
||||
.routes(routes!(misc_api::api_stats_hive))
|
||||
.routes(routes!(misc_api::api_container_resources))
|
||||
|
|
@ -371,10 +364,6 @@ mod router_build_probe {
|
|||
.routes(routes!(journal::get_journal_host))
|
||||
.routes(routes!(state_snapshot::api_state))
|
||||
.routes(routes!(state_files::get_state_file))
|
||||
.routes(routes!(
|
||||
matrix_accounts::post_github_account,
|
||||
matrix_accounts::get_github_account
|
||||
))
|
||||
.routes(routes!(misc_api::api_operator_inbox))
|
||||
.routes(routes!(misc_api::api_stats_hive))
|
||||
.routes(routes!(misc_api::api_container_resources))
|
||||
|
|
|
|||
|
|
@ -94,8 +94,7 @@ pub(super) struct StateSnapshot {
|
|||
/// `"https://forge.pr1ma.darkest.space"`). Sourced from the
|
||||
/// `HIVE_FORGE_PUBLIC_URL` env var, which the c0re NixOS module
|
||||
/// sets from `services.hyperhive.swarm.forge.publicUrl` (defaults to
|
||||
/// the gateway vhost URL when `deploy.forgejo.behindGateway = true`,
|
||||
/// `null` otherwise). `None` when absent — the frontend **hides**
|
||||
/// the gateway vhost URL). `None` when absent — the frontend **hides**
|
||||
/// forge links rather than guessing `http://<hostname>:3000`,
|
||||
/// which is only right by accident on deployments that aren't
|
||||
/// plain localhost.
|
||||
|
|
|
|||
|
|
@ -222,3 +222,141 @@ pub(super) async fn post_webhook_config_pr(
|
|||
|
||||
(StatusCode::OK, "ok").into_response()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use axum::{
|
||||
body::Bytes,
|
||||
extract::State,
|
||||
http::{HeaderMap, HeaderValue, StatusCode},
|
||||
};
|
||||
|
||||
use super::{AppState, post_webhook_config_pr, verify_hmac};
|
||||
|
||||
const SECRET: &str = "s3cr3t";
|
||||
|
||||
/// The `TempDir` holds the coordinator's sqlite files; keep it alive as
|
||||
/// long as the state.
|
||||
fn state(webhook_secret: Option<&str>) -> (tempfile::TempDir, AppState) {
|
||||
let (dir, coord) = crate::socket_server::coordinator();
|
||||
let state = AppState {
|
||||
coord,
|
||||
webhook_secret: webhook_secret.map(str::to_owned),
|
||||
};
|
||||
(dir, state)
|
||||
}
|
||||
|
||||
/// The `X-Hub-Signature-256` header Forgejo would send for `secret` + `body`.
|
||||
fn signed(secret: &str, body: &[u8]) -> HeaderMap {
|
||||
use std::fmt::Write as _;
|
||||
|
||||
use hmac::{Hmac, KeyInit, Mac};
|
||||
use sha2::Sha256;
|
||||
let mut mac = Hmac::<Sha256>::new_from_slice(secret.as_bytes()).unwrap();
|
||||
mac.update(body);
|
||||
let mut hex = String::new();
|
||||
for b in mac.finalize().into_bytes() {
|
||||
write!(hex, "{b:02x}").unwrap();
|
||||
}
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
"x-hub-signature-256",
|
||||
HeaderValue::from_str(&format!("sha256={hex}")).unwrap(),
|
||||
);
|
||||
headers
|
||||
}
|
||||
|
||||
/// With no secret loaded, nothing verifies — including a delivery signed
|
||||
/// with the empty key. The message must say "unavailable": the handler
|
||||
/// maps on that word to 503.
|
||||
#[test]
|
||||
fn verify_hmac_rejects_every_delivery_when_no_secret_is_loaded() {
|
||||
let (_dir, state) = state(None);
|
||||
let body = Bytes::from_static(b"{}");
|
||||
for (label, headers) in [
|
||||
("signed with the empty key", signed("", &body)),
|
||||
("signed with some key", signed(SECRET, &body)),
|
||||
("unsigned", HeaderMap::new()),
|
||||
] {
|
||||
let err = verify_hmac(&state, &headers, &body).expect_err(label);
|
||||
assert!(err.contains("unavailable"), "{label}: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
/// An absent header, an empty one and one that is not valid UTF-8 all
|
||||
/// read as "no signature", and are refused before any HMAC is computed.
|
||||
#[test]
|
||||
fn verify_hmac_rejects_a_delivery_with_no_usable_signature_header() {
|
||||
let (_dir, state) = state(Some(SECRET));
|
||||
let body = Bytes::from_static(b"{}");
|
||||
let mut empty = HeaderMap::new();
|
||||
empty.insert("x-hub-signature-256", HeaderValue::from_static(""));
|
||||
let mut not_utf8 = HeaderMap::new();
|
||||
not_utf8.insert(
|
||||
"x-hub-signature-256",
|
||||
HeaderValue::from_bytes(b"sha256=\xff").unwrap(),
|
||||
);
|
||||
for (label, headers) in [
|
||||
("absent", HeaderMap::new()),
|
||||
("empty", empty),
|
||||
("not UTF-8", not_utf8),
|
||||
] {
|
||||
assert_eq!(
|
||||
verify_hmac(&state, &headers, &body),
|
||||
Err("missing X-Hub-Signature-256 header".to_owned()),
|
||||
"{label}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The other side of both guards: a loaded secret and a matching
|
||||
/// signature pass.
|
||||
#[test]
|
||||
fn verify_hmac_accepts_a_correctly_signed_delivery() {
|
||||
let (_dir, state) = state(Some(SECRET));
|
||||
let body = Bytes::from_static(b"{}");
|
||||
assert_eq!(verify_hmac(&state, &signed(SECRET, &body), &body), Ok(()));
|
||||
}
|
||||
|
||||
/// 503 means "this hive cannot verify any delivery", 401 means "this
|
||||
/// delivery is not authentic".
|
||||
#[tokio::test]
|
||||
async fn config_pr_answers_503_when_no_secret_is_loaded() {
|
||||
let (_dir, state) = state(None);
|
||||
let body = Bytes::from_static(b"{}");
|
||||
let headers = signed(SECRET, &body);
|
||||
let resp = post_webhook_config_pr(State(state), headers, body).await;
|
||||
assert_eq!(resp.status(), StatusCode::SERVICE_UNAVAILABLE);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn config_pr_answers_401_for_a_missing_or_wrong_signature() {
|
||||
let body = Bytes::from_static(b"{}");
|
||||
for (label, headers) in [
|
||||
("missing", HeaderMap::new()),
|
||||
("wrong secret", signed("different-secret", &body)),
|
||||
("other body", signed(SECRET, b"tampered")),
|
||||
] {
|
||||
let (_dir, state) = state(Some(SECRET));
|
||||
let resp = post_webhook_config_pr(State(state), headers, body.clone()).await;
|
||||
assert_eq!(resp.status(), StatusCode::UNAUTHORIZED, "{label}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A verified delivery reaches payload handling: an action the handler
|
||||
/// ignores comes back 200, and an unparseable body 400, neither of which
|
||||
/// is an auth status.
|
||||
#[tokio::test]
|
||||
async fn config_pr_lets_a_correctly_signed_delivery_through() {
|
||||
for (body, expected) in [
|
||||
(&br#"{"action":"closed"}"#[..], StatusCode::OK),
|
||||
(&b"not json"[..], StatusCode::BAD_REQUEST),
|
||||
] {
|
||||
let (_dir, state) = state(Some(SECRET));
|
||||
let body = Bytes::from_static(body);
|
||||
let headers = signed(SECRET, &body);
|
||||
let resp = post_webhook_config_pr(State(state), headers, body).await;
|
||||
assert_eq!(resp.status(), expected);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -369,25 +369,6 @@ pub async fn set_agent_paused(agent_name: &str, paused: bool) -> Result<()> {
|
|||
.await?)
|
||||
}
|
||||
|
||||
/// Write a GitHub personal access token (PAT) for `agent_name` via hive-priv
|
||||
/// (running as root). Writes `<state>/github-token` 0600, chowned to the agent
|
||||
/// user so the `gh` wrapper / git credential helper can read it from inside the
|
||||
/// container. Single account per agent — no account suffix. The token value is
|
||||
/// operator-supplied (for the agent's GitHub integration, `services.hyperhive.agent.github.enable`).
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if the hive-priv call fails — the socket is unreachable,
|
||||
/// `agent_name` is rejected by the root-side validation, or the file
|
||||
/// write/chown fails.
|
||||
pub async fn write_agent_github_token(agent_name: &str, token: &str) -> Result<()> {
|
||||
ok(call(&PrivRequest::WriteAgentGithubToken {
|
||||
agent_name: agent_name.to_owned(),
|
||||
token: token.to_owned(),
|
||||
})
|
||||
.await?)
|
||||
}
|
||||
|
||||
/// Register the hive-ci Forgejo Actions runner: hand the freshly-minted
|
||||
/// registration token to hive-priv, which writes it to the host-side
|
||||
/// `/run/hive-ci/runner-token` env-file and restarts the in-container runner.
|
||||
|
|
|
|||
|
|
@ -224,9 +224,6 @@ async fn dispatch(req: &HostRequest, coord: Arc<Coordinator>) -> HostResponse {
|
|||
HostRequest::GatewayListUsers => {
|
||||
HostResponse::messages(crate::gateway_nginx::list_users()?)
|
||||
}
|
||||
HostRequest::SetAgentGithubToken { agent, token } => {
|
||||
handle_set_agent_github_token(agent.as_str(), token).await?
|
||||
}
|
||||
HostRequest::QuotaEnable => handle_quota_enable().await?,
|
||||
HostRequest::QuotaLimit { name, limit } => {
|
||||
handle_quota_limit(name.as_str(), *limit).await?
|
||||
|
|
@ -468,16 +465,6 @@ fn require_matrix_present() -> Result<()> {
|
|||
)
|
||||
}
|
||||
|
||||
async fn handle_set_agent_github_token(agent: &str, token: &str) -> Result<HostResponse> {
|
||||
crate::priv_client::write_agent_github_token(agent, token)
|
||||
.await
|
||||
.with_context(|| format!("write github-token for agent {agent}"))?;
|
||||
Ok(HostResponse::messages(vec![format!(
|
||||
"wrote github-token for agent '{agent}' \
|
||||
(read live by the gh wrapper / git credential helper — no rebuild needed)"
|
||||
)]))
|
||||
}
|
||||
|
||||
async fn handle_quota_enable() -> Result<HostResponse> {
|
||||
crate::priv_client::ensure_btrfs_quota()
|
||||
.await
|
||||
|
|
@ -865,8 +852,8 @@ fn is_broad_scope(scope: &LifecycleScope) -> bool {
|
|||
|
||||
/// Assemble this hive's domain + browser-facing web URLs from c0re's
|
||||
/// service env (injected by the hyperhive NixOS module). Each field is `None` when its
|
||||
/// surface isn't browser-reachable (domain unset, forge not behind the
|
||||
/// gateway, matrix GUI off), so the CLI can hint precisely instead of
|
||||
/// surface isn't browser-reachable (domain unset, forge `publicUrl`
|
||||
/// null, matrix GUI off), so the CLI can hint precisely instead of
|
||||
/// opening a dead link. Scheme matches the existing `HIVE_FORGE_PUBLIC_URL`
|
||||
/// convention (gateway terminates TLS, so https).
|
||||
fn hive_urls() -> hive_host_sock::HiveUrls {
|
||||
|
|
|
|||
|
|
@ -27,6 +27,8 @@ mod schedules;
|
|||
pub(crate) use config_approvals::submit_merge_config_pr;
|
||||
pub(crate) use schedules::filter_ghost_schedule_targets;
|
||||
pub use schedules::schedule_to_wire_public;
|
||||
#[cfg(test)]
|
||||
pub(crate) use schedules::tests::coordinator;
|
||||
|
||||
use schedules::{
|
||||
EditSchedulePatch, handle_cancel_schedule, handle_edit_schedule, handle_fire_schedule_now,
|
||||
|
|
|
|||
|
|
@ -380,7 +380,7 @@ pub(super) mod tests {
|
|||
/// A real `Coordinator` over a throwaway sqlite dir. The socket-server
|
||||
/// handlers take `&Arc<Coordinator>`, so there is no lighter way in;
|
||||
/// `open` touches nothing outside the db path it is handed.
|
||||
pub(in crate::socket_server) fn coordinator() -> (tempfile::TempDir, Arc<Coordinator>) {
|
||||
pub(crate) fn coordinator() -> (tempfile::TempDir, Arc<Coordinator>) {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let coord = Coordinator::open(
|
||||
&dir.path().join("broker.sqlite"),
|
||||
|
|
|
|||
|
|
@ -4,8 +4,9 @@
|
|||
//! mark read on the source.
|
||||
//!
|
||||
//! Takes no arguments. `HYPERHIVE_STATE_DIR` holds the PAT
|
||||
//! (`github-token`, provisioned from the dashboard credentials tab — see
|
||||
//! `docs/integrations/github.md`) and `HIVE_AGENT_SOCKET` is the harness's todo socket.
|
||||
//! (`github-token`, fetched from the swarm secret store by
|
||||
//! `nix/agent-modules/github-token.nix` — see `docs/integrations/github.md`)
|
||||
//! and `HIVE_AGENT_SOCKET` is the harness's todo socket.
|
||||
//! Having a PAT *is* the opt-in: with no token the poller logs why and
|
||||
//! exits 0, so deploying this unit to an agent that never gets one costs a
|
||||
//! settled process rather than a restart loop.
|
||||
|
|
@ -39,9 +40,9 @@ async fn main() {
|
|||
github_loop(hive_forge_notify::state_dir(), socket).await;
|
||||
}
|
||||
|
||||
/// Waits for the PAT to appear: the token is written out of band from the
|
||||
/// dashboard and takes effect without a rebuild, so an agent that gains a
|
||||
/// PAT mid-session starts getting notifications on the next tick rather
|
||||
/// Waits for the PAT to appear: the agent's store fetch writes the token
|
||||
/// out of band and it takes effect without a rebuild, so an agent that gains
|
||||
/// a PAT mid-session starts getting notifications on the next tick rather
|
||||
/// than after a restart. Gives up — returning, so the process exits 0
|
||||
/// rather than restart-looping — when no PAT ever arrives, which is the
|
||||
/// common case for an agent that has the unit but no account.
|
||||
|
|
|
|||
|
|
@ -212,7 +212,7 @@ pub enum HostRequest {
|
|||
/// (`services.hyperhive.domain`) plus the browser-facing home /
|
||||
/// forge / matrix URLs, daemon-sourced so custom forge/matrix
|
||||
/// domains resolve correctly. Each URL is `None` when its subsystem
|
||||
/// is unreachable from a browser (e.g. forge not behind the gateway,
|
||||
/// is unreachable from a browser (e.g. forge `publicUrl` null,
|
||||
/// matrix GUI disabled). Backs `hivectl open` + the federation
|
||||
/// peer-config block (which reads the bare `domain`).
|
||||
Urls,
|
||||
|
|
@ -315,12 +315,6 @@ pub enum HostRequest {
|
|||
/// Daemon-side equivalent of `hivectl gateway list-users`; the usernames
|
||||
/// come back in [`HostResponse::messages`], one per line.
|
||||
GatewayListUsers,
|
||||
/// Write (or overwrite) an agent's GitHub PAT under its state dir, via
|
||||
/// the privileged helper. Daemon-side equivalent of `hivectl github
|
||||
/// set-token`. `token` is resolved + non-empty-validated client-side
|
||||
/// (inline flag or stdin); the daemon just persists it. Read live by
|
||||
/// the agent's `gh` wrapper / git credential helper — no rebuild needed.
|
||||
SetAgentGithubToken { agent: Ident, token: String },
|
||||
/// Turn on btrfs qgroup accounting on the agent-state filesystem, via
|
||||
/// the privileged helper. Daemon-side equivalent of `hivectl
|
||||
/// quota-enable`. Returns advisory lines in [`HostResponse::messages`].
|
||||
|
|
@ -457,7 +451,7 @@ impl LifecycleScope {
|
|||
/// This hive's canonical domain plus the browser-facing URLs for its
|
||||
/// web surfaces — the `Urls` request result. Every field is `None` when
|
||||
/// the corresponding surface can't be reached from a browser (domain
|
||||
/// unset, forge not behind the gateway, matrix GUI disabled), so the CLI
|
||||
/// unset, forge `publicUrl` null, matrix GUI disabled), so the CLI
|
||||
/// can give a precise hint instead of opening a dead link.
|
||||
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
|
||||
pub struct HiveUrls {
|
||||
|
|
@ -467,8 +461,8 @@ pub struct HiveUrls {
|
|||
/// Operator dashboard root (`https://<domain>/`).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub home: Option<String>,
|
||||
/// Forge browser URL (`HIVE_FORGE_PUBLIC_URL`) — only the
|
||||
/// behind-gateway public URL; `None` on direct-port forge deploys.
|
||||
/// Forge browser URL (`HIVE_FORGE_PUBLIC_URL`) — `None` when
|
||||
/// `swarm.forge.publicUrl` is `null`.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub forge: Option<String>,
|
||||
/// Matrix GUI (fluffychat) browser URL — `None` when the matrix GUI
|
||||
|
|
|
|||
|
|
@ -478,22 +478,6 @@ pub enum PrivRequest {
|
|||
paused: bool,
|
||||
},
|
||||
|
||||
// --- Agent credential writes ---
|
||||
/// Write `github-token` into `AGENT_STATE_ROOT/<agent_name>/state/github-token`.
|
||||
///
|
||||
/// The operator-supplied GitHub personal access token (PAT) for the
|
||||
/// agent's GitHub integration (`services.hyperhive.agent.github.enable`).
|
||||
/// hive-priv validates `agent_name`, creates the state dir
|
||||
/// if absent, writes the file 0600, and chowns it to the agent so the
|
||||
/// `gh` wrapper / git credential helper can read it. No account suffix
|
||||
/// (single GitHub account per agent).
|
||||
WriteAgentGithubToken {
|
||||
/// Logical agent name (validated by `validate_agent_name`).
|
||||
agent_name: String,
|
||||
/// PAT value. hive-priv appends a trailing newline before writing.
|
||||
token: String,
|
||||
},
|
||||
|
||||
/// Register the hive-ci Forgejo Actions runner: write the registration
|
||||
/// token to the host-side `/run/hive-ci/runner-token` env-file (root-owned,
|
||||
/// bind-mounted read-only into the container) as `TOKEN=<token>`, then
|
||||
|
|
|
|||
|
|
@ -407,11 +407,6 @@ async fn exec(
|
|||
paused,
|
||||
} => exec_set_agent_paused(agent_name, paused),
|
||||
|
||||
PrivRequest::WriteAgentGithubToken {
|
||||
ref agent_name,
|
||||
ref token,
|
||||
} => write_github_token(agent_name, token),
|
||||
|
||||
PrivRequest::RegisterCiRunner { ref token } => register_ci_runner(token).await,
|
||||
|
||||
PrivRequest::ControlInfraContainer { container, action } => {
|
||||
|
|
@ -547,12 +542,6 @@ fn exec_set_agent_paused(agent_name: &str, paused: bool) -> Result<(String, Stri
|
|||
set_agent_paused(agent_name, paused)
|
||||
}
|
||||
|
||||
/// `WriteAgentGithubToken`.
|
||||
fn write_github_token(agent_name: &str, token: &str) -> Result<(String, String)> {
|
||||
validate_agent_name(agent_name)?;
|
||||
write_agent_state_file(agent_name, "github-token", &format!("{token}\n"))
|
||||
}
|
||||
|
||||
/// `EnsureAgentSubvolume`.
|
||||
async fn exec_ensure_agent_subvolume(agent_name: &str) -> Result<(String, String)> {
|
||||
validate_agent_name(agent_name)?;
|
||||
|
|
@ -1486,22 +1475,6 @@ fn publish_file(path: &Path, content: &[u8], mode: u32, owner: Option<(u32, u32)
|
|||
staged.publish()
|
||||
}
|
||||
|
||||
/// Shared helper for the `WriteAgent*Token` requests.
|
||||
/// Writes `content` to `AGENT_STATE_ROOT/<agent_name>/state/<filename>`,
|
||||
/// chowns to the agent user (derived from the state dir's existing owner),
|
||||
/// and chmods 0600. Running as root (hive-priv), so this succeeds
|
||||
/// regardless of the file's prior owner/permissions.
|
||||
fn write_agent_state_file(
|
||||
agent_name: &str,
|
||||
filename: &str,
|
||||
content: &str,
|
||||
) -> Result<(String, String)> {
|
||||
let state_dir = PathBuf::from(AGENT_STATE_ROOT)
|
||||
.join(agent_name)
|
||||
.join("state");
|
||||
write_agent_dir_file(agent_name, &state_dir, filename, content)
|
||||
}
|
||||
|
||||
/// Create or remove an agent's pause marker under its harness dir. The
|
||||
/// marker is written empty and chowned to the harness dir's owner (the
|
||||
/// agent), matching how the harness itself would have created it.
|
||||
|
|
@ -1536,10 +1509,9 @@ fn remove_marker_in(dir: &Path, filename: &str) -> Result<()> {
|
|||
}
|
||||
|
||||
/// Write `content` to `dir/filename` as root, chowning the result to `dir`'s
|
||||
/// owner so the agent process can read it back. Shared by the credential
|
||||
/// writes (which target `state/`) and the pause marker (which targets
|
||||
/// `harness/`) — both write into a directory owned by the agent, which is
|
||||
/// precisely why they need hive-priv at all.
|
||||
/// owner so the agent process can read it back. Its caller is the pause
|
||||
/// marker, in `harness/`, a directory owned by the agent, which is precisely
|
||||
/// why it needs hive-priv at all.
|
||||
///
|
||||
/// The file is published through a [`StagedFile`], so a reader woken by its
|
||||
/// appearance cannot catch it empty or half-written: several of these paths
|
||||
|
|
@ -1563,8 +1535,7 @@ fn write_agent_dir_file(
|
|||
// yet (container being provisioned for the first time), the newly created dir
|
||||
// is root:root. The `stat state_dir` chown below will then see uid=0 and
|
||||
// leave the file root-owned (0600). The agent won't be able to read it until
|
||||
// its lifecycle completes. If that happens, a `systemctl restart hive-c0re`
|
||||
// after provisioning will re-mint and re-write the token correctly.
|
||||
// its lifecycle completes.
|
||||
std::fs::create_dir_all(&state_dir)
|
||||
.with_context(|| format!("create state dir {}", state_dir.display()))?;
|
||||
|
||||
|
|
@ -1592,7 +1563,7 @@ fn write_agent_dir_file(
|
|||
agent = %agent_name,
|
||||
path = %path.display(),
|
||||
error = %e,
|
||||
"write_agent_state_file: fchown failed"
|
||||
"write_agent_dir_file: fchown failed"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1600,7 +1571,7 @@ fn write_agent_dir_file(
|
|||
tracing::warn!(
|
||||
agent = %agent_name,
|
||||
error = %e,
|
||||
"write_agent_state_file: stat state_dir failed, leaving file root-owned"
|
||||
"write_agent_dir_file: stat state_dir failed, leaving file root-owned"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
The operator-facing host CLI. A thin client for the `hive-c0re`
|
||||
daemon — speaks the host admin socket protocol (`hive-host-sock`) and
|
||||
does not link the daemon crate. Container lifecycle, the approval
|
||||
queue, and the matrix/github/gateway verbs all forward to the daemon
|
||||
queue, and the matrix/gateway verbs all forward to the daemon
|
||||
and need it running; a few (`wg`/`peer-config`, `choom`) work off local
|
||||
host state instead.
|
||||
|
||||
|
|
@ -30,7 +30,6 @@ dispatch:
|
|||
- **`forge.rs`** — reconciles an agent's config against its forge
|
||||
repo.
|
||||
- **`matrix.rs`** — invite a matrix user to the hive Space or a room.
|
||||
- **`github.rs`** — per-agent GitHub PAT writes (`set-token`).
|
||||
- **`gateway.rs`** — gateway Basic-auth user management (htpasswd).
|
||||
- **`wg.rs`** — WireGuard mesh helpers.
|
||||
- **`subvol.rs`** — btrfs state-subvolume ops.
|
||||
|
|
|
|||
|
|
@ -45,15 +45,6 @@ pub enum Cmd {
|
|||
#[command(subcommand)]
|
||||
cmd: MatrixCmd,
|
||||
},
|
||||
/// GitHub account provisioning.
|
||||
///
|
||||
/// Store an operator-supplied personal access token (PAT) for an agent
|
||||
/// so its `gh` and git can authenticate. Creates no account — the PAT
|
||||
/// is for an existing GitHub account.
|
||||
Github {
|
||||
#[command(subcommand)]
|
||||
cmd: GithubCmd,
|
||||
},
|
||||
/// Gateway htpasswd user management.
|
||||
///
|
||||
/// Add, remove, or list users for the gateway's HTTP Basic auth.
|
||||
|
|
@ -299,26 +290,6 @@ pub enum MatrixCmd {
|
|||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
pub enum GithubCmd {
|
||||
/// Store a GitHub PAT for `<agent>` so its `gh` and git can
|
||||
/// authenticate.
|
||||
///
|
||||
/// Prefer `--token-stdin` — an inline `--token` is visible in shell
|
||||
/// history.
|
||||
SetToken {
|
||||
/// Logical agent name (the container/agent name).
|
||||
agent: String,
|
||||
/// The PAT value inline. Mutually exclusive with `--token-stdin`.
|
||||
#[arg(long)]
|
||||
token: Option<String>,
|
||||
/// Read the PAT from stdin (trailing newline stripped). Mutually
|
||||
/// exclusive with `--token`.
|
||||
#[arg(long, conflicts_with = "token")]
|
||||
token_stdin: bool,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
pub enum GatewayCmd {
|
||||
/// Add a user or update an existing user's password in the gateway
|
||||
|
|
|
|||
|
|
@ -1,48 +0,0 @@
|
|||
//! `hivectl github set-token <agent>` — write an operator-supplied GitHub PAT
|
||||
//! into an agent's `github-token` state file via the daemon's privileged
|
||||
//! helper, so the agent's `gh` wrapper + git credential helper authenticate.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
use anyhow::{Result, bail};
|
||||
|
||||
use crate::util::daemon_request;
|
||||
|
||||
/// `hivectl github set-token <agent>`: write an operator-supplied GitHub PAT
|
||||
/// into the agent's `github-token` state file (0600, agent-owned) via
|
||||
/// hive-priv, so the agent's `gh` wrapper + git credential helper can
|
||||
/// authenticate. Read live at invocation, so no rebuild/restart is needed.
|
||||
pub(crate) async fn github_set_token(
|
||||
socket: &Path,
|
||||
agent: &str,
|
||||
token: Option<String>,
|
||||
token_stdin: bool,
|
||||
) -> Result<()> {
|
||||
// Resolve + validate the token client-side (inline flag or stdin read);
|
||||
// the daemon never touches this process's stdin. Persistence happens
|
||||
// daemon-side via the privileged helper.
|
||||
let token = match (token, token_stdin) {
|
||||
(Some(t), _) => t,
|
||||
(None, true) => {
|
||||
let mut s = String::new();
|
||||
std::io::Read::read_to_string(&mut std::io::stdin(), &mut s)?;
|
||||
s.trim_end_matches(['\n', '\r']).to_owned()
|
||||
}
|
||||
(None, false) => bail!(
|
||||
"provide the PAT via --token <pat> or --token-stdin (stdin preferred — \
|
||||
an inline token is visible in shell history + process listings)"
|
||||
),
|
||||
};
|
||||
if token.is_empty() {
|
||||
bail!("refusing to write an empty GitHub token for agent '{agent}'");
|
||||
}
|
||||
daemon_request(
|
||||
socket,
|
||||
hive_host_sock::HostRequest::SetAgentGithubToken {
|
||||
agent: crate::util::parse_ident(agent)?,
|
||||
token,
|
||||
},
|
||||
"github",
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
|
@ -5,7 +5,7 @@
|
|||
//! Container lifecycle + the approval queue (`agent <name> <spawn|kill|
|
||||
//! rebuild|restart|choom|…>`, `list-agents`, `approvals <pending|approve|
|
||||
//! deny>`, `stop` / `start`) and provisioning (`forge` / `matrix` /
|
||||
//! `github` / `gateway`) all forward to the daemon, which owns the broker,
|
||||
//! `gateway`) all forward to the daemon, which owns the broker,
|
||||
//! the credentials, and the provisioning logic — a running daemon is
|
||||
//! required for those. A couple of verbs work off local host state
|
||||
//! directly instead (`wg` / `peer-config` read the mesh key + TLS CA), so
|
||||
|
|
@ -27,7 +27,7 @@ mod client;
|
|||
/// Rebuild-queue node progress rendering (`wait_for_nodes` + the spinner /
|
||||
/// plain renderers), split out to keep this file manageable.
|
||||
mod dag_progress;
|
||||
use cli::{Cli, Cmd, ForgeCmd, GatewayCmd, GithubCmd, WgCmd};
|
||||
use cli::{Cli, Cmd, ForgeCmd, GatewayCmd, WgCmd};
|
||||
mod completions;
|
||||
mod quota;
|
||||
mod util;
|
||||
|
|
@ -41,10 +41,8 @@ use open::open_url;
|
|||
mod wg;
|
||||
use wg::{peer_config, require_hive_domain, wg_init, wg_peer, wg_status};
|
||||
mod choom;
|
||||
mod github;
|
||||
mod watch;
|
||||
use github::github_set_token;
|
||||
mod forge;
|
||||
mod watch;
|
||||
use forge::forge_reconcile_config;
|
||||
mod agents;
|
||||
use agents::{agents_list, run_agent};
|
||||
|
|
@ -73,13 +71,6 @@ async fn main() -> Result<()> {
|
|||
} => forge_reconcile_config(&socket, &agent, from, verbose).await,
|
||||
},
|
||||
Cmd::Matrix { cmd } => run_matrix_cmd(&socket, cmd).await,
|
||||
Cmd::Github { cmd } => match cmd {
|
||||
GithubCmd::SetToken {
|
||||
agent,
|
||||
token,
|
||||
token_stdin,
|
||||
} => github_set_token(&socket, &agent, token, token_stdin).await,
|
||||
},
|
||||
Cmd::Gateway { cmd } => match cmd {
|
||||
GatewayCmd::CreateUser {
|
||||
username,
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ pub(crate) async fn open_url(socket: &Path, target: OpenTarget) -> Result<()> {
|
|||
),
|
||||
OpenTarget::Forge => (
|
||||
urls.forge,
|
||||
"the public forge URL needs `services.hyperhive.deploy.forgejo.behindGateway = true`",
|
||||
"the public forge URL needs `services.hyperhive.swarm.forge.publicUrl` to be set",
|
||||
),
|
||||
OpenTarget::Matrix => (
|
||||
urls.matrix,
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ pub(crate) fn parse_ident(name: &str) -> Result<hive_types::Ident> {
|
|||
|
||||
/// Send a provisioning request to the daemon and print its result lines.
|
||||
/// The daemon owns the provisioning logic; hivectl just relays the outcome,
|
||||
/// prefixing any error with `label` (e.g. `forge` / `github`).
|
||||
/// prefixing any error with `label` (e.g. `forge` / `gateway`).
|
||||
pub(crate) async fn daemon_request(
|
||||
socket: &Path,
|
||||
req: hive_host_sock::HostRequest,
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ in
|
|||
./forge-token.nix
|
||||
./frontend.nix
|
||||
./github.nix
|
||||
./github-token.nix
|
||||
./logs.nix
|
||||
./matrix.nix
|
||||
./mcp.nix
|
||||
|
|
|
|||
154
nix/agent-modules/github-token.nix
Normal file
154
nix/agent-modules/github-token.nix
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
# This agent's GitHub personal access token, fetched from the swarm secret
|
||||
# store by the agent itself, into the file ./github.nix's readers use.
|
||||
#
|
||||
# An operator links the token in the swarm UI; `swarm-controller` stores it at
|
||||
# `swarm/agents/<agent>/github-token` (`swarm_secret_client::github`). The
|
||||
# agent's own read grant covers that path, so this unit reads it and writes
|
||||
# `<state>/github-token`, which the `gh` wrapper, the git credential helper and
|
||||
# `hive-github-notify` read.
|
||||
#
|
||||
# It never deletes. A `github-token` already in place stays when the store has
|
||||
# none or cannot be read. The file is replaced by rename, and only when its
|
||||
# bytes changed.
|
||||
{
|
||||
pkgs,
|
||||
lib,
|
||||
config,
|
||||
...
|
||||
}:
|
||||
let
|
||||
cfg = config.services.hyperhive.agent.bao;
|
||||
|
||||
agentName = config.services.hyperhive.agent.user.name;
|
||||
stateDir = "/agents/${agentName}/state";
|
||||
|
||||
# The same three ids ./bao.nix and ./forge-accounts.nix load.
|
||||
certCredential = "hive-agent-bao-cert";
|
||||
keyCredential = "hive-agent-bao-key";
|
||||
serverCaCredential = "hive-agent-bao-server-ca";
|
||||
|
||||
unitName = "hive-agent-github-token";
|
||||
|
||||
# The nix half of `swarm_secret_client::github::account_path` plus
|
||||
# `path::MOUNT`.
|
||||
tokenPath = "secret/swarm/agents/${agentName}/github-token";
|
||||
|
||||
runtimeDir = unitName;
|
||||
# The store's whole answer, token included: kept in the unit's own `0700`
|
||||
# directory, never in the state dir.
|
||||
rawFile = "/run/${runtimeDir}/account.json";
|
||||
errFile = "/run/${runtimeDir}/bao.err";
|
||||
|
||||
tokenFile = "${stateDir}/github-token";
|
||||
stagedFile = "${stateDir}/.github-token.new";
|
||||
|
||||
configured = cfg.addr != null && config.services.hyperhive.agent.github.enable;
|
||||
|
||||
storeRetry = import ../host-modules/lib/store-retry.nix { };
|
||||
in
|
||||
{
|
||||
config = lib.mkIf configured {
|
||||
systemd.services.${unitName} = {
|
||||
description = "fetch this agent's GitHub token from the secret store";
|
||||
after = [
|
||||
"network.target"
|
||||
"hive-agent-bao-identity.service"
|
||||
];
|
||||
# The poller reads the token once at start.
|
||||
before = [ "hive-github-notify.service" ];
|
||||
wantedBy = [ "multi-user.target" ];
|
||||
path = [
|
||||
pkgs.openbao
|
||||
pkgs.coreutils
|
||||
pkgs.diffutils
|
||||
pkgs.jq
|
||||
];
|
||||
# ../host-modules/lib/store-retry.nix.
|
||||
inherit (storeRetry) startLimitBurst startLimitIntervalSec;
|
||||
serviceConfig = storeRetry.serviceConfig // {
|
||||
Type = "oneshot";
|
||||
# Not `RemainAfterExit`, so the timer below can start it again.
|
||||
RemainAfterExit = false;
|
||||
TimeoutStartSec = 30;
|
||||
User = agentName;
|
||||
Group = agentName;
|
||||
RuntimeDirectory = runtimeDir;
|
||||
RuntimeDirectoryMode = "0700";
|
||||
# `0600`, the mode `github-token` has.
|
||||
UMask = "0077";
|
||||
LoadCredential = [
|
||||
certCredential
|
||||
keyCredential
|
||||
serverCaCredential
|
||||
];
|
||||
};
|
||||
environment = {
|
||||
BAO_ADDR = cfg.addr;
|
||||
BAO_CLIENT_CERT = "%d/${certCredential}";
|
||||
BAO_CLIENT_KEY = "%d/${keyCredential}";
|
||||
};
|
||||
script = ''
|
||||
set -euo pipefail
|
||||
|
||||
# No identity delivered: ./bao.nix's check reports that.
|
||||
for id in ${lib.escapeShellArg certCredential} ${lib.escapeShellArg keyCredential}; do
|
||||
if [ ! -s "$CREDENTIALS_DIRECTORY/$id" ]; then
|
||||
echo "this agent has no store identity, so it cannot fetch its GitHub token." >&2
|
||||
exit 0
|
||||
fi
|
||||
done
|
||||
|
||||
if [ -s "$CREDENTIALS_DIRECTORY/${serverCaCredential}" ]; then
|
||||
export BAO_CACERT="$CREDENTIALS_DIRECTORY/${serverCaCredential}"
|
||||
fi
|
||||
|
||||
err=${lib.escapeShellArg errFile}
|
||||
raw=${lib.escapeShellArg rawFile}
|
||||
staged=${lib.escapeShellArg stagedFile}
|
||||
token=${lib.escapeShellArg tokenFile}
|
||||
trap 'rm -f "$err" "$raw" "$staged"' EXIT
|
||||
|
||||
if ! BAO_TOKEN="$(bao login -method=cert -token-only 2>"$err")"; then
|
||||
echo "the swarm secret store at $BAO_ADDR did not accept this agent's certificate login:" >&2
|
||||
if [ -s "$err" ]; then cat "$err" >&2; fi
|
||||
exit 1
|
||||
fi
|
||||
export BAO_TOKEN
|
||||
|
||||
# No token linked and a store that cannot answer look alike here, and
|
||||
# either way the file in place, if any, is kept.
|
||||
if ! bao kv get -format=json ${lib.escapeShellArg tokenPath} >"$raw" 2>"$err"; then
|
||||
echo "no GitHub token read from ${tokenPath}; github-token left as it is:" >&2
|
||||
if [ -s "$err" ]; then cat "$err" >&2; fi
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# ⚠️ The token goes from the store's answer straight into a file; it is
|
||||
# never in a variable or an argument. A malformed object exits 0:
|
||||
# failing the unit would only restart it into the same answer.
|
||||
rm -f "$staged"
|
||||
if ! jq -er '.data.data.value | strings' "$raw" >"$staged"; then
|
||||
echo "${tokenPath} holds no string value; github-token left as it is." >&2
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if cmp -s "$staged" "$token"; then
|
||||
echo "this agent's GitHub token at ${tokenPath} is unchanged."
|
||||
exit 0
|
||||
fi
|
||||
mv -f "$staged" "$token"
|
||||
echo "fetched this agent's GitHub token from ${tokenPath}."
|
||||
'';
|
||||
};
|
||||
|
||||
# The same cadence as ./forge-accounts.nix.
|
||||
systemd.timers.${unitName} = {
|
||||
description = "re-fetch this agent's GitHub token from the secret store";
|
||||
wantedBy = [ "timers.target" ];
|
||||
timerConfig = {
|
||||
OnUnitInactiveSec = "2min";
|
||||
RandomizedDelaySec = "20s";
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
# GitHub integration (services.hyperhive.agent.github.enable): a `gh` wrapper + a git
|
||||
# credential helper, both reading the PAT from the agent's
|
||||
# `github-token` state file at invocation, so a dashboard-pasted token
|
||||
# takes effect with no rebuild. The token PATH is baked in at build
|
||||
# `github-token` state file at invocation, so a token ./github-token.nix
|
||||
# fetches takes effect with no rebuild. The token PATH is baked in at build
|
||||
# time (nix knows `userName`) — NOT read from `$HIVE_GITHUB_TOKEN_FILE`,
|
||||
# because claude's Bash tool runs `bash -c` in a minimal env that
|
||||
# doesn't source `/etc/set-environment`, so the env var isn't present
|
||||
|
|
@ -41,12 +41,13 @@ in
|
|||
description = ''
|
||||
Install the GitHub integration in this agent: a `gh` CLI wrapper and a
|
||||
git credential helper for `https://github.com`, both authenticated from
|
||||
an operator-supplied personal access token (PAT). The PAT is written to
|
||||
`<state>/github-token` out of band --- the dashboard credentials tab or
|
||||
`hivectl github set-token` --- so giving an agent GitHub is a runtime
|
||||
paste, no per-agent config or rebuild. The wrappers read the token file
|
||||
at invocation, so a freshly-pasted PAT takes effect immediately; until
|
||||
one exists, `gh` / `git push` just fail unauthenticated.
|
||||
an operator-supplied personal access token (PAT). The operator links the
|
||||
PAT in the swarm UI, and the agent fetches it from the swarm secret store
|
||||
into `<state>/github-token` (needs `services.hyperhive.agent.bao.addr`),
|
||||
so giving an agent GitHub is a runtime action, no per-agent config or
|
||||
rebuild. The wrappers read the token file at invocation, so a fetched
|
||||
PAT takes effect immediately; until one exists, `gh` / `git push` just
|
||||
fail unauthenticated.
|
||||
|
||||
github.com only. git authenticates as `x-access-token` + the PAT (GitHub
|
||||
ignores the username for PAT auth); `gh` derives its identity from the
|
||||
|
|
|
|||
|
|
@ -126,6 +126,10 @@ in
|
|||
inherit pkgs self nixosSystem;
|
||||
inherit (pkgs) lib;
|
||||
};
|
||||
module-eval-agent-github-bao = import ./module-eval/agent-github-bao.nix {
|
||||
inherit pkgs self nixosSystem;
|
||||
inherit (pkgs) lib;
|
||||
};
|
||||
module-eval-agent-memory = import ./module-eval/agent-memory.nix {
|
||||
inherit pkgs self nixosSystem;
|
||||
inherit (pkgs) lib;
|
||||
|
|
|
|||
|
|
@ -209,7 +209,7 @@ in
|
|||
# The rest of the forge split. What stays under `swarm.forge` is what the
|
||||
# forge IS from any hive's point of view — the names and ports it answers
|
||||
# on, the URLs it advertises, the client id it is registered under; these
|
||||
# five are what the host running it decides. Its package moved as well,
|
||||
# four are what the host running it decides. Its package moved as well,
|
||||
# further down with the other `*.package` moves. `sso` splits
|
||||
# because its two halves are different facts: the client id must match the
|
||||
# entry in authelia's register, the secret is a path on this machine.
|
||||
|
|
@ -218,10 +218,6 @@ in
|
|||
# option of a list-of-submodule type, so the rename carries its whole
|
||||
# value. The `ci` block above needs five because it is a plain attrset of
|
||||
# separate options, which is the case with no parent path to rename.
|
||||
(lib.mkRenamedOptionModule
|
||||
[ "services" "hyperhive" "swarm" "forge" "behindGateway" ]
|
||||
[ "services" "hyperhive" "deploy" "forgejo" "behindGateway" ]
|
||||
)
|
||||
(lib.mkRenamedOptionModule
|
||||
[ "services" "hyperhive" "swarm" "forge" "openFirewall" ]
|
||||
[ "services" "hyperhive" "deploy" "forgejo" "openFirewall" ]
|
||||
|
|
|
|||
|
|
@ -210,8 +210,8 @@ in
|
|||
# breaks the moment the operator's browser hostname isn't the forge
|
||||
# host, e.g. through the gateway or a reverse proxy). Sourced from
|
||||
# `services.hyperhive.swarm.forge.publicUrl`, which itself defaults to the
|
||||
# gateway vhost URL when `deploy.forgejo.behindGateway = true` and `null`
|
||||
# otherwise — see that option's doc for the "hide, don't guess" rationale.
|
||||
# gateway vhost URL — see that option's doc for the "hide, don't guess"
|
||||
# rationale.
|
||||
# Absent here whenever `publicUrl` is `null`; the dashboard hides
|
||||
# forge links rather than emitting one it can't justify.
|
||||
HIVE_FORGE_PUBLIC_URL = config.services.hyperhive.swarm.forge.publicUrl;
|
||||
|
|
|
|||
|
|
@ -52,8 +52,7 @@ let
|
|||
# Private network namespace, attached to the hive bridge so the
|
||||
# runner reaches the forge via the gateway — and cannot reach
|
||||
# host-loopback (127.0.0.1:7000 dashboard, raw forge port, etc.).
|
||||
# Requires `deploy.forgejo.behindGateway = true` (asserted in the
|
||||
# config block below). See docs/networking/network.md.
|
||||
# See docs/networking/network.md.
|
||||
privateNetwork = true;
|
||||
in
|
||||
{
|
||||
|
|
@ -161,22 +160,7 @@ in
|
|||
};
|
||||
|
||||
config = lib.mkIf cfg.enable {
|
||||
# `deploy.forgejo.behindGateway = true` (the default) is required because
|
||||
# the CI container uses private networking and reaches the forge through
|
||||
# the gateway vhost. Without the gateway vhost there is no HTTP
|
||||
# listener for `forgeCfg.domain` on the bridge that the runner can
|
||||
# connect to.
|
||||
assertions = [
|
||||
{
|
||||
assertion = forgeDeployCfg.behindGateway;
|
||||
message = ''
|
||||
services.hyperhive.deploy.forgejo.ci.enable requires
|
||||
services.hyperhive.deploy.forgejo.behindGateway = true.
|
||||
The CI container runs with a private network namespace and
|
||||
reaches the forge through the gateway vhost on the bridge IP.
|
||||
Set behindGateway = true (it is the default).
|
||||
'';
|
||||
}
|
||||
{
|
||||
# The runner reaches the forge through THIS host's gateway, and
|
||||
# hive-c0re registers it through the local forge container; neither
|
||||
|
|
|
|||
|
|
@ -58,26 +58,20 @@ let
|
|||
|
||||
caTrust = import ../lib/hive-ca-trust.nix { inherit lib tlsCfg gatewayCfg; };
|
||||
|
||||
# ROOT_URL forgejo advertises in clone links + outbound URLs. When
|
||||
# served behind the gateway, `cfg.domain` doubles as both the
|
||||
# forgejo `DOMAIN` setting AND the gateway vhost server-name, so
|
||||
# ROOT_URL just uses it directly. The gateway always terminates TLS
|
||||
# (self-signed is the implicit floor when neither `tls.certDir` nor
|
||||
# ACME is configured), so behind the gateway the forge is always
|
||||
# advertised over `https` on `httpsPort` — the canonical 443 elides
|
||||
# the port suffix. When direct (`behindGateway = false`), keep the
|
||||
# host:httpPort shape so direct browser access still produces correct
|
||||
# links. Operators can still override via `cfg.rootUrl` for bespoke
|
||||
# shapes. Both bindings are duplicated in ./service.nix, which builds
|
||||
# `sso.redirectUri` from them; keep the two equal.
|
||||
# ROOT_URL forgejo advertises in clone links + outbound URLs.
|
||||
# `cfg.domain` doubles as both the forgejo `DOMAIN` setting AND the
|
||||
# gateway vhost server-name, so ROOT_URL just uses it directly. The
|
||||
# gateway always terminates TLS (self-signed is the implicit floor when
|
||||
# neither `tls.certDir` nor ACME is configured), so the forge is always
|
||||
# advertised over `https` on `httpsPort` — the canonical 443 elides the
|
||||
# port suffix. Operators can still override via `cfg.rootUrl` for
|
||||
# bespoke shapes. Both bindings are duplicated in ./service.nix, which
|
||||
# builds `sso.redirectUri` from them; keep the two equal.
|
||||
defaultRootUrl =
|
||||
if deployCfg.forgejo.behindGateway then
|
||||
let
|
||||
portSuffix = if gatewayCfg.httpsPort == 443 then "" else ":${toString gatewayCfg.httpsPort}";
|
||||
in
|
||||
"https://${cfg.domain}${portSuffix}/"
|
||||
else
|
||||
"http://${cfg.domain}:${toString cfg.httpPort}/";
|
||||
let
|
||||
portSuffix = if gatewayCfg.httpsPort == 443 then "" else ":${toString gatewayCfg.httpsPort}";
|
||||
in
|
||||
"https://${cfg.domain}${portSuffix}/";
|
||||
effectiveRootUrl = if cfg.rootUrl != null then cfg.rootUrl else defaultRootUrl;
|
||||
|
||||
# When CI is enabled, the runner needs `actions/checkout` resolvable
|
||||
|
|
@ -136,35 +130,6 @@ in
|
|||
'';
|
||||
};
|
||||
|
||||
behindGateway = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = true;
|
||||
description = ''
|
||||
Serve forgejo through the hive-gateway nginx as a sub-domain
|
||||
vhost (`server_name = cfg.domain`) instead of directly on
|
||||
`httpPort` (sub-domain routing — see `docs/networking/gateway.md`).
|
||||
|
||||
When `true`:
|
||||
- The gateway adds a `server { server_name = ''${cfg.domain}; }`
|
||||
block that proxies all `/` → `http://127.0.0.1:''${httpPort}/`.
|
||||
- Forgejo's `ROOT_URL` flips to `http(s)://''${cfg.domain}/`
|
||||
(sub-domain root, no port suffix when gateway is on 80).
|
||||
- `gateway.localHostsEntry = true` extends `/etc/hosts` to
|
||||
include `cfg.domain → 127.0.0.1` for local dev.
|
||||
|
||||
Defaults to `true` (the gateway always runs alongside
|
||||
hyperhive, so forge auto-routes through it). Set `false`
|
||||
explicitly to keep forge on the direct port even though the
|
||||
gateway is running (e.g. an external git client that doesn't
|
||||
traverse the gateway).
|
||||
|
||||
Sub-domain routing is the preferred shape for forge + matrix
|
||||
(both are external standard apps with sub-domain-native config
|
||||
defaults). Per-agent UIs stay on sub-path (`/agent/<name>/`)
|
||||
because they're hyperhive-internal + already base-path-aware.
|
||||
'';
|
||||
};
|
||||
|
||||
openFirewall = lib.mkOption {
|
||||
type = lib.types.bool;
|
||||
default = false;
|
||||
|
|
@ -281,49 +246,38 @@ in
|
|||
# the gateway supplies the primitives (`lib.listen`, `lib.tlsFor`,
|
||||
# `lib.securityHeaders`) and never needs to know this service by
|
||||
# name.
|
||||
#
|
||||
# Both halves are gated on `behindGateway`: with it off the operator
|
||||
# fronts forgejo themselves, so this hive must neither claim the
|
||||
# vhost nor answer DNS for it.
|
||||
services.hyperhive.gateway.localNames = lib.optional deployCfg.forgejo.behindGateway cfg.domain;
|
||||
services.hyperhive.gateway.enable = lib.mkIf deployCfg.forgejo.behindGateway (lib.mkDefault true);
|
||||
services.hyperhive.gateway.localNames = [ cfg.domain ];
|
||||
services.hyperhive.gateway.enable = lib.mkDefault true;
|
||||
|
||||
# Not conditional on `behindGateway`: the forge container resolves the
|
||||
# rest of the hive through dnsmasq whoever fronts it.
|
||||
# The forge container resolves the rest of the hive through dnsmasq.
|
||||
services.hyperhive.gateway.dns.enable = lib.mkDefault true;
|
||||
|
||||
# This swarm-ui quick-links entry, same `behindGateway` guard as the
|
||||
# vhost/DNS name above — with it off, this host doesn't actually
|
||||
# serve `cfg.domain`, so linking to it would be dead. See
|
||||
# This swarm-ui quick-links entry. See
|
||||
# `services.hyperhive.swarm.controller.links`'s description.
|
||||
services.hyperhive.swarm.controller.links = lib.optional deployCfg.forgejo.behindGateway {
|
||||
label = "Forge";
|
||||
icon = "⚒";
|
||||
url = "https://${cfg.domain}/";
|
||||
};
|
||||
services.hyperhive.swarm.controller.links = [
|
||||
{
|
||||
label = "Forge";
|
||||
icon = "⚒";
|
||||
url = "https://${cfg.domain}/";
|
||||
}
|
||||
];
|
||||
|
||||
# The metrics endpoint, declared once: the collector both scrapes this
|
||||
# URL and derives from it the audience its token is minted for. Same
|
||||
# `behindGateway` guard, and for a stronger reason than the two above:
|
||||
# with it off there is no `= /metrics` location and no `auth_request`
|
||||
# in front of it, so the URL this names does not exist to be scraped
|
||||
# or authorised.
|
||||
# URL and derives from it the audience its token is minted for.
|
||||
#
|
||||
# ⚠️ Written as the exact URL a collector requests, because that is
|
||||
# what authelia compares against — this string agreeing with the
|
||||
# `location` block above it is the whole mechanism. A near miss is a
|
||||
# correctly minted token refused at the target.
|
||||
services.hyperhive.swarm.otel.publishedScrapeTargets =
|
||||
lib.optionalAttrs deployCfg.forgejo.behindGateway
|
||||
{
|
||||
forgejo = "https://${cfg.domain}/metrics";
|
||||
};
|
||||
services.hyperhive.swarm.otel.publishedScrapeTargets = {
|
||||
forgejo = "https://${cfg.domain}/metrics";
|
||||
};
|
||||
|
||||
# `server_name = forge.domain`, proxies all `/` → forgejo. Tuned for
|
||||
# git: `client_max_body_size 1G`, `proxy_read_timeout 1h` (multi-GB
|
||||
# clones). SSH stays direct on `forge.sshPort`. See
|
||||
# `docs/networking/gateway.md`.
|
||||
services.nginx.virtualHosts = lib.optionalAttrs deployCfg.forgejo.behindGateway {
|
||||
services.nginx.virtualHosts = {
|
||||
"${cfg.domain}" = (gatewayCfg.lib.tlsFor cfg.domain) // {
|
||||
listen = gatewayCfg.lib.listen;
|
||||
extraConfig = gatewayCfg.lib.securityHeaders;
|
||||
|
|
@ -600,21 +554,17 @@ in
|
|||
DEFAULT_PRIVATE = "private";
|
||||
};
|
||||
# Not an option: a swarm-integrated, auto-deployed forge
|
||||
# always has metrics. Tied to `behindGateway` because that
|
||||
# IS the swarm-integrated shape — it is the condition under
|
||||
# which the protected `= /metrics` location below exists.
|
||||
# Serving the endpoint without that location would put it on
|
||||
# a listener `openFirewall` can expose, with nothing in
|
||||
# front of it.
|
||||
# always has metrics, behind the protected `= /metrics`
|
||||
# location on its gateway vhost.
|
||||
#
|
||||
# No `TOKEN` here on purpose. Forgejo can guard this itself
|
||||
# with a static bearer, but the swarm authenticates the
|
||||
# scraper at the gateway, so a second credential system per
|
||||
# service would buy nothing and would be the one that stops
|
||||
# getting rotated.
|
||||
metrics.ENABLED = deployCfg.forgejo.behindGateway;
|
||||
# The two per-dimension breakdowns, on the same condition as
|
||||
# the endpoint itself: `gitea_issues_by_label{label=…}` and
|
||||
metrics.ENABLED = true;
|
||||
# The two per-dimension breakdowns, alongside the endpoint
|
||||
# itself: `gitea_issues_by_label{label=…}` and
|
||||
# `gitea_issues_by_repository{repository=…}`. Off by default
|
||||
# upstream because they are the only metrics here whose series
|
||||
# count grows with the CONTENT of the forge rather than with
|
||||
|
|
@ -631,8 +581,8 @@ in
|
|||
# forge that grew to thousands of repos would want this
|
||||
# revisited — that is a real trigger, unlike a time-based one:
|
||||
# `count(gitea_issues_by_repository)` answers it directly.
|
||||
metrics.ENABLED_ISSUE_BY_LABEL = deployCfg.forgejo.behindGateway;
|
||||
metrics.ENABLED_ISSUE_BY_REPOSITORY = deployCfg.forgejo.behindGateway;
|
||||
metrics.ENABLED_ISSUE_BY_LABEL = true;
|
||||
metrics.ENABLED_ISSUE_BY_REPOSITORY = true;
|
||||
# Repo migrations / pull-mirrors fetch from the source
|
||||
# URL *inside* Forgejo. hyperhive code is synced from
|
||||
# `localhost` (and the host LAN), which Forgejo's
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ let
|
|||
cfg = config.services.hyperhive.swarm.forge;
|
||||
gatewayCfg = config.services.hyperhive.gateway;
|
||||
swarmDomain = config.services.hyperhive.swarm.domain;
|
||||
deployCfg = config.services.hyperhive.deploy;
|
||||
|
||||
# Forgejo's name for the login source. Duplicated in ./default.nix, which
|
||||
# registers the source under it.
|
||||
|
|
@ -24,13 +23,10 @@ let
|
|||
# Forgejo's `ROOT_URL`, duplicated from ./default.nix, which documents its
|
||||
# shape.
|
||||
defaultRootUrl =
|
||||
if deployCfg.forgejo.behindGateway then
|
||||
let
|
||||
portSuffix = if gatewayCfg.httpsPort == 443 then "" else ":${toString gatewayCfg.httpsPort}";
|
||||
in
|
||||
"https://${cfg.domain}${portSuffix}/"
|
||||
else
|
||||
"http://${cfg.domain}:${toString cfg.httpPort}/";
|
||||
let
|
||||
portSuffix = if gatewayCfg.httpsPort == 443 then "" else ":${toString gatewayCfg.httpsPort}";
|
||||
in
|
||||
"https://${cfg.domain}${portSuffix}/";
|
||||
effectiveRootUrl = if cfg.rootUrl != null then cfg.rootUrl else defaultRootUrl;
|
||||
in
|
||||
{
|
||||
|
|
@ -94,8 +90,8 @@ in
|
|||
description = ''
|
||||
Public hostname for the forge. Doubles as both the forgejo
|
||||
`DOMAIN` setting (clone URLs forgejo advertises) AND the
|
||||
gateway vhost server-name when `deploy.forgejo.behindGateway = true`
|
||||
(sub-domain routing — see `docs/networking/gateway.md`).
|
||||
gateway vhost server-name (sub-domain routing — see
|
||||
`docs/networking/gateway.md`).
|
||||
|
||||
Defaults to `forge.''${services.hyperhive.swarm.domain}` — the
|
||||
swarm's domain, not this hive's, because a swarm runs **one**
|
||||
|
|
@ -116,10 +112,8 @@ in
|
|||
|
||||
publicUrl = lib.mkOption {
|
||||
type = lib.types.nullOr lib.types.str;
|
||||
default = if deployCfg.forgejo.behindGateway then "https://${cfg.domain}" else null;
|
||||
defaultText = lib.literalExpression ''
|
||||
if behindGateway then "https://''${domain}" else null
|
||||
'';
|
||||
default = "https://${cfg.domain}";
|
||||
defaultText = lib.literalExpression ''"https://''${domain}"'';
|
||||
example = "https://forge.example.com";
|
||||
description = ''
|
||||
Browser-facing forge URL the dashboard uses to build clickable
|
||||
|
|
@ -127,20 +121,12 @@ in
|
|||
the approval-queue's "review PR on forge" link) — sourced into
|
||||
every agent container + hive-c0re as `HIVE_FORGE_PUBLIC_URL`.
|
||||
|
||||
Defaults to `https://''${cfg.domain}` when `deploy.forgejo.behindGateway =
|
||||
true` (the gateway vhost is genuinely reachable at that URL)
|
||||
and `null` otherwise. When `null`, the dashboard **hides**
|
||||
forge links rather than guessing one — see
|
||||
`docs/web-ui/dashboard.md::H0M3 page` for the rationale (a
|
||||
link built from the operator's own browser hostname + a
|
||||
container port is only an accident away from wrong on any
|
||||
deployment that isn't plain localhost).
|
||||
|
||||
**Set this explicitly if `deploy.forgejo.behindGateway = false`** and the
|
||||
forge is still reachable at a stable URL you want linked from
|
||||
the dashboard (e.g. `http://<lan-host>:''${toString cfg.httpPort}`
|
||||
for an all-LAN deployment) — leaving it unset there means the
|
||||
dashboard's forge links are simply absent, not broken.
|
||||
Defaults to `https://''${cfg.domain}`, the gateway vhost. When
|
||||
`null`, the dashboard **hides** forge links rather than
|
||||
guessing one — see `docs/web-ui/dashboard.md::H0M3 page` for
|
||||
the rationale (a link built from the operator's own browser
|
||||
hostname + a container port is only an accident away from
|
||||
wrong on any deployment that isn't plain localhost).
|
||||
'';
|
||||
};
|
||||
|
||||
|
|
@ -150,20 +136,15 @@ in
|
|||
example = "https://forge.example.com/";
|
||||
description = ''
|
||||
Override the auto-derived forgejo `ROOT_URL`. When `null`
|
||||
(default), `ROOT_URL` is derived from `cfg.domain` + gateway
|
||||
state, including the scheme:
|
||||
(default), `ROOT_URL` is `https://''${cfg.domain}/`. The gateway
|
||||
always terminates TLS (self-signed is the implicit floor when no
|
||||
`gateway.tls.certDir` / ACME is set), so the forge is always
|
||||
advertised over https. A non-canonical `gateway.httpsPort` is
|
||||
appended as `:<port>`.
|
||||
|
||||
- `deploy.forgejo.behindGateway = true` → `https://''${cfg.domain}/`. The gateway
|
||||
always terminates TLS (self-signed is the implicit floor when no
|
||||
`gateway.tls.certDir` / ACME is set), so the forge is always
|
||||
advertised over https. A non-canonical `gateway.httpsPort` is
|
||||
appended as `:<port>`.
|
||||
- `deploy.forgejo.behindGateway = false` → `http://''${cfg.domain}:''${cfg.httpPort}/`
|
||||
|
||||
The TLS scheme is derived automatically now, so you only need to
|
||||
set this for a genuinely bespoke shape (e.g. an external reverse
|
||||
proxy on a different host/path). Must end with `/` per forgejo's
|
||||
`ROOT_URL` contract.
|
||||
Set this only for a genuinely bespoke shape (e.g. an external
|
||||
reverse proxy on a different host/path). Must end with `/` per
|
||||
forgejo's `ROOT_URL` contract.
|
||||
'';
|
||||
};
|
||||
|
||||
|
|
@ -202,11 +183,10 @@ in
|
|||
format.
|
||||
|
||||
⚠️ With {option}`services.hyperhive.swarm.forge.rootUrl` unset,
|
||||
`ROOT_URL` follows
|
||||
{option}`services.hyperhive.deploy.forgejo.behindGateway` and
|
||||
the gateway's `httpsPort`, which are per-host. An authelia host
|
||||
that is not the forge's host renders the forge's callback only
|
||||
if the two agree on them; set `rootUrl` if they do not.
|
||||
`ROOT_URL` follows the gateway's `httpsPort`, which is
|
||||
per-host. An authelia host that is not the forge's host renders
|
||||
the forge's callback only if the two agree on it; set `rootUrl`
|
||||
if they do not.
|
||||
'';
|
||||
};
|
||||
# The secret half is a path on the host that runs the forge, so it
|
||||
|
|
|
|||
|
|
@ -219,9 +219,8 @@ in
|
|||
description = ''
|
||||
Hive-wide switch for the per-agent GitHub integration (the `gh` CLI
|
||||
wrapper + git credential helper, per `hyperhive.github.enable`). On by
|
||||
default: every agent gets the integration, inert until a PAT is
|
||||
provisioned via the dashboard credentials tab or `hivectl github
|
||||
set-token`. Set `false` to turn it off for the whole hive --- the
|
||||
default: every agent gets the integration, inert until an operator links
|
||||
a PAT for it in the swarm UI. Set `false` to turn it off for the whole hive --- the
|
||||
meta-flake renderer (`hive-c0re/src/meta.rs`) then injects
|
||||
`hyperhive.github.enable = false` into every agent. Exposed to hive-c0re
|
||||
as `HYPERHIVE_GITHUB_DISABLED` (set only when the integration is off).
|
||||
|
|
|
|||
|
|
@ -1060,7 +1060,7 @@ in
|
|||
# Denied or client-scoped depending on whether a
|
||||
# collector is registered — see `metricsRule` above,
|
||||
# which is where the reasoning for both halves lives.
|
||||
lib.optional deployCfg.forgejo.behindGateway metricsRule
|
||||
[ metricsRule ]
|
||||
# Also guarded on the domain being set: without it a null
|
||||
# apex would render a rule matching the string "null".
|
||||
++ lib.optional (deployCfg.swarm-ui.enable && swarmDomain != null) {
|
||||
|
|
|
|||
114
nix/module-eval/agent-github-bao.nix
Normal file
114
nix/module-eval/agent-github-bao.nix
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
# `checks.module-eval-agent-github-bao` — see ./lib.nix for the shared
|
||||
# rationale (why this suite exists, naming convention, "evaluates
|
||||
# not executes").
|
||||
#
|
||||
# The agent side of the swarm-stored GitHub token: ../agent-modules/github-token.nix
|
||||
# fetches it into the file ../agent-modules/github.nix's readers use.
|
||||
{
|
||||
pkgs,
|
||||
lib,
|
||||
self,
|
||||
nixosSystem,
|
||||
}:
|
||||
let
|
||||
inherit
|
||||
(import ./lib.nix {
|
||||
inherit
|
||||
pkgs
|
||||
lib
|
||||
self
|
||||
nixosSystem
|
||||
;
|
||||
})
|
||||
agentWith
|
||||
runGroup
|
||||
;
|
||||
|
||||
baoAddr = "https://bao.t.local:8200";
|
||||
|
||||
# A store, and the integration on by default.
|
||||
agentGithubBao = agentWith {
|
||||
services.hyperhive.agent.bao.addr = baoAddr;
|
||||
};
|
||||
|
||||
# No store: the absence arm, and what makes the cases above able to fail.
|
||||
agentGithubNoBao = agentWith { };
|
||||
|
||||
# A store, and the integration switched off.
|
||||
agentGithubOff = agentWith {
|
||||
services.hyperhive.agent.bao.addr = baoAddr;
|
||||
services.hyperhive.agent.github.enable = false;
|
||||
};
|
||||
|
||||
fetchUnit = machine: machine.systemd.services.hive-agent-github-token;
|
||||
has = machine: machine.systemd.services ? hive-agent-github-token;
|
||||
hasTimer = machine: machine.systemd.timers ? hive-agent-github-token;
|
||||
in
|
||||
let
|
||||
cases = [
|
||||
{
|
||||
name = "an agent with a store address and the integration on fetches its github token";
|
||||
ok = has agentGithubBao && hasTimer agentGithubBao;
|
||||
}
|
||||
{
|
||||
name = "an agent with no store address, or with the integration off, fetches none";
|
||||
ok =
|
||||
!(has agentGithubNoBao)
|
||||
&& !(hasTimer agentGithubNoBao)
|
||||
&& !(has agentGithubOff)
|
||||
&& !(hasTimer agentGithubOff);
|
||||
}
|
||||
{
|
||||
# The nix half of `swarm_secret_client::github::account_path`, and the
|
||||
# file ./github.nix's `gh` wrapper, credential helper and poller read.
|
||||
name = "the fetch reads the agent's own github-token path into its state-dir file";
|
||||
ok =
|
||||
let
|
||||
name = agentGithubBao.services.hyperhive.agent.user.name;
|
||||
s = (fetchUnit agentGithubBao).script;
|
||||
in
|
||||
lib.hasInfix "bao kv get -format=json secret/swarm/agents/${name}/github-token >" s
|
||||
&& lib.hasInfix "token=/agents/${name}/state/github-token" s
|
||||
&& lib.hasInfix ".data.data.value | strings" s;
|
||||
}
|
||||
{
|
||||
# The agent user owns its state dir (./user.nix), and the file keeps
|
||||
# its `0600` mode.
|
||||
name = "the fetch runs as the agent, with its own store identity";
|
||||
ok =
|
||||
let
|
||||
u = fetchUnit agentGithubBao;
|
||||
name = agentGithubBao.services.hyperhive.agent.user.name;
|
||||
in
|
||||
u.serviceConfig.User == name
|
||||
&& u.serviceConfig.UMask == "0077"
|
||||
&& builtins.elem "hive-agent-bao-cert" u.serviceConfig.LoadCredential
|
||||
&& builtins.elem "hive-agent-bao-key" u.serviceConfig.LoadCredential
|
||||
&& u.environment.BAO_ADDR == baoAddr
|
||||
&& u.environment.BAO_CLIENT_CERT == "%d/hive-agent-bao-cert"
|
||||
&& u.environment.BAO_CLIENT_KEY == "%d/hive-agent-bao-key";
|
||||
}
|
||||
{
|
||||
# A file a hive wrote keeps working until the operator links a token in
|
||||
# the swarm UI.
|
||||
name = "the fetch never deletes the state-dir token, and swaps it in only on a change";
|
||||
ok =
|
||||
let
|
||||
s = (fetchUnit agentGithubBao).script;
|
||||
in
|
||||
!(lib.hasInfix "rm -f \"$token\"" s)
|
||||
&& lib.hasInfix "cmp -s \"$staged\" \"$token\"" s
|
||||
&& lib.hasInfix "mv -f \"$staged\" \"$token\"" s;
|
||||
}
|
||||
{
|
||||
# The poller reads the token once at start.
|
||||
name = "the fetch runs before the github poller starts";
|
||||
ok = builtins.elem "hive-github-notify.service" (fetchUnit agentGithubBao).before;
|
||||
}
|
||||
{
|
||||
name = "the fetch re-runs every two minutes";
|
||||
ok = agentGithubBao.systemd.timers.hive-agent-github-token.timerConfig.OnUnitInactiveSec == "2min";
|
||||
}
|
||||
];
|
||||
in
|
||||
runGroup "agent-github-bao" cases
|
||||
|
|
@ -52,10 +52,6 @@ let
|
|||
deploy.forgejo.ci.enable = true;
|
||||
};
|
||||
|
||||
# A hive whose one gateway-published swarm service sits on another host:
|
||||
# every swarm name still configured, not one of them served here.
|
||||
forgeElsewhere = hive { deploy.forgejo.behindGateway = false; };
|
||||
|
||||
# A priority collision is a property of the *option*, not
|
||||
# of the merged value's interior — nix throws the moment the value is
|
||||
# demanded at all, so `seq`-ing each `serviceConfig` value to WHNF is
|
||||
|
|
@ -72,31 +68,12 @@ let
|
|||
builtins.foldl' (acc: v: builtins.seq v acc) true vals;
|
||||
cases = [
|
||||
{
|
||||
# Both halves matter. The equality is the "no longer consults the central
|
||||
# toggle" half; the literal is the "and still renders what it always
|
||||
# did" half, which an equality on its own would let drift to `false` in
|
||||
# lockstep.
|
||||
name = "the forge's behindGateway default is true regardless of the central toggle";
|
||||
ok =
|
||||
bare.services.hyperhive.deploy.forgejo.behindGateway == true
|
||||
&& centralToggleOff.services.hyperhive.deploy.forgejo.behindGateway == true;
|
||||
}
|
||||
{
|
||||
# Downstream of the one above — publicUrl reads `behindGateway`, so it
|
||||
# tracked the central toggle transitively as well as directly. The domain
|
||||
# is the stub's swarm domain, which both fixtures share.
|
||||
name = "the forge's publicUrl default follows behindGateway alone, not the central toggle";
|
||||
# The domain is the stub's swarm domain, which both fixtures share.
|
||||
name = "the forge's publicUrl default does not consult the central toggle";
|
||||
ok =
|
||||
bare.services.hyperhive.swarm.forge.publicUrl == "https://forge.t.local"
|
||||
&& centralToggleOff.services.hyperhive.swarm.forge.publicUrl == "https://forge.t.local";
|
||||
}
|
||||
{
|
||||
# And that it still tracks `behindGateway` at all: without this arm the
|
||||
# case above passes just as well for a default hardcoded to the URL.
|
||||
name = "the forge's publicUrl default is still null with behindGateway off";
|
||||
ok =
|
||||
(hive { deploy.forgejo.behindGateway = false; }).services.hyperhive.swarm.forge.publicUrl == null;
|
||||
}
|
||||
{
|
||||
# The controller's token path follows where the forge runs
|
||||
# (./forge-placement.nix), never the central toggle: a forge host with
|
||||
|
|
@ -226,9 +203,9 @@ let
|
|||
# vhosts are the hive leaf's, which this host still signs.
|
||||
name = "a host fronting none of the swarm's service names requests no services leaf";
|
||||
ok =
|
||||
forgeElsewhere.services.hyperhive.swarm.localServiceDomains == [ ]
|
||||
&& forgeElsewhere.services.hyperhive.swarm.serviceDomains != [ ]
|
||||
&& lib.hasInfix "want_svc=0" forgeElsewhere.systemd.services.swarm-services-cert.script;
|
||||
bare.services.hyperhive.swarm.localServiceDomains == [ ]
|
||||
&& bare.services.hyperhive.swarm.serviceDomains != [ ]
|
||||
&& lib.hasInfix "want_svc=0" bare.systemd.services.swarm-services-cert.script;
|
||||
}
|
||||
{
|
||||
# nixos asserts when a vhost declares both, so this is also a
|
||||
|
|
|
|||
|
|
@ -28,10 +28,10 @@ The swarm's control plane. The swarm UI and `swarmctl` are its clients.
|
|||
- **Agent credentials** — at start and every five minutes it re-checks every
|
||||
agent's forge token and matrix account, and renews store certificates and
|
||||
queue secrets as they age.
|
||||
- **Linked external accounts** — an operator-supplied matrix or forge
|
||||
account for one agent, stored in the swarm secret store
|
||||
- **Linked external accounts** — an operator-supplied matrix, forge or
|
||||
GitHub account for one agent, stored in the swarm secret store
|
||||
(`PUT /api/hives/{hive}/agents/{agent}/matrix-accounts/{account}`,
|
||||
`.../forge-accounts/{label}`); distinct from the agent's own swarm-minted
|
||||
`.../forge-accounts/{label}`, `.../github-account`); distinct from the agent's own swarm-minted
|
||||
accounts above.
|
||||
- **Config PR status** — each agent's open config-repo PR, cached from forge
|
||||
webhooks (`GET /api/config-prs`, `/api/agents/{name}/config-pr`).
|
||||
|
|
|
|||
176
swarm-controller/src/github_account.rs
Normal file
176
swarm-controller/src/github_account.rs
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
//! An agent's GitHub personal access token: an operator hands us the token, we
|
||||
//! put it in the swarm's secret store.
|
||||
//!
|
||||
//! The agent end is `nix/agent-modules/github-token.nix`, which reads it under
|
||||
//! the agent's own certificate into the `github-token` file its `gh` wrapper,
|
||||
//! git credential helper and `hive-github-notify` read. No hive is in the path.
|
||||
|
||||
use axum::Json;
|
||||
use axum::extract::State;
|
||||
use axum::http::StatusCode;
|
||||
use serde::Deserialize;
|
||||
use swarm_secret_client::github;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use super::{AppState, error_problem, swarm_hive};
|
||||
|
||||
/// The token to store for one agent.
|
||||
///
|
||||
/// No `Debug` derive: this carries a token.
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub struct PutGithubAccountRequest {
|
||||
/// The personal access token. Never logged, and never returned by this
|
||||
/// route.
|
||||
token: String,
|
||||
}
|
||||
|
||||
/// Store an agent's GitHub token.
|
||||
///
|
||||
/// Idempotent: the store keeps versions, so repeating a call replaces the
|
||||
/// token the agent will next read.
|
||||
#[utoipa::path(
|
||||
put,
|
||||
path = "/api/hives/{hive}/agents/{agent}/github-account",
|
||||
params(
|
||||
("hive" = String, Path, description = "hive the agent runs on"),
|
||||
("agent" = String, Path, description = "agent the token belongs to"),
|
||||
),
|
||||
request_body = PutGithubAccountRequest,
|
||||
responses(
|
||||
(status = 204, description = "stored"),
|
||||
(status = 400, description = "the agent is not an identifier, the token is empty, or the hive is not in this swarm (problem+json)", body = String),
|
||||
(status = 500, description = "the store write failed (problem+json)", body = String),
|
||||
),
|
||||
tag = "agents"
|
||||
)]
|
||||
pub async fn put_github_account(
|
||||
State(state): State<AppState>,
|
||||
axum::extract::Path((hive, agent)): axum::extract::Path<(String, String)>,
|
||||
Json(req): Json<PutGithubAccountRequest>,
|
||||
) -> Result<StatusCode, problem_details::ProblemDetails> {
|
||||
let hive = swarm_hive(&state, &hive).map_err(|(s, d)| error_problem(s, &d))?;
|
||||
let agent = hive_types::Ident::parse(&agent)
|
||||
.map_err(|reason| error_problem(StatusCode::BAD_REQUEST, reason))?
|
||||
.into_string();
|
||||
let secret_path = github::account_path(&agent)
|
||||
.map_err(|e| error_problem(StatusCode::BAD_REQUEST, &e.to_string()))?;
|
||||
let credential = credential(&req).map_err(|e| error_problem(StatusCode::BAD_REQUEST, e))?;
|
||||
|
||||
let store = crate::store::connect().await.map_err(|e| {
|
||||
tracing::warn!(error = %e, "connecting to the swarm secret store failed");
|
||||
error_problem(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string())
|
||||
})?;
|
||||
store.write(&secret_path, &credential).await.map_err(|e| {
|
||||
// The path names the agent; the value is not in it.
|
||||
tracing::warn!(path = %secret_path, error = %e, "writing the github token failed");
|
||||
error_problem(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string())
|
||||
})?;
|
||||
|
||||
tracing::info!(%hive, %agent, "github token stored");
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
/// The request as it is stored, or why it cannot be.
|
||||
fn credential(req: &PutGithubAccountRequest) -> Result<github::Credential, &'static str> {
|
||||
let token = req.token.trim();
|
||||
if token.is_empty() {
|
||||
return Err("token is required");
|
||||
}
|
||||
Ok(github::Credential {
|
||||
value: token.to_owned(),
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{PutGithubAccountRequest, credential};
|
||||
|
||||
fn request(token: &str) -> PutGithubAccountRequest {
|
||||
PutGithubAccountRequest {
|
||||
token: token.to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_token_is_kept_without_surrounding_whitespace() {
|
||||
let c = credential(&request(" t0k3n\n")).expect("valid");
|
||||
assert_eq!(c.value, "t0k3n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_token_is_refused() {
|
||||
assert!(credential(&request(" ")).is_err());
|
||||
assert!(credential(&request("")).is_err());
|
||||
}
|
||||
|
||||
/// Bare-minimum `AppState`, as `forge_account`'s tests build it.
|
||||
fn state() -> super::super::AppState {
|
||||
super::super::AppState {
|
||||
hives: std::sync::Arc::new(vec![super::super::HiveEntry {
|
||||
name: "pr1ma".to_owned(),
|
||||
domain: "pr1ma.example".to_owned(),
|
||||
}]),
|
||||
links: std::sync::Arc::new(Vec::new()),
|
||||
status: None,
|
||||
wanted: None,
|
||||
agent_status: None,
|
||||
agent_icons: None,
|
||||
jobq: std::sync::Arc::new(std::sync::Mutex::new(hive_jobq::scheduler::Scheduler::new(
|
||||
hive_jobq::Graph::new(),
|
||||
hive_jobq::resources::ResourceTable::new(),
|
||||
))),
|
||||
webhook_secret: None,
|
||||
config_prs: None,
|
||||
swarm_name: None,
|
||||
auth: None,
|
||||
forge: None,
|
||||
create_gate: std::sync::Arc::default(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn put(agent: &str, token: &str) -> problem_details::ProblemDetails {
|
||||
super::put_github_account(
|
||||
axum::extract::State(state()),
|
||||
axum::extract::Path(("pr1ma".to_owned(), agent.to_owned())),
|
||||
axum::Json(request(token)),
|
||||
)
|
||||
.await
|
||||
.expect_err("no store is configured in a test")
|
||||
}
|
||||
|
||||
fn assert_store_unset() {
|
||||
for var in ["BAO_ADDR", "BAO_CLIENT_CERT", "BAO_CLIENT_KEY"] {
|
||||
assert!(
|
||||
std::env::var(var).is_err(),
|
||||
"{var} must be unset for this test to prove anything"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// An agent name or an empty token is refused before the store: with
|
||||
/// `BAO_*` unset a store connect would answer 500.
|
||||
#[tokio::test]
|
||||
async fn a_bad_agent_or_an_empty_token_is_refused_before_the_store() {
|
||||
assert_store_unset();
|
||||
for (agent, token) in [("Atlas", "t0k3n"), ("../x", "t0k3n"), ("atlas", " ")] {
|
||||
let problem = put(agent, token).await;
|
||||
assert_eq!(
|
||||
problem.status,
|
||||
Some(axum::http::StatusCode::BAD_REQUEST),
|
||||
"{agent:?}: {problem:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The control: a plain agent and a token reach the store connect.
|
||||
#[tokio::test]
|
||||
async fn a_plain_request_reaches_the_store() {
|
||||
assert_store_unset();
|
||||
let problem = put("atlas", "t0k3n").await;
|
||||
assert_eq!(
|
||||
problem.status,
|
||||
Some(axum::http::StatusCode::INTERNAL_SERVER_ERROR),
|
||||
"{problem:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -49,6 +49,7 @@ mod auth;
|
|||
mod config_pr;
|
||||
mod forge;
|
||||
mod forge_account;
|
||||
mod github_account;
|
||||
mod issue_report;
|
||||
mod matrix_account;
|
||||
mod otel_http_client;
|
||||
|
|
@ -2890,6 +2891,7 @@ fn build_app(state: AppState) -> axum::Router {
|
|||
.routes(routes!(set_agent_state))
|
||||
.routes(routes!(matrix_account::put_matrix_account))
|
||||
.routes(routes!(forge_account::put_forge_account))
|
||||
.routes(routes!(github_account::put_github_account))
|
||||
.routes(routes!(get_hive_wanted))
|
||||
.routes(routes!(term_stream::stream_agent_term))
|
||||
.routes(routes!(agent_state_stream::stream_agent_state))
|
||||
|
|
|
|||
129
swarm-secret-client/src/github.rs
Normal file
129
swarm-secret-client/src/github.rs
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
//! The GitHub agreement: where an agent's GitHub personal access token lives in
|
||||
//! the store, and what the object at that path holds.
|
||||
//!
|
||||
//! An operator hands `swarm-controller` the token, the controller stores it at
|
||||
//! [`account_path`], and `nix/agent-modules/github-token.nix` reads it back
|
||||
//! under the agent's own certificate. One token per agent, so one path and no
|
||||
//! directory to list. Neither end is senior, so both halves are stated once,
|
||||
//! here, beside [`crate::forge`].
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{
|
||||
Error,
|
||||
path::{Kind, principal_prefix},
|
||||
};
|
||||
|
||||
/// The path holding `agent`'s GitHub token.
|
||||
///
|
||||
/// A flat leaf under the agent's prefix, like
|
||||
/// [`crate::forge::agent_token_path`], so the agent's own read stanza
|
||||
/// ([`crate::policy::render_agent`]) already covers it.
|
||||
///
|
||||
/// # Errors
|
||||
/// [`Error::PathSegment`] when `agent` contains anything but `[A-Za-z0-9_-]`,
|
||||
/// which is what keeps one agent's name from addressing another agent's secret.
|
||||
pub fn account_path(agent: &str) -> Result<String, Error> {
|
||||
let prefix = principal_prefix(Kind::Agent, agent)?;
|
||||
Ok(format!("{prefix}/github-token"))
|
||||
}
|
||||
|
||||
/// What an [`account_path`] holds.
|
||||
///
|
||||
/// No `Debug` derive: `value` is a live GitHub credential, and a derived
|
||||
/// `Debug` is one `{:?}` away from a log line.
|
||||
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Credential {
|
||||
/// The token. `github-token.nix` reads the store with
|
||||
/// `bao kv get -format=json` and pulls `.data.data.value` out with
|
||||
/// `jq`, so this name is load-bearing for a reader this crate does not
|
||||
/// control.
|
||||
pub value: String,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for Credential {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("Credential")
|
||||
.field("value", &"<redacted>")
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn covered_by(policy: &str, path: &str) -> bool {
|
||||
policy.lines().any(|line| {
|
||||
line.strip_prefix("path \"")
|
||||
.and_then(|rest| rest.split_once("\" {"))
|
||||
.and_then(|(p, _)| p.strip_suffix('*'))
|
||||
.is_some_and(|prefix| format!("secret/data/{path}").starts_with(prefix))
|
||||
})
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_token_lands_under_the_agent_prefix() {
|
||||
// Spelled out: `github-token.nix` spells the same string.
|
||||
assert_eq!(
|
||||
account_path("atlas").expect("a plain name is legal"),
|
||||
"swarm/agents/atlas/github-token"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_traversal_in_the_agent_name_is_refused() {
|
||||
for bad in ["../argus", "a/b", "atlas/../argus", "a.b", ""] {
|
||||
let e = account_path(bad).expect_err("a traversal is not legal");
|
||||
assert!(matches!(e, Error::PathSegment { kind: "agent", .. }), "{e}");
|
||||
}
|
||||
// The control: the legal charset stays reachable.
|
||||
assert!(account_path("a-b_C9").is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_agents_own_read_grant_covers_the_path() {
|
||||
let path = account_path("atlas").expect("legal");
|
||||
let own = crate::policy::render_agent("atlas").expect("legal");
|
||||
assert!(
|
||||
covered_by(&own, &path),
|
||||
"no stanza in\n{own}\ncovers {path}"
|
||||
);
|
||||
// The control: another agent's grant does not reach it.
|
||||
let other = crate::policy::render_agent("argus").expect("legal");
|
||||
assert!(!covered_by(&other, &path), "argus's policy reaches {path}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_github_key_is_also_a_directory() {
|
||||
// KV-v2 cannot hold a key whose name is also a prefix of another key.
|
||||
let key = account_path("atlas").expect("legal");
|
||||
for other in [
|
||||
crate::forge::agent_token_path("atlas").expect("legal"),
|
||||
crate::forge::accounts_dir("atlas").expect("legal"),
|
||||
] {
|
||||
assert_ne!(key, other);
|
||||
assert!(!other.starts_with(&format!("{key}/")), "{other}");
|
||||
assert!(!key.starts_with(&format!("{other}/")), "{key}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_value_field_matches_what_the_nix_reader_asks_for() {
|
||||
let json = serde_json::to_string(&Credential {
|
||||
value: "t".to_owned(),
|
||||
})
|
||||
.expect("a String serialises");
|
||||
assert_eq!(json, r#"{"value":"t"}"#);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn debug_never_prints_the_value() {
|
||||
let c = Credential {
|
||||
value: "0123456789abcdef".to_owned(),
|
||||
};
|
||||
let shown = format!("{c:?}");
|
||||
assert!(!shown.contains("0123456789abcdef"), "{shown}");
|
||||
assert!(shown.contains("redacted"), "{shown}");
|
||||
}
|
||||
}
|
||||
|
|
@ -5,7 +5,7 @@
|
|||
//! the rules every path obeys ([`path`]), the translation from this
|
||||
//! deployment's environment into a logged-in client ([`client`]), and, per kind
|
||||
//! of secret, the path it lives at together with the fields it holds
|
||||
//! ([`matrix`], [`queue`], [`mtls`], [`forge`], [`acp`]). Each of those is a thing the controller
|
||||
//! ([`matrix`], [`queue`], [`mtls`], [`forge`], [`github`], [`acp`]). Each of those is a thing the controller
|
||||
//! and a hive must say identically, so it is said once here.
|
||||
//!
|
||||
//! [`policy`] is the same kind of agreement seen from the other side: which of
|
||||
|
|
@ -25,6 +25,7 @@
|
|||
pub mod acp;
|
||||
pub mod client;
|
||||
pub mod forge;
|
||||
pub mod github;
|
||||
pub mod matrix;
|
||||
pub mod mtls;
|
||||
pub mod path;
|
||||
|
|
|
|||
Loading…
Reference in a new issue