hyperhive/hive-c0re/src/forge/mod.rs
atlas 44572d1e1a fix(#2911): keep the forge token out of argv
`forge_git_url` spliced `core:<token>@` between scheme and authority, and
that URL is a process argument. `/proc/<pid>/cmdline` is mode 0444 —
world-readable — so the core admin token, which provisions every agent's
forge account, was published to any local user for the lifetime of each
git child. Seven call sites built such a URL.

The credential now travels in the environment instead:
`git_command_authed` sets `http.extraHeader` via `GIT_CONFIG_*`, which
git reads exactly like a config file, and `/proc/<pid>/environ` is 0400 —
owner-only. Same credential, materially smaller audience. The remote is a
plain `http://forge/<org>/<repo>.git`, and `forge_git_url` no longer takes
a token, so the old shape cannot be rebuilt by accident.

`knowledge`'s clone was the one place a credentialed URL was stored as a
named remote — git persists the clone URL into `.git/config`, so the
token sat on disk and every later `pull` authenticated from there. That
is the case `forge::repos::push_config` documents as forbidden ("the
tokenised URL ... deliberately never stored as a named remote"). `pull`
now rewrites `origin` to the plain URL first, which also scrubs the
persisted token from existing deployments, and authenticates from the
environment when a token is available. The repo is public, so the pull
still works without one.

Three call sites also stopped spawning `Command::new("git")` directly,
so they honour the `HYPERHIVE_GIT` path the NixOS module bakes in and
the `kill_on_drop` every other git spawn gets.

The two URL-shape tests now assert the *absence* of a credential, and a
new one decodes the header back to `core:<token>` — without that, a
malformed header would leave every forge operation silently anonymous
with the other assertions still green.
2026-08-02 13:21:42 +02:00

635 lines
28 KiB
Rust

//! Optional Forgejo wiring — per-agent user + token provisioning,
//! config-repo mirroring, meta read-access grants. Also seeds
//! `internal/docs` — a private repo every agent gets read-only
//! collaborator access to for operator-curated shared content.
//! No-op when `hive-forge` isn't running. Full design: `docs/forge.md`.
mod ci_runner;
pub mod config_pr_poll;
mod pr_merge;
mod reconcile;
mod repos;
mod users;
pub use pr_merge::{
ForgeMergeError, config_repo, fetch_pr_head_into_applied, merge_config_pr_ff, post_pr_comment,
pr_head_sha, pr_is_open,
};
pub use reconcile::{reconcile_config_apply, reconcile_config_status};
pub use repos::{
create_agent_repo, ensure_config_repo, ensure_knowledge_repo, ensure_meta_remote, ensure_repo,
ensure_shared_docs_repo, meta_read_access, push_config, push_meta, shared_docs_access,
};
pub use users::{core_token, ensure_user_for, provision_user_token};
use std::sync::OnceLock;
use std::time::{Duration, Instant};
use anyhow::{Context, Result};
use forgejo_api::{Auth, Forgejo};
use url::Url;
use repos::{ensure_mirrors, ensure_operators_team, ensure_org};
use users::{
ensure_config_org_avatar, ensure_core_avatar, ensure_core_user_and_token,
ensure_repo_creation_disabled, ensure_user_email,
};
const FORGE_CONTAINER: &str = "hive-forge";
/// Base HTTP URL for the local Forgejo instance, from `HIVE_FORGE_URL`
/// (set unconditionally by `hive-c0re.nix` to `http://<forge.domain>`).
///
/// # Panics
///
/// When `HIVE_FORGE_URL` is unset. That is deliberate: this daemon only
/// runs under the NixOS module, which always sets it, so an unset var
/// means the deployment is broken. There is no loopback fallback,
/// because a guess is wrong in exactly the cases that matter — the
/// forge may live on a different host from the daemon, and a fallback
/// turns "misconfigured" into "silently talking to the wrong machine"
/// or, worse, "connection refused" surfacing far from its cause.
pub(crate) fn forge_http_base() -> &'static str {
static BASE: OnceLock<String> = OnceLock::new();
BASE.get_or_init(|| {
std::env::var("HIVE_FORGE_URL").expect(
"HIVE_FORGE_URL is unset — hive-c0re.nix sets it unconditionally, \
so this process was started outside the NixOS module",
)
})
}
/// Git remote for `repo` (e.g. `"core/meta"`) — **credential-free**.
///
/// The token does not go here. A URL is a process argument, and `argv` is
/// world-readable through `/proc/<pid>/cmdline` for as long as the git child
/// lives, so a credentialed remote publishes the core admin token to every
/// local user on the host. Credentials travel in the environment instead, via
/// [`core_auth_header`] and [`crate::lifecycle::git_command_authed`] —
/// `/proc/<pid>/environ` is owner-only.
pub(crate) fn forge_git_url(repo: &str) -> String {
git_url_with_base(forge_http_base(), repo)
}
/// The pure half of [`forge_git_url`], split out so it can be tested without a
/// process-wide env var (which would race every other test in this binary).
fn git_url_with_base(base: &str, repo: &str) -> String {
format!("{base}/{repo}.git")
}
/// The `http.extraHeader` value authenticating as the forge core user.
///
/// Basic auth over a header rather than userinfo in the URL, so the secret
/// reaches git through the environment (see [`forge_git_url`]). Pair with
/// [`crate::lifecycle::git_command_authed`], which is the only thing that
/// should ever hold the result.
pub(crate) fn core_auth_header(token: &str) -> String {
use base64::Engine as _;
let basic = base64::engine::general_purpose::STANDARD.encode(format!("core:{token}"));
format!("Authorization: Basic {basic}")
}
/// Forgejo org grouping every agent's config repo. Core is a site admin
/// and reads + writes every repo here. As of the agent-config-PR flow each
/// agent is a **write collaborator on its own** `agent-configs/<name>` repo —
/// the editable PR surface it pushes config-change branches to — but `main` is
/// branch-protected core-only, so only hive-c0re's verify-and-ff-push merge
/// handler lands on it (operator approval required; the agent can't push
/// `main` or self-merge). The repos remain private, so an agent still can't
/// reach *another* agent's config. `main` is fast-forward-only — hive-c0re
/// never force-pushes; the `push_config` mirror runs best-effort until the
/// PR-merge flow retires it.
pub(crate) const CONFIG_ORG: &str = "agent-configs";
/// Forgejo org hosting the operator-curated shared docs/skills repo
/// that every agent gets read-only access to. Agents use it as a
/// common reference without the operator having to bake content into
/// the system prompt or rely on `/shared`. Only the manager + operator
/// (i.e. `core` user) can push.
const SHARED_ORG: &str = "internal";
/// The shared docs repo inside `SHARED_ORG`. Cloneable by every agent
/// at `{forge_http_base()}/internal/docs.git`.
const SHARED_DOCS_REPO: &str = "docs";
/// The hive-wide knowledge repo inside `SHARED_ORG`. Public — agents
/// can fork it and open PRs without explicit collaborator grants.
/// Bind-mounted read-only into every container at `/knowledge`.
/// See `hive-c0re/src/knowledge.rs`.
const KNOWLEDGE_REPO: &str = crate::knowledge::REPO;
/// Forgejo org that owns agent-created repos. Agents can't create
/// repos with their own token (`max_repo_creation = 0`); instead hive-c0re
/// creates them here and adds the requesting agent as a **write** member
/// (not owner/admin). Because the org — not the agent — owns the repo,
/// perms stay c0re-managed and branch protection (referencing
/// [`OPERATORS_TEAM`]) can block the author from merging their own PR. This
/// is the "agents namespace" repos land in by default.
const AGENTS_ORG: &str = "agents";
/// Operator merge-gate team inside [`AGENTS_ORG`]. Provisioned **empty** by
/// hive-c0re (so perms can be set before anyone joins); the operator adds
/// herself via the forge UI / hivectl. Branch protection on agents-org repos
/// references this team by name for the merge/approval whitelist, so the
/// rule never hardcodes a specific reviewer agent (which may not exist).
const OPERATORS_TEAM: &str = "operators";
/// Forgejo orgs hive-c0re ensures on startup. The meta repo lives at
/// `core/meta` (the `core` user's own namespace — no org needed).
const SEEDED_ORGS: &[&str] = &[CONFIG_ORG, SHARED_ORG, AGENTS_ORG];
/// Leak `s` to get a `&'static str` warning `kind` for the small, bounded
/// set of per-org boot warnings in [`ensure_all`] (one per seeded org, at
/// most a handful per process). [`crate::warnings::set_boot_warning`]
/// requires a `'static` kind so distinct orgs/repos don't clobber each
/// other's banner entry; leaking a few short strings once per boot is
/// cheap and bounded, unlike a per-request or per-loop-iteration leak.
fn static_kind(s: String) -> &'static str {
Box::leak(s.into_boxed_str())
}
/// Probe whether `hive-forge` exists as a nixos-container. Cheap —
/// `nixos-container list` is just a directory scan in /etc. Routed
/// through hive-priv: `nixos-container` needs root, and hive-c0re runs
/// unprivileged (privsep).
pub async fn is_present() -> bool {
let Ok(stdout) = crate::priv_client::list_containers().await else {
return false;
};
stdout.lines().any(|l| l.trim() == FORGE_CONTAINER)
}
/// Name a `forgejo admin` invocation for an error message without
/// reproducing its arguments: keep the leading verb path, stop at the
/// first flag. `["user", "create", "--username", "iris", "--password",
/// "…"]` becomes `forgejo admin user create`.
///
/// The verbs are an **allowlist**, and that is the whole point. Some
/// callers pass a live secret as an argument value (`--password`), so a
/// message built from the raw vector puts it in the log. Listing the
/// flags to *hide* instead would repeat the bug this guards against: a
/// newly added secret-bearing flag would leak until someone remembered
/// to extend the list. Verbs are a closed set this crate chooses
/// itself; argument values never are.
fn describe_forge_admin(args: &[&str]) -> String {
let verbs: Vec<&str> = args
.iter()
.take_while(|a| !a.starts_with('-'))
.copied()
.collect();
if verbs.is_empty() {
"forgejo admin".to_owned()
} else {
format!("forgejo admin {}", verbs.join(" "))
}
}
/// Run `forgejo admin <args>` inside the hive-forge container as the
/// forgejo user (the only uid with write access to the state dir).
/// Returns stdout on success; bails with stderr context on failure.
async fn forge_admin(args: &[&str]) -> Result<String> {
// Route through hive-priv (root helper) because `nixos-container run`
// uses nsenter to enter the container's namespaces, which requires root.
// hive-c0re runs as the unprivileged `hive-core` user and cannot call
// nsenter directly — doing so produces:
// nsenter: stat of /proc/<pid>/ns/user failed: Permission denied
let (stdout, _stderr) = crate::priv_client::run_forge_admin(args)
.await
.with_context(|| format!("{} (via hive-priv)", describe_forge_admin(args)))?;
Ok(stdout)
}
/// Typed Forgejo API client for the local forge ([`forge_http_base()`]),
/// authenticated as `token`. All Forgejo API calls that don't shell
/// out to `forgejo admin` go through clients built here — one place
/// for the base URL and auth. Tokens differ per call site (core admin
/// token vs per-agent tokens), so the token is passed per call; the
/// base URL is parsed once. Failures surface as
/// `forgejo_api::ForgejoError`, whose Display carries the HTTP status
/// and the API's error message (e.g. the validation reason on a 422).
pub(crate) fn api(token: &str) -> Result<Forgejo> {
static URL: OnceLock<Url> = OnceLock::new();
let url = URL
.get_or_init(|| Url::parse(forge_http_base()).expect("forge_http_base() is a valid URL"))
.clone();
Forgejo::new(Auth::Token(token), url).context("build forgejo api client")
}
/// Per-agent forge sync: ensure the agent has a forgejo user + token,
/// a mirrored config repo, read access to `core/meta`, and the `meta`
/// remote in its proposed repo. All operations are idempotent; failures
/// are logged as warnings but don't abort the caller.
///
/// `core_token` is `core_token()` — passed in so callers that already
/// fetched it don't re-read the file. Pass `None` to skip the
/// `meta_read_access` step (safe: the access grant is best-effort).
///
/// Called by both `ensure_all()` (startup sweep) and `rebuild_agent`
/// (per-rebuild) so the two paths stay equivalent.
/// Returns `true` if all steps succeeded, `false` if any step failed. The
/// caller can use the return value to aggregate per-agent failures into a
/// dashboard warning (see [`ensure_all`]); the rebuild path ignores it and
/// relies on the journal `warn!` lines alone (a rebuild is its own retry).
pub async fn sync_agent(name: &str, core_token: Option<&str>) -> bool {
let mut ok = true;
if let Err(e) = ensure_user_for(name).await {
tracing::warn!(%name, error = ?e, "forge: ensure_user failed");
ok = false;
}
// Align email to match the git user.email set by meta::render_flake
// so commits link to the agent's Forgejo profile. Best-effort;
// also patches up agents created before this fix (old @hive.local).
ensure_user_email(name).await;
// Block direct agent-initiated repo creation: agents create
// repos through hive-c0re, never with their own token. Idempotent +
// marker-guarded; also covers agents provisioned before this landed.
ensure_repo_creation_disabled(name).await;
// Mirror the agent's applied config repo into agent-configs.
// ensure_config_repo is idempotent; push_config catches any
// drift since the last run — e.g. the startup migration just
// relocated `deployed/0`, or a deploy landed while the forge
// was down.
if let Err(e) = ensure_config_repo(name).await {
tracing::warn!(%name, error = ?e, "forge: ensure_config_repo failed");
ok = false;
}
if let Err(e) = push_config(name).await {
tracing::warn!(%name, error = ?e, "forge: push_config failed");
ok = false;
}
// Grant read-only access to core/meta and wire the `meta` remote
// into the proposed repo so agents can fetch their deployment context.
if let Some(token) = core_token
&& let Err(e) = meta_read_access(name, token).await
{
tracing::warn!(%name, error = ?e, "forge: ensure_meta_read_access failed");
ok = false;
}
if let Err(e) = ensure_meta_remote(name).await {
tracing::warn!(%name, error = ?e, "forge: ensure_meta_remote failed");
ok = false;
}
// Grant read-only access to internal/docs so the agent can clone
// the operator-curated shared skills/runbook repo. Best-effort.
if let Some(token) = core_token
&& let Err(e) = shared_docs_access(name, token).await
{
tracing::warn!(%name, error = ?e, "forge: shared_docs_access failed");
ok = false;
}
// internal/knowledge is public — no per-agent collaborator grant needed.
ok
}
/// The `core_token.is_some()` half of [`ensure_all`]: orgs, teams, the meta
/// repo, shared/knowledge repos, avatars, and CI runner registration — every
/// step that needs an authenticated forge client. Split out purely to keep
/// `ensure_all` under clippy's function-length limit; not meant to be called
/// from anywhere else.
async fn ensure_all_orgs_and_repos(token: &str) {
for org in SEEDED_ORGS {
if let Err(e) = ensure_org(org, token).await {
tracing::warn!(%org, error = ?e, "forge: ensure_org failed");
crate::warnings::set_boot_warning(
static_kind(format!("forge_ensure_org_{org}")),
"crit",
format!("forge: org {org} provisioning failed: {e}"),
);
}
}
// Seed the operator-declared pull-mirrors (nix `forge.mirrors` +
// the CI-auto `actions/checkout`, forwarded via the
// `HYPERHIVE_FORGE_MIRRORS` env). Each ensures its own dest org, so
// this is independent of the SEEDED_ORGS loop above.
ensure_mirrors(token).await;
// Provision the operator merge-gate team (empty) inside BOTH the
// agents org and the agent-configs org so branch protection in each
// can reference it before anyone joins. Gitea teams are org-scoped —
// missing the agent-configs copy 422'd every config-repo protection
// apply, leaving those repos unprotected and letting operator-merged
// config PRs bypass the deploy pipeline. The operator adds herself as
// a member out-of-band.
for org in [AGENTS_ORG, CONFIG_ORG] {
if let Err(e) = ensure_operators_team(org, token).await {
tracing::warn!(%org, error = ?e, "forge: ensure_operators_team failed");
crate::warnings::set_boot_warning(
static_kind(format!("forge_ensure_operators_team_{org}")),
"crit",
format!("forge: operators team in {org} provisioning failed: {e}"),
);
}
}
// Meta repo lives at core/meta — pushed from git_commit in
// meta.rs on every deploy/lock-update. Make sure it exists
// before the first push hits a 404.
if let Err(e) = ensure_repo("meta", token).await {
tracing::warn!(error = ?e, "forge: ensure_repo core/meta failed");
crate::warnings::set_boot_warning(
"forge_ensure_meta_repo",
"crit",
format!("forge: core/meta repo provisioning failed: {e}"),
);
}
// Seed the shared docs repo. internal is already in
// SEEDED_ORGS above so the org exists; ensure the repo itself.
if let Err(e) = ensure_shared_docs_repo(token).await {
tracing::warn!(error = ?e, "forge: ensure_shared_docs_repo failed");
crate::warnings::set_boot_warning(
"forge_ensure_shared_docs_repo",
"warn",
format!("forge: shared docs repo provisioning failed: {e}"),
);
}
// Seed the hive-wide knowledge repo.
if let Err(e) = ensure_knowledge_repo(token).await {
tracing::warn!(error = ?e, "forge: ensure_knowledge_repo failed");
crate::warnings::set_boot_warning(
"forge_ensure_knowledge_repo",
"crit",
format!("forge: knowledge repo provisioning failed: {e}"),
);
}
// Clone knowledge repo locally so it can be bind-mounted into agents.
if let Err(e) = crate::knowledge::ensure_local_clone(token).await {
tracing::warn!(error = ?e, "knowledge: ensure_local_clone failed");
crate::warnings::set_boot_warning(
"forge_knowledge_local_clone",
"crit",
format!("knowledge: local clone failed: {e}"),
);
}
if let Err(e) = ensure_core_avatar(token).await {
tracing::warn!(error = ?e, "forge: ensure_core_avatar failed");
crate::warnings::set_boot_warning(
"forge_ensure_core_avatar",
"warn",
format!("forge: core avatar upload failed: {e}"),
);
}
if let Err(e) = ensure_config_org_avatar(token).await {
tracing::warn!(error = ?e, "forge: ensure_config_org_avatar failed");
crate::warnings::set_boot_warning(
"forge_ensure_config_org_avatar",
"warn",
format!("forge: agent-configs org avatar upload failed: {e}"),
);
}
// Register the hive-ci Actions runner (off the container's boot path;
// no-op when CI is disabled or the runner already holds valid creds).
ci_runner::ensure_ci_runner_registered(token).await;
}
/// Poll the local Forgejo API until it answers, bounded by [`READY_TIMEOUT`].
/// The whole boot provisioning below hits the API, and [`is_present`] only
/// confirms the container exists — not that Forgejo is *listening*. A
/// `nixos-rebuild` that restarts hive-forge and hive-c0re together races:
/// without this gate every `ensure_*` fires at a refused socket and leaves a
/// stale "provisioning failed" banner that never clears (the pass is one-shot).
/// Returns whether the endpoint became ready before the deadline; on timeout
/// the caller proceeds anyway so a genuinely-down forge still surfaces its real
/// errors. Uses the unauthenticated `get_version` endpoint (no token yet).
async fn wait_until_ready() -> bool {
const READY_TIMEOUT: Duration = Duration::from_mins(1);
const POLL_INTERVAL: Duration = Duration::from_millis(500);
let Ok(url) = Url::parse(forge_http_base()) else {
tracing::warn!("forge: HIVE_FORGE_URL is not a valid URL; skipping readiness wait");
return false;
};
let Ok(client) = Forgejo::new(Auth::None, url) else {
tracing::warn!("forge: could not build readiness client; skipping readiness wait");
return false;
};
let deadline = Instant::now() + READY_TIMEOUT;
let mut waited = false;
loop {
if client.get_version().await.is_ok() {
if waited {
tracing::info!("forge: endpoint ready, proceeding with provisioning");
}
return true;
}
if Instant::now() >= deadline {
tracing::warn!(
"forge: endpoint not ready after {READY_TIMEOUT:?}; provisioning anyway"
);
return false;
}
waited = true;
tokio::time::sleep(POLL_INTERVAL).await;
}
}
/// Sweep every existing container (manager + sub-agents) and ensure
/// each has a forgejo user + token, plus an `agent-configs/<name>`
/// repo mirroring its applied config. Also seeds the `core` admin
/// user (hive-c0re's own identity for pushing the meta repo + driving
/// the API), the `agent-configs` org, and the `core/meta` repo.
/// Called once at hive-c0re startup. Per-step failures are logged
/// but don't abort the sweep.
pub async fn ensure_all() {
if !is_present().await {
tracing::debug!("forge: hive-forge container absent, skipping user sweep");
return;
}
// The container exists, but its HTTP may not be up yet (a rebuild restarts
// hive-forge + hive-c0re together). Wait for the API to answer before the
// provisioning pass so a boot race doesn't leave stale failure banners.
wait_until_ready().await;
let core_token = match ensure_core_user_and_token().await {
Ok(t) => Some(t),
Err(e) => {
tracing::warn!(error = ?e, "forge: ensure_core_user_and_token failed");
crate::warnings::set_boot_warning(
"forge_ensure_core_user",
"crit",
format!("forge: core user/token provisioning failed: {e}"),
);
None
}
};
if let Some(token) = core_token.as_deref() {
ensure_all_orgs_and_repos(token).await;
}
let Ok(containers) = crate::lifecycle::list().await else {
tracing::warn!("forge: nixos-container list failed; skipping user sweep");
crate::warnings::set_boot_warning(
"forge_container_list",
"crit",
"forge: nixos-container list failed; per-agent forge sync skipped this boot",
);
return;
};
let mut sync_failed: Vec<String> = Vec::new();
for c in containers {
let Some(name) = c.strip_prefix(crate::lifecycle::AGENT_PREFIX) else {
continue;
};
if !sync_agent(name, core_token.as_deref()).await {
sync_failed.push(name.to_owned());
}
}
if !sync_failed.is_empty() {
// Use static_kind to mint a `&'static str` key from the failed-agent
// list (bounded leak: one per hive-c0re boot, not per request).
let key = static_kind(format!("forge_sync_agent_{}", sync_failed.join("_")));
crate::warnings::set_boot_warning(
key,
"warn",
format!(
"forge: per-agent sync failed for: {} (see journal for per-step detail)",
sync_failed.join(", ")
),
);
}
}
/// Ensure a Forgejo `pull_request` org-webhook for `agent-configs` exists and
/// points at hive-c0re's `/webhook/config-pr` endpoint. Idempotent — lists
/// existing hooks first and skips creation when one is already targeting the
/// correct URL.
///
/// `hive_domain` is the public domain name of the hive (e.g.
/// `pr1ma.darkest.space`); the webhook URL is
/// `https://<hive_domain>/webhook/config-pr` (routed through the gateway,
/// avoiding the Forgejo SSRF guard that blocks loopback delivery).
///
/// `webhook_secret` is the HMAC secret Forgejo will attach as
/// `X-Hub-Signature-256` on each delivery; hive-c0re verifies this header
/// in [`crate::dashboard::webhook::post_webhook_config_pr`].
///
/// An org-level hook covers every repo in `agent-configs` automatically,
/// so no per-repo setup is needed as new agents are provisioned.
///
/// Called at startup alongside `knowledge::ensure_webhook`. No-op when the
/// core token is absent (forge not yet provisioned).
///
/// # Errors
///
/// Returns an error if:
/// - `hive_domain` produces a URL that `url::Url::parse` rejects.
/// - The Forgejo `org_create_hook` API call fails (transport error, auth
/// failure, or the `agent-configs` org does not exist).
/// - The HTTP call times out (10 s limit).
///
/// Listing failures are treated as best-effort: they fall through to the
/// create attempt rather than surfacing an error.
pub async fn ensure_config_pr_webhook(
core_token: &str,
hive_domain: &str,
webhook_secret: &str,
) -> Result<()> {
use forgejo_api::structs::{CreateHookOption, CreateHookOptionConfig, CreateHookOptionType};
use std::collections::BTreeMap;
const HTTP_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
let target_url = format!("https://{hive_domain}/webhook/config-pr");
let client = api(core_token)?;
// List existing org hooks — skip creation if ours is already there.
// Best-effort: a listing failure falls through to the create attempt.
let listed = tokio::time::timeout(HTTP_TIMEOUT, client.org_list_hooks(CONFIG_ORG).send())
.await
.map_err(anyhow::Error::from)
.and_then(|r| r.map_err(anyhow::Error::from));
match listed {
Ok(hooks) => {
let already_exists = hooks.iter().any(|h| {
h.config
.as_ref()
.and_then(|c| c.get("url"))
.map(String::as_str)
== Some(target_url.as_str())
});
if already_exists {
tracing::debug!(%target_url, "forge: config-pr webhook already configured");
return Ok(());
}
// Delete stale hooks that point at our path but a different base
// (e.g. old loopback hooks from before the SSRF-bypass migration).
for h in &hooks {
let hook_url = h
.config
.as_ref()
.and_then(|c| c.get("url"))
.map_or("", String::as_str);
if hook_url.ends_with("/webhook/config-pr")
&& hook_url != target_url
&& let Some(id) = h.id
{
tracing::info!(
hook_url,
org = CONFIG_ORG,
"forge: deleting stale config-pr webhook (wrong base)"
);
let _ = tokio::time::timeout(
HTTP_TIMEOUT,
client.org_delete_hook(CONFIG_ORG, id).send(),
)
.await;
}
}
}
Err(e) => {
tracing::debug!(error = %e, "forge: listing config-pr hooks failed; attempting create");
}
}
let mut additional = BTreeMap::new();
additional.insert("secret".to_owned(), webhook_secret.to_owned());
let hook = CreateHookOption {
active: Some(true),
authorization_header: None,
branch_filter: None,
config: CreateHookOptionConfig {
content_type: "json".to_owned(),
url: Url::parse(&target_url).context("parse config-pr webhook target url")?,
additional,
},
events: Some(vec!["pull_request".to_owned()]),
r#type: CreateHookOptionType::Forgejo,
};
tokio::time::timeout(HTTP_TIMEOUT, client.org_create_hook(CONFIG_ORG, hook))
.await
.map_err(anyhow::Error::from)
.and_then(|r| r.map_err(anyhow::Error::from))
.with_context(|| format!("create config-pr webhook on org {CONFIG_ORG}"))?;
tracing::info!(%target_url, "forge: config-pr webhook created on org {CONFIG_ORG}");
Ok(())
}
#[cfg(test)]
mod tests {
use super::describe_forge_admin;
#[test]
fn describe_keeps_the_verb_path_and_drops_every_value() {
assert_eq!(
describe_forge_admin(&[
"user",
"create",
"--username",
"iris",
"--email",
"iris@hyperhive.local",
"--random-password",
]),
"forgejo admin user create"
);
}
#[test]
fn describe_does_not_reproduce_a_password_argument() {
let described = describe_forge_admin(&[
"user",
"change-password",
"--username",
"iris",
"--password",
"correct-horse-battery-staple",
]);
assert_eq!(described, "forgejo admin user change-password");
assert!(!described.contains("correct-horse-battery-staple"));
}
#[test]
fn describe_survives_a_leading_flag_and_an_empty_vector() {
assert_eq!(describe_forge_admin(&["--help"]), "forgejo admin");
assert_eq!(describe_forge_admin(&[]), "forgejo admin");
}
}