Watch
0
0
Fork
You've already forked hyperhive
0

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:
atlas 2026-10-02 17:48:27 +02:00
commit 8e23feb01b
41 changed files with 804 additions and 846 deletions

View file

@ -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()
}

View file

@ -38,7 +38,6 @@ use crate::lifecycle;
(name = "approvals", description = "approve/deny pending approval rows"),
(name = "build_logs", description = "build log headers, full rows, and raw text downloads"),
(name = "lifecycle_ops", description = "agent container lifecycle: rebuild/restart/start/stop/pause/limits"),
(name = "matrix_accounts", description = "github account provisioning for agents"),
(name = "meta_inputs", description = "bulk flake-input update for the meta flake"),
(name = "misc_api", description = "operator inbox, compose, spawn-request, hive stats"),
(name = "permissions", description = "tool-group + capability assignment for agents"),
@ -61,7 +60,6 @@ pub(crate) use hive_types::Ident;
mod health;
mod journal;
mod lifecycle_ops;
mod matrix_accounts;
mod meta_inputs;
mod misc_api;
pub(crate) mod permissions;
@ -126,8 +124,7 @@ pub async fn serve(
// call below is therefore scoped to exactly one path — two handlers
// in the same call only when they genuinely share a path with
// different methods (`schedules::api_schedules`/`post_schedule_new`
// on `/api/schedules`, `matrix_accounts::get_github_account`/
// `post_github_account` on `/api/github-account`) — chained via
// on `/api/schedules`) — chained via
// repeated `.routes(...)` calls instead of one giant `routes!(...)`
// with everything in it.
let (router, api) = OpenApiRouter::<AppState>::with_openapi(ApiDoc::openapi())
@ -137,10 +134,6 @@ pub async fn serve(
.routes(routes!(journal::get_journal_host))
.routes(routes!(state_snapshot::api_state))
.routes(routes!(state_files::get_state_file))
.routes(routes!(
matrix_accounts::post_github_account,
matrix_accounts::get_github_account
))
.routes(routes!(misc_api::api_operator_inbox))
.routes(routes!(misc_api::api_stats_hive))
.routes(routes!(misc_api::api_container_resources))
@ -371,10 +364,6 @@ mod router_build_probe {
.routes(routes!(journal::get_journal_host))
.routes(routes!(state_snapshot::api_state))
.routes(routes!(state_files::get_state_file))
.routes(routes!(
matrix_accounts::post_github_account,
matrix_accounts::get_github_account
))
.routes(routes!(misc_api::api_operator_inbox))
.routes(routes!(misc_api::api_stats_hive))
.routes(routes!(misc_api::api_container_resources))

View file

@ -369,25 +369,6 @@ pub async fn set_agent_paused(agent_name: &str, paused: bool) -> Result<()> {
.await?)
}
/// Write a GitHub personal access token (PAT) for `agent_name` via hive-priv
/// (running as root). Writes `<state>/github-token` 0600, chowned to the agent
/// user so the `gh` wrapper / git credential helper can read it from inside the
/// container. Single account per agent — no account suffix. The token value is
/// operator-supplied (for the agent's GitHub integration, `services.hyperhive.agent.github.enable`).
///
/// # Errors
///
/// Returns an error if the hive-priv call fails — the socket is unreachable,
/// `agent_name` is rejected by the root-side validation, or the file
/// write/chown fails.
pub async fn write_agent_github_token(agent_name: &str, token: &str) -> Result<()> {
ok(call(&PrivRequest::WriteAgentGithubToken {
agent_name: agent_name.to_owned(),
token: token.to_owned(),
})
.await?)
}
/// Register the hive-ci Forgejo Actions runner: hand the freshly-minted
/// registration token to hive-priv, which writes it to the host-side
/// `/run/hive-ci/runner-token` env-file and restarts the in-container runner.

View file

@ -224,9 +224,6 @@ async fn dispatch(req: &HostRequest, coord: Arc<Coordinator>) -> HostResponse {
HostRequest::GatewayListUsers => {
HostResponse::messages(crate::gateway_nginx::list_users()?)
}
HostRequest::SetAgentGithubToken { agent, token } => {
handle_set_agent_github_token(agent.as_str(), token).await?
}
HostRequest::QuotaEnable => handle_quota_enable().await?,
HostRequest::QuotaLimit { name, limit } => {
handle_quota_limit(name.as_str(), *limit).await?
@ -468,16 +465,6 @@ fn require_matrix_present() -> Result<()> {
)
}
async fn handle_set_agent_github_token(agent: &str, token: &str) -> Result<HostResponse> {
crate::priv_client::write_agent_github_token(agent, token)
.await
.with_context(|| format!("write github-token for agent {agent}"))?;
Ok(HostResponse::messages(vec![format!(
"wrote github-token for agent '{agent}' \
(read live by the gh wrapper / git credential helper — no rebuild needed)"
)]))
}
async fn handle_quota_enable() -> Result<HostResponse> {
crate::priv_client::ensure_btrfs_quota()
.await