Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,17 @@ jobs:
- name: Check connect card checking state
run: nix build -L .#checks.x86_64-linux.connect-card && cat result

# Candidate releases: the validator between an agent's sudo grant and
# what root builds, plus agent-box-source's half (the marker it
# consumes, and the one narrow case where the fast-forward guard
# relaxes). Both are seconds, and there is no VM test that can price
# a refusal.
- name: Check candidate release validator
run: nix build -L .#checks.x86_64-linux.candidate && cat result

- name: Check source tree manager
run: nix build -L .#checks.x86_64-linux.source-tree && cat result

# Issue #374: the native backend's two flake checks, which nothing
# ran until that bug was found by hand. `runtime-profile` builds
# packages.<system>.runtime — the profile a native box installs at
Expand Down
76 changes: 75 additions & 1 deletion bin/agentbox
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,18 @@ UPDATE_TRIGGER = f"{SYSTEMCTL} start --no-block {UPDATE_UNIT}"
# daemon's HTTP answer is already on the wire when systemd starts stopping
# units, this daemon among them.
REBOOT_TRIGGER = f"{SYSTEMCTL} reboot --no-block"
# Candidate releases (this box before the fleet). The wrapper is rendered
# rather than shipped as a profile payload because it runs under sudo, whose
# env_reset means it can carry no environment: its repo, tree and trigger are
# baked in by the renderer, so a caller cannot point root at another repo.
# Spelled once for the same #353 reason as the three above — the sudoers line
# and the path the guide tells the agent to run must agree byte for byte.
CANDIDATE_HELPER = "/etc/agent-box/bin/agent-box-candidate"
CANDIDATE_TRIGGER = f"{CANDIDATE_HELPER} *"
# Where that wrapper queues the branch it wants, and where agent-box-source
# consumes it. One update reads it and deletes it: a candidate lasts until
# the next update and never pins the box.
CANDIDATE_FILE = "/var/lib/agent-box/candidate"
# Store housekeeping (issue #394). A full root wedges the whole box —
# no journal, no profile swap, and an agent that cannot write — and on a
# native box nobody is around to run `nix-collect-garbage` by hand. The
Expand Down Expand Up @@ -1047,6 +1059,10 @@ class Renderer:
self.memory_protection(t)
else:
self.memory_protection_off(t)
# Before the web branch, and outside it: the sudo grant for this is
# box-wide (implied_sudo_commands), so the wrapper must exist on a
# web-disabled box too.
self.candidate_helper(t)
if self.spec.web_enable:
self.caddy(t)
self.fail2ban(t)
Expand Down Expand Up @@ -2628,6 +2644,15 @@ class Renderer:
# per-user password grant above exists to avoid.
if self.spec.reboot_button and u is self.spec.root_user:
cmds.append(REBOOT_TRIGGER)
# Installing a candidate release goes to the ROOT user alone,
# for the same reason the reboot does: it takes every user's
# sessions down with it, and one user's power over another's
# work is what the per-user grants exist to avoid. `*` allows
# exactly one argument of anything, which is all the wrapper
# accepts — and the wrapper, not sudoers, is what decides
# whether that argument names an installable branch.
if u is self.spec.root_user:
cmds.append(CANDIDATE_TRIGGER)
cmds += self.spec.sudo_allowlist
lines.append(f"{u.name} ALL=(root) NOPASSWD: " + ", ".join(cmds))
t.file(self.p(SUDOERS_DIR, "agent-box"),
Expand Down Expand Up @@ -2737,6 +2762,54 @@ class Renderer:
+ '[credential "https://gist.github.com"]\n'
+ f"\thelper = !{gh} auth git-credential\n")

def candidate_helper(self, t):
"""agent-box-candidate: install a branch on THIS box before it is
merged and the fleet takes it (see modules/src/candidate.sh for what
it refuses).

Called UNCONDITIONALLY, unlike the password helper it renders beside
on a web box. sudoers() grants CANDIDATE_TRIGGER from
implied_sudo_commands(), which is not gated on web.enable either --
native has no selfUpdate.enable to gate the update trigger on, so
every native box has both. Rendering this from caddy() instead left a
web-disabled box granting a command that was not on disk: sudo would
find the rule, exec nothing, and the agent would see a bare "No such
file or directory" with no hint that the box was built without it.
Same shape as the web-gated paths #198 leaked into the ungated agent
unit, which is what tests/memory-protection.nix exists to catch.

Constants are PREPENDED, not appended: this is shell, where the body's
`case` runs the moment it is reached, so a tail would assign after the
code that reads it. They are compiled in for the same sudo reason as
the password helper -- env_reset means the wrapper carries no
environment, and a caller-supplied repo URL would be root building
code from anywhere.
"""
candidate_src = self.profile / "libexec" / "agent-box" / \
"candidate.sh"
if candidate_src.is_file():
# spec.repo, which prefers the PROFILE's manifest over the
# config key: the manifest cannot disagree with what is
# installed, and this wrapper must fetch from the repo the box
# actually came from. No branch key exists natively — an empty
# SRC_BRANCH is what tells the wrapper and agent-box-source to
# follow the remote's own default, rather than guessing
# "master" at a repo that may have renamed it.
url = f"https://github.com/{self.spec.repo}.git"
head = (
"# Box constants, prepended by `agentbox apply`.\n"
f"CANDIDATE_FILE={shlex.quote(CANDIDATE_FILE)}\n"
f"SRC_DIR={shlex.quote(SRC_DIR)}\n"
f"SRC_URL={shlex.quote(url)}\n"
"SRC_BRANCH=''\n"
f"SYSTEMCTL={shlex.quote(SYSTEMCTL)}\n"
f"UPDATE_TRIGGER={shlex.quote(UPDATE_TRIGGER)}\n"
"\n"
)
t.file(self.p(CANDIDATE_HELPER),
"#!/bin/sh\n" + head + candidate_src.read_text(),
0o755)

def interactive_clis(self, t):
"""agent-box-session and agent-box-profile, on every box.

Expand Down Expand Up @@ -3840,7 +3913,8 @@ def source_tree(profile, verb, *rest, src=SRC_DIR, url=None, rev=None,
the two backends cannot drift on what "fast-forward the box" means —
only on what they do with the rev it returns.
"""
env = dict(os.environ, AGENT_BOX_SRC_DIR=src)
env = dict(os.environ, AGENT_BOX_SRC_DIR=src,
AGENT_BOX_CANDIDATE_FILE=CANDIDATE_FILE)
if url:
env["AGENT_BOX_SRC_URL"] = url
if rev:
Expand Down
20 changes: 20 additions & 0 deletions flake.nix
Original file line number Diff line number Diff line change
Expand Up @@ -1526,6 +1526,26 @@ open(sys.argv[3], "w").write(header + yaml.safe_dump(data, sort_keys=True))' \
cp log "$out"
'';

# agent-box-candidate (this box before the fleet): the validator
# standing between an agent's sudo grant and what root builds.
# Same shape and same reasoning as source-tree above -- weighted
# at the refusals, `origin` a local repository so there is no
# network, and natively runnable on every architecture.
candidate =
pkgs.runCommand "agent-box-candidate-check"
{
nativeBuildInputs = [ pkgs.bash pkgs.coreutils pkgs.git pkgs.gnugrep pkgs.gnused ];
script = ./modules/src/candidate.sh;
tests = ./tests/test-candidate.sh;
} ''
bash "$tests" "$script" > log 2>&1 || {
cat log
exit 1
}
cat log
cp log "$out"
'';

# Issue #425: a box with no webhook panel used to render an
# empty string, so its operator could not tell a feature that is
# off from one that is wired up wrong — which is how #425 was
Expand Down
Loading