`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.
351 lines
14 KiB
Rust
351 lines
14 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::coordinator::Coordinator;
|
|
use crate::forge::{core_auth_header, 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(&format!("{ORG}/{REPO}"));
|
|
let out = crate::lifecycle::git_command_authed(&core_auth_header(core_token))
|
|
.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(&format!("{ORG}/{REPO}"));
|
|
let out = crate::lifecycle::git_command_authed(&core_auth_header(core_token))
|
|
.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(())
|
|
}
|
|
|
|
/// Current `HEAD` sha of the local clone, best-effort. `None` on any
|
|
/// failure (used only to detect whether a pull actually moved `HEAD` —
|
|
/// worth skipping the broadcast over, not worth failing the pull for).
|
|
async fn head_sha() -> Option<String> {
|
|
let out = tokio::process::Command::new("git")
|
|
.args(["-C", LOCAL_DIR, "rev-parse", "HEAD"])
|
|
.output()
|
|
.await
|
|
.ok()?;
|
|
out.status
|
|
.success()
|
|
.then(|| String::from_utf8_lossy(&out.stdout).trim().to_owned())
|
|
}
|
|
|
|
/// 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.
|
|
///
|
|
/// When the pull actually moves `HEAD` (a real change, not a no-op),
|
|
/// broadcasts a short `git diff --stat` summary to every live agent's
|
|
/// inbox via `coord` — inbox-only, no forced wake, and shared by both
|
|
/// call sites since the broadcast lives in here rather than in each
|
|
/// caller.
|
|
pub async fn pull(coord: &Coordinator) -> 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");
|
|
}
|
|
// Rewrite any credentialed `origin` left by an older clone, which spliced
|
|
// `core:<token>@` into the URL and persisted it in `.git/config` — the one
|
|
// place this repo stored a token in a named remote, against the rule
|
|
// `forge::repos::push_config` states. Harmless to repeat once the URL is
|
|
// already clean.
|
|
let plain = crate::forge::forge_git_url(&format!("{ORG}/{REPO}"));
|
|
let _ = crate::lifecycle::git_command()
|
|
.args(["-C", LOCAL_DIR, "remote", "set-url", "origin", &plain])
|
|
.output()
|
|
.await;
|
|
let before = head_sha().await;
|
|
// The repo is public (`ensure_knowledge_repo` makes it so), so an
|
|
// unauthenticated pull is enough — but authenticate when a token is around,
|
|
// which keeps this working if the repo is ever made private again.
|
|
let auth = crate::forge::core_token().map(|t| core_auth_header(&t));
|
|
let mut cmd = match &auth {
|
|
Some(header) => crate::lifecycle::git_command_authed(header),
|
|
None => crate::lifecycle::git_command(),
|
|
};
|
|
let out = cmd
|
|
.args(["-C", LOCAL_DIR, "pull", "--ff-only"])
|
|
.output()
|
|
.await
|
|
.context("git pull knowledge")?;
|
|
if !out.status.success() {
|
|
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_owned();
|
|
anyhow::bail!("git pull {ORG}/{REPO} failed: {stderr}")
|
|
}
|
|
let stdout = String::from_utf8_lossy(&out.stdout).trim().to_owned();
|
|
tracing::info!(result = %stdout, "knowledge: pull succeeded");
|
|
|
|
let after = head_sha().await;
|
|
if let (Some(before), Some(after)) = (before, after)
|
|
&& before != after
|
|
{
|
|
broadcast_change(coord, &before, &after).await;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Diff `before..after` in the local clone and broadcast a one-line-per-file
|
|
/// summary to every live agent. Best-effort — a diff or send failure is
|
|
/// logged, never propagated (the pull itself already succeeded).
|
|
async fn broadcast_change(coord: &Coordinator, before: &str, after: &str) {
|
|
let diff = tokio::process::Command::new("git")
|
|
.args([
|
|
"-C",
|
|
LOCAL_DIR,
|
|
"diff",
|
|
"--stat",
|
|
&format!("{before}..{after}"),
|
|
])
|
|
.output()
|
|
.await;
|
|
let stat = match diff {
|
|
Ok(out) if out.status.success() => String::from_utf8_lossy(&out.stdout).trim().to_owned(),
|
|
Ok(out) => {
|
|
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_owned();
|
|
tracing::warn!(%stderr, "knowledge: diff --stat failed; broadcasting without it");
|
|
String::new()
|
|
}
|
|
Err(e) => {
|
|
tracing::warn!(error = ?e, "knowledge: diff --stat failed; broadcasting without it");
|
|
String::new()
|
|
}
|
|
};
|
|
let body = if stat.is_empty() {
|
|
"[system] /knowledge updated — see the repo for what changed.".to_owned()
|
|
} else {
|
|
format!("[system] /knowledge updated:\n{stat}")
|
|
};
|
|
let errors = coord.broadcast_send(hive_sh4re::SYSTEM_SENDER, &body);
|
|
if !errors.is_empty() {
|
|
tracing::warn!(?errors, "knowledge: broadcast had per-agent failures");
|
|
}
|
|
}
|