`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.
264 lines
10 KiB
Rust
264 lines
10 KiB
Rust
//! Git shellout helpers for the per-agent proposed/applied repos: run
|
|
//! `git` with the hive-c0re identity, resolve/plant refs and tags, and
|
|
//! fetch proposal commits into the applied repo.
|
|
|
|
use std::path::Path;
|
|
|
|
use anyhow::{Context, Result, bail};
|
|
use tokio::process::Command;
|
|
|
|
const GIT_NAME: &str = "c0re";
|
|
const GIT_EMAIL: &str = "c0re@hyperhive.local";
|
|
|
|
/// Return the SHA of the root (oldest, no-parent) commit in a repo.
|
|
/// Used to seed the applied repo at the template baseline rather than at
|
|
/// `main`, so `deployed/0` records the template, not the manager's first commit.
|
|
pub(super) async fn git_root_commit(dir: &Path) -> Result<String> {
|
|
let out = git_command()
|
|
.current_dir(dir)
|
|
.args(["rev-list", "--max-parents=0", "HEAD"])
|
|
.output()
|
|
.await
|
|
.with_context(|| format!("git rev-list --max-parents=0 HEAD in {}", dir.display()))?;
|
|
if !out.status.success() {
|
|
anyhow::bail!(
|
|
"git rev-list --max-parents=0 failed: {}",
|
|
String::from_utf8_lossy(&out.stderr).trim()
|
|
);
|
|
}
|
|
Ok(String::from_utf8_lossy(&out.stdout).trim().to_owned())
|
|
}
|
|
|
|
pub(super) async fn git_commit(dir: &Path, message: &str) -> Result<()> {
|
|
git(
|
|
dir,
|
|
&[
|
|
"-c",
|
|
&format!("user.name={GIT_NAME}"),
|
|
"-c",
|
|
&format!("user.email={GIT_EMAIL}"),
|
|
"commit",
|
|
"-m",
|
|
message,
|
|
],
|
|
)
|
|
.await
|
|
}
|
|
|
|
/// Spawn `git` honoring the `HYPERHIVE_GIT` env var (absolute path baked in
|
|
/// by the NixOS module), falling back to bare `git` (PATH lookup) otherwise.
|
|
///
|
|
/// `kill_on_drop(true)`: if the caller's future is dropped before the child
|
|
/// exits — e.g. a `tokio::time::timeout` around startup migration fires — the
|
|
/// git child is killed instead of orphaned (left retrying an unreachable
|
|
/// forge). No-op on normal completion, where the child has already exited.
|
|
#[must_use]
|
|
pub fn git_command() -> Command {
|
|
let exe = std::env::var("HYPERHIVE_GIT").unwrap_or_else(|_| "git".into());
|
|
let mut cmd = Command::new(exe);
|
|
cmd.kill_on_drop(true);
|
|
cmd
|
|
}
|
|
|
|
/// [`git_command`] carrying HTTP credentials **in its environment**.
|
|
///
|
|
/// `auth_header` is an `Authorization:` line (see
|
|
/// [`crate::forge::core_auth_header`]) handed to git as `http.extraHeader`
|
|
/// through `GIT_CONFIG_*`, which git reads exactly like a config file. The
|
|
/// alternative — userinfo in the remote URL — puts the secret in `argv`, and
|
|
/// `/proc/<pid>/cmdline` is world-readable while `/proc/<pid>/environ` is
|
|
/// owner-only. Same credential, materially smaller audience.
|
|
///
|
|
/// `GIT_CONFIG_COUNT` needs git >= 2.31; `HYPERHIVE_GIT` points at a pinned
|
|
/// nixpkgs git well past that.
|
|
#[must_use]
|
|
pub fn git_command_authed(auth_header: &str) -> Command {
|
|
let mut cmd = git_command();
|
|
cmd.env("GIT_CONFIG_COUNT", "1")
|
|
.env("GIT_CONFIG_KEY_0", "http.extraHeader")
|
|
.env("GIT_CONFIG_VALUE_0", auth_header);
|
|
cmd
|
|
}
|
|
|
|
/// Run `cmd` as `git <args>` in `dir`, erroring on a non-zero exit.
|
|
async fn run(mut cmd: Command, dir: &Path, args: &[&str]) -> Result<()> {
|
|
let out = cmd
|
|
.current_dir(dir)
|
|
.args(args)
|
|
.output()
|
|
.await
|
|
.with_context(|| format!("git {} in {}", args.join(" "), dir.display()))?;
|
|
if !out.status.success() {
|
|
bail!(
|
|
"git {} failed ({}): {}",
|
|
args.join(" "),
|
|
out.status,
|
|
String::from_utf8_lossy(&out.stderr).trim()
|
|
);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn git(dir: &Path, args: &[&str]) -> Result<()> {
|
|
run(git_command(), dir, args).await
|
|
}
|
|
|
|
/// [`git`] against an authenticated remote — for the paths that talk to the
|
|
/// forge. The credential rides the environment, so `args` (and therefore the
|
|
/// error above) stay free of it.
|
|
pub async fn git_authed(dir: &Path, args: &[&str], auth_header: &str) -> Result<()> {
|
|
run(git_command_authed(auth_header), dir, args).await
|
|
}
|
|
|
|
/// Resolve `refname` (a tag, branch, or sha) in `dir` to its full sha.
|
|
pub async fn git_rev_parse(dir: &Path, refname: &str) -> Result<String> {
|
|
let out = git_command()
|
|
.current_dir(dir)
|
|
.args(["rev-parse", refname])
|
|
.output()
|
|
.await
|
|
.with_context(|| format!("git rev-parse {refname} in {}", dir.display()))?;
|
|
if !out.status.success() {
|
|
bail!(
|
|
"git rev-parse {refname} failed ({}): {}",
|
|
out.status,
|
|
String::from_utf8_lossy(&out.stderr).trim()
|
|
);
|
|
}
|
|
Ok(String::from_utf8_lossy(&out.stdout).trim().to_owned())
|
|
}
|
|
|
|
/// Plant a lightweight tag at `target`. Errors if the tag already
|
|
/// exists — we want loud failures on id reuse, not silent
|
|
/// overwrites.
|
|
pub async fn git_tag(dir: &Path, name: &str, target: &str) -> Result<()> {
|
|
git(dir, &["tag", name, target]).await
|
|
}
|
|
|
|
/// Plant an annotated tag with `body` as the message. Used for
|
|
/// `failed/<id>` (body = build error) and `denied/<id>` (body =
|
|
/// operator note). Multi-line bodies handled via stdin so we don't
|
|
/// have to escape anything.
|
|
pub async fn git_tag_annotated(dir: &Path, name: &str, target: &str, body: &str) -> Result<()> {
|
|
use tokio::io::AsyncWriteExt;
|
|
// Annotated tags are git objects, so they need a tagger identity
|
|
// (same constraint as a commit). Pass the hive-c0re identity
|
|
// inline rather than relying on a global git config — applied
|
|
// repos are hive-c0re-owned and the host's user might not have
|
|
// user.email set.
|
|
let mut child = git_command()
|
|
.current_dir(dir)
|
|
.args([
|
|
"-c",
|
|
&format!("user.name={GIT_NAME}"),
|
|
"-c",
|
|
&format!("user.email={GIT_EMAIL}"),
|
|
"tag",
|
|
"-a",
|
|
name,
|
|
target,
|
|
"-F",
|
|
"-",
|
|
])
|
|
.stdin(std::process::Stdio::piped())
|
|
.stdout(std::process::Stdio::piped())
|
|
.stderr(std::process::Stdio::piped())
|
|
.spawn()
|
|
.with_context(|| format!("spawn git tag -a {name} in {}", dir.display()))?;
|
|
if let Some(mut stdin) = child.stdin.take() {
|
|
stdin
|
|
.write_all(body.as_bytes())
|
|
.await
|
|
.context("write tag body to git stdin")?;
|
|
// Drop closes stdin so git can finish reading.
|
|
drop(stdin);
|
|
}
|
|
let out = child.wait_with_output().await.context("wait git tag -a")?;
|
|
if !out.status.success() {
|
|
bail!(
|
|
"git tag -a {name} failed ({}): {}",
|
|
out.status,
|
|
String::from_utf8_lossy(&out.stderr).trim()
|
|
);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Replace working tree + index with the tree at `target` without
|
|
/// moving HEAD. `applied/main` stays pointing at the last known-good
|
|
/// `deployed/*` while we let `nixos-container update` evaluate the
|
|
/// candidate. On build failure callers reset back to HEAD; on
|
|
/// success they fast-forward main to `target`.
|
|
pub async fn git_read_tree_reset(dir: &Path, target: &str) -> Result<()> {
|
|
git(dir, &["read-tree", "--reset", "-u", target]).await
|
|
}
|
|
|
|
/// Hard-set a ref to `target`. Used to fast-forward `refs/heads/main`
|
|
/// to the just-deployed proposal commit. Uses `update-ref`, not
|
|
/// `branch -f`, so it works regardless of where HEAD currently sits.
|
|
pub async fn git_update_ref(dir: &Path, refname: &str, target: &str) -> Result<()> {
|
|
git(dir, &["update-ref", refname, target]).await
|
|
}
|
|
|
|
/// Compare-and-swap a ref: move `refname` to `new` only if it currently points
|
|
/// at `old`. `git update-ref <ref> <new> <old>` refuses — leaving the ref
|
|
/// untouched — when the current value differs. That refusal is the whole
|
|
/// difference between "advance this branch" and "overwrite whatever is there".
|
|
///
|
|
/// Prefer this over [`git_update_ref`] whenever the caller already knows the
|
|
/// value it believes the ref holds: a plain `update-ref` that raced another
|
|
/// writer discards the other writer's commits without a word.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Returns an error if the ref does not currently point at `old` (the CAS lost)
|
|
/// or if the `git` invocation itself fails.
|
|
pub async fn git_update_ref_cas(dir: &Path, refname: &str, new: &str, old: &str) -> Result<()> {
|
|
git(dir, &["update-ref", refname, new, old]).await
|
|
}
|
|
|
|
/// True when `ancestor` is reachable from `descendant` — i.e. moving a branch
|
|
/// from `ancestor` to `descendant` is a genuine fast-forward that discards
|
|
/// nothing.
|
|
///
|
|
/// Not the same question as "did this branch land upstream": a squash-merge
|
|
/// rewrites the commit, so `--is-ancestor` correctly answers `false` for a
|
|
/// branch whose *contents* were merged. This helper compares two commits with
|
|
/// real shared ancestry inside one repo, which is precisely what it decides.
|
|
///
|
|
/// # Errors
|
|
///
|
|
/// Returns an error if `git` fails to run or either revision can't be resolved.
|
|
/// A clean "no, not an ancestor" is `Ok(false)`, not an error.
|
|
pub async fn git_is_ancestor(dir: &Path, ancestor: &str, descendant: &str) -> Result<bool> {
|
|
let out = git_command()
|
|
.current_dir(dir)
|
|
.args(["merge-base", "--is-ancestor", ancestor, descendant])
|
|
.output()
|
|
.await
|
|
.with_context(|| {
|
|
format!(
|
|
"git merge-base --is-ancestor {ancestor} {descendant} in {}",
|
|
dir.display()
|
|
)
|
|
})?;
|
|
match out.status.code() {
|
|
Some(0) => Ok(true),
|
|
Some(1) => Ok(false),
|
|
_ => bail!(
|
|
"git merge-base --is-ancestor {ancestor} {descendant} failed ({}): {}",
|
|
out.status,
|
|
String::from_utf8_lossy(&out.stderr).trim()
|
|
),
|
|
}
|
|
}
|
|
|
|
/// Delete a ref. The counterpart to [`git_update_ref`] for the bookkeeping
|
|
/// refs a deploy parks in the applied repo (`refs/hyperhive/rollback/<id>`,
|
|
/// which records the pre-merge `main` so the deploy tail can compensate a
|
|
/// merge that landed but never finalized). `update-ref -d` is a no-op-free
|
|
/// delete: it errors if the ref does not exist, so callers that treat absence
|
|
/// as "nothing to undo" should check with [`git_rev_parse`] first.
|
|
pub async fn git_delete_ref(dir: &Path, refname: &str) -> Result<()> {
|
|
git(dir, &["update-ref", "-d", refname]).await
|
|
}
|