NO VERSION BUMP and nothing built: no Go code changed. The agent stays v0.120.0. scripts/release-agent.sh — THE way to release. build -> tag -> publish -> verify by INDEPENDENT download. Publishing was a separate remembered step and was forgotten three times in five days (R-111's 17 stranded releases, 0.114.0, and 0.120.0 — deployed to both demo hosts and undownloadable, so a documented-path reinstall would have silently downgraded them WHILE REPORTING SUCCESS). R-111's own closing line named this leg and closed SHIPPED without it; it recurred the same afternoon, which is the evidence that a note is not a mechanism. It tags because felhom-host-install.sh now fetches the sixteen agent config files from raw/tag/v<version>/ (R-183): a released version with no tag 404s a box mid-install, as root, on a virgin machine. It verifies by downloading what it just published and comparing the sha to what it built — the publish step's own success is a report on its own write; a fetch returning the right bytes is a different claim. It refuses a dirty/unpushed tree and refuses to re-release an existing version. It does NOT vouch: that points machines at a version and stays the operator's act. scripts/check-published-versions.py — the gate. Every v<semver> tag must have a downloadable package AND a tag tree serving the agent's configs. Registered as NOT --fast (needs network; a push must not fail because Gitea blinked), and the CI workflow now runs the FULL gate set instead of --fast — otherwise the gate would have been registered and never run, the built-but-never-wired failure this project has shipped four times. The invariant is not the one specified, and the reason was measured, not assumed: the hub artifact manifest is 401 without a per-customer passphrase and Gitea's package LISTING api is 401 without a token, while the package DOWNLOAD url and the git TAGS api are anonymous. So CI cannot ask "what is vouched" without an operator credential — whose addition is the operator's call. The tag-based invariant needs none and catches all three recorded instances. What it does not catch (the hub vouching a version never released at all) is filed as R-184.
13 KiB
CLAUDE.md — felhom-agent
Loads when Claude Code touches this repo. Stable orientation only — current state lives in
CONTEXT.mdand the top ofCHANGELOG.md, never here. Cross-repo orientation: workspace-root/mnt/5_hdd/felhom.eu/git/CLAUDE.md.
What this repo is
felhom-agent is the operator-tier host agent that runs on each Proxmox host and owns all
Proxmox interaction: provision/restore guests, host storage, backup/restore orchestration, the hub
control loop, and a narrow per-guest local API. It is the most privilege-sensitive component.
- Renamed former
proxmox-controllerrepo. - Distinct from
felhom-controller— that is the in-guest controller (Docker-only, no Proxmox creds). Do not confuse them. - Control plane, not data plane: if the agent dies, apps keep serving; only management degrades.
Read before writing code
REUSE.md— canonical helpers, format-safety guards, traps, seams. Check it first; update it in the same commit that changes a shared helper or pattern.CONTEXT.md(current state + open threads) and the topCHANGELOG.mdentry (authoritative history).- Design doc:
felhom.eu/documentation/architecture/03-host-agent.md(locked). Platform facts:felhom.eu/documentation/proxmox-platform.md+tests/phase{0,1-2,3,4}-findings.md.
Layout (verified against the tree)
cmd/felhom-agent/ main + flags + --selftest modes + the daemon entry
cmd/felhom-opsign/ offline operator signing CLI (SSHSIG)
internal/authz/ operator signed-op verifier (SSHSIG) + durable FileNonceStore
internal/backup/ vzdump backup runner + restore-test scheduler + report store
internal/capability/ live sudo-policy capability probe (degradation visibility)
internal/config/ JSON config + FELHOM_AGENT_* env overlay; secrets redacted (Redacted())
internal/desired/ hub desired-state syncer (envelope observer)
internal/escrow/ PBS-key escrow (zero-knowledge recovery code)
internal/guesthook/ pre-start self-heal hookscript install
internal/hub/ daemon: HostReport collector + Bearer client + resilient Loop
internal/lanresolver/ split-horizon DNS on guest IP change (dnsmasq RESTART, not reload)
internal/localapi/ per-guest local API: token store, disks/format, guest binds, controller swap,
stale-lock recovery, pinned self-signed leaf
internal/log/ slog setup
internal/pbs/ PBS-API client (fingerprint-pinned) + verify maintenance loop
internal/provision/ guest bootstrap back-half (token mint → bootstrap.json → pct bind)
internal/proxmox/ API-first Client + fenced root-CLI Privileged + UPID WaitTask
internal/reconcile/ reconcile engine + reversibility gate + op journal + crash recovery
internal/signedjobs/ operator-signed destructive executors (wipe, decommission)
internal/storage/ storage observer + durable ids + role/claim classifiers + SudoHostOps + watchdog
Build / run
- Module
gitea.dooplex.hu/admin/felhom-agent; binaryfelhom-agent(cmd/felhom-agent/). - Pure Go stdlib +
golang.org/x/cryptoonly — no web frameworks.go.moddirective go 1.25.0; DooPlex (192.168.0.180, where CC runs) has the Go toolchain and is on the same LAN as the demo host — build and run live tests locally. - Version via
-ldflags "-X main.version=<v>";--versionflag. Bump on meaningful changes + CHANGELOG entry. - Full build/deploy/publish runbook: use the
felhom-build-deployskill. Summary:
Clean-tree gate before any build:
git status --porcelainmust be empty andgit rev-parse HEADmust equalgit rev-parse origin/mainin the repo being built. An unpushed change does not exist — never build a dirty or unpushed tree. Thegit pullin the build step stays (it is a no-op when you work in this tree, and load-bearing if anything was pushed from elsewhere).
RELEASING IS ONE COMMAND, AND IT PUBLISHES (R-115). There used to be a raw
go buildline here and a separate "Publish" row, so publishing was a step someone had to remember — and it was forgotten three times in five days, the last leaving agent v0.120.0 deployed on both demo hosts and undownloadable, where a documented-path reinstall would have silently downgraded them while reporting success. Do not hand-roll the build: the script also creates thev<version>git TAG thatfelhom-host-install.shfetches this version's sixteen config files from (R-183), and it verifies by an independent download rather than trusting the publish step's own output.scripts/publish-agent.shstill exists and is still correct — the release script CALLS it rather than reimplementing it.
| Step | Where | One-liner |
|---|---|---|
| Release (build + tag + publish + verify) | DooPlex (local) | GITEA_USER=admin GITEA_TOKEN=<tok> scripts/release-agent.sh <ver> — refuses a dirty/unpushed tree and refuses to re-release an existing version |
| Copy | local → felhom-pve | scp /tmp/felhom-agent-<v> felhom-pve:/tmp/ (one hop) |
| Deploy | felhom-pve | backup .bak-<old> → install -m0755 → systemctl restart felhom-agent (non-root felhom-agent user, config /etc/felhom-agent/agent.json) |
| Ship configs | felhom-pve | sudoers (/etc/sudoers.d/felhom-agent) + guarded-mkfs wrapper WITH the binary when configs/ changed |
| Vouch | hub operator UI | Configs → Day-0 artifacts. Deliberately NOT automated — vouching is what points machines at a version, and it stays your act (prove-then-vouch) |
| Verify | felhom-pve | felhom-agent --version + journal (clean ReassertGuestBinds, no capability degradation) |
Proxmox model (the load-bearing rules)
- API-first via a scoped
FelhomAgenttoken. Raw root-CLI is fenced to exactly 3 exceptions: keyctlpct create(golden image), USB mount/fstab, SMART/sensors.Clientnever shells out;Privilegednever makes HTTP calls (asserted byrouting_test.go). Keep that fence. - Every mutating op is async → returns a UPID →
WaitTaskassertsexitstatus == "OK". A 200 on the POST is not success; authorization can fail at task execution. - TLS: SHA-256 leaf-cert pinning (self-signed host cert). No insecure default.
- Privsep token gotcha: a
--privsep 1token's rights = intersection of the backing user's perms AND the token's ACLs — the role must be granted on both, or every call 403s. - Destructive ops go through the reconcile gate / signed-jobs path — never call
Client.DestroyLXC/Vzdump/SetConfigad-hoc (REUSE.md §3).
Demo host (for live tests)
Node demo-felhom, API https://192.168.0.162:8006. SSH alias felhom-pve (root@pam) —
available to CC as plain ssh felhom-pve. A second demo node demo-hp (HP t740, node name
felhom-host, ssh demo-hp — no baked key; break-glass root via hub host_recovery/demo-hp-bb76ea +
sshpass) is the designated drill+build VM host per the 2026-07-25 operator ruling, and that ruling
is realized — it hosts drill VM 300 (drill-r50), so start there, not on DooPlex. (The
historical golden-bake drill.qcow2 still lives on DooPlex and is a bake fixture, not a drill target.)
Which box is safe to break, and what may be done to each:
felhom.eu/documentation/runbooks/target-selection.md — read it before any destructive test. Both
nodes + the break-glass recipe: felhom.eu/documentation/operations/nodes.md. The agent pins the served leaf cert — verify the
fingerprint still matches before a live run. Selftest modes (run locally on DooPlex, pointed at the
demo API): --selftest[=read|task|hub|storage|backup|restore-test|pbs-verify]; no flag = the daemon.
TEMPORARY — felhom-pve is at a remote site (until ~2026-08-02). The home-LAN literal
192.168.0.162is NOT reachable from DooPlex for the duration. Access via Tailscale: felhom-pve = 100.70.170.35; theHost felhom-pveentry in~/.ssh/configon DooPlex already points there (the direct-LAN path stays available asHost felhom-pve-lan). Delete this block on return. All documentedssh felhom-pve/pct execworkflows are unchanged. Path is direct (not DERP), ~37 ms rtt per hop. At the remote site the host is on DHCP; re-check its address rather than trusting one written here (ip -br addr show vmbr0— it read192.168.0.162/24on 2026-07-30, andfelhom-pve-lanfrom DooPlex is stillNo route to host). Details + findings:felhom.eu/documentation/audits/AUDIT-vacation-remote-ops-2026-07-20.mdThe "agent does not run at the remote site" warning this block used to carry is RETRACTED (2026-07-30) — it was true before R-50 and is false now.
localapino longer binds a LAN literal: since the R-50 island migration (2026-07-25) it binds169.254.253.1:8443onvmbr9, which is location-independent by design, andproxmox.endpointishttps://127.0.0.1:8006. Verified live:systemctl is-active felhom-agent→active,felhom-agent --version→ 0.115.0, and the per-guest local API answeredGET /disksover the island. No config edit and no Viktor GO are outstanding.
Legacy: Windows workstation. Until 2026-07-19 CC ran on Windows 11;
pctcommands over SSH neededexport MSYS_NO_PATHCONV=1, and every remote command usedSSH=/c/Windows/System32/OpenSSH/ssh.exe. Agent deploy was a two-hop copy via the Windows box (cygpath -wfor the local scp path; CRLF hazard on config files).
Conventions
Trunk-based — no branches
All shippable work commits directly to main; main equals what is deployed.
- Report-only artifacts (audits, findings, fixspecs) →
felhom.eu/documentation/(audits/,backlog/). - Risky/supervised fixes are spec'd, then implemented during the supervised session, on
main. - Unattended escape hatch: if a fix can't be cleanly verified/shipped, revert + report — never park on a branch.
In every repository where you make a change, update both files in that repo:
CHANGELOG.md— cumulative log, newest on top.REPORT.md— overwrite with the most recent implementation/validation summary only.Never write secrets into any committed file — reference them as "stored out-of-band".
- Code quality: verify generated code for bugs/edge cases; add debug logging; ask rather than guess when you'd otherwise invent input/output.
- A health check issues no block I/O — no
statfs, nogetdents, no read, write orfsync, not even behind a timeout. Liveness is decided from/procand kernel state. The full rule + the measurement lives infelhom.eu/CLAUDE.md"Code quality rules"; it is repeated here because health checks are written in THIS repo and that file does not load in an agent-only session. R-117 spike §6.3. - Update
REUSE.mdif you added/changed/deprecated a shared helper or pattern (same commit). - Run
python3 scripts/agent_gates.pyfrom the repo root after ANY change in this repo. It is the ONE entry point for this repo's gates. Today it runs one —reuse_refs_checkover this repo'sREUSE.md— and it exists at one gate on purpose: a census on 2026-08-02 found that every check aCLAUDE.mdnames was passing and two of the four nobody is told to run were failing, and this repo was the extreme case, with nothing running against it at all and 90 cited paths checked by no one. It grows when the agent grows a second check.--fastselects the gates that touch no network and no container runtime; today that is all of them. A missing gate is a FAILURE, never a skip. The sharedreuse_refs_check.pylives infelhom.eu/scripts/and is never copied here — a copy would recreate the drift it detects; an absent sibling clone FAILS the gate. The pre-push hook (.githooks/pre-push) runs it with--fastand refuses a failing push. It is per-clone — switch it on once withgit config core.hooksPath .githooks, and a manual run WARNS when this clone is unarmed.git push --no-verifybypasses it deliberately; say so in the session report when you use it. Both facts are why CI is still owed (OPEN-ITEMS.mdR-168). - Testing doctrine (non-hollow tests, red-proofs, seams): use the
felhom-testingskill. - Logging: the slog logger fans out to journald (configured level) + the always-DEBUG
applog.Ring(remote pulls) — English, keys-never-values, durations on outcomes; full rules infelhom.eu/documentation/runbooks/logging-conventions.md.
Live validation
Exercise the SERVER-SIDE PIPELINE a real user triggers, end-to-end. The forbidden shortcut is BYPASSING it (the F9 episode: raw guest-attach + hand-set state). Invoking the exact endpoint the UI invokes is an acceptable proxy when a browser isn't available — say which method was used. Low-level mechanism tests where the direct call IS the mechanism are exempt.
Workflow & artifacts
- Implement
TASK.md/TASK-*.mdspecs (when placed asTASK.mdor told to), then push + CHANGELOG + REPORT.md. RUNBOOK-*.md— an operational procedure. CC executes the steps it has access and capability for, including live validation on the demo Proxmox host (CC has root@felhom-pve SSH + the felhom-agent token). Mark a step HUMAN only when it genuinely needs physical presence, a real-world decision, or credentials CC truly lacks. Judgment still applies: confirm before irreversible ops on real customer data — demo scratch guests are fair game.