hyperhive/hive-c0re/src/workers/knowledge.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

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");
}
}