feat: agent-capability gate for coupled features — typed StatusError + Supports probe/cache + netstorage add gate (option-1)

- agentapi: non-2xx GETs now surface as typed *StatusError (same text); features.go
  adds Feature/SupportState/SupportCache (route probe, TTL 5m, Yes/No cached,
  Unknown never cached or refused) + Client.Supports
- web: handleNetStorageAdd refuses up front (412, code agent_outdated, Hungarian
  message) when the agent predates /netstorage/verify-status (= pre-0.81 add
  semantics); gate runs BEFORE the single-flight claim; SupportUnknown passes
  through to the existing agent-error paths
- netAddSupport page-render helper lands here; its template consumer follows
- tests: T1 gate refusal (job never starts, slot free), T2 unchanged happy path +
  warm-cache negative assertion, T3 indeterminate never 'too old', T4
  classification incl. the string-match trap, T6 TTL, wire-level 404-typing;
  red-proofs RP1-RP4 run and reverted

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 13:45:49 +02:00
parent 5d06ecf374
commit d347dc48d2
6 changed files with 511 additions and 1 deletions
+15 -1
View File
@@ -26,6 +26,8 @@ type Client struct {
baseURL string
token string
hc *http.Client
// features caches capability-probe verdicts for Supports (features.go).
features SupportCache
}
// MountInfo mirrors the agent's GET /storage mount entry (doc 03 §6).
@@ -853,6 +855,18 @@ func (c *Client) HostMetrics(ctx context.Context) (HostMetricsResponse, error) {
return out, nil
}
// StatusError is a non-2xx agent HTTP status surfaced as a TYPED error (same text the old
// fmt.Errorf produced). errors.As-able — the capability probe (features.go) keys on Code 404 to
// distinguish "this agent predates the route" from every other failure. Never match the string.
type StatusError struct {
Path string
Code int
}
func (e *StatusError) Error() string {
return fmt.Sprintf("agentapi: GET %s: HTTP %d", e.Path, e.Code)
}
// get issues an authenticated GET and unwraps the {ok,data,error} envelope.
func (c *Client) get(ctx context.Context, path string) (json.RawMessage, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+path, nil)
@@ -867,7 +881,7 @@ func (c *Client) get(ctx context.Context, path string) (json.RawMessage, error)
defer resp.Body.Close()
raw, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("agentapi: GET %s: HTTP %d", path, resp.StatusCode)
return nil, &StatusError{Path: path, Code: resp.StatusCode}
}
var env apiResponse
if err := json.Unmarshal(raw, &env); err != nil {