feat(agentapi): surface agent disk-op refusal reasons; v0.101.0 + CHANGELOG/REPORT
EjectDisk/Decommission switched from c.post (drops non-2xx body) to
postWithStatus + shared refusalError, so the agent's informative 403 body
("…decommission refused (role: X)") reaches the operator instead of a bare
"HTTP 403" (campaign F2 evidence gap). Generic post + other callers untouched.
Tests T-D1/T-D2/T-D3 + ok:false case; T-D1 red-proof shows the pre-fix bare
"HTTP 403". Bundles the v0.101.0 CHANGELOG entry (this + the F3 sync deadline).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PSK5g6qYLknKj8u3QAFEr6
This commit is contained in:
@@ -461,13 +461,18 @@ type EjectResult struct {
|
||||
}
|
||||
|
||||
// EjectDisk safe-unmounts a host mount (data preserved) and returns the dependent guests.
|
||||
// Status-aware POST (campaign F2 evidence gap): the agent's refusal body carries the reason
|
||||
// (e.g. "…eject refused (role: system)") — surface it instead of a bare "HTTP 403".
|
||||
func (c *Client) EjectDisk(ctx context.Context, where string) (EjectResult, error) {
|
||||
var out EjectResult
|
||||
body, err := c.post(ctx, "/disks/eject", map[string]string{"where": where})
|
||||
env, status, err := c.postWithStatus(ctx, "/disks/eject", map[string]string{"where": where})
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
if err := json.Unmarshal(body, &out); err != nil {
|
||||
if err := refusalError("/disks/eject", status, env); err != nil {
|
||||
return out, err
|
||||
}
|
||||
if err := json.Unmarshal(env.Data, &out); err != nil {
|
||||
return out, fmt.Errorf("agentapi: decode /disks/eject: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
@@ -483,18 +488,48 @@ type DecommissionResult struct {
|
||||
// Decommission permanently removes a user-data drive (self-serve, non-destructive — the agent records
|
||||
// IntentDecommissioned, prunes the bind record, and unmounts; it NEVER formats). Data stays on the
|
||||
// drive. The agent role-gates to user-data and refuses a system/backup mount regardless.
|
||||
// Status-aware POST (campaign F2 evidence gap): the agent's refusal body carries the reason
|
||||
// (e.g. "…decommission refused (role: system)") — surface it instead of a bare "HTTP 403".
|
||||
func (c *Client) Decommission(ctx context.Context, where string) (DecommissionResult, error) {
|
||||
var out DecommissionResult
|
||||
body, err := c.post(ctx, "/disks/decommission", map[string]string{"where": where})
|
||||
env, status, err := c.postWithStatus(ctx, "/disks/decommission", map[string]string{"where": where})
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
if err := json.Unmarshal(body, &out); err != nil {
|
||||
if err := refusalError("/disks/decommission", status, env); err != nil {
|
||||
return out, err
|
||||
}
|
||||
if err := json.Unmarshal(env.Data, &out); err != nil {
|
||||
return out, fmt.Errorf("agentapi: decode /disks/decommission: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// refusalError converts a non-2xx status or an ok:false envelope into an error that CARRIES the
|
||||
// agent's reason (truncated; never request bodies or secrets). nil on an accepted 200/202+ok=true.
|
||||
func refusalError(path string, status int, env apiResponse) error {
|
||||
accepted := status == http.StatusOK || status == http.StatusAccepted
|
||||
if accepted && env.OK {
|
||||
return nil
|
||||
}
|
||||
reason := truncateErr(env.Error, 300)
|
||||
if reason == "" {
|
||||
reason = "(no reason in agent response)"
|
||||
}
|
||||
if accepted { // 2xx but ok:false — business refusal without an HTTP error code
|
||||
return fmt.Errorf("agentapi: POST %s: %s", path, reason)
|
||||
}
|
||||
return fmt.Errorf("agentapi: POST %s: HTTP %d: %s", path, status, reason)
|
||||
}
|
||||
|
||||
// truncateErr mirrors stacks.truncateStr for agent refusal reasons.
|
||||
func truncateErr(s string, maxLen int) string {
|
||||
if len(s) <= maxLen {
|
||||
return s
|
||||
}
|
||||
return s[:maxLen] + "..."
|
||||
}
|
||||
|
||||
// FormatDisk asks the agent to format a device. The AGENT inspects the device and tiers it by ROLE
|
||||
// (its own classification, never the controller's claim):
|
||||
// - blank device → formatted.
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
package agentapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Campaign F2 evidence gap: the agent's 403 refusal body carries the informative reason
|
||||
// (agent disks.go handleDiskDecommission — "…decommission refused (role: X)"), but the old
|
||||
// c.post discarded any non-2xx body, so operators saw a bare
|
||||
// "agentapi: POST /disks/decommission: HTTP 403". These tests pin the reason surfacing.
|
||||
|
||||
func refusalStub(t *testing.T) (*httptest.Server, string) {
|
||||
t.Helper()
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("POST /disks/decommission", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
_, _ = w.Write([]byte(`{"ok":false,"error":"mount is system/backup-protected — decommission refused (role: system)"}`))
|
||||
})
|
||||
mux.HandleFunc("POST /disks/eject", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusForbidden)
|
||||
_, _ = w.Write([]byte(`{"ok":false,"error":"mount is system/backup-protected — eject refused (role: backup)"}`))
|
||||
})
|
||||
s := httptest.NewTLSServer(mux)
|
||||
return s, strings.TrimPrefix(s.URL, "https://")
|
||||
}
|
||||
|
||||
// T-D1: Decommission surfaces the agent's refusal reason, not a bare HTTP code.
|
||||
// RED-PROOF: on the pre-fix c.post shape the error is exactly
|
||||
// "agentapi: POST /disks/decommission: HTTP 403" → the Contains assertion FAILS.
|
||||
func TestDecommission_RefusalReasonSurfaced(t *testing.T) {
|
||||
s, ep := refusalStub(t)
|
||||
defer s.Close()
|
||||
c := clientFor(t, s, ep)
|
||||
|
||||
_, err := c.Decommission(context.Background(), "/mnt/sys_drive")
|
||||
if err == nil {
|
||||
t.Fatal("expected the 403 refusal to be an error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "(role: system)") {
|
||||
t.Fatalf("agent refusal reason discarded — operator sees only: %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "HTTP 403") {
|
||||
t.Fatalf("HTTP status lost from the error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// T-D2: same surfacing for EjectDisk.
|
||||
func TestEjectDisk_RefusalReasonSurfaced(t *testing.T) {
|
||||
s, ep := refusalStub(t)
|
||||
defer s.Close()
|
||||
c := clientFor(t, s, ep)
|
||||
|
||||
_, err := c.EjectDisk(context.Background(), "/mnt/felhom-flash")
|
||||
if err == nil {
|
||||
t.Fatal("expected the 403 refusal to be an error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "(role: backup)") {
|
||||
t.Fatalf("agent refusal reason discarded — operator sees only: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// T-D3: the success path is unchanged — a 200 envelope still decodes into the result struct.
|
||||
// (EjectDisk success is covered by TestEject_Dependents; this pins Decommission.)
|
||||
func TestDecommission_SuccessUnchanged(t *testing.T) {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("POST /disks/decommission", func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"ok":true,"data":{"vmid":8200,"decommissioned":"/mnt/bulk","dependent_guests":[8200]}}`))
|
||||
})
|
||||
s := httptest.NewTLSServer(mux)
|
||||
defer s.Close()
|
||||
c := clientFor(t, s, strings.TrimPrefix(s.URL, "https://"))
|
||||
|
||||
out, err := c.Decommission(context.Background(), "/mnt/bulk")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if out.Decommissioned != "/mnt/bulk" || len(out.DependentGuests) != 1 {
|
||||
t.Fatalf("success payload mis-decoded: %+v", out)
|
||||
}
|
||||
}
|
||||
|
||||
// A 2xx envelope with ok:false (business refusal without an HTTP error) must also carry the reason.
|
||||
func TestDecommission_OkFalseBusinessRefusal(t *testing.T) {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("POST /disks/decommission", func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"ok":false,"error":"drive is busy: unmount blocked by open files"}`))
|
||||
})
|
||||
s := httptest.NewTLSServer(mux)
|
||||
defer s.Close()
|
||||
c := clientFor(t, s, strings.TrimPrefix(s.URL, "https://"))
|
||||
|
||||
_, err := c.Decommission(context.Background(), "/mnt/bulk")
|
||||
if err == nil || !strings.Contains(err.Error(), "unmount blocked by open files") {
|
||||
t.Fatalf("ok:false reason not surfaced: %v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user