From 29c4a0840be450f898d7367e16aa5fb7a991dfb0 Mon Sep 17 00:00:00 2001 From: claude-bot-andresmgsl Date: Sat, 25 Jul 2026 18:54:27 +0000 Subject: [PATCH] fix: arm cron on agent tenant boxes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent tenants exist to run the cron-driven duty engine, and the engine's installer runs unprivileged — it can detect a missing cron but never apt-get it. Install cron with the shared toolbelt, then assert the effective state, not the package: crontab on PATH AND cron.service enabled and active, converging (unmask/enable/start) best-effort first. A masked daemon with the binary present is exactly the silent-inert box the issue reports, so the service asserts are the authority and either failing dies naming cron. staging-box stays exempt with the rest of the agent-assert block: no agent, no duty engine. Machine roles (bootstrap.sh) are deliberately not widened — the issue names tenants as the demonstrated gap. Closes #162 Co-Authored-By: Claude Fable 5 --- changelog.d/162.md | 3 +++ commands/bootstrap-tenant.sh | 23 ++++++++++++++++++++++- test/cli.sh | 12 ++++++++++++ 3 files changed, 37 insertions(+), 1 deletion(-) create mode 100644 changelog.d/162.md diff --git a/changelog.d/162.md b/changelog.d/162.md new file mode 100644 index 0000000..e57e3ae --- /dev/null +++ b/changelog.d/162.md @@ -0,0 +1,3 @@ +### Fixed + +- Agent tenant boxes ship cron — binary asserted, service enabled and active — so the duty engine can arm its timer (#162) diff --git a/commands/bootstrap-tenant.sh b/commands/bootstrap-tenant.sh index 1eb9d12..23a160d 100755 --- a/commands/bootstrap-tenant.sh +++ b/commands/bootstrap-tenant.sh @@ -246,8 +246,11 @@ else # The shared agent toolbelt the templates carried, plus the definition's # APT_EXTRAS (claude-box's zsh rides there). Unquoted on purpose — it is a # word list, every word already vetted by the parser's package-name gate. + # cron is toolbelt, not a template flavour: every agent tenant exists to + # run the cron-driven duty engine, whose unprivileged installer can detect + # a missing cron but never apt-get it (#162). # shellcheck disable=SC2086 - apt-get install -y -qq git gh curl ca-certificates gnupg ripgrep jq tmux age unzip build-essential $TPL_APT_EXTRAS + apt-get install -y -qq git gh curl ca-certificates gnupg ripgrep jq tmux age unzip build-essential cron $TPL_APT_EXTRAS fi # Assert the effective toolbelt, not apt's exit code — tmux is the box#65 # contract ('box tmux' runs tmux new-session inside every box) and gh is how @@ -256,6 +259,24 @@ command -v tmux >/dev/null 2>&1 || die "tmux missing after package install — ' if [ "$ROLE" != "staging-box" ]; then command -v gh >/dev/null 2>&1 || die "gh missing after package install" command -v git >/dev/null 2>&1 || die "git missing after package install" + command -v crontab >/dev/null 2>&1 || die "crontab missing after package install — the duty engine arms itself with cron (#162)" + # The binary on PATH is not the effective state — an image can ship crontab + # with cron.service masked or stopped, and an unarmed timer is exactly the + # silent-inert box #162 is about. Converge best-effort, then assert what + # systemd actually reports; the assert is the authority. Enabling an + # already-enabled unit is a no-op and no path here touches any crontab. + # staging-box is exempt with the rest of this block: no agent, no engine. + if ! systemctl is-enabled cron >/dev/null 2>&1; then + systemctl unmask cron >/dev/null 2>&1 || true + systemctl enable cron >/dev/null 2>&1 || true + log "enabled cron.service" + fi + if ! systemctl is-active cron >/dev/null 2>&1; then + systemctl start cron >/dev/null 2>&1 || true + log "started cron.service" + fi + systemctl is-enabled cron >/dev/null 2>&1 || die "cron.service is not enabled after converge — the duty engine's timer never fires without it (#162)" + systemctl is-active cron >/dev/null 2>&1 || die "cron.service is not active after converge — the duty engine's timer never fires without it (#162)" fi # --- docker ------------------------------------------------------------------ diff --git a/test/cli.sh b/test/cli.sh index d0b6af8..48cf3da 100644 --- a/test/cli.sh +++ b/test/cli.sh @@ -655,6 +655,18 @@ check "tenant: a crafted role name dies at the charset gate" 2 "invalid tenant r "$ROOT/commands/bootstrap-tenant.sh" 'UPPER-box' check "tenant: dockerd effective-state assert is present" 0 "" \ grep -qF "docker info" "$ROOT/commands/bootstrap-tenant.sh" +# The #162 contract, both halves: cron installs with the agent toolbelt (the +# duty engine's unprivileged installer can never apt-get it), and PATH is not +# the effective state — the service must be asserted enabled AND active, or a +# masked daemon leaves every tenant crontab silently inert. +check "tenant: cron rides the agent toolbelt install" 0 "" \ + grep -qE '^ *apt-get install .* cron ' "$ROOT/commands/bootstrap-tenant.sh" +check "tenant: crontab toolbelt assert is present" 0 "" \ + grep -qF "command -v crontab" "$ROOT/commands/bootstrap-tenant.sh" +check "tenant: cron.service enabled assert is present" 0 "" \ + grep -qF "systemctl is-enabled cron" "$ROOT/commands/bootstrap-tenant.sh" +check "tenant: cron.service active assert is present" 0 "" \ + grep -qF "systemctl is-active cron" "$ROOT/commands/bootstrap-tenant.sh" # The machine-role traits die with the tenant story, never "unknown flag" — an # operator coming from the machine families needs the boundary, not a shrug. check "tenant: trait flags die with the tenant story" 2 "have no traits" \