v0.115.0: version-aware Supports (agent header channel) + DSM-validated NFS guidance — MinAgent: 0.81.0

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
This commit is contained in:
2026-07-11 15:03:41 +02:00
parent ac6adaaa44
commit b6842a4bc3
8 changed files with 394 additions and 72 deletions
+36
View File
@@ -17,7 +17,9 @@ import (
"fmt"
"io"
"net/http"
"regexp"
"strings"
"sync"
"time"
)
@@ -28,6 +30,36 @@ type Client struct {
hc *http.Client
// features caches capability-probe verdicts for Supports (features.go).
features SupportCache
// verMu guards lastAgentVersion — the most recent STRICTLY-VALIDATED X-Felhom-Agent-Version
// seen on any agent response (v0.82.0 version channel). "" = never seen (pre-0.82 agent) →
// Supports falls back to the route probe.
verMu sync.Mutex
lastAgentVersion string
}
// reAgentVersion is the bare-semver shape the publish pipeline enforces (publish-agent.sh) — the
// ONLY header values trusted for capability comparison. Anything else (garbage, "dev", suffixes)
// is ignored and the probe fallback stays in charge.
var reAgentVersion = regexp.MustCompile(`^[0-9]+\.[0-9]+\.[0-9]+$`)
// noteAgentVersion records a response's version header (passive capture — called on EVERY response
// path). Invalid/absent headers never overwrite a previously-seen valid version.
func (c *Client) noteAgentVersion(resp *http.Response) {
v := strings.TrimSpace(resp.Header.Get("X-Felhom-Agent-Version"))
if v == "" || !reAgentVersion.MatchString(v) {
return
}
c.verMu.Lock()
c.lastAgentVersion = v
c.verMu.Unlock()
}
// AgentVersion returns the last strictly-validated agent version seen on this client's traffic
// ("" = unknown — header-less agent or no traffic yet). This is the Supports comparison source.
func (c *Client) AgentVersion() string {
c.verMu.Lock()
defer c.verMu.Unlock()
return c.lastAgentVersion
}
// MountInfo mirrors the agent's GET /storage mount entry (doc 03 §6).
@@ -491,6 +523,7 @@ func (c *Client) WipeStagedEscrowSecret(ctx context.Context) error {
return fmt.Errorf("agentapi: DELETE /escrow/stage-secret: %w", err)
}
defer resp.Body.Close()
c.noteAgentVersion(resp) // v0.82.0 version channel: passive capture on EVERY response
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
var env apiResponse
if err := json.Unmarshal(raw, &env); err != nil {
@@ -771,6 +804,7 @@ func (c *Client) postWithStatus(ctx context.Context, path string, body any) (api
return env, 0, fmt.Errorf("agentapi: POST %s: %w", path, err)
}
defer resp.Body.Close()
c.noteAgentVersion(resp) // v0.82.0 version channel: passive capture on EVERY response
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err := json.Unmarshal(raw, &env); err != nil {
return env, resp.StatusCode, fmt.Errorf("agentapi: POST %s: HTTP %d, bad envelope: %w", path, resp.StatusCode, err)
@@ -879,6 +913,7 @@ func (c *Client) get(ctx context.Context, path string) (json.RawMessage, error)
return nil, fmt.Errorf("agentapi: GET %s: %w", path, err)
}
defer resp.Body.Close()
c.noteAgentVersion(resp) // v0.82.0 version channel: passive capture on EVERY response
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if resp.StatusCode != http.StatusOK {
return nil, &StatusError{Path: path, Code: resp.StatusCode}
@@ -911,6 +946,7 @@ func (c *Client) post(ctx context.Context, path string, body any) (json.RawMessa
return nil, fmt.Errorf("agentapi: POST %s: %w", path, err)
}
defer resp.Body.Close()
c.noteAgentVersion(resp) // v0.82.0 version channel: passive capture on EVERY response
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusAccepted {
return nil, fmt.Errorf("agentapi: POST %s: HTTP %d", path, resp.StatusCode)