v0.82.0: X-Felhom-Agent-Version response header — the controller capability channel
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
This commit is contained in:
@@ -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)
|
## 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
|
Implements the agent half of the "NAS verify-before-commit" task on the SPIKE-nas-verify-2026-07-11
|
||||||
|
|||||||
@@ -989,6 +989,7 @@ func buildLocalAPIServer(cfg config.Config, px *proxmox.Client, store *backup.St
|
|||||||
srv, err := localapi.NewServer(localapi.Options{
|
srv, err := localapi.NewServer(localapi.Options{
|
||||||
ListenAddr: cfg.LocalAPI.ListenAddr,
|
ListenAddr: cfg.LocalAPI.ListenAddr,
|
||||||
Cert: cert,
|
Cert: cert,
|
||||||
|
AgentVersion: version, // v0.82.0: the X-Felhom-Agent-Version capability channel
|
||||||
Guests: px,
|
Guests: px,
|
||||||
Backups: runner,
|
Backups: runner,
|
||||||
Store: store,
|
Store: store,
|
||||||
|
|||||||
@@ -128,6 +128,11 @@ type Options struct {
|
|||||||
// HostID is this agent's host id — surfaced in a data-bearing-format pending-op so the operator
|
// 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).
|
// signs an op bound to THIS host (slice 10B anti-retarget). Optional (only used for the hint).
|
||||||
HostID string
|
HostID string
|
||||||
|
// 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
|
Logger *slog.Logger
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -185,6 +190,7 @@ type Server struct {
|
|||||||
|
|
||||||
hostMetrics HostMetricsProvider // slice 9 (optional)
|
hostMetrics HostMetricsProvider // slice 9 (optional)
|
||||||
hostID string // slice 10B: for the data-bearing-format pending-op hint
|
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
|
// reresolveWipe performs the [AGENT-001] anti-retarget re-resolution before an
|
||||||
// inline customer-confirmed wipe (durable id → current device, re-derive+match,
|
// inline customer-confirmed wipe (durable id → current device, re-derive+match,
|
||||||
@@ -274,6 +280,7 @@ func NewServer(o Options) (*Server, error) {
|
|||||||
host: o.HostReader,
|
host: o.HostReader,
|
||||||
hostMetrics: o.HostMetrics,
|
hostMetrics: o.HostMetrics,
|
||||||
hostID: o.HostID,
|
hostID: o.HostID,
|
||||||
|
agentVersion: o.AgentVersion,
|
||||||
jobs: map[int]*backupJob{},
|
jobs: map[int]*backupJob{},
|
||||||
swapInFlight: map[int]bool{},
|
swapInFlight: map[int]bool{},
|
||||||
}
|
}
|
||||||
@@ -336,7 +343,17 @@ func (s *Server) Handler() http.Handler {
|
|||||||
mux.HandleFunc("POST /escrow/stage-secret", s.withGuest(s.handleStageEscrowSecret))
|
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.
|
// 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))
|
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
|
// Run binds the bridge socket, serves TLS, and shuts down gracefully on ctx cancellation. It
|
||||||
|
|||||||
@@ -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"))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user