# REPORT — v0.101.0: campaign findings F3 (sync deadline) + F2 evidence gap (agent refusal surfacing) **Date:** 2026-07-06 · **Class:** implementation (code + tests + deploy + live validation) **Baseline:** `main` @ `af7ea0b` (v0.100.0, matched the task's confirmed baseline exactly). **Provenance:** `felhom.eu/documentation/audits/CAMPAIGN-nomercy-2026-07-06.md` findings F3, F2. **Companion repos:** catalog `d86e256` (F1 vaultwarden fix); felhom.eu audit addendum (F1/F2/F3 verdicts). ## What changed ### F3 — git subprocess deadline (`internal/sync/sync.go`) `runGitInDir` took no context; a hung remote parked the sync goroutine in `cmd.Run()`, the `doSync` defer never ran, `syncing` stayed true, and every subsequent manual + periodic sync was refused ("Szinkronizálás már folyamatban") until a controller restart. Now: `const gitCmdTimeout = 120s`; a `gitCmd` wrapper derives a fresh per-command `context.WithTimeout`; `runGitInDir` takes a `context.Context` and uses `exec.CommandContext`; on `ctx.Err()` the error names the timeout + the masked git args. All three call sites (clone via `runGit`, fetch, reset) route through it. Debounce and failed-sync-arms-debounce semantics unchanged. ### F2 evidence gap — agent refusal surfacing (`internal/agentapi/client.go`) `EjectDisk` + `Decommission` used `c.post`, which drops any non-2xx body, flattening the agent's informative refusal to a bare `HTTP 403` (the exact campaign evidence: `agentapi: POST /disks/decommission: HTTP 403`). Both switched to the purpose-built `postWithStatus` + a new shared `refusalError(path, status, env)` that returns `agentapi: POST : HTTP : ` (and handles the ok:false-on-2xx business-refusal case). `truncateErr` mirrors `stacks.truncateStr`. The generic `post` and every other caller are untouched. `internal/web/storage_handlers.go` already wraps the client error into its Hungarian message (`"a meghajtó leszerelése sikertelen: %w"` / eject `err.Error()`), so the role reason now reaches the UI/API response verbatim — verified by reading the handler paths (no re-truncation layer). ## Tests + red-proofs (non-hollow) | Test | Asserts | Red-proof (pre-fix shape) | |---|---|---| | `TestRunGitInDir_CancelledContextKillsSubprocess` (T-C1) | cancelled ctx → error names the ctx cause + "deadline/killed", returns < 5s | reverted to `exec.Command` → **FAIL**: *"got nil (git --version ran to completion — the pre-fix behavior)"* | | `TestTriggerSync_FailureReleasesSyncingAndAllowsRetry` (T-C2) | failed sync carries "Git hiba", `syncing` false after, immediate retry hits debounce, post-debounce retry EXECUTES | (behavioral; guards the lockup) | | `TestDecommission_RefusalReasonSurfaced` (T-D1) | error contains "(role: system)" + "HTTP 403" | reverted to `c.post` → **FAIL**: *"agent refusal reason discarded — operator sees only: agentapi: POST /disks/decommission: HTTP 403"* | | `TestEjectDisk_RefusalReasonSurfaced` (T-D2) | error contains "(role: backup)" | — | | `TestDecommission_SuccessUnchanged` (T-D3) | 200 envelope decodes to result | — | | `TestDecommission_OkFalseBusinessRefusal` | ok:false-on-2xx reason surfaced | — | Both red-proofs demonstrated live (revert → FAIL with the wrong value shown → restore → pass; `git diff` clean). Green gate `go build ./... && go vet ./... && go test ./...` = **PASS** (exit 0); 30 passing tests across `internal/sync` + `internal/agentapi` (+2 new test files, +6 new tests). ## Deploy + live validation (executed, real UI pipeline) - Built + pushed `gitea.dooplex.hu/admin/felhom-controller:0.101.0` on 180; deployed to guest 9201 via golden/bootstrap → `docker ps` `:0.101.0 Up (healthy)`. - Catalog synced (startup sync pulled the F1 template; `grep _ENABLE_SMTP` present in the guest `docker-compose.yml` + `.felhom.yml`). - **Scenario A (F1 acceptance):** stopped + removed the crash-looping vaultwarden via the UI `/stop` + `/remove` endpoints (remove correctly 409'd until stopped — "still running"); fresh `POST /api/stacks/vaultwarden/deploy` (app-email default off) → 202 → `running` in ~70s, `docker inspect` **restarts=0**; env: `_ENABLE_SMTP=false`, `SMTP_HOST=`, `SMTP_FROM=` (empty). No crash-loop — the pre-fix instance that was `Restarting` is gone. - **Scenario B (regression):** `POST /stacks/vaultwarden/app-email app_email_enabled=on` → recreate → healthy, restarts=0, `_ENABLE_SMTP=true`, `SMTP_HOST=felhom-controller`, `SMTP_FROM=vaultwarden@felhom.eu`; then `app_email_enabled=false` → healthy, `_ENABLE_SMTP=false`. Left OFF. The send-tested ON path is unchanged. - **Sync smoke (F3):** `POST /api/sync` → HTTP 200 ("Sablonok naprakészek") — the deadline change is transparent on the healthy path. ## F2 verdict (real finding — agent fix deferred) Campaign-time agent journal (persistent, survived the P6 reboots): `"local-api: protected — decommission refused by role" where=/mnt/teszt_enroll role=system`. `roleForMountPath` (agent `disks.go:1031`) fail-safe-returns `system` when no PVE **storage target** matches the mount — but `/disks` already classifies teszt_enroll as `user-data`. Two role paths disagree; a bind-mounted user-data drive that isn't a standalone PVE storage can't be decommissioned. Over-refusal (fail-safe), not a hole → **MEDIUM**. Full write-up: felhom.eu audit addendum. Agent untouched (read-only per scope); fix is a separate follow-up task. ## NOT yet live-validated Clean backup/restore (F3-volume-tar / C2 Tier-2) coverage on the now-healthy vaultwarden — awaits the separate targeted P1–P3 campaign re-run (a runbook, not this task). ## Not changed felhom-agent (read-only; F2 is diagnosis only). The generic `agentapi.post` error format, the sync debounce, and the other five smtp-mapped catalog templates.