From fa9c7fe1980901ccc6d05bb9f7342c207eb25f54 Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Sat, 11 Jul 2026 14:51:48 +0200 Subject: [PATCH] =?UTF-8?q?v0.82.0:=20X-Felhom-Agent-Version=20response=20?= =?UTF-8?q?header=20=E2=80=94=20the=20controller=20capability=20channel?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6 --- CHANGELOG.md | 12 ++++++ cmd/felhom-agent/main.go | 1 + internal/localapi/server.go | 25 ++++++++++-- internal/localapi/version_header_test.go | 52 ++++++++++++++++++++++++ 4 files changed, 86 insertions(+), 4 deletions(-) create mode 100644 internal/localapi/version_header_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 8261672..f590a7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,15 @@ +## v0.82.0 — local-API version channel: X-Felhom-Agent-Version on every response (2026-07-11) + +The controller's capability detection upgrades from route-probing to version comparison: the +local-API mux is wrapped so EVERY response (any route, any status, including auth failures) carries +`X-Felhom-Agent-Version` = the build version (`localapi.Options.AgentVersion`, wired from +main.version). The controller (v0.115.0) reads it passively from ordinary traffic and compares it +against a per-feature MinAgent table; header-less (≤0.81.0) agents keep working — the controller +falls back to the v0.114.0 route probe unchanged. No new routes, no envelope changes, no sudoers +changes. Test: header asserted on authed/unauthed/404 responses (red-proof: wrap dropped → fails); +empty version emits NO header. Demo-deploy only — NOT published (Peti stays 0.81.0 = the live +fallback path). + ## v0.81.0 — NAS verify-before-commit: retry=0 + detached verify job + journal classification (2026-07-11) Implements the agent half of the "NAS verify-before-commit" task on the SPIKE-nas-verify-2026-07-11 diff --git a/cmd/felhom-agent/main.go b/cmd/felhom-agent/main.go index 0cb9554..caaa741 100644 --- a/cmd/felhom-agent/main.go +++ b/cmd/felhom-agent/main.go @@ -989,6 +989,7 @@ func buildLocalAPIServer(cfg config.Config, px *proxmox.Client, store *backup.St srv, err := localapi.NewServer(localapi.Options{ ListenAddr: cfg.LocalAPI.ListenAddr, Cert: cert, + AgentVersion: version, // v0.82.0: the X-Felhom-Agent-Version capability channel Guests: px, Backups: runner, Store: store, diff --git a/internal/localapi/server.go b/internal/localapi/server.go index eee825e..754d043 100644 --- a/internal/localapi/server.go +++ b/internal/localapi/server.go @@ -128,7 +128,12 @@ type Options struct { // HostID is this agent's host id — surfaced in a data-bearing-format pending-op so the operator // signs an op bound to THIS host (slice 10B anti-retarget). Optional (only used for the hint). HostID string - Logger *slog.Logger + // AgentVersion is this agent's build version (main.version). When set, EVERY local-API response + // carries it in the X-Felhom-Agent-Version header — the controller's capability channel: its + // Supports() compares this against a per-feature MinAgent table instead of route-probing + // (v0.82.0; the probe stays as the fallback for header-less agents). Optional. + AgentVersion string + Logger *slog.Logger } // defaultBackupCadence is the fallback /backup/due window when none is configured. @@ -183,8 +188,9 @@ type Server struct { staleLock StaleLockController // F2-b startup stale-lock recovery (optional) host storage.HostReader // role classification source (optional; defaults to ProcHostReader) - hostMetrics HostMetricsProvider // slice 9 (optional) - hostID string // slice 10B: for the data-bearing-format pending-op hint + hostMetrics HostMetricsProvider // slice 9 (optional) + hostID string // slice 10B: for the data-bearing-format pending-op hint + agentVersion string // v0.82.0: the X-Felhom-Agent-Version response header value // reresolveWipe performs the [AGENT-001] anti-retarget re-resolution before an // inline customer-confirmed wipe (durable id → current device, re-derive+match, @@ -274,6 +280,7 @@ func NewServer(o Options) (*Server, error) { host: o.HostReader, hostMetrics: o.HostMetrics, hostID: o.HostID, + agentVersion: o.AgentVersion, jobs: map[int]*backupJob{}, swapInFlight: map[int]bool{}, } @@ -336,7 +343,17 @@ func (s *Server) Handler() http.Handler { mux.HandleFunc("POST /escrow/stage-secret", s.withGuest(s.handleStageEscrowSecret)) // fork-4 hygiene: wipe the staged secret once escrowed (controller calls this on confirm). Idempotent. mux.HandleFunc("DELETE /escrow/stage-secret", s.withGuest(s.handleWipeStagedEscrowSecret)) - return mux + + // v0.82.0 version channel: EVERY response (any route, any status — including auth failures) + // carries X-Felhom-Agent-Version, so the controller learns the agent version passively from its + // ordinary traffic and can capability-gate by comparison instead of route-probing. Header-less + // (pre-0.82) agents keep working — the controller falls back to the probe. + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if s.agentVersion != "" { + w.Header().Set("X-Felhom-Agent-Version", s.agentVersion) + } + mux.ServeHTTP(w, r) + }) } // Run binds the bridge socket, serves TLS, and shuts down gracefully on ctx cancellation. It diff --git a/internal/localapi/version_header_test.go b/internal/localapi/version_header_test.go new file mode 100644 index 0000000..8111b24 --- /dev/null +++ b/internal/localapi/version_header_test.go @@ -0,0 +1,52 @@ +package localapi + +import ( + "io" + "log/slog" + "testing" +) + +// v0.82.0 version channel: EVERY local-API response carries X-Felhom-Agent-Version with the +// injected version — the controller's capability comparison source. Asserted across an authed +// route, an UNAUTHED request (the middleware wraps auth), and a 404 route, so no response class +// can silently lose the header. Companion red-proof: drop the Handler() wrap (return mux) → every +// row fails with an empty header. +func TestVersionHeader_OnEveryResponse(t *testing.T) { + srv, err := NewServer(Options{ + ListenAddr: "127.0.0.1:0", + AgentVersion: "9.9.9-test", + Guests: &fakeGuests{}, + Backups: &fakeBackups{}, + Store: &fakeStore{}, + Storage: fakeStorage{}, + Tokens: staticTokens{"A": 8200}, + Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + }) + if err != nil { + t.Fatalf("new server: %v", err) + } + h := srv.Handler() + for _, tc := range []struct { + name, method, path, token string + }{ + {"authed route", "GET", "/storage", "A"}, + {"unauthed request", "GET", "/storage", ""}, + {"unknown route", "GET", "/nonexistent", "A"}, + } { + t.Run(tc.name, func(t *testing.T) { + w := do(t, h, tc.method, tc.path, tc.token, "") + if got := w.Header().Get("X-Felhom-Agent-Version"); got != "9.9.9-test" { + t.Errorf("X-Felhom-Agent-Version = %q, want %q (status %d)", got, "9.9.9-test", w.Code) + } + }) + } +} + +// An empty AgentVersion (misconfigured/test construction) must not emit an empty header. +func TestVersionHeader_OmittedWhenUnset(t *testing.T) { + srv := newNetServer(t, &fakeNetOps{}, t.TempDir()) // helper does not set AgentVersion + w := do(t, srv.Handler(), "GET", "/netstorage", "A", "") + if _, present := w.Result().Header["X-Felhom-Agent-Version"]; present { + t.Errorf("header must be absent when no version is configured, got %q", w.Header().Get("X-Felhom-Agent-Version")) + } +}