R-1254 (controller half): escrow claim/confirm outwait the agent (60 s); an unknown confirm outcome promises nothing
gates / gates (push) Successful in 28s

504 from the agent (it cannot learn which package the hub holds) or a failed call -> api.escrow.confirm_store_unknown
(hu+en); only 502 keeps 'your previous code still works'. Tests TestR1254_* red before the fix. Commit only.
This commit is contained in:
admin committed 2026-10-28 22:59:52 +01:00
1 parent 342a394bff
commit e33cffaee1
10 files changed
+133 -4

No files matched your search

+13 -1
View File
@@ -1071,6 +1071,18 @@ func (c *Client) RemoveNetStorage(ctx context.Context, name string) error {
// status, even on a non-2xx (so callers like FormatDisk can read a 403 refusal body). A transport or
// envelope-parse failure is still an error; an `ok:false` business refusal is NOT (the data carries it).
func (c *Client) postWithStatus(ctx context.Context, path string, body any) (apiResponse, int, error) {
return c.postWithStatusOn(c.hc, ctx, path, body)
}
// postWithStatusTimeout is postWithStatus with its own overall timeout (the escrow claim and confirm, R-1254). It shares
// the pinned transport.
func (c *Client) postWithStatusTimeout(ctx context.Context, path string, body any, timeout time.Duration) (apiResponse, int, error) {
hc := *c.hc
hc.Timeout = timeout
return c.postWithStatusOn(&hc, ctx, path, body)
}
func (c *Client) postWithStatusOn(hc *http.Client, ctx context.Context, path string, body any) (apiResponse, int, error) {
var env apiResponse
buf, err := json.Marshal(body)
if err != nil {
@@ -1083,7 +1095,7 @@ func (c *Client) postWithStatus(ctx context.Context, path string, body any) (api
req.Header.Set("Authorization", "Bearer "+c.token)
req.Header.Set("Content-Type", "application/json")
start := time.Now()
resp, err := c.hc.Do(req)
resp, err := hc.Do(req)
if err != nil {
logx.Debugf(c.logger, "[agentapi] POST %s failed after %dms: %v", path, time.Since(start).Milliseconds(), err)
return env, 0, fmt.Errorf("agentapi: POST %s: %w", path, err)
+10 -2
View File
@@ -6,6 +6,7 @@ import (
"errors"
"fmt"
"net/http"
"time"
)
// Controller-driven escrow ceremony client methods (v0.127.0, agent ≥ v0.88.0). The claim call
@@ -108,8 +109,15 @@ type EscrowClaim struct {
//
// R-1151: the claim asks the agent to keep the new package until the household typed the code back
// (`defer_upload`). An agent older than 0.160.0 ignores the body and uploads as before.
// escrowCallTimeout bounds the claim and the confirm (R-1254 H2). The agent may take its whole upload timeout (30 s)
// plus its question to the hub (15 s) before it answers; the client's own 15 s used to abandon the call while the
// agent went on — a claim abandoned that way could still store the package, and its code reached nobody. 60 s
// outlasts both (TestR1254_ClaimAndConfirmOutwaitTheAgentsUpload; the agent side: escrowUploadTimeout,
// escrowHoldTimeout in felhom-agent internal/localapi/escrow_ceremony.go — if either grows, this must too).
var escrowCallTimeout = 60 * time.Second
func (c *Client) EscrowCeremonyClaim(ctx context.Context) (EscrowClaim, int, error) {
env, status, err := c.postWithStatus(ctx, "/escrow/ceremony/claim", map[string]bool{"defer_upload": true})
env, status, err := c.postWithStatusTimeout(ctx, "/escrow/ceremony/claim", map[string]bool{"defer_upload": true}, escrowCallTimeout)
if err != nil {
return EscrowClaim{}, status, err
}
@@ -141,7 +149,7 @@ func (c *Client) EscrowCeremonyClaim(ctx context.Context) (EscrowClaim, int, err
// match (still waiting), 410 the window ran out, 404 nothing waits (superseded, restarted), 502 the hub failed (still
// waiting). The words are part of the recovery code — never logged.
func (c *Client) EscrowCeremonyConfirm(ctx context.Context, words []string) (int, error) {
env, status, err := c.postWithStatus(ctx, "/escrow/ceremony/confirm", map[string][]string{"words": words})
env, status, err := c.postWithStatusTimeout(ctx, "/escrow/ceremony/confirm", map[string][]string{"words": words}, escrowCallTimeout)
if err != nil {
return status, err
}
@@ -0,0 +1,42 @@
package agentapi
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
// R-1254 H2: the agent's claim/confirm may take its whole upload timeout (30 s) plus the hub question (15 s) before it
// answers. The controller's agent client gives up at 15 s — and a claim the controller abandons can still complete on
// the agent: the hub then holds a package whose code nobody saw. The two calls carry their own, longer timeout.
// RED-PROOF: send claim/confirm through the plain 15 s client again → the slow agent below times them out → FAILS.
func TestR1254_ClaimAndConfirmOutwaitTheAgentsUpload(t *testing.T) {
if escrowCallTimeout < 45*time.Second+10*time.Second {
t.Fatalf("escrowCallTimeout %s must outlast the agent's upload (30 s) + hub question (15 s) with room", escrowCallTimeout)
}
mux := http.NewServeMux()
mux.HandleFunc("POST /escrow/ceremony/claim", func(w http.ResponseWriter, r *http.Request) {
time.Sleep(300 * time.Millisecond)
_, _ = w.Write([]byte(`{"ok":true,"data":{"recovery_code":"a-b-c"}}`))
})
mux.HandleFunc("POST /escrow/ceremony/confirm", func(w http.ResponseWriter, r *http.Request) {
time.Sleep(300 * time.Millisecond)
_, _ = w.Write([]byte(`{"ok":true,"data":{"uploaded":true}}`))
})
s := httptest.NewTLSServer(mux)
defer s.Close()
c := clientFor(t, s, strings.TrimPrefix(s.URL, "https://"))
c.hc.Timeout = 100 * time.Millisecond // the client's own short timeout, scaled down
old := escrowCallTimeout
escrowCallTimeout = 5 * time.Second
defer func() { escrowCallTimeout = old }()
if claim, st, err := c.EscrowCeremonyClaim(context.Background()); err != nil || st != http.StatusOK || claim.Code != "a-b-c" {
t.Fatalf("a slow claim was abandoned: %+v %d %v", claim, st, err)
}
if st, err := c.EscrowCeremonyConfirm(context.Background(), []string{"a", "c"}); err != nil || st != http.StatusOK {
t.Fatalf("a slow confirm was abandoned: %d %v", st, err)
}
}