Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
8.2 KiB
REPORT — TASK D1: agent self-update (operator-signed, A/B slots, crash-loop auto-rollback)
Date: 2026-07-05 · Class: implementation (code + host artifacts + tests + deploy + live drills)
Baselines (reconfirmed): felhom-agent main @ 72d7f05 (v0.69.0) → v0.70.0; felhom.eu @ 582917d.
Not behind origin. Design provenance: felhom.eu/documentation/audits/SPIKE-agent-selfupdate-2026-07-05.md.
Shipped + live-validated on felhom-pve (demo host only; Peti's box untouched).
Commits
| Repo | Hash | What |
|---|---|---|
| felhom-agent | b7cbded |
P1 host artifacts (wrapper, rollback unit, [Unit] limits drop-in, sudoers alias) |
| felhom-agent | 8033a52 |
P2 Go plumbing (ClassAgentUpdate, opsign, executor, commit-manager, report field, wiring) |
| felhom-agent | 915642a |
P3 docs (README self-update, REUSE, CHANGELOG v0.70.0, CONTEXT) |
| felhom.eu | b6bad95 |
P3 host-install day-0 install of the artifacts + architecture §11 (implemented) |
What shipped
- Trust model: an operator-signed
agent_updateop (reconcile.ClassAgentUpdate, always Destructive) through the signed-jobs pipeline; params pin version + sha256 → the sha is the ONLY integrity root (hub = dumb transport, Gitea = dumb storage).felhom-opsign -op agent_update. - Host artifacts (
configs/):felhom-selfupdate-guarded(apply/commit/rollback — sha re-verify as root before.prev, path confinement, same-fs assert, atomic mv, pending marker, detached restart; rollback pending-guarded);felhom-agent-rollback.service;felhom-agent-limits.conf([Unit]-only,StartLimitIntervalSec=120+Burst=4+OnFailure=);FELHOM_SELFUPDATEsudoers. - Go (
internal/selfupdate/):Executor(download→verify-vs-signed-sha→wrapper apply; job completed after verify+download, before apply) +Manager(startup dwell→commit; version-mismatch no-commit; report seam). Wired as the 3rd executor-chain element + aMaybeCommitgoroutine after core init. ConfigSelfUpdateConfig; additive reportselfupdate_pending; 3 capability probes. - felhom.eu:
felhom-host-install.shinstalls wrapper+rollback-unit+drop-in on day-0.
Tests & companion red-proofs
Full go build ./... && go vet ./... && go test ./... green; shellcheck clean on the wrapper;
visudo -cf on the full sudoers.
- Executor (Group A): happy-path (staged sha asserted, apply args exact, job completed before
apply); C2 sha-mismatch refused + its companion (neutering the Go verify → the bad binary
reaches the apply call →
TestExecutor_ShaMismatchRefusedfails; verified, reverted); bad-params; wrapper-failure surfacing. - Gate ride-along (Group B):
agent_updaterides the REAL authz.Verifier + reconcile.Gate — pinned-key executes, non-pinned + retarget rejected. Backing companion:TestClassify_AgentUpdateAlwaysDestructive(if it flipped Benign the unsigned op would execute). - Commit (Group C): dwell→commit; version-mismatch → no commit + WARN + marker left; no-pending
no-op; shutdown-before-dwell leaves pending. Opsign (Group D):
isHex64. All packages green.
Live validation (felhom-pve; the full agent-side pipeline ran for real)
Build v0.70.0 on 180 (sha c5eb84cc…), published to Gitea (anon-pullable, round-trip sha OK), and
manually deployed (the last manual agent deploy). Host artifacts installed; systemctl show confirmed
StartLimitIntervalUSec=2min StartLimitBurst=4; capabilities 56/56 degraded=0 (the 3 selfupdate
probes pass). A scratch operator key was generated + pinned and a selfupdate config block added for
the drill.
Delivery method note (stated per the live-validation rule): the hub's enqueue endpoint needs a
global operator key CC does not hold, so the operator-signed envelope (produced by the real
felhom-opsign over the pinned key) was injected directly into the hub's signed_jobs queue —
exactly what Store.EnqueueSignedJob does. Everything downstream is the real, unmodified pipeline:
the agent polls the hub, the real gate verifies the SSHSIG, the real executor downloads+verifies, the
real wrapper flips, the real commit-manager commits. Only the hub-side enqueue-auth (unit-tested
hub-side) was bypassed. Pickup was triggered by an agent restart (the loop reports immediately on
start → envelope has_signed_ops → RunOnce).
- Happy path (Scenario A) — PASS. Signed
agent_update{0.70.1, sha 908700ab…}→ gateallowed=true reason=signed key_id=d1-drill-op→ executor downloaded + verified → wrapperapplied 0.70.1 (prev 0.70.0)+ detached restart → 0.70.1 running,.prev=0.70.0 (shac5eb84cc), pending marker written, job COMPLETED on the queue. After the 20s dwell:selfupdate: update committed, marker cleared,.prevretained, hub queue empty. - Crash-loop auto-rollback (Scenario B) — PASS (the safety property). Signed
agent_update{0.70.2-crash, sha c358d7bc…}(a throwawayos.Exit(1)build, never committed) → applied → crash-to-recovered ≈ 2 s:15:49:50 apply→15:49:52 Main process exited status=1/FAILURE→Triggering OnFailure=→rolled back to previous binary and restarted. Live binary byte-identical to published 0.70.1 (908700ab…); pending cleared; hub queue cleared; stable, no crash loop (NRestarts=0, active). (Two near-simultaneous OnFailure fires raced; both restored the same.prevbytes — idempotent, correct.) - No-pending guard (C4) — PASS.
systemctl start felhom-agent-rollback.servicewith no pending → wrapperno pending update — no-op; MainPID unchanged, binary untouched. - Gate refusal (C1) — PASS.
agent_updatesigned by a NON-pinned key, injected + triggered →REJECTED signed op — executor not called … err="authz: signer not in allowed set"; nothing downloaded; version unchanged; job cleared.
Cleanup + final state
- 0.70.2-crash and 0.70.1 deleted from Gitea (never leave a crashing artifact published); 0.70.0 retained (the release).
- Box restored: v0.70.0 active/running, NRestarts=0, capabilities 56/56 degraded=0; original
agent.jsonrestored (scratch operator key + drill selfupdate block removed — no scratch key left pinned); staging dir empty;.prev/nonces cleared. Host artifacts kept installed (the shipped feature): wrapper + rollback unit + [Unit] drop-in (3/3). Hubsigned_jobsqueue empty. - Drill scratch removed from 180 and the hub pod.
felhom-agent.bak-0.69.0retained on the box (the rollback safety net, expected). - Self-inflicted incident during cleanup (recorded honestly): restoring
agent.jsonviamvof a root-owned backup left it root-owned → the non-root agent gotpermission denied→ 4 crashes → the start-limitfailedstate. Fixed withchown felhom-agent:felhom-agent+chmod 0600+reset-failed+ start. Not a product defect (a cleanup ownership slip); it did incidentally demonstrate the OnFailure path no-op'ing on a pending-less crash and thereset-failedrequirement ([SF-4]) live.
Observations (not acted on — v1 scope-outs / follow-ups)
- Operator-key provisioning is an operator follow-up (parallel to the Day-0 vouch): the demo box
was returned to its pre-drill config with NO signer pinned, so self-update is installed-but-dormant
until an operator pins their real operational key + (optionally) a
selfupdateconfig block. The default URL template + anon Gitea pull mean no creds are needed for the download. - Per-crash OnFailure can double-fire (spike [SF-1], seen live): two rollback invocations raced,
both idempotent. Harmless, but a future hardening could serialize the rollback oneshot
(a flock in the wrapper, or
StartLimitIntervalSecon the rollback unit) to make it single-shot. - v1 scope-outs (locked, §8): no hub-floor-driven auto-update, no failed-update auto-retry (the
operator re-signs with a fresh nonce), no pending-timeout auto-rollback (a runs-but-never-commits
binary is caught by the hub's
host_staleness+ theselfupdate_pendingreport flag). - Hub enqueue-auth was not exercised end-to-end from CC (no global operator key available); it is
covered by the hub-side unit tests (
desired_test.go— enqueue requires the global key).