hyperhive/hive-c0re/src/forge/config_pr_poll.rs
atlas 79a29873e3 fix(#2164): domain-URL webhooks + HMAC + config-PR polling fallback
Both webhook registrations (knowledge push + config-PR pull_request) now
use the public hive domain instead of loopback:
  https://<HYPERHIVE_HIVE_DOMAIN>/webhook/{knowledge,config-pr}

This routes deliveries through the gateway, bypassing the Forgejo SSRF
guard that blocked loopback delivery and silently broke the config-PR
merge flow since launch.

Changes:
- webhook_secret: new module — auto-generate + persist a 32-byte HMAC
  secret to STATE_ROOT/webhook-secret on first startup; verify
  X-Hub-Signature-256 on every incoming webhook POST (HMAC-SHA256).
- forge/mod.rs: ensure_config_pr_webhook now takes hive_domain +
  webhook_secret; sets secret in Forgejo hook config.
- workers/knowledge.rs: ensure_webhook same update.
- dashboard/webhook.rs: both handlers read raw Bytes first, verify HMAC,
  then parse JSON. Returns 401 on signature mismatch.
- dashboard/mod.rs: AppState carries webhook_secret; serve() takes it.
- main.rs: load/generate secret at startup; pass to registration tasks
  + dashboard; add 5-minute config-PR polling fallback task.
- forge/config_pr_poll.rs: new — scan agent-configs/* for open PRs with
  no pending MergeConfigPr approval; queue them. Idempotent.
- stores/approvals.rs: has_pending_merge_config_pr() for poll dedup.
- nix/modules/hive-gateway.nix: remove dashboardAuth from /webhook/
  location (HMAC replaces basic auth for webhook endpoints; Forgejo
  cannot send HTTP Basic credentials with webhook deliveries).
2026-07-11 23:28:16 +02:00

130 lines
4.4 KiB
Rust

//! Polling fallback for the config-PR webhook.
//!
//! The webhook (`/webhook/config-pr`) is the primary path for detecting open
//! PRs on `agent-configs/*` repos and queuing `MergeConfigPr` approvals.
//! But webhooks can be missed — hive-c0re might be down when a PR is opened,
//! or Forgejo might fail a delivery.
//!
//! This module provides [`poll_open_config_prs`], called periodically from
//! `main.rs`, which scans all `agent-configs/*` repos for open PRs that have
//! no pending `MergeConfigPr` approval yet, and queues one. Idempotent: PRs
//! that already have a pending approval are skipped.
use std::sync::Arc;
use anyhow::Result;
use forgejo_api::structs::{RepoListPullRequestsQuery, RepoListPullRequestsQueryState};
use crate::coordinator::Coordinator;
use crate::forge::CONFIG_ORG;
const HTTP_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15);
/// Scan every repo in `agent-configs` for open PRs that have no pending
/// `MergeConfigPr` approval yet, and queue one for each gap found.
///
/// Designed to be called on a periodic timer (e.g. every 5 minutes) as a
/// fault-tolerance backstop for the Forgejo webhook. The webhook fires
/// immediately; this catches anything the webhook missed.
pub async fn poll_open_config_prs(core_token: &str, coord: &Arc<Coordinator>) -> Result<()> {
let client = crate::forge::api(core_token)?;
// List all repos in agent-configs org.
let repos = tokio::time::timeout(HTTP_TIMEOUT, client.org_list_repos(CONFIG_ORG).all())
.await
.map_err(anyhow::Error::from)
.and_then(|r| r.map_err(anyhow::Error::from))?;
for repo in repos {
let Some(repo_name) = repo.name.as_deref() else {
continue;
};
// The repo name is the agent name (agent-configs/<agent>).
let agent = repo_name;
let query = RepoListPullRequestsQuery {
state: Some(RepoListPullRequestsQueryState::Open),
sort: None,
milestone: None,
labels: None,
poster: None,
base: None,
head: None,
};
let prs = match tokio::time::timeout(
HTTP_TIMEOUT,
client
.repo_list_pull_requests(CONFIG_ORG, repo_name, query)
.all(),
)
.await
{
Ok(Ok(prs)) => prs,
Ok(Err(e)) => {
tracing::debug!(
%agent, error = %e,
"config-pr poll: listing PRs failed, skipping repo"
);
continue;
}
Err(_) => {
tracing::debug!(
%agent,
"config-pr poll: timeout listing PRs, skipping repo"
);
continue;
}
};
for pr in prs {
let Some(pr_number) = pr.number.and_then(|n| u64::try_from(n).ok()) else {
continue;
};
// Skip if a pending approval already exists for this PR.
match coord
.approvals
.has_pending_merge_config_pr(agent, pr_number)
{
Ok(true) => {
tracing::debug!(
%agent, %pr_number,
"config-pr poll: approval already pending, skipping"
);
continue;
}
Ok(false) => {}
Err(e) => {
tracing::warn!(
%agent, %pr_number, error = ?e,
"config-pr poll: DB check failed, skipping"
);
continue;
}
}
tracing::info!(
%agent, %pr_number,
"config-pr poll: queuing missed MergeConfigPr approval"
);
let description = format!("PR #{pr_number} on {CONFIG_ORG}/{agent} (poll fallback)");
if let Err(e) = crate::socket_server::submit_merge_config_pr(
coord,
agent,
pr_number,
Some(&description),
"poll", // submitter — identifies the polling path in the audit trail
)
.await
{
tracing::warn!(
%agent, %pr_number, error = ?e,
"config-pr poll: failed to queue MergeConfigPr approval"
);
}
}
}
Ok(())
}