Two issues flagged by argus in PR #2388 review: 1. Empty-key fallback: when load_or_generate() failed, webhook_secret was String::new(). An attacker knowing this could forge deliveries with a valid HMAC of the empty key. Fix: change to Option<String>; on None, skip hook registration entirely and return 503 from /webhook/* handlers (rather than 401 with a misleadingly-verifiable empty-key HMAC). 2. Stale hook cleanup: on upgrade from old code, old loopback hooks (http://127.0.0.1:.../webhook/knowledge, .../webhook/config-pr) were left alongside the new domain-URL hook. Fix: during ensure_webhook / ensure_config_pr_webhook, after listing hooks, delete any that end with our path suffix but point at a different base URL. clippy + nix fmt clean.
268 lines
11 KiB
Rust
268 lines
11 KiB
Rust
//! Hive-wide knowledge repository management.
|
|
//!
|
|
//! `internal/knowledge` on the forge is cloned to
|
|
//! [`LOCAL_DIR`] and bind-mounted read-only into every agent container
|
|
//! at `/knowledge`. Agents read documents from it directly and
|
|
//! contribute by forking the repo and opening PRs — they never write
|
|
//! to the bind-mounted path inside the container.
|
|
//!
|
|
//! hive-c0re maintains the local clone. A Forgejo webhook notifies it
|
|
//! on push to main so agents always see an up-to-date snapshot. The
|
|
//! webhook is auto-created by [`ensure_webhook`] at startup. A
|
|
//! periodic pull in `main.rs` provides a fallback cadence.
|
|
|
|
use std::collections::BTreeMap;
|
|
|
|
use anyhow::{Context, Result};
|
|
use forgejo_api::structs::{CreateHookOption, CreateHookOptionConfig, CreateHookOptionType};
|
|
|
|
use crate::forge::forge_git_url;
|
|
|
|
pub const ORG: &str = "internal";
|
|
pub const REPO: &str = "knowledge";
|
|
|
|
/// Host-side path for the local clone. Also referenced from
|
|
/// `lifecycle.rs` (bind-mount source) and the dashboard webhook handler.
|
|
/// The literal lives in [`crate::paths`]; re-exported here under the name
|
|
/// this module and its consumers have always used.
|
|
pub use crate::paths::KNOWLEDGE_DIR as LOCAL_DIR;
|
|
|
|
/// In-container mount point for the knowledge repo. Bind-mounted
|
|
/// read-only from [`LOCAL_DIR`] into every agent container.
|
|
pub const CONTAINER_MOUNT: &str = "/knowledge";
|
|
|
|
/// Default README pushed to a freshly created `internal/knowledge` repo.
|
|
/// Short explanation + empty table-of-contents with an HTML comment instructing contributors
|
|
/// to add entries when they create new files.
|
|
const README_CONTENT: &str = "\
|
|
# knowledge
|
|
|
|
Hive-wide reference documents: conventions, runbooks, and anything that \
|
|
every agent should know.
|
|
|
|
## How to contribute
|
|
|
|
1. Fork this repo into your own namespace on the forge.
|
|
2. Create a branch, add or update a document.
|
|
3. Open a pull request — the operator reviews and merges.
|
|
4. Every agent container updates automatically on merge.
|
|
|
|
Do **not** push directly to `main` — agents have read-only access.
|
|
|
|
## Contents
|
|
|
|
<!-- Add an entry here each time you create a new document:
|
|
- [Title](path/to/file.md) - one-line description
|
|
-->
|
|
";
|
|
|
|
/// Clone `internal/knowledge` to [`LOCAL_DIR`] if it is not already a git
|
|
/// repository. `core_token` authenticates the HTTPS clone so private repos
|
|
/// work. Idempotent — skips if `LOCAL_DIR/.git` exists.
|
|
///
|
|
/// When the upstream repo is empty (freshly created), seeds it with a
|
|
/// README.md before returning so the local clone is always non-empty and
|
|
/// agents see a useful starting document.
|
|
///
|
|
/// Called once at hive-c0re startup after `forge::ensure_all`.
|
|
pub async fn ensure_local_clone(core_token: &str) -> Result<()> {
|
|
let git_dir = std::path::Path::new(LOCAL_DIR).join(".git");
|
|
if git_dir.exists() {
|
|
tracing::debug!("knowledge: local clone already present at {LOCAL_DIR}");
|
|
return Ok(());
|
|
}
|
|
std::fs::create_dir_all(LOCAL_DIR).context("create knowledge local dir")?;
|
|
let url = forge_git_url(core_token, &format!("{ORG}/{REPO}"));
|
|
let out = tokio::process::Command::new("git")
|
|
.args(["clone", &url, LOCAL_DIR])
|
|
.output()
|
|
.await
|
|
.context("git clone knowledge repo")?;
|
|
if !out.status.success() {
|
|
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_owned();
|
|
anyhow::bail!("git clone {ORG}/{REPO} failed: {stderr}");
|
|
}
|
|
tracing::info!("knowledge: cloned {ORG}/{REPO} to {LOCAL_DIR}");
|
|
// If the repo is brand new (no commits), seed it with a README.
|
|
let head_out = tokio::process::Command::new("git")
|
|
.args(["-C", LOCAL_DIR, "rev-parse", "HEAD"])
|
|
.output()
|
|
.await
|
|
.context("git rev-parse HEAD")?;
|
|
if !head_out.status.success() {
|
|
seed_readme(core_token).await?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Write the initial README.md, commit, and push to `internal/knowledge`.
|
|
/// Called only when the upstream repo is empty.
|
|
async fn seed_readme(core_token: &str) -> Result<()> {
|
|
let readme = std::path::Path::new(LOCAL_DIR).join("README.md");
|
|
std::fs::write(&readme, README_CONTENT).context("write README.md")?;
|
|
// Set a minimal git identity for the seed commit.
|
|
for (k, v) in [("user.email", "core@hive"), ("user.name", "hive-c0re")] {
|
|
let out = tokio::process::Command::new("git")
|
|
.args(["-C", LOCAL_DIR, "config", k, v])
|
|
.output()
|
|
.await
|
|
.with_context(|| format!("git config {k}"))?;
|
|
if !out.status.success() {
|
|
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_owned();
|
|
anyhow::bail!("git config {k} failed: {stderr}");
|
|
}
|
|
}
|
|
for args in [
|
|
vec!["add", "README.md"],
|
|
vec!["commit", "-m", "init: seed README"],
|
|
] {
|
|
let out = tokio::process::Command::new("git")
|
|
.args(["-C", LOCAL_DIR].iter().chain(args.iter()))
|
|
.output()
|
|
.await
|
|
.with_context(|| format!("git {args:?}"))?;
|
|
if !out.status.success() {
|
|
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_owned();
|
|
anyhow::bail!("git {args:?} failed: {stderr}");
|
|
}
|
|
}
|
|
let url = forge_git_url(core_token, &format!("{ORG}/{REPO}"));
|
|
let out = tokio::process::Command::new("git")
|
|
.args(["-C", LOCAL_DIR, "push", &url, "HEAD:main"])
|
|
.output()
|
|
.await
|
|
.context("git push knowledge README")?;
|
|
if out.status.success() {
|
|
tracing::info!("knowledge: seeded README.md and pushed to {ORG}/{REPO}");
|
|
Ok(())
|
|
} else {
|
|
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_owned();
|
|
anyhow::bail!("git push {ORG}/{REPO} failed: {stderr}")
|
|
}
|
|
}
|
|
|
|
/// Ensure a Forgejo push webhook for `internal/knowledge` exists and
|
|
/// points at hive-c0re's `/webhook/knowledge` 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; the webhook URL is
|
|
/// `https://<hive_domain>/webhook/knowledge` (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_knowledge`].
|
|
///
|
|
/// Called at startup alongside [`ensure_local_clone`]. No-op when the
|
|
/// core token is absent (forge not yet provisioned).
|
|
pub async fn ensure_webhook(
|
|
core_token: &str,
|
|
hive_domain: &str,
|
|
webhook_secret: &str,
|
|
) -> Result<()> {
|
|
// The typed client carries no per-request timeout, so each call is
|
|
// wrapped in one: this runs as a detached startup task, and a forge
|
|
// that accepts connections but never answers would otherwise hang
|
|
// it forever (and the hourly pull fallback masks the missing hook).
|
|
const HTTP_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10);
|
|
|
|
let target_url = format!("https://{hive_domain}/webhook/knowledge");
|
|
let client = crate::forge::api(core_token)?;
|
|
|
|
// List existing hooks — skip creation if ours is already there.
|
|
// Best-effort like the raw-HTTP predecessor: a listing failure
|
|
// falls through to the create attempt.
|
|
let listed = tokio::time::timeout(HTTP_TIMEOUT, client.repo_list_hooks(ORG, REPO).all())
|
|
.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, "knowledge: push 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/knowledge")
|
|
&& hook_url != target_url
|
|
&& let Some(id) = h.id
|
|
{
|
|
tracing::info!(hook_url, "knowledge: deleting stale webhook (wrong base)");
|
|
let _ = tokio::time::timeout(
|
|
HTTP_TIMEOUT,
|
|
client.repo_delete_hook(ORG, REPO, id).send(),
|
|
)
|
|
.await;
|
|
}
|
|
}
|
|
}
|
|
Err(e) => {
|
|
tracing::debug!(error = %e, "knowledge: listing hooks failed; attempting create");
|
|
}
|
|
}
|
|
|
|
// Create the webhook.
|
|
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::Url::parse(&target_url).context("parse webhook target url")?,
|
|
additional,
|
|
},
|
|
events: Some(vec!["push".to_owned()]),
|
|
r#type: CreateHookOptionType::Forgejo,
|
|
};
|
|
tokio::time::timeout(HTTP_TIMEOUT, client.repo_create_hook(ORG, REPO, hook))
|
|
.await
|
|
.map_err(anyhow::Error::from)
|
|
.and_then(|r| r.map_err(anyhow::Error::from))
|
|
.with_context(|| format!("create webhook for {ORG}/{REPO}"))?;
|
|
tracing::info!(%target_url, "knowledge: push webhook created");
|
|
Ok(())
|
|
}
|
|
|
|
/// Pull the latest changes in the local clone. Called from the webhook
|
|
/// handler on every push to `internal/knowledge` main, and periodically
|
|
/// from `main.rs` as a fallback. Uses `--ff-only` so a force-push to
|
|
/// the knowledge repo never wedges the local copy silently.
|
|
pub async fn pull() -> Result<()> {
|
|
// Sanity: if the clone is missing (e.g. storage was wiped), refuse
|
|
// to pull and let the caller decide whether to re-clone.
|
|
let git_dir = std::path::Path::new(LOCAL_DIR).join(".git");
|
|
if !git_dir.exists() {
|
|
anyhow::bail!("knowledge: {LOCAL_DIR}/.git not found — clone first");
|
|
}
|
|
let out = tokio::process::Command::new("git")
|
|
.args(["-C", LOCAL_DIR, "pull", "--ff-only"])
|
|
.output()
|
|
.await
|
|
.context("git pull knowledge")?;
|
|
if out.status.success() {
|
|
let stdout = String::from_utf8_lossy(&out.stdout).trim().to_owned();
|
|
tracing::info!(result = %stdout, "knowledge: pull succeeded");
|
|
Ok(())
|
|
} else {
|
|
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_owned();
|
|
anyhow::bail!("git pull {ORG}/{REPO} failed: {stderr}")
|
|
}
|
|
}
|