`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.
158 lines
6 KiB
Rust
158 lines
6 KiB
Rust
//! Reconcile an agent's local applied config checkout against its forge
|
|
//! `agent-configs/<agent>` `main`. Backs `hivectl forge reconcile-config`
|
|
//! via the host-socket `ReconcileConfig{Status,Apply}` requests. hive-c0re
|
|
//! owns the applied checkout + the forge credential, so the reconcile op
|
|
//! lives here rather than in the CLI.
|
|
//!
|
|
//! `Status` is read-only (fetches forge `main` into a scratch ref, reports
|
|
//! the divergence). `Apply(Forge)` resets the local applied checkout to
|
|
//! forge `main` — the change takes effect on the next deploy (the deploy's
|
|
//! `--override-input` re-locks against the reset local tree); it does NOT
|
|
//! auto-deploy. `Apply(Local)` is not supported: forge `main` is core-only
|
|
//! branch-protected (advanced solely by the config-PR ff-merge API).
|
|
|
|
use std::path::{Path, PathBuf};
|
|
|
|
use anyhow::{Context, Result};
|
|
|
|
use hive_host_sock::{HostResponse, ReconcileDirection};
|
|
|
|
use super::{CONFIG_ORG, core_auth_header, core_token, forge_git_url, is_present};
|
|
|
|
/// Scratch ref the forge `main` is fetched into — outside the normal
|
|
/// branch/tag namespace so it never collides with real refs.
|
|
const FORGE_MAIN_REF: &str = "refs/hyperhive/forge-config-main";
|
|
|
|
/// Fetch `agent-configs/<agent>` `main` into the applied repo's scratch
|
|
/// ref (read-only, no working-tree change) and return the applied dir.
|
|
async fn fetch_forge_main(agent: &str) -> Result<PathBuf> {
|
|
if !is_present().await {
|
|
anyhow::bail!("forge is not running");
|
|
}
|
|
let Some(token) = core_token() else {
|
|
anyhow::bail!("forge core token not available");
|
|
};
|
|
let dir = crate::paths::applied_dir(agent);
|
|
if !dir.join(".git").exists() {
|
|
anyhow::bail!("agent `{agent}` has no applied config checkout");
|
|
}
|
|
let url = forge_git_url(&format!("{CONFIG_ORG}/{agent}"));
|
|
crate::lifecycle::git_authed(
|
|
&dir,
|
|
&["fetch", "--force", &url, &format!("main:{FORGE_MAIN_REF}")],
|
|
&core_auth_header(&token),
|
|
)
|
|
.await
|
|
.context("fetch forge config main (does forge main exist yet?)")?;
|
|
Ok(dir)
|
|
}
|
|
|
|
/// Run a git command in `dir` and capture stdout, erroring on non-zero.
|
|
async fn git_out(dir: &Path, args: &[&str]) -> Result<String> {
|
|
let out = crate::lifecycle::git_command()
|
|
.current_dir(dir)
|
|
.args(args)
|
|
.output()
|
|
.await
|
|
.with_context(|| format!("invoke git {args:?}"))?;
|
|
if !out.status.success() {
|
|
anyhow::bail!(
|
|
"git {args:?} failed: {}",
|
|
String::from_utf8_lossy(&out.stderr).trim()
|
|
);
|
|
}
|
|
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
|
|
}
|
|
|
|
/// Append `lines` (from a git text block) to `messages`, each indented.
|
|
fn push_indented(messages: &mut Vec<String>, block: &str) {
|
|
messages.extend(block.lines().map(|l| format!(" {l}")));
|
|
}
|
|
|
|
/// Report the divergence between the local applied checkout and forge
|
|
/// `main` (host request `ReconcileConfigStatus`). Read-only.
|
|
pub async fn reconcile_config_status(agent: &str, verbose: bool) -> Result<HostResponse> {
|
|
let dir = fetch_forge_main(agent).await?;
|
|
let counts = git_out(
|
|
&dir,
|
|
&[
|
|
"rev-list",
|
|
"--left-right",
|
|
"--count",
|
|
&format!("HEAD...{FORGE_MAIN_REF}"),
|
|
],
|
|
)
|
|
.await?;
|
|
let mut parts = counts.split_whitespace();
|
|
let local_ahead = parts.next().unwrap_or("0");
|
|
let forge_ahead = parts.next().unwrap_or("0");
|
|
let mut messages = vec![
|
|
format!("agent {agent}: local applied main <-> forge agent-configs/{agent} main"),
|
|
format!(" local ahead by {local_ahead}, forge ahead by {forge_ahead} commit(s)"),
|
|
];
|
|
if local_ahead == "0" && forge_ahead == "0" {
|
|
messages.push(" in sync — nothing to reconcile".to_owned());
|
|
return Ok(HostResponse::messages(messages));
|
|
}
|
|
if forge_ahead != "0" {
|
|
messages.push(" forge-only commits (applied by --from forge):".to_owned());
|
|
push_indented(
|
|
&mut messages,
|
|
&git_out(
|
|
&dir,
|
|
&["log", "--oneline", &format!("HEAD..{FORGE_MAIN_REF}")],
|
|
)
|
|
.await?,
|
|
);
|
|
}
|
|
if local_ahead != "0" {
|
|
messages.push(" local-only commits (discarded by --from forge):".to_owned());
|
|
push_indented(
|
|
&mut messages,
|
|
&git_out(
|
|
&dir,
|
|
&["log", "--oneline", &format!("{FORGE_MAIN_REF}..HEAD")],
|
|
)
|
|
.await?,
|
|
);
|
|
}
|
|
messages.push(" diff --stat (local HEAD -> forge main):".to_owned());
|
|
push_indented(
|
|
&mut messages,
|
|
&git_out(&dir, &["diff", "--stat", "HEAD", FORGE_MAIN_REF]).await?,
|
|
);
|
|
if verbose {
|
|
messages.push(" full diff:".to_owned());
|
|
push_indented(
|
|
&mut messages,
|
|
&git_out(&dir, &["diff", "HEAD", FORGE_MAIN_REF]).await?,
|
|
);
|
|
}
|
|
Ok(HostResponse::messages(messages))
|
|
}
|
|
|
|
/// Apply a reconcile in `direction` (host request `ReconcileConfigApply`).
|
|
/// `Forge` resets the local applied checkout to forge `main`; `Local` is
|
|
/// unsupported and returns a clear error.
|
|
pub async fn reconcile_config_apply(
|
|
agent: &str,
|
|
direction: ReconcileDirection,
|
|
) -> Result<HostResponse> {
|
|
match direction {
|
|
ReconcileDirection::Local => Ok(HostResponse::error(
|
|
"reconciling forge from local is not supported yet: forge main is core-only \
|
|
branch-protected (no-push, ff-merge-API-only). Resolve via a config PR, or use \
|
|
`--from forge` to reset the local checkout to forge main.",
|
|
)),
|
|
ReconcileDirection::Forge => {
|
|
let dir = fetch_forge_main(agent).await?;
|
|
crate::lifecycle::git(&dir, &["reset", "--hard", FORGE_MAIN_REF])
|
|
.await
|
|
.context("reset applied main to forge main")?;
|
|
Ok(HostResponse::messages(vec![
|
|
format!("reconciled applied/{agent} to forge agent-configs/{agent} main"),
|
|
format!("effective on next deploy — rebuild {agent} to apply now"),
|
|
]))
|
|
}
|
|
}
|
|
}
|