github: PATs live in swarm bao; the agent fetches them itself
An operator links an agent's GitHub personal access token in the swarm UI
(LinkGithubAccountForm, "link github account" on /agents). swarm-controller's
PUT /api/hives/{hive}/agents/{agent}/github-account stores it at
swarm/agents/<agent>/github-token (swarm_secret_client::github), a flat leaf
under the agent's prefix that the agent's existing read grant already covers:
no policy change, and no list grant, since there is one token per agent.
In the agent, hive-agent-github-token (oneshot + 2-minute timer, as the agent
user, under its own store certificate, ordered before hive-github-notify)
reads that path and writes <state>/github-token, 0600 and agent-owned, the
file the gh wrapper, git credential helper and hive-github-notify already
read. It replaces the file by rename only when the bytes changed and never
deletes it: a hive-written github-token stays until a token is linked in the
swarm UI. It is installed only with a store address and
services.hyperhive.agent.github.enable.
Removed: the dashboard's CR3D3NTIALS page (credentials.html/js/css, its
build entries and H0M3 tile; GITHUB was its only tab), hive-c0re's
dashboard/matrix_accounts.rs with GET/POST /api/github-account,
priv_client::write_agent_github_token, the host socket's
SetAgentGithubToken and `hivectl github set-token`, and hive-priv's
WriteAgentGithubToken with write_agent_state_file, its only caller gone.
Docs: integrations/github.md and swarm/ui.md describe the swarm path,
swarm/credentials.md gains the store-path row, and the hive UI docs,
hivectl docs and security.md's hive-priv table drop the removed pieces.
Closes #4347
This commit is contained in:
parent
2b2608a491
commit
8e23feb01b
41 changed files with 804 additions and 846 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
|
(PAT) — so it can run GitHub API calls and push commits without any manual
|
||||||
`gh auth login`.
|
`gh auth login`.
|
||||||
|
|
||||||
Provisioning is UI-driven: paste a PAT into the agent's credentials tab
|
Provisioning is UI-driven: link a PAT to the agent in the swarm UI and it
|
||||||
and it works. No per-agent nix declaration, no rebuild — hive-c0re
|
works. No per-agent nix declaration, no rebuild — the agent fetches the token
|
||||||
injects the token into the agent's state dir out of band.
|
from the swarm secret store itself.
|
||||||
|
|
||||||
## Enabling
|
## 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
|
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
|
= 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
|
needed — an agent gains GitHub by having a PAT stored for it.
|
||||||
file.
|
|
||||||
|
|
||||||
<!-- vale write-good.Passive = YES -->
|
<!-- 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`
|
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
|
(`services.hyperhive.agent.github.enable` also exists per-agent for completeness, but the
|
||||||
hive-wide host switch is the intended control.)
|
hive-wide host switch is the intended control.)
|
||||||
|
|
||||||
github.com only. The token **value** never touches nix — it's written to
|
github.com only. The token **value** never touches nix — the agent writes it
|
||||||
`<state>/github-token` separately (see [Provisioning](#provisioning)).
|
to `<state>/github-token` at runtime (see [Provisioning](#provisioning)).
|
||||||
|
|
||||||
## Provisioning
|
## Provisioning
|
||||||
|
|
||||||
The PAT is operator-supplied. The primary path is the **dashboard
|
The PAT is operator-supplied. In the [swarm UI](../swarm/ui.md#linking-external-accounts),
|
||||||
credentials tab** (github sub-tab): paste the PAT for an agent and submit
|
open the agent on `/agents`, choose **link github account** and paste the PAT
|
||||||
(`POST /api/github-account`). A CLI path also exists for
|
(`PUT /api/hives/{hive}/agents/{agent}/github-account`). swarm-controller
|
||||||
recovery/scripting:
|
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
|
The agent's `hive-agent-github-token` unit reads that path under the
|
||||||
hivectl github set-token <agent> --token-stdin # paste the PAT on stdin (preferred)
|
agent's own store certificate and writes `<state>/github-token` (`0600`,
|
||||||
hivectl github set-token <agent> --token <pat> # inline (visible in shell history)
|
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.
|
||||||
Either path has hive-c0re delegate the write to hive-priv, which stores the
|
The unit never deletes the file: a `github-token` already in place stays
|
||||||
file `0600` owned by the agent (so the container can read it) — the same
|
when the store holds none or doesn't answer. Where the token lives and who
|
||||||
credential-injection path as forge/matrix tokens. See
|
reads it: [credentials.md](../swarm/credentials.md).
|
||||||
[hivectl → GitHub](../tools/hivectl.md#github).
|
|
||||||
|
|
||||||
## Security
|
## Security
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -69,6 +69,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>/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-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>/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/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>/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 |
|
| `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
|
## What it shows
|
||||||
|
|
||||||
| route | what |
|
| route | what |
|
||||||
| ------------------------- | ------------------------------------------------------------------------------------------- |
|
| ------------------------- | --------------------------------------------------------------------------------------------------- |
|
||||||
| `/` | the hive directory, each hive with its last reported status |
|
| `/` | 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` | every agent: status, config PR, wanted state; create agents, link forge, matrix and GitHub accounts |
|
||||||
| `/agents/<name>/terminal` | one agent's live terminal |
|
| `/agents/<name>/terminal` | one agent's live terminal |
|
||||||
| `/jobs` | the controller's job graph — where agent creation and credential mints show progress |
|
| `/jobs` | the controller's job graph — where agent creation and credential mints show progress |
|
||||||
| `/issues` | a cross-repo issue report |
|
| `/issues` | a cross-repo issue report |
|
||||||
|
|
||||||
Everything it shows comes from [`swarm-controller`](../../swarm-controller/README.md).
|
Everything it shows comes from [`swarm-controller`](../../swarm-controller/README.md).
|
||||||
An agent created here or with `swarmctl agent create` starts `paused`; set it
|
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
|
### Linking external accounts
|
||||||
|
|
||||||
Each agent on `/agents` opens two dialogs that write a credential for it
|
Each agent on `/agents` opens three dialogs that write a credential for it
|
||||||
into the swarm secret store through swarm-controller. Both are blind
|
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.
|
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}`,
|
- **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
|
`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
|
the same label again overwrites both files, and a pair whose label the
|
||||||
store doesn't list stays untouched.
|
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:
|
Where each credential lives and who reads it:
|
||||||
[`credentials.md`](credentials.md).
|
[`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 forge reconcile-config`↴](#hivectl-forge-reconcile-config)
|
||||||
* [`hivectl matrix`↴](#hivectl-matrix)
|
* [`hivectl matrix`↴](#hivectl-matrix)
|
||||||
* [`hivectl matrix invite`↴](#hivectl-matrix-invite)
|
* [`hivectl matrix invite`↴](#hivectl-matrix-invite)
|
||||||
* [`hivectl github`↴](#hivectl-github)
|
|
||||||
* [`hivectl github set-token`↴](#hivectl-github-set-token)
|
|
||||||
* [`hivectl gateway`↴](#hivectl-gateway)
|
* [`hivectl gateway`↴](#hivectl-gateway)
|
||||||
* [`hivectl gateway create-user`↴](#hivectl-gateway-create-user)
|
* [`hivectl gateway create-user`↴](#hivectl-gateway-create-user)
|
||||||
* [`hivectl gateway delete-user`↴](#hivectl-gateway-delete-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
|
* `forge` — Reconcile an agent's config between this hive and the forge
|
||||||
* `matrix` — matrix-tuwunel invites
|
* `matrix` — matrix-tuwunel invites
|
||||||
* `github` — GitHub account provisioning
|
|
||||||
* `gateway` — Gateway htpasswd user management
|
* `gateway` — Gateway htpasswd user management
|
||||||
* `agent` — Lifecycle actions on ONE managed agent container. Needs the hive-c0re daemon running
|
* `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
|
* `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`
|
## `hivectl gateway`
|
||||||
|
|
||||||
Gateway htpasswd user management.
|
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).
|
invite power (it owns the hive Space, so that case always works).
|
||||||
Idempotent — already-member / already-invited is a no-op.
|
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
|
## Gateway
|
||||||
|
|
||||||
Manage users in the gateway's HTTP Basic auth htpasswd file
|
Manage users in the gateway's HTTP Basic auth htpasswd file
|
||||||
|
|
|
||||||
|
|
@ -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) |
|
| `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` |
|
| `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 |
|
| `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 |
|
| `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 |
|
| `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 |
|
| `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 |
|
||||||
|
|
|
||||||
|
|
@ -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
|
H0M3 hub: **Flow** (`/flow.html`, the raw live message stream across
|
||||||
the whole swarm), **Logs** (`/logs.html`, per-agent and host
|
the whole swarm), **Logs** (`/logs.html`, per-agent and host
|
||||||
journals), **Stats** (`/stats.html`, swarm-wide usage stats),
|
journals), **Stats** (`/stats.html`, swarm-wide usage stats),
|
||||||
**Builds** (`/builds.html`, the rebuild queue and build history),
|
**Builds** (`/builds.html`, the rebuild queue and build history), and
|
||||||
**Core** (`/core.html`, tombstones and container resource use), and
|
**Core** (`/core.html`, tombstones and container resource use). Your local
|
||||||
**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
|
|
||||||
browser preferences (notifications) live in the dashboard's Y3R C4LL
|
browser preferences (notifications) live in the dashboard's Y3R C4LL
|
||||||
tab.
|
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
|
recurring or one-shot prompts you schedule for one or more agents, and
|
||||||
reminders agents have set for themselves.
|
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
|
## More depth
|
||||||
|
|
||||||
Both the dashboard and the per-agent pages are SPAs sharing one
|
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.
|
— sits below the tab strip.
|
||||||
- **Server-warnings banner** — a generic, sticky top-of-page strip shown
|
- **Server-warnings banner** — a generic, sticky top-of-page strip shown
|
||||||
on **every** page (dashboard + every stand-alone page — FL0W, L0GS,
|
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
|
of `<body>` by `renderServerWarnings` in `common.js`. Driven by
|
||||||
`state.server_warnings` — a list of `{ kind, level, message }` — and
|
`state.server_warnings` — a list of `{ kind, level, message }` — and
|
||||||
coloured by `level` (`warn` amber / `crit` red). hive-c0re owns the
|
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
|
(suspends on manual scroll-up). Deep-link: `?id=N#buildlogs` opens the
|
||||||
entry with that id pre-expanded.
|
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
|
## P3RM1SS10NS tab
|
||||||
|
|
||||||
Per-agent permission configuration. Two sections, each rendered as a
|
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
|
The H0M3 hub is the primary landing page (served at `/` by default). A
|
||||||
responsive grid of link tiles — Dashboard, Flow, Logs, Builds, Stats,
|
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
|
surfaces, all unconditionally shown (no gating). The page is a pure
|
||||||
portal with no tab-bar or SSE subscriptions. Typography + colours inherit
|
portal with no tab-bar or SSE subscriptions. Typography + colours inherit
|
||||||
from the shared theme (Catppuccin Mocha via `common.css` + `theme.css`).
|
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`,
|
CSS bundle per page (`colors.css` + `theme.css` + `common.css`,
|
||||||
loaded by every page, plus a page-specific bundle —
|
loaded by every page, plus a page-specific bundle —
|
||||||
`dashboard.css` / `flow.css` / `logs.css` / `home.css` /
|
`dashboard.css` / `flow.css` / `logs.css` / `home.css` /
|
||||||
`stats.css` / `core.css` / `builds.css` /
|
`stats.css` / `core.css` / `builds.css`);
|
||||||
`credentials.css`); `common.css` inlines `@hive/shared`'s
|
`common.css` inlines `@hive/shared`'s
|
||||||
`base.css` + `terminal.css` + `tabs.css` + `chrome.css` +
|
`base.css` + `terminal.css` + `tabs.css` + `chrome.css` +
|
||||||
`pill.css` via esbuild's `@import` resolution.
|
`pill.css` via esbuild's `@import` resolution.
|
||||||
`terminal.js` exports `{ create, linkify }` as ES module
|
`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
|
// 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
|
// `.html`), each named after its own entry point below — see the
|
||||||
// `entryPoints`/`for` lists for the exact map, not repeated here to
|
// `entryPoints`/`for` lists for the exact map, not repeated here to
|
||||||
// avoid this comment drifting out of sync with the real build steps.
|
// avoid this comment drifting out of sync with the real build steps.
|
||||||
|
|
@ -40,7 +40,6 @@ await build({
|
||||||
src("stats.js"),
|
src("stats.js"),
|
||||||
src("core.js"),
|
src("core.js"),
|
||||||
src("builds.js"),
|
src("builds.js"),
|
||||||
src("credentials.js"),
|
|
||||||
],
|
],
|
||||||
outdir: staticDir(""),
|
outdir: staticDir(""),
|
||||||
bundle: true,
|
bundle: true,
|
||||||
|
|
@ -107,7 +106,6 @@ for (const entry of [
|
||||||
"stats.css",
|
"stats.css",
|
||||||
"core.css",
|
"core.css",
|
||||||
"builds.css",
|
"builds.css",
|
||||||
"credentials.css",
|
|
||||||
]) {
|
]) {
|
||||||
await build({
|
await build({
|
||||||
entryPoints: [src(entry)],
|
entryPoints: [src(entry)],
|
||||||
|
|
@ -126,7 +124,6 @@ for (const html of [
|
||||||
"stats.html",
|
"stats.html",
|
||||||
"core.html",
|
"core.html",
|
||||||
"builds.html",
|
"builds.html",
|
||||||
"credentials.html",
|
|
||||||
]) {
|
]) {
|
||||||
copyFileSync(src(html), dist(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>
|
<span class="home-tile-desc">kept state · container load</span>
|
||||||
</a>
|
</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
|
<!-- API tile: the OpenAPI spec + Swagger UI are always served by
|
||||||
hive-c0re itself (docs/web-ui/dashboard.md::Dashboard
|
hive-c0re itself (docs/web-ui/dashboard.md::Dashboard
|
||||||
endpoints), so this tile is never gated/hidden. -->
|
endpoints), so this tile is never gated/hidden. -->
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
// hive-tab-strip.js — <hive-tab-strip>, the markup-owning tab-strip custom
|
// 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
|
// rendering the <a class="hive-tab"> markup from a declarative `tabs` list
|
||||||
// instead of every page hand-writing the same <nav><a>...</a></nav>
|
// instead of every page hand-writing the same <nav><a>...</a></nav>
|
||||||
// boilerplate, then wires the existing createTabStrip() behaviour
|
// 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 now; 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 { CreateAgentForm } from "../CreateAgentForm.js";
|
||||||
import { LinkMatrixAccountForm } from "../LinkMatrixAccountForm.js";
|
import { LinkMatrixAccountForm } from "../LinkMatrixAccountForm.js";
|
||||||
import { LinkForgeAccountForm } from "../LinkForgeAccountForm.js";
|
import { LinkForgeAccountForm } from "../LinkForgeAccountForm.js";
|
||||||
|
import { LinkGithubAccountForm } from "../LinkGithubAccountForm.js";
|
||||||
import { WantedMenu } from "./WantedMenu.js";
|
import { WantedMenu } from "./WantedMenu.js";
|
||||||
import "./AgentsPage.css";
|
import "./AgentsPage.css";
|
||||||
|
|
||||||
|
|
@ -98,6 +99,8 @@ const VIEW_MODE_KEY = "swarm-ui:agents:view-mode";
|
||||||
|
|
||||||
export function AgentsPage() {
|
export function AgentsPage() {
|
||||||
const [rows, setRows] = useState<AgentRow[] | null>(null);
|
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 [error, setError] = useState<ProblemDetails | null>(null);
|
||||||
const [intervalMs, setIntervalMs] =
|
const [intervalMs, setIntervalMs] =
|
||||||
useState<RefreshIntervalMs>(DEFAULT_INTERVAL_MS);
|
useState<RefreshIntervalMs>(DEFAULT_INTERVAL_MS);
|
||||||
|
|
@ -600,6 +603,22 @@ export function AgentsPage() {
|
||||||
: "no hive on record for this agent — nothing to link against"
|
: "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>
|
</div>
|
||||||
{/* MVP scope per mara's own ruling: a small read-only
|
{/* MVP scope per mara's own ruling: a small read-only
|
||||||
preview, no header/no input — the full terminal
|
preview, no header/no input — the full terminal
|
||||||
|
|
@ -651,6 +670,20 @@ export function AgentsPage() {
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</Dialog>
|
</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
|
<ConfirmDialog
|
||||||
open={confirmTarget !== null}
|
open={confirmTarget !== null}
|
||||||
label={confirmTarget ? CONFIRM_COPY[confirmTarget.state].label : ""}
|
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 = "approvals", description = "approve/deny pending approval rows"),
|
||||||
(name = "build_logs", description = "build log headers, full rows, and raw text downloads"),
|
(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 = "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 = "meta_inputs", description = "bulk flake-input update for the meta flake"),
|
||||||
(name = "misc_api", description = "operator inbox, compose, spawn-request, hive stats"),
|
(name = "misc_api", description = "operator inbox, compose, spawn-request, hive stats"),
|
||||||
(name = "permissions", description = "tool-group + capability assignment for agents"),
|
(name = "permissions", description = "tool-group + capability assignment for agents"),
|
||||||
|
|
@ -61,7 +60,6 @@ pub(crate) use hive_types::Ident;
|
||||||
mod health;
|
mod health;
|
||||||
mod journal;
|
mod journal;
|
||||||
mod lifecycle_ops;
|
mod lifecycle_ops;
|
||||||
mod matrix_accounts;
|
|
||||||
mod meta_inputs;
|
mod meta_inputs;
|
||||||
mod misc_api;
|
mod misc_api;
|
||||||
pub(crate) mod permissions;
|
pub(crate) mod permissions;
|
||||||
|
|
@ -126,8 +124,7 @@ pub async fn serve(
|
||||||
// call below is therefore scoped to exactly one path — two handlers
|
// call below is therefore scoped to exactly one path — two handlers
|
||||||
// in the same call only when they genuinely share a path with
|
// in the same call only when they genuinely share a path with
|
||||||
// different methods (`schedules::api_schedules`/`post_schedule_new`
|
// different methods (`schedules::api_schedules`/`post_schedule_new`
|
||||||
// on `/api/schedules`, `matrix_accounts::get_github_account`/
|
// on `/api/schedules`) — chained via
|
||||||
// `post_github_account` on `/api/github-account`) — chained via
|
|
||||||
// repeated `.routes(...)` calls instead of one giant `routes!(...)`
|
// repeated `.routes(...)` calls instead of one giant `routes!(...)`
|
||||||
// with everything in it.
|
// with everything in it.
|
||||||
let (router, api) = OpenApiRouter::<AppState>::with_openapi(ApiDoc::openapi())
|
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!(journal::get_journal_host))
|
||||||
.routes(routes!(state_snapshot::api_state))
|
.routes(routes!(state_snapshot::api_state))
|
||||||
.routes(routes!(state_files::get_state_file))
|
.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_operator_inbox))
|
||||||
.routes(routes!(misc_api::api_stats_hive))
|
.routes(routes!(misc_api::api_stats_hive))
|
||||||
.routes(routes!(misc_api::api_container_resources))
|
.routes(routes!(misc_api::api_container_resources))
|
||||||
|
|
@ -371,10 +364,6 @@ mod router_build_probe {
|
||||||
.routes(routes!(journal::get_journal_host))
|
.routes(routes!(journal::get_journal_host))
|
||||||
.routes(routes!(state_snapshot::api_state))
|
.routes(routes!(state_snapshot::api_state))
|
||||||
.routes(routes!(state_files::get_state_file))
|
.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_operator_inbox))
|
||||||
.routes(routes!(misc_api::api_stats_hive))
|
.routes(routes!(misc_api::api_stats_hive))
|
||||||
.routes(routes!(misc_api::api_container_resources))
|
.routes(routes!(misc_api::api_container_resources))
|
||||||
|
|
|
||||||
|
|
@ -369,25 +369,6 @@ pub async fn set_agent_paused(agent_name: &str, paused: bool) -> Result<()> {
|
||||||
.await?)
|
.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
|
/// Register the hive-ci Forgejo Actions runner: hand the freshly-minted
|
||||||
/// registration token to hive-priv, which writes it to the host-side
|
/// 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.
|
/// `/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 => {
|
HostRequest::GatewayListUsers => {
|
||||||
HostResponse::messages(crate::gateway_nginx::list_users()?)
|
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::QuotaEnable => handle_quota_enable().await?,
|
||||||
HostRequest::QuotaLimit { name, limit } => {
|
HostRequest::QuotaLimit { name, limit } => {
|
||||||
handle_quota_limit(name.as_str(), *limit).await?
|
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> {
|
async fn handle_quota_enable() -> Result<HostResponse> {
|
||||||
crate::priv_client::ensure_btrfs_quota()
|
crate::priv_client::ensure_btrfs_quota()
|
||||||
.await
|
.await
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,9 @@
|
||||||
//! mark read on the source.
|
//! mark read on the source.
|
||||||
//!
|
//!
|
||||||
//! Takes no arguments. `HYPERHIVE_STATE_DIR` holds the PAT
|
//! Takes no arguments. `HYPERHIVE_STATE_DIR` holds the PAT
|
||||||
//! (`github-token`, provisioned from the dashboard credentials tab — see
|
//! (`github-token`, fetched from the swarm secret store by
|
||||||
//! `docs/integrations/github.md`) and `HIVE_AGENT_SOCKET` is the harness's todo socket.
|
//! `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
|
//! 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
|
//! exits 0, so deploying this unit to an agent that never gets one costs a
|
||||||
//! settled process rather than a restart loop.
|
//! settled process rather than a restart loop.
|
||||||
|
|
@ -39,9 +40,9 @@ async fn main() {
|
||||||
github_loop(hive_forge_notify::state_dir(), socket).await;
|
github_loop(hive_forge_notify::state_dir(), socket).await;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Waits for the PAT to appear: the token is written out of band from the
|
/// Waits for the PAT to appear: the agent's store fetch writes the token
|
||||||
/// dashboard and takes effect without a rebuild, so an agent that gains a
|
/// out of band and it takes effect without a rebuild, so an agent that gains
|
||||||
/// PAT mid-session starts getting notifications on the next tick rather
|
/// a PAT mid-session starts getting notifications on the next tick rather
|
||||||
/// than after a restart. Gives up — returning, so the process exits 0
|
/// than after a restart. Gives up — returning, so the process exits 0
|
||||||
/// rather than restart-looping — when no PAT ever arrives, which is the
|
/// rather than restart-looping — when no PAT ever arrives, which is the
|
||||||
/// common case for an agent that has the unit but no account.
|
/// common case for an agent that has the unit but no account.
|
||||||
|
|
|
||||||
|
|
@ -315,12 +315,6 @@ pub enum HostRequest {
|
||||||
/// Daemon-side equivalent of `hivectl gateway list-users`; the usernames
|
/// Daemon-side equivalent of `hivectl gateway list-users`; the usernames
|
||||||
/// come back in [`HostResponse::messages`], one per line.
|
/// come back in [`HostResponse::messages`], one per line.
|
||||||
GatewayListUsers,
|
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
|
/// Turn on btrfs qgroup accounting on the agent-state filesystem, via
|
||||||
/// the privileged helper. Daemon-side equivalent of `hivectl
|
/// the privileged helper. Daemon-side equivalent of `hivectl
|
||||||
/// quota-enable`. Returns advisory lines in [`HostResponse::messages`].
|
/// quota-enable`. Returns advisory lines in [`HostResponse::messages`].
|
||||||
|
|
|
||||||
|
|
@ -478,22 +478,6 @@ pub enum PrivRequest {
|
||||||
paused: bool,
|
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
|
/// Register the hive-ci Forgejo Actions runner: write the registration
|
||||||
/// token to the host-side `/run/hive-ci/runner-token` env-file (root-owned,
|
/// 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
|
/// bind-mounted read-only into the container) as `TOKEN=<token>`, then
|
||||||
|
|
|
||||||
|
|
@ -407,11 +407,6 @@ async fn exec(
|
||||||
paused,
|
paused,
|
||||||
} => exec_set_agent_paused(agent_name, 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::RegisterCiRunner { ref token } => register_ci_runner(token).await,
|
||||||
|
|
||||||
PrivRequest::ControlInfraContainer { container, action } => {
|
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)
|
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`.
|
/// `EnsureAgentSubvolume`.
|
||||||
async fn exec_ensure_agent_subvolume(agent_name: &str) -> Result<(String, String)> {
|
async fn exec_ensure_agent_subvolume(agent_name: &str) -> Result<(String, String)> {
|
||||||
validate_agent_name(agent_name)?;
|
validate_agent_name(agent_name)?;
|
||||||
|
|
@ -1486,22 +1475,6 @@ fn publish_file(path: &Path, content: &[u8], mode: u32, owner: Option<(u32, u32)
|
||||||
staged.publish()
|
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
|
/// 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
|
/// marker is written empty and chowned to the harness dir's owner (the
|
||||||
/// agent), matching how the harness itself would have created it.
|
/// 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
|
/// 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
|
/// owner so the agent process can read it back. Its caller is the pause
|
||||||
/// writes (which target `state/`) and the pause marker (which targets
|
/// marker, in `harness/`, a directory owned by the agent, which is precisely
|
||||||
/// `harness/`) — both write into a directory owned by the agent, which is
|
/// why it needs hive-priv at all.
|
||||||
/// precisely why they need hive-priv at all.
|
|
||||||
///
|
///
|
||||||
/// The file is published through a [`StagedFile`], so a reader woken by its
|
/// 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
|
/// 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
|
// 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
|
// 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
|
// 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`
|
// its lifecycle completes.
|
||||||
// after provisioning will re-mint and re-write the token correctly.
|
|
||||||
std::fs::create_dir_all(&state_dir)
|
std::fs::create_dir_all(&state_dir)
|
||||||
.with_context(|| format!("create state dir {}", state_dir.display()))?;
|
.with_context(|| format!("create state dir {}", state_dir.display()))?;
|
||||||
|
|
||||||
|
|
@ -1592,7 +1563,7 @@ fn write_agent_dir_file(
|
||||||
agent = %agent_name,
|
agent = %agent_name,
|
||||||
path = %path.display(),
|
path = %path.display(),
|
||||||
error = %e,
|
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!(
|
tracing::warn!(
|
||||||
agent = %agent_name,
|
agent = %agent_name,
|
||||||
error = %e,
|
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`
|
The operator-facing host CLI. A thin client for the `hive-c0re`
|
||||||
daemon — speaks the host admin socket protocol (`hive-host-sock`) and
|
daemon — speaks the host admin socket protocol (`hive-host-sock`) and
|
||||||
does not link the daemon crate. Container lifecycle, the approval
|
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
|
and need it running; a few (`wg`/`peer-config`, `choom`) work off local
|
||||||
host state instead.
|
host state instead.
|
||||||
|
|
||||||
|
|
@ -30,7 +30,6 @@ dispatch:
|
||||||
- **`forge.rs`** — reconciles an agent's config against its forge
|
- **`forge.rs`** — reconciles an agent's config against its forge
|
||||||
repo.
|
repo.
|
||||||
- **`matrix.rs`** — invite a matrix user to the hive Space or a room.
|
- **`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).
|
- **`gateway.rs`** — gateway Basic-auth user management (htpasswd).
|
||||||
- **`wg.rs`** — WireGuard mesh helpers.
|
- **`wg.rs`** — WireGuard mesh helpers.
|
||||||
- **`subvol.rs`** — btrfs state-subvolume ops.
|
- **`subvol.rs`** — btrfs state-subvolume ops.
|
||||||
|
|
|
||||||
|
|
@ -45,15 +45,6 @@ pub enum Cmd {
|
||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
cmd: MatrixCmd,
|
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.
|
/// Gateway htpasswd user management.
|
||||||
///
|
///
|
||||||
/// Add, remove, or list users for the gateway's HTTP Basic auth.
|
/// 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)]
|
#[derive(Subcommand)]
|
||||||
pub enum GatewayCmd {
|
pub enum GatewayCmd {
|
||||||
/// Add a user or update an existing user's password in the gateway
|
/// 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|
|
//! Container lifecycle + the approval queue (`agent <name> <spawn|kill|
|
||||||
//! rebuild|restart|choom|…>`, `list-agents`, `approvals <pending|approve|
|
//! rebuild|restart|choom|…>`, `list-agents`, `approvals <pending|approve|
|
||||||
//! deny>`, `stop` / `start`) and provisioning (`forge` / `matrix` /
|
//! 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
|
//! the credentials, and the provisioning logic — a running daemon is
|
||||||
//! required for those. A couple of verbs work off local host state
|
//! required for those. A couple of verbs work off local host state
|
||||||
//! directly instead (`wg` / `peer-config` read the mesh key + TLS CA), so
|
//! 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 /
|
/// Rebuild-queue node progress rendering (`wait_for_nodes` + the spinner /
|
||||||
/// plain renderers), split out to keep this file manageable.
|
/// plain renderers), split out to keep this file manageable.
|
||||||
mod dag_progress;
|
mod dag_progress;
|
||||||
use cli::{Cli, Cmd, ForgeCmd, GatewayCmd, GithubCmd, WgCmd};
|
use cli::{Cli, Cmd, ForgeCmd, GatewayCmd, WgCmd};
|
||||||
mod completions;
|
mod completions;
|
||||||
mod quota;
|
mod quota;
|
||||||
mod util;
|
mod util;
|
||||||
|
|
@ -41,10 +41,8 @@ use open::open_url;
|
||||||
mod wg;
|
mod wg;
|
||||||
use wg::{peer_config, require_hive_domain, wg_init, wg_peer, wg_status};
|
use wg::{peer_config, require_hive_domain, wg_init, wg_peer, wg_status};
|
||||||
mod choom;
|
mod choom;
|
||||||
mod github;
|
|
||||||
mod watch;
|
|
||||||
use github::github_set_token;
|
|
||||||
mod forge;
|
mod forge;
|
||||||
|
mod watch;
|
||||||
use forge::forge_reconcile_config;
|
use forge::forge_reconcile_config;
|
||||||
mod agents;
|
mod agents;
|
||||||
use agents::{agents_list, run_agent};
|
use agents::{agents_list, run_agent};
|
||||||
|
|
@ -73,13 +71,6 @@ async fn main() -> Result<()> {
|
||||||
} => forge_reconcile_config(&socket, &agent, from, verbose).await,
|
} => forge_reconcile_config(&socket, &agent, from, verbose).await,
|
||||||
},
|
},
|
||||||
Cmd::Matrix { cmd } => run_matrix_cmd(&socket, cmd).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 {
|
Cmd::Gateway { cmd } => match cmd {
|
||||||
GatewayCmd::CreateUser {
|
GatewayCmd::CreateUser {
|
||||||
username,
|
username,
|
||||||
|
|
|
||||||
|
|
@ -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.
|
/// Send a provisioning request to the daemon and print its result lines.
|
||||||
/// The daemon owns the provisioning logic; hivectl just relays the outcome,
|
/// 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(
|
pub(crate) async fn daemon_request(
|
||||||
socket: &Path,
|
socket: &Path,
|
||||||
req: hive_host_sock::HostRequest,
|
req: hive_host_sock::HostRequest,
|
||||||
|
|
|
||||||
|
|
@ -50,6 +50,7 @@ in
|
||||||
./forge-token.nix
|
./forge-token.nix
|
||||||
./frontend.nix
|
./frontend.nix
|
||||||
./github.nix
|
./github.nix
|
||||||
|
./github-token.nix
|
||||||
./logs.nix
|
./logs.nix
|
||||||
./matrix.nix
|
./matrix.nix
|
||||||
./mcp.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 always had.
|
||||||
|
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
|
# GitHub integration (services.hyperhive.agent.github.enable): a `gh` wrapper + a git
|
||||||
# credential helper, both reading the PAT from the agent's
|
# credential helper, both reading the PAT from the agent's
|
||||||
# `github-token` state file at invocation, so a dashboard-pasted token
|
# `github-token` state file at invocation, so a token ./github-token.nix
|
||||||
# takes effect with no rebuild. The token PATH is baked in at build
|
# 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`,
|
# time (nix knows `userName`) — NOT read from `$HIVE_GITHUB_TOKEN_FILE`,
|
||||||
# because claude's Bash tool runs `bash -c` in a minimal env that
|
# 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
|
# doesn't source `/etc/set-environment`, so the env var isn't present
|
||||||
|
|
@ -41,12 +41,13 @@ in
|
||||||
description = ''
|
description = ''
|
||||||
Install the GitHub integration in this agent: a `gh` CLI wrapper and a
|
Install the GitHub integration in this agent: a `gh` CLI wrapper and a
|
||||||
git credential helper for `https://github.com`, both authenticated from
|
git credential helper for `https://github.com`, both authenticated from
|
||||||
an operator-supplied personal access token (PAT). The PAT is written to
|
an operator-supplied personal access token (PAT). The operator links the
|
||||||
`<state>/github-token` out of band --- the dashboard credentials tab or
|
PAT in the swarm UI, and the agent fetches it from the swarm secret store
|
||||||
`hivectl github set-token` --- so giving an agent GitHub is a runtime
|
into `<state>/github-token` (needs `services.hyperhive.agent.bao.addr`),
|
||||||
paste, no per-agent config or rebuild. The wrappers read the token file
|
so giving an agent GitHub is a runtime action, no per-agent config or
|
||||||
at invocation, so a freshly-pasted PAT takes effect immediately; until
|
rebuild. The wrappers read the token file at invocation, so a fetched
|
||||||
one exists, `gh` / `git push` just fail unauthenticated.
|
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
|
github.com only. git authenticates as `x-access-token` + the PAT (GitHub
|
||||||
ignores the username for PAT auth); `gh` derives its identity from the
|
ignores the username for PAT auth); `gh` derives its identity from the
|
||||||
|
|
|
||||||
|
|
@ -126,6 +126,10 @@ in
|
||||||
inherit pkgs self nixosSystem;
|
inherit pkgs self nixosSystem;
|
||||||
inherit (pkgs) lib;
|
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 {
|
module-eval-agent-memory = import ./module-eval/agent-memory.nix {
|
||||||
inherit pkgs self nixosSystem;
|
inherit pkgs self nixosSystem;
|
||||||
inherit (pkgs) lib;
|
inherit (pkgs) lib;
|
||||||
|
|
|
||||||
|
|
@ -219,9 +219,8 @@ in
|
||||||
description = ''
|
description = ''
|
||||||
Hive-wide switch for the per-agent GitHub integration (the `gh` CLI
|
Hive-wide switch for the per-agent GitHub integration (the `gh` CLI
|
||||||
wrapper + git credential helper, per `hyperhive.github.enable`). On by
|
wrapper + git credential helper, per `hyperhive.github.enable`). On by
|
||||||
default: every agent gets the integration, inert until a PAT is
|
default: every agent gets the integration, inert until an operator links
|
||||||
provisioned via the dashboard credentials tab or `hivectl github
|
a PAT for it in the swarm UI. Set `false` to turn it off for the whole hive --- the
|
||||||
set-token`. Set `false` to turn it off for the whole hive --- the
|
|
||||||
meta-flake renderer (`hive-c0re/src/meta.rs`) then injects
|
meta-flake renderer (`hive-c0re/src/meta.rs`) then injects
|
||||||
`hyperhive.github.enable = false` into every agent. Exposed to hive-c0re
|
`hyperhive.github.enable = false` into every agent. Exposed to hive-c0re
|
||||||
as `HYPERHIVE_GITHUB_DISABLED` (set only when the integration is off).
|
as `HYPERHIVE_GITHUB_DISABLED` (set only when the integration is off).
|
||||||
|
|
|
||||||
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 the
|
||||||
|
# `0600` it has always had.
|
||||||
|
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
|
||||||
|
|
@ -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 credentials** — at start and every five minutes it re-checks every
|
||||||
agent's forge token and matrix account, and renews store certificates and
|
agent's forge token and matrix account, and renews store certificates and
|
||||||
queue secrets as they age.
|
queue secrets as they age.
|
||||||
- **Linked external accounts** — an operator-supplied matrix or forge
|
- **Linked external accounts** — an operator-supplied matrix, forge or
|
||||||
account for one agent, stored in the swarm secret store
|
GitHub account for one agent, stored in the swarm secret store
|
||||||
(`PUT /api/hives/{hive}/agents/{agent}/matrix-accounts/{account}`,
|
(`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.
|
accounts above.
|
||||||
- **Config PR status** — each agent's open config-repo PR, cached from forge
|
- **Config PR status** — each agent's open config-repo PR, cached from forge
|
||||||
webhooks (`GET /api/config-prs`, `/api/agents/{name}/config-pr`).
|
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 config_pr;
|
||||||
mod forge;
|
mod forge;
|
||||||
mod forge_account;
|
mod forge_account;
|
||||||
|
mod github_account;
|
||||||
mod issue_report;
|
mod issue_report;
|
||||||
mod matrix_account;
|
mod matrix_account;
|
||||||
mod otel_http_client;
|
mod otel_http_client;
|
||||||
|
|
@ -2890,6 +2891,7 @@ fn build_app(state: AppState) -> axum::Router {
|
||||||
.routes(routes!(set_agent_state))
|
.routes(routes!(set_agent_state))
|
||||||
.routes(routes!(matrix_account::put_matrix_account))
|
.routes(routes!(matrix_account::put_matrix_account))
|
||||||
.routes(routes!(forge_account::put_forge_account))
|
.routes(routes!(forge_account::put_forge_account))
|
||||||
|
.routes(routes!(github_account::put_github_account))
|
||||||
.routes(routes!(get_hive_wanted))
|
.routes(routes!(get_hive_wanted))
|
||||||
.routes(routes!(term_stream::stream_agent_term))
|
.routes(routes!(term_stream::stream_agent_term))
|
||||||
.routes(routes!(agent_state_stream::stream_agent_state))
|
.routes(routes!(agent_state_stream::stream_agent_state))
|
||||||
|
|
|
||||||
128
swarm-secret-client/src/github.rs
Normal file
128
swarm-secret-client/src/github.rs
Normal file
|
|
@ -0,0 +1,128 @@
|
||||||
|
//! 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 -field=value`, 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
|
//! the rules every path obeys ([`path`]), the translation from this
|
||||||
//! deployment's environment into a logged-in client ([`client`]), and, per kind
|
//! 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
|
//! 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.
|
//! 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
|
//! [`policy`] is the same kind of agreement seen from the other side: which of
|
||||||
|
|
@ -25,6 +25,7 @@
|
||||||
pub mod acp;
|
pub mod acp;
|
||||||
pub mod client;
|
pub mod client;
|
||||||
pub mod forge;
|
pub mod forge;
|
||||||
|
pub mod github;
|
||||||
pub mod matrix;
|
pub mod matrix;
|
||||||
pub mod mtls;
|
pub mod mtls;
|
||||||
pub mod path;
|
pub mod path;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue