Watch
0
0
Fork
You've already forked hyperhive
0

add a per-agent nix builds mode, host or none, carried like privateUsers

This commit is contained in:
atlas 2026-11-03 00:30:55 +01:00 • committed by mara
commit 5485151ba6
27 changed files with 618 additions and 58 deletions

3
Cargo.lock generated
View file

@ -2003,6 +2003,7 @@ version = "0.1.0"
dependencies = [
"anyhow",
"hive-priv-sock",
"hive-types",
"libc",
"mockall",
"rustix",
@ -2016,6 +2017,7 @@ dependencies = [
name = "hive-priv-sock"
version = "0.1.0"
dependencies = [
"hive-types",
"serde",
"serde_json",
]
@ -4929,6 +4931,7 @@ dependencies = [
"async-nats",
"futures-util",
"hive-log",
"hive-types",
"mockall",
"opentelemetry",
"opentelemetry-otlp",

View file

@ -306,7 +306,10 @@ nix build "git+<forge>/internal/meta.git?rev=<rev>#nixosConfigurations.argus.con
### Containerized nix-daemon needs `sandbox-fallback = true`
Agent containers bind-mount the host's nix-daemon socket. nspawn
nixos-containers' start script bind-mounts the host's nix-daemon socket
directory into every container (hyperhive adds no bind for it; an agent
with `services.hyperhive.agent.nixBuilds = "none"` gets it shadowed, see
`docs/trust-boundary/security.md`). nspawn
containers don't get user-namespaces by default, so `nix build`
invocations _inside_ the container can't set up the build sandbox
and fail outright if the host daemon's

View file

@ -174,15 +174,53 @@ before building `agent_notes_dir(name)`, so a `..` component can't traverse.
### Background
Agent containers bind-mount the host's `nix-daemon` socket. The host daemon may
nixos-containers' start script bind-mounts the host's `nix-daemon` socket
directory (`/nix/var/nix/daemon-socket`) into every container, hyperhive's
agents included; hyperhive adds no bind for it. The host daemon may
have `sandbox-fallback = false` (strict NixOS defaults), which causes `nix build`
inside nspawn containers to fail — containers lack kernel user namespaces, so nix
can't set up its build sandbox. the agent modules set `sandbox-fallback = true`
so that builds fall back to unsandboxed execution rather than failing outright.
### Per-agent nix builds mode
`services.hyperhive.agent.nixBuilds` picks where an agent's nix builds run. It
has two values, and defaults to `host`:
- **`host`**: builds go to the host daemon through the socket above. The daemon
forks them in its own cgroup, so they run **outside the agent container's
CPU and memory caps**, which sit on `container-<name>.service`.
- **`none`**: the container has no route to the host daemon. hive-priv adds
`--tmpfs=/nix/var/nix/daemon-socket` to the container's nspawn flags. nspawn
mounts its custom mounts sorted by destination, then by kind (a bind before
a tmpfs), so the empty tmpfs lands on top of the start script's bind
whatever order the flags are in, and the container finds no `socket` in the
directory. `NIX_REMOTE=daemon` stays, so a build fails with `cannot connect
to socket at '/nix/var/nix/daemon-socket/socket'`, which names the cause. The
agent's `/nix/store` and its read-only nix database stay bound, so store
paths already there stay readable; only a realisation of a new path, or any
`nix` command that needs the daemon, fails.
The mode reaches the container as `privateUsers` does: the swarm builder reads
the option from the evaluated config into the agent's closure record
(`nix_builds`), hive-c0re reads the record when it writes the container's
flags and sends the mode to hive-priv, and the flags apply at the container's
next start.
- A record or builder from before the field reads `host`.
- A mode this build doesn't know (a record written by a newer swarm) reads as
`none`, never `host`: an agent that asked for a mode its hive can't give gets
no builds, not builds outside its limits.
- A record hive-c0re can't read, or none, sends no mode, and hive-priv keeps
what the container's last write chose.
- A hive-c0re or hive-priv from before the option ignores it, so the agent keeps
host builds until you upgrade both.
`none` cuts the daemon only. It leaves the agent's egress alone.
### Threat model
Unsandboxed nix builds run as `nixbld` users (non-root, typically UIDs 30001-30010).
Unsandboxed nix builds run as `nixbld` users (non-root; `nrBuildUsers` is at least 32, so UIDs 30001 and up).
Without sandbox isolation, a build derivation's builder script has read access to
any file in the container that the nixbld user can read.

View file

@ -174,6 +174,7 @@ mod tests {
built_unix: 1,
cached,
private_users: false,
nix_builds: hive_types::NixBuilds::Host,
}
}

View file

@ -246,6 +246,7 @@ mod tests {
built_unix: 1,
cached: true,
private_users: false,
nix_builds: hive_types::NixBuilds::Host,
}
}

View file

@ -275,6 +275,7 @@ fn closure(cached: bool) -> AgentClosure {
built_unix: 1,
cached,
private_users: false,
nix_builds: hive_types::NixBuilds::Host,
}
}

View file

@ -7,7 +7,7 @@ use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use hive_priv_sock::BindMount;
use hive_types::Permission;
use hive_types::{NixBuilds, Permission};
use crate::coordinator::{AgentPaths, HiveEnv};
@ -451,9 +451,11 @@ async fn set_nspawn_flags(
});
}
// Whether the agent's config sets `privateUsers`, as the swarm builder
// recorded it. Read from the record, never from the agent's nix.
let private_users = private_users_of(&crate::attic::closure::record(agent_name).await);
// What the agent's config sets for `privateUsers` and `nixBuilds`, as the
// swarm builder recorded it. Read from the record, never from the agent's nix.
let record = crate::attic::closure::record(agent_name).await;
let private_users = private_users_of(&record);
let nix_builds = nix_builds_of(&record);
// Web-socket subdir: bind-mount `/run/hive-agent/<name>/` into the
// container so the harness can bind `web.sock` there and the host-side
@ -487,6 +489,7 @@ async fn set_nspawn_flags(
&load_creds,
super::hive_facts(hive),
private_users,
nix_builds,
)
.await
}
@ -529,6 +532,21 @@ fn private_users_of(
}
}
/// What hive-priv is told about the agent's nix builds: the record's mode, or
/// `None` (leave the agent as the last write set it) when there is no record or
/// it cannot be read, so a swarm outage never turns an agent's host builds on.
fn nix_builds_of(
record: &Result<Option<swarm_wire::agent_closures::AgentClosure>>,
) -> Option<NixBuilds> {
match record {
Ok(record) => record.as_ref().map(|r| r.nix_builds),
Err(e) => {
tracing::warn!(error = %format!("{e:#}"), "closure record unreadable; the agent's nix builds are left as they are");
None
}
}
}
/// The binds every agent container gets, before its own state, config and
/// role-specific dirs.
fn base_binds(runtime_dir: &Path, claude_dir: &Path, claude_mount: String) -> Vec<BindMount> {
@ -559,8 +577,16 @@ mod tests {
stage_limits,
};
use crate::coordinator::HiveEnv;
use hive_types::NixBuilds;
fn record_with(private_users: bool) -> swarm_wire::agent_closures::AgentClosure {
record_with_builds(private_users, NixBuilds::Host)
}
fn record_with_builds(
private_users: bool,
nix_builds: NixBuilds,
) -> swarm_wire::agent_closures::AgentClosure {
swarm_wire::agent_closures::AgentClosure {
meta_rev: "a".repeat(40),
store_path: "/nix/store/x".to_owned(),
@ -568,6 +594,7 @@ mod tests {
built_unix: 1,
cached: true,
private_users,
nix_builds,
}
}
@ -609,6 +636,38 @@ mod tests {
assert_eq!(super::private_users_of(&Ok(Some(old))), Some(false));
}
/// The record's mode is what hive-priv is told, both ways. No record and an
/// unreadable one say nothing, so a swarm outage changes no agent; a record
/// from before the field reads as host builds.
#[test]
fn the_records_nix_builds_is_what_the_start_path_passes() {
for mode in [NixBuilds::Host, NixBuilds::Disabled] {
assert_eq!(
super::nix_builds_of(&Ok(Some(record_with_builds(false, mode)))),
Some(mode)
);
}
assert_eq!(super::nix_builds_of(&Ok(None)), None);
assert_eq!(super::nix_builds_of(&Err(anyhow::anyhow!("down"))), None);
let old = serde_json::json!({
"meta_rev": "a", "store_path": "/nix/store/x",
"system": "s", "built_unix": 1,
});
let old = serde_json::from_value(old).expect("decodes");
assert_eq!(super::nix_builds_of(&Ok(Some(old))), Some(NixBuilds::Host));
// A mode a newer swarm wrote, which this build does not know, decodes
// and builds nothing.
let newer = serde_json::json!({
"meta_rev": "a", "store_path": "/nix/store/x",
"system": "s", "built_unix": 1, "nix_builds": "container",
});
let newer = serde_json::from_value(newer).expect("decodes");
assert_eq!(
super::nix_builds_of(&Ok(Some(newer))),
Some(NixBuilds::Disabled)
);
}
/// No agent container gets a cross-agent scratch dir; `/knowledge` is the
/// control that the lookup finds a bind that is there.
#[test]

View file

@ -11,6 +11,7 @@ use hive_priv_sock::{
BindMount, CredentialMount, HiveFacts, InfraAction, InfraContainer, MEASURE_AGENT_DISK_TIMEOUT,
NetworkIsolation, PRIV_SOCK, PrivEvent, PrivRequest, PrivResponse, PrivStream,
};
use hive_types::NixBuilds;
use std::os::fd::{AsRawFd as _, OwnedFd, RawFd};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader, Interest};
@ -320,6 +321,7 @@ pub async fn write_nspawn_flags(
load_credentials: &[CredentialMount],
hive_facts: HiveFacts,
private_users: Option<bool>,
nix_builds: Option<NixBuilds>,
) -> Result<()> {
ok(call(&PrivRequest::WriteNspawnFlags {
container: container.to_owned(),
@ -328,6 +330,7 @@ pub async fn write_nspawn_flags(
load_credentials: load_credentials.to_vec(),
hive_facts: Box::new(hive_facts),
private_users,
nix_builds,
})
.await?)
}

View file

@ -8,6 +8,8 @@ readme = "README.md"
workspace = true
[dependencies]
# `NixBuilds`, shared with the closure record.
hive-types.workspace = true
serde.workspace = true
[dev-dependencies]

View file

@ -10,6 +10,7 @@
//! audit. No server/client implementation lives here, only the wire
//! contract (mirrors `hive-host-sock`'s split for the host admin socket).
use hive_types::NixBuilds;
use serde::{Deserialize, Serialize};
/// Default socket path for the privileged helper.
@ -350,6 +351,16 @@ pub enum PrivRequest {
/// agent as it is: namespaced if hive-priv moved its data, else not.
#[serde(default)]
private_users: Option<bool>,
/// Whether the container may reach the host's nix-daemon, as the
/// swarm builder recorded the agent's `services.hyperhive.agent.nixBuilds`.
/// `Some(NixBuilds::Disabled)` has hive-priv shadow the daemon socket
/// directory with an empty tmpfs. `None`, which is what a hive-c0re
/// without the field or without a readable record sends, keeps the
/// agent as the last write left it, so a swarm outage never turns an
/// agent's host builds on. An agent never written before gets the
/// host daemon.
#[serde(default)]
nix_builds: Option<NixBuilds>,
},
/// Create the agent's socket dir `/run/hive-agent/<name>` as `0751
@ -718,6 +729,8 @@ pub enum PrivEvent {
#[cfg(test)]
mod tests {
use hive_types::NixBuilds;
use super::{InfraContainer, PrivRequest, valid_closure_path, valid_meta_rev};
/// A request from a hive-c0re that predates the field decodes with the
@ -746,6 +759,42 @@ mod tests {
assert_eq!(private_users, want);
}
}
/// The nix-builds mode is optional on the wire: a request from a hive-c0re
/// that predates the field reads as `None`, the field round-trips, and a
/// mode this build does not know reads as the one that builds nothing.
#[test]
fn nix_builds_is_optional_on_the_wire() {
let request = |extra: Option<(&str, serde_json::Value)>| {
let mut wire = serde_json::json!({
"op": "write_nspawn_flags",
"container": "h-iris",
"binds": [],
"isolation": {"bridge": "hive0", "gateway_ip": "10.42.0.1"},
});
if let Some((key, value)) = extra {
wire[key] = value;
}
let PrivRequest::WriteNspawnFlags { nix_builds, .. } =
serde_json::from_value(wire).expect("decodes")
else {
panic!("variant");
};
nix_builds
};
assert_eq!(request(None), None);
for (spelling, want) in [
("host", NixBuilds::Host),
("none", NixBuilds::Disabled),
("container", NixBuilds::Disabled),
] {
assert_eq!(
request(Some(("nix_builds", spelling.into()))),
Some(want),
"{spelling}"
);
}
}
#[test]
fn infra_control_names_no_c0re_or_swarm_service() {
// Neither the daemon nor the swarm's forge and matrix have a

View file

@ -10,6 +10,7 @@ workspace = true
[dependencies]
anyhow.workspace = true
hive-priv-sock.workspace = true
hive-types.workspace = true
libc.workspace = true
rustix.workspace = true
serde_json.workspace = true

View file

@ -14,6 +14,7 @@ use anyhow::{Context as _, Result, bail};
use hive_priv_sock::{
AGENT_PREFIX, AGENT_RUNTIME_ROOT, BindMount, CredentialMount, HiveFacts, NetworkIsolation,
};
use hive_types::NixBuilds;
use tokio::process::Command;
use crate::shared::{StagedFile, open_dir, validate_credential_name, validate_name_chars};
@ -27,6 +28,7 @@ pub(crate) fn handle_write_nspawn_flags(
load_credentials: &[CredentialMount],
hive_facts: &HiveFacts,
private_users: Option<bool>,
nix_builds: Option<NixBuilds>,
) -> Result<(String, String)> {
let agent = validate_container_system_name(container)?;
for bind in binds {
@ -60,8 +62,11 @@ pub(crate) fn handle_write_nspawn_flags(
isolation,
load_credentials,
&hive_facts,
ephemeral,
userns,
StartMode {
ephemeral,
userns,
nix_builds,
},
)?;
Ok((String::new(), String::new()))
}
@ -79,6 +84,7 @@ pub(crate) async fn handle_write_nspawn_flags_blocking(
load_credentials: Vec<CredentialMount>,
hive_facts: HiveFacts,
private_users: Option<bool>,
nix_builds: Option<NixBuilds>,
) -> Result<(String, String)> {
tokio::task::spawn_blocking(move || {
handle_write_nspawn_flags(
@ -88,6 +94,7 @@ pub(crate) async fn handle_write_nspawn_flags_blocking(
&load_credentials,
&hive_facts,
private_users,
nix_builds,
)
})
.await
@ -358,6 +365,18 @@ fn git_overlay_flags(binds: &[BindMount]) -> Vec<String> {
.collect()
}
/// What a start of the container asks of nspawn besides its binds, as
/// [`write_nspawn_flags`] writes it.
#[derive(Clone, Copy)]
struct StartMode {
/// `--ephemeral`, once [`crate::rootfs`] has made the root fit for it.
ephemeral: bool,
/// The container runs in its own user namespace.
userns: bool,
/// `None` keeps the choice the conf's last write made.
nix_builds: Option<NixBuilds>,
}
/// Update `/etc/nixos-containers/<container>.conf`: strip old network vars
/// (`PRIVATE_NETWORK`, `HOST_ADDRESS*`, `LOCAL_ADDRESS*`, `HOST_BRIDGE`),
/// write the current network-isolation settings, then append
@ -370,11 +389,15 @@ fn write_nspawn_flags(
isolation: &NetworkIsolation,
load_credentials: &[CredentialMount],
hive_facts: &str,
ephemeral: bool,
userns: bool,
mode: StartMode,
) -> Result<()> {
use std::fmt::Write as _;
use std::os::unix::fs::MetadataExt as _;
let StartMode {
ephemeral,
userns,
nix_builds,
} = mode;
let path = format!("/etc/nixos-containers/{container}.conf");
let original = std::fs::read_to_string(&path).with_context(|| format!("read {path}"))?;
let lines: Vec<&str> = original
@ -406,7 +429,9 @@ fn write_nspawn_flags(
out.push_str("LOCAL_ADDRESS6=\n");
let _ = writeln!(out, "HOST_BRIDGE={}", iso.bridge);
}
let flags_joined = nspawn_flags(binds, load_credentials, ephemeral, userns).join(" ");
let nix_builds = chosen_nix_builds(nix_builds, &original);
let flags_joined =
nspawn_flags(binds, load_credentials, ephemeral, userns, nix_builds).join(" ");
let _ = writeln!(out, "EXTRA_NSPAWN_FLAGS=\"{flags_joined}\"");
// Keeps the owner and mode `nixos-container` created the file with; this
// helper only rewrites its lines.
@ -457,6 +482,38 @@ fn private_users_line(userns: bool) -> &'static str {
if userns { "PRIVATE_USERS=pick\n" } else { "" }
}
/// An empty tmpfs over `/nix/var/nix/daemon-socket`, the directory nixos-containers
/// always binds the host's nix-daemon socket through, so the container finds no
/// socket to connect to. nspawn mounts its custom mounts sorted by destination
/// and then by kind, and a tmpfs sorts after a bind, so this lands on top of the
/// start script's bind whatever order the flags are in.
const NIX_DAEMON_SHADOW_FLAG: &str = "--tmpfs=/nix/var/nix/daemon-socket";
/// What the previous write of the conf chose: [`NixBuilds::Disabled`] when its
/// `EXTRA_NSPAWN_FLAGS` line carries [`NIX_DAEMON_SHADOW_FLAG`], else
/// [`NixBuilds::Host`], which is also what a conf never written gets.
fn previous_nix_builds(conf: &str) -> NixBuilds {
let shadowed = conf
.lines()
.filter_map(|line| line.trim_start().strip_prefix("EXTRA_NSPAWN_FLAGS="))
.any(|flags| {
flags
.split(|c: char| c.is_whitespace() || c == '"')
.any(|flag| flag == NIX_DAEMON_SHADOW_FLAG)
});
if shadowed {
NixBuilds::Disabled
} else {
NixBuilds::Host
}
}
/// The mode a write uses: the one asked for, else what the conf's last write
/// chose.
fn chosen_nix_builds(requested: Option<NixBuilds>, conf: &str) -> NixBuilds {
requested.unwrap_or_else(|| previous_nix_builds(conf))
}
/// The flags `EXTRA_NSPAWN_FLAGS` carries, in the order nspawn needs them.
///
/// `--ephemeral` runs the container on a snapshot of its root and drops the
@ -464,6 +521,8 @@ fn private_users_line(userns: bool) -> &'static str {
/// the flag is asked for. The git overlays and credentials follow the binds
/// they refer to.
///
/// `nix_builds` set to [`NixBuilds::Disabled`] adds [`NIX_DAEMON_SHADOW_FLAG`].
///
/// `userns` mounts every bind `owneridmap` and idmaps the root instead of
/// chowning it, so a different `pick` range never walks the tree.
fn nspawn_flags(
@ -471,6 +530,7 @@ fn nspawn_flags(
load_credentials: &[CredentialMount],
ephemeral: bool,
userns: bool,
nix_builds: NixBuilds,
) -> Vec<String> {
let mut flags: Vec<String> = ephemeral
.then(|| "--ephemeral".to_owned())
@ -485,6 +545,9 @@ fn nspawn_flags(
format!("{flag}={}:{}{idmap}", b.host_path, b.container_path)
}));
flags.extend(git_overlay_flags(binds));
if nix_builds == NixBuilds::Disabled {
flags.push(NIX_DAEMON_SHADOW_FLAG.to_owned());
}
// Credential forwarding: nspawn loads each host secret into the
// container's credential store under `<name>`; inner units inherit it
// via `LoadCredential=<name>`. Validated (name charset + bind-path
@ -653,8 +716,9 @@ pub(crate) fn write_container_etc_file_in(rootfs: &Path, name: &str, contents: &
#[cfg(test)]
mod tests {
use super::{
BindMount, HiveFacts, NetworkIsolation, git_overlay_flags, handle_write_nspawn_flags,
hive_priv_exe, is_owned_var, limits_dropin_body, nspawn_flags, private_users_line,
BindMount, HiveFacts, NIX_DAEMON_SHADOW_FLAG, NetworkIsolation, NixBuilds,
chosen_nix_builds, git_overlay_flags, handle_write_nspawn_flags, hive_priv_exe,
is_owned_var, limits_dropin_body, nspawn_flags, previous_nix_builds, private_users_line,
publish_file, remove_service_dropin, render_hive_facts, rootfs_check_command,
validate_container_system_name, write_bridge_dns_marker_in, write_resource_limits,
};
@ -1105,10 +1169,10 @@ mod tests {
#[test]
fn ephemeral_is_passed_only_when_asked() {
let binds = [bind("/agents/atlas/work")];
let with = nspawn_flags(&binds, &[], true, false);
let with = nspawn_flags(&binds, &[], true, false, NixBuilds::Host);
assert_eq!(with[0], "--ephemeral", "{with:?}");
assert!(with.iter().any(|f| f.starts_with("--bind-ro=")), "{with:?}");
let without = nspawn_flags(&binds, &[], false, false);
let without = nspawn_flags(&binds, &[], false, false, NixBuilds::Host);
assert!(!without.iter().any(|f| f == "--ephemeral"), "{without:?}");
assert_eq!(without.len() + 1, with.len());
}
@ -1119,7 +1183,7 @@ mod tests {
#[test]
fn binds_are_owneridmap_only_for_a_namespaced_agent() {
let binds = [bind("/knowledge"), bind("/agents/a/config")];
let plain = nspawn_flags(&binds, &[], false, false);
let plain = nspawn_flags(&binds, &[], false, false, NixBuilds::Host);
assert!(
plain.iter().any(|f| f.starts_with("--bind-ro=")),
"{plain:?}"
@ -1130,7 +1194,7 @@ mod tests {
.all(|f| !f.contains("idmap") && !f.contains("ownership")),
"{plain:?}"
);
let userns = nspawn_flags(&binds, &[], false, true);
let userns = nspawn_flags(&binds, &[], false, true, NixBuilds::Host);
assert!(
userns.contains(&"--private-users-ownership=map".to_owned()),
"{userns:?}"
@ -1143,6 +1207,63 @@ mod tests {
);
}
/// `Disabled` puts the tmpfs over the daemon socket directory after the
/// binds; `Host` leaves it out. The binds are the control that the lookup
/// finds what is there, and the flag is the whole of the difference.
#[test]
fn nix_builds_none_shadows_the_daemon_socket_dir() {
let binds = [bind("/agents/atlas/work")];
let host = nspawn_flags(&binds, &[], false, false, NixBuilds::Host);
let none = nspawn_flags(&binds, &[], false, false, NixBuilds::Disabled);
assert!(host.iter().any(|f| f.starts_with("--bind-ro=")), "{host:?}");
assert!(
!host.iter().any(|f| f == NIX_DAEMON_SHADOW_FLAG),
"{host:?}"
);
assert_eq!(
none.last().map(String::as_str),
Some(NIX_DAEMON_SHADOW_FLAG)
);
assert_eq!(none.len(), host.len() + 1);
assert_eq!(NIX_DAEMON_SHADOW_FLAG, "--tmpfs=/nix/var/nix/daemon-socket");
}
/// An unspecified mode keeps what the conf's last write chose, whatever
/// else the line carries; a conf with no such line is a host-builds agent.
#[test]
fn an_unspecified_nix_builds_mode_keeps_the_last_writes_choice() {
let binds = [bind("/knowledge")];
for want in [NixBuilds::Host, NixBuilds::Disabled] {
let flags = nspawn_flags(&binds, &[], true, true, want).join(" ");
let conf = format!("AUTO_START=1\nEXTRA_NSPAWN_FLAGS=\"{flags}\"\n");
assert_eq!(previous_nix_builds(&conf), want, "{conf}");
}
assert_eq!(previous_nix_builds(""), NixBuilds::Host);
// A mode asked for wins over the conf; none asked for keeps it.
let shadowed = format!("EXTRA_NSPAWN_FLAGS=\"{NIX_DAEMON_SHADOW_FLAG}\"\n");
assert_eq!(chosen_nix_builds(None, &shadowed), NixBuilds::Disabled);
assert_eq!(chosen_nix_builds(None, ""), NixBuilds::Host);
assert_eq!(
chosen_nix_builds(Some(NixBuilds::Host), &shadowed),
NixBuilds::Host
);
assert_eq!(
chosen_nix_builds(Some(NixBuilds::Disabled), ""),
NixBuilds::Disabled
);
// The flag has to be a flag of its own, on the flags line.
assert_eq!(
previous_nix_builds(&format!(
"EXTRA_NSPAWN_FLAGS=\"{NIX_DAEMON_SHADOW_FLAG}/x\"\n"
)),
NixBuilds::Host
);
assert_eq!(
previous_nix_builds(&format!("OTHER=\"{NIX_DAEMON_SHADOW_FLAG}\"\n")),
NixBuilds::Host
);
}
/// `PRIVATE_USERS=` is dropped from the conf whatever the agent now is, so
/// leaving the list cannot leave the line behind. `AUTO_START=` is the
/// control: a line that is not ours stays.
@ -1166,7 +1287,8 @@ mod tests {
let facts = HiveFacts::default();
for name in ["hive-forge", "hive-matrix"] {
let errs = [
handle_write_nspawn_flags(name, &[], &isolation, &[], &facts, None).unwrap_err(),
handle_write_nspawn_flags(name, &[], &isolation, &[], &facts, None, None)
.unwrap_err(),
write_resource_limits(name, "4G", "200%", None, None).unwrap_err(),
remove_service_dropin(name).unwrap_err(),
];

View file

@ -414,6 +414,7 @@ async fn exec(
load_credentials,
hive_facts,
private_users,
nix_builds,
} => {
container_config::handle_write_nspawn_flags_blocking(
container,
@ -422,6 +423,7 @@ async fn exec(
load_credentials,
*hive_facts,
private_users,
nix_builds,
)
.await
}

View file

@ -10,6 +10,10 @@ Foundational shared **newtypes** for the hyperhive workspace. A zero-dependency
- **`Permission`** — the set of named permissions an agent may hold, with its
stored `snake_case` names and the MCP and built-in tools each grants.
- **`NixBuilds`** — where an agent's nix builds run (`host` or `none`), as
`services.hyperhive.agent.nixBuilds` sets it. It rides the swarm closure record,
the builder's reply and the `hive-priv` wire; a spelling it doesn't know reads
as `none`.
- **`Ident`** — a validated agent-name newtype. Typing agent-name fields as
`Ident` (rather than bare `String`) gets serde-validated parsing **at the
socket boundary for free**: a malformed name is rejected at deserialize time

View file

@ -1,4 +1,4 @@
//! Foundational shared types for the hyperhive workspace: [`Ident`], [`Permission`],
//! Foundational shared types for the hyperhive workspace: [`Ident`], [`Permission`], [`NixBuilds`],
//! the [`term_msg`] row markers and the [`review`] rule.
//!
//! A zero-dependency (bar `serde`) leaf crate so every wire-type crate
@ -7,8 +7,10 @@
//! and get serde-validated parsing at the socket boundary for free — with
//! no cross-crate coupling and without growing `hive-sh4re`.
mod nix_builds;
mod permission;
pub use nix_builds::NixBuilds;
pub use permission::{Permission, deserialize_known};
pub mod review;

View file

@ -0,0 +1,58 @@
//! How an agent's nix builds run: [`NixBuilds`].
//!
//! Lives in this leaf crate because the same value is read by the swarm
//! builder's reply, the closure record, `hive-c0re` and the `hive-priv` wire.
use serde::{Deserialize, Serialize};
/// Where an agent's nix builds run, as `services.hyperhive.agent.nixBuilds`
/// sets it. Stored by its option spelling.
///
/// A spelling this build does not know reads as [`NixBuilds::Disabled`], so a
/// record written by a newer swarm never fails to decode. An agent that asked
/// for a mode this build lacks therefore gets no builds, never builds on the
/// host daemon outside its limits.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum NixBuilds {
/// Builds go to the host's nix-daemon, which runs them outside the agent
/// container's CPU and memory caps. The default.
#[default]
#[serde(rename = "host")]
Host,
/// The container cannot reach the host's nix-daemon, so it builds nothing.
#[serde(rename = "none", other)]
Disabled,
}
#[cfg(test)]
mod tests {
use super::NixBuilds;
#[test]
fn the_spellings_are_pinned() {
for (value, wire) in [(NixBuilds::Host, "host"), (NixBuilds::Disabled, "none")] {
assert_eq!(serde_json::to_value(value).expect("encodes"), wire);
assert_eq!(
serde_json::from_value::<NixBuilds>(serde_json::json!(wire)).expect("decodes"),
value
);
}
}
#[test]
fn the_default_is_the_host_daemon() {
assert_eq!(NixBuilds::default(), NixBuilds::Host);
}
/// A mode a newer swarm writes decodes, and as the one that builds nothing.
#[test]
fn an_unknown_spelling_reads_as_disabled() {
for unknown in ["container", "", "HOST"] {
assert_eq!(
serde_json::from_value::<NixBuilds>(serde_json::json!(unknown)).expect("decodes"),
NixBuilds::Disabled,
"{unknown:?}"
);
}
}
}

View file

@ -135,6 +135,32 @@ in
'';
};
options.services.hyperhive.agent.nixBuilds = lib.mkOption {
type = lib.types.enum [
"host"
"none"
];
default = "host";
example = "none";
description = ''
Where this agent's nix builds run.
- `host` (default): builds go through the host's nix-daemon socket,
which every agent container gets bound in by default. The daemon runs
them outside this container, so the container's CPU and memory caps do
not apply to a build.
- `none`: the container cannot reach the host's nix-daemon, so
`nix build`, `nix run`, `nix shell` and the like fail for anything not
already in the store, with a "cannot connect to socket" error. The
agent's own system closure stays readable.
The swarm builder reads the option from the evaluated config into the
agent's closure record, and the hive applies it when it next starts the
container, so a running agent keeps its mode until restarted. A host
that does not know the option leaves the agent on `host`.
'';
};
config = {
assertions = [
# Guard the inputs-routed-as-output pattern: the agent flake.nix is
@ -235,6 +261,10 @@ in
# Non-root nix clients already default to the daemon socket, so this
# is a no-op for them; it only matters for root services that would
# otherwise silently use the local store.
#
# Stays set under `nixBuilds = "none"`: the missing socket then fails a
# build with an error that names it, where the local store would fail on a
# read-only filesystem.
systemd.globalEnvironment.NIX_REMOTE = "daemon";
# `claude-code` is unfree. Each per-agent container's nixosConfiguration

View file

@ -22,11 +22,20 @@ let
;
# A named agent, so the rendered script carries a name no default supplies.
migrate =
(agentWith { services.hyperhive.agent.user.name = "iris"; })
.system.activationScripts.hive-agent-user-migrate.text;
iris = agentWith { services.hyperhive.agent.user.name = "iris"; };
migrate = iris.system.activationScripts.hive-agent-user-migrate.text;
# The tmpfiles rules of the same named agent.
rules = (agentWith { services.hyperhive.agent.user.name = "iris"; }).systemd.tmpfiles.rules;
rules = iris.systemd.tmpfiles.rules;
# The same agent with its nix builds switched off, and one asking for a mode
# the option does not have.
noBuilds = agentWith {
services.hyperhive.agent.user.name = "iris";
services.hyperhive.agent.nixBuilds = "none";
};
unknownBuilds = agentWith {
services.hyperhive.agent.user.name = "iris";
services.hyperhive.agent.nixBuilds = "container";
};
# `privateUsers` with the given pins; the agent name is the one the cases
# above use, so only the pins differ.
withUsers =
@ -113,6 +122,18 @@ let
&& lib.elem "L /home/iris/.local/state/nix - - - - /nix/var/nix/profiles/per-user/iris" rules
&& lib.elem "L /home/iris/.nix-profile - - - - /home/iris/.local/state/nix/profiles/profile" rules;
}
{
# The option defaults to host builds, accepts `none`, and refuses a mode
# it does not declare. The default and `none` arms are each other's
# control; `none` leaves the in-container routing alone, because the
# error an agent gets then comes from the missing socket.
name = "nixBuilds defaults to host, takes none and refuses a mode it does not declare";
ok =
iris.services.hyperhive.agent.nixBuilds == "host"
&& noBuilds.services.hyperhive.agent.nixBuilds == "none"
&& noBuilds.systemd.globalEnvironment.NIX_REMOTE == "daemon"
&& !(builtins.tryEval unknownBuilds.services.hyperhive.agent.nixBuilds).success;
}
];
in
runGroup "agent-user" cases

View file

@ -16,15 +16,15 @@
# - a link planted at `root` in the template refuses the next start through
# the limits drop-in's `ExecStartPre=` check (whose build has a GC root),
# leaves the link's target untouched, and the start goes through once the
# link is gone.
#
# link is gone;
# - an agent whose config sets `services.hyperhive.agent.user.privateUsers`
# (hive-c0re sends the flag from the closure record; the test sends it)
# starts in its own
# user namespace with state it already had, owned by uid 1000 on disk: the
# data moves once to `dataOwner`, the agent user reads and writes it as
# itself, the other agent's conf (no flag sent) is untouched, and a second
# `WriteNspawnFlags` moves nothing.
# starts in its own user namespace with state it already had, owned by uid
# 1000 on disk: the data moves once to `dataOwner`, the agent user reads and
# writes it as itself, the other agent's conf (no flag sent) is untouched,
# and a second `WriteNspawnFlags` moves nothing;
# - an agent set to `services.hyperhive.agent.nixBuilds = "none"` has no
# nix-daemon socket and cannot build; a request naming no mode keeps that.
#
# The agent's journal forwarder itself (an OTLP push to the hive's collector) is
# not booted here; the module-eval suite covers its wiring.
@ -277,6 +277,9 @@ pkgs.testers.runNixOSTest {
assert host.succeed(f"cat {WORK}/kept.txt").strip() == "kept"
as_agent("hello")
as_agent("tree --version")
# The default mode: the host daemon's socket is there and answers.
assert "socket" in in_ct("ls /nix/var/nix/daemon-socket").split()
as_agent(f"nix-store --store daemon -q --hash {HELLO}")
with subtest("the switch ran once"):
salvages = [d for d in salvage_dirs() if d.startswith("salvaged-rootfs-")]
@ -335,10 +338,14 @@ pkgs.testers.runNixOSTest {
"binds": NS_BINDS,
"isolation": {"bridge": "${bridge}", "gateway_ip": "${gateway}"},
"private_users": True,
"nix_builds": "none",
}
priv_request(host, write)
ns_conf = host.succeed(f"cat /etc/nixos-containers/{NS}.conf")
assert "PRIVATE_USERS=pick" in ns_conf, ns_conf
assert "--tmpfs=/nix/var/nix/daemon-socket" in ns_conf, ns_conf
# The other agent's conf has no such flag: the control for the line above.
assert "daemon-socket" not in conf(), conf()
assert "--private-users-ownership=map" in ns_conf, ns_conf
# Every bind, the read-only config included, is mounted owneridmap.
assert ns_conf.count(":owneridmap") == len(NS_BINDS), ns_conf
@ -379,12 +386,27 @@ pkgs.testers.runNixOSTest {
host.succeed("stat -c %u:%g ${nsSocketDir}/web.sock | grep -x ${dataOwner}:${dataOwner}")
host.succeed(f"nixos-container run {NS} -- cat /proc/self/uid_map | grep -v ' 4294967295$'")
def no_daemon():
# The start script's bind is under the tmpfs: no socket in the
# directory, and a build cannot connect. The store path is still
# readable, and the first agent's directory (checked above) has the
# socket where this one has none.
host.fail(f"nixos-container run {NS} -- test -S /nix/var/nix/daemon-socket/socket")
assert as_ns("ls -A /nix/var/nix/daemon-socket") == ""
host.fail(f"nixos-container run {NS} -- runuser -l {NS_AGENT} -c 'nix-store --store daemon -q --hash {HELLO}'")
as_ns(f"test -r {HELLO}/bin/hello")
no_daemon()
# The move runs once: a file a host process leaves owned by 1000 after it
# is not chowned by the next request, and the agent restarts.
host.succeed(f"nixos-container stop {NS}")
host.succeed(f"echo late > {NS_DIR}/state/late.txt && chown 1000:1000 {NS_DIR}/state/late.txt")
priv_request(host, write)
# A request that names no nix-builds mode, as one with no readable
# record is, keeps the choice the last write made.
priv_request(host, {k: v for k, v in write.items() if k != "nix_builds"})
host.succeed(f"stat -c %u:%g {NS_DIR}/state/late.txt | grep -x 1000:1000")
assert "--tmpfs=/nix/var/nix/daemon-socket" in host.succeed(f"cat /etc/nixos-containers/{NS}.conf")
boot_ns()
no_daemon()
'';
}

View file

@ -14,6 +14,8 @@ async-nats.workspace = true
# StreamExt::next on the subscription.
futures-util.workspace = true
hive-log.workspace = true
# `NixBuilds`, the type of the reply's `nix_builds`.
hive-types.workspace = true
# The build metrics, exported over OTLP like the review classifier's.
opentelemetry.workspace = true
opentelemetry-otlp.workspace = true

Some files were not shown because too many files have changed in this diff Show more