Files
admin 3ed5e3e770
gates / gates (push) Successful in 13s
v0.214.0 — the recovery screen stops hedging about a code it can now check (R-311)
MinAgent: 0.129.0

What was already right: the screen did not bluntly accuse. R-222/R-226 hedged,
naming both causes and the kept package, and saying it could not tell them apart.
That was honest - and it could not tell them apart because nothing ever looked.
Agent v0.129.0 looks, so the hedge becomes an answer.

New class RecoveryCodeOpensRetained on HTTP 422, gated by
FeatureRetainedRecoveryClass (MinAgent 0.129.0). The gate is SEPARATE from the
R-224 one because the two name different agent versions and a box can sit between
them, where a 422 is a shape we did not design. ClassifyRecoveryFailure therefore
takes both flags; the compiler found every call site.

The message says the code is correct, names the supersession date, says the
earlier package is kept, and says the CURRENT backups are unaffected - the half a
customer will otherwise assume wrong. It promises NO restore: there is no
in-product route to a set-aside store (R-312) and the retained package may itself
predate the repository-password field. It routes to support, which can do it.

The claim guard grew a surface and immediately convicted something. It scanned
templates only, while every recovery message is a Go string in a handler - the
highest-stakes copy in the product, never scanned. It now scans recovery_handlers.go
too, and found a PRE-EXISTING unregistered claim on its first run.

Six handler tests asserting which SENTENCE the customer sees; red-proofs asserted
applied, including: 422 unconditional makes an agent that never looked read as
having looked, and routing 400 to the new class congratulates a mistype.
2026-08-12 18:42:02 +02:00

283 lines
12 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package agentapi
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
)
// Controller-driven escrow ceremony client methods (v0.127.0, agent ≥ v0.88.0). The claim call
// is the ONLY place the recovery code R crosses this client — its response body must never be
// logged (the shared helpers log path/status/duration only, never bodies) and the caller hands
// R straight to the wizard's claim XHR, nowhere else.
// EscrowPreflightItem mirrors one agent preflight checklist row.
type EscrowPreflightItem struct {
ID string `json:"id"`
OK bool `json:"ok"`
Detail string `json:"detail"`
}
// EscrowPreflightResponse mirrors GET /escrow/preflight.
type EscrowPreflightResponse struct {
VMID int `json:"vmid"`
OK bool `json:"ok"`
Items []EscrowPreflightItem `json:"items"`
}
// EscrowPreflight fetches the agent's ceremony prerequisite checklist.
func (c *Client) EscrowPreflight(ctx context.Context) (EscrowPreflightResponse, error) {
var out EscrowPreflightResponse
body, err := c.get(ctx, "/escrow/preflight")
if err != nil {
return out, err
}
if err := json.Unmarshal(body, &out); err != nil {
return out, fmt.Errorf("agentapi: decode /escrow/preflight: %w", err)
}
return out, nil
}
// EscrowCeremonyStartResponse mirrors the POST /escrow/ceremony 202 payload.
type EscrowCeremonyStartResponse struct {
JobID string `json:"job_id"`
Phase string `json:"phase"`
}
// EscrowCeremonyStart triggers the agent's detached root ceremony job. Status-aware: the HTTP
// status is returned so the caller can map the agent's 409 (a ceremony already running) to its
// own house-style refusal.
func (c *Client) EscrowCeremonyStart(ctx context.Context) (EscrowCeremonyStartResponse, int, error) {
var out EscrowCeremonyStartResponse
env, status, err := c.postWithStatus(ctx, "/escrow/ceremony", struct{}{})
if err != nil {
return out, status, err
}
if err := refusalError("/escrow/ceremony", status, env); err != nil {
return out, status, err
}
if err := json.Unmarshal(env.Data, &out); err != nil {
return out, status, fmt.Errorf("agentapi: decode /escrow/ceremony: %w", err)
}
return out, status, nil
}
// EscrowCeremonyStatusResponse mirrors GET /escrow/ceremony/status — the NON-SECRET job view
// (R is structurally absent from the agent's payload).
type EscrowCeremonyStatusResponse struct {
Phase string `json:"phase"` // none | running | done | failed | unclaimed_void
JobID string `json:"job_id"`
KeyFingerprint string `json:"key_fingerprint"`
EntropyBits float64 `json:"entropy_bits"`
ResticPwSealed bool `json:"restic_pw_sealed"`
Uploaded bool `json:"uploaded"`
Claimable bool `json:"claimable"`
Claimed bool `json:"claimed"`
ClaimExpiresInSec int `json:"claim_expires_in_sec"`
Detail string `json:"detail"`
}
// EscrowCeremonyStatus polls the ceremony job.
func (c *Client) EscrowCeremonyStatus(ctx context.Context) (EscrowCeremonyStatusResponse, error) {
var out EscrowCeremonyStatusResponse
body, err := c.get(ctx, "/escrow/ceremony/status")
if err != nil {
return out, err
}
if err := json.Unmarshal(body, &out); err != nil {
return out, fmt.Errorf("agentapi: decode /escrow/ceremony/status: %w", err)
}
return out, nil
}
// EscrowCeremonyClaim performs the ONE-SHOT R claim. Returns the recovery code + the agent's
// HTTP status (410 = already claimed / expired — the wizard's void state). The code must never
// be logged, persisted, or placed anywhere but the claim XHR response; the error path carries
// the agent's reason text, never the code.
func (c *Client) EscrowCeremonyClaim(ctx context.Context) (string, int, error) {
env, status, err := c.postWithStatus(ctx, "/escrow/ceremony/claim", struct{}{})
if err != nil {
return "", status, err
}
if status == http.StatusGone {
return "", status, fmt.Errorf("agentapi: POST /escrow/ceremony/claim: gone (claimed or expired)")
}
if err := refusalError("/escrow/ceremony/claim", status, env); err != nil {
return "", status, err
}
var out struct {
RecoveryCode string `json:"recovery_code"`
}
if err := json.Unmarshal(env.Data, &out); err != nil {
return "", status, fmt.Errorf("agentapi: decode /escrow/ceremony/claim: %w", err)
}
if out.RecoveryCode == "" {
return "", status, fmt.Errorf("agentapi: /escrow/ceremony/claim returned no code")
}
return out.RecoveryCode, status, nil
}
// RecoverOffsiteRepoPassword asks the agent to open this host's hub-held sealed bundle with the
// customer's recovery code and return ONLY the offsite restic repository password, plus its sha256
// (R-199, agent >= v0.125.0).
//
// R CROSSES HERE, AND NOWHERE ELSE IN THIS DIRECTION. It travels in the request body over the pinned
// local-API channel (the operator's 2026-08-04 acceptance) and is not retained by this client. The
// shared POST helper logs path/status/duration and never bodies — do not add a body log, on either
// the request or the response side: the request carries R and the response carries the password.
func (c *Client) RecoverOffsiteRepoPassword(ctx context.Context, recoveryCode string) (password, sha256hex string, err error) {
env, status, perr := c.postWithStatus(ctx, "/escrow/recover-offsite-password",
map[string]string{"recovery_code": recoveryCode})
if perr != nil {
return "", "", perr
}
// R-224: this route's refusal keeps its STATUS as a value. `refusalError` flattens status into a
// sentence, and a sentence is not something a caller can branch on — which is exactly how a failed
// fetch and a wrong recovery code came to produce one customer-facing message.
if status < 200 || status > 299 || !env.OK {
return "", "", &RecoveryRefusal{Status: status, Reason: truncateErr(env.Error, 300)}
}
var out struct {
ResticRepoPassword string `json:"restic_repo_password"`
ResticPwSHA256 string `json:"restic_pw_sha256"`
}
if uerr := json.Unmarshal(env.Data, &out); uerr != nil {
return "", "", fmt.Errorf("agentapi: decode /escrow/recover-offsite-password: %w", uerr)
}
if out.ResticRepoPassword == "" || out.ResticPwSHA256 == "" {
return "", "", fmt.Errorf("agentapi: the agent returned an empty recovery result")
}
return out.ResticRepoPassword, out.ResticPwSHA256, nil
}
// ── R-224 — CLASSIFYING A FAILED UNLOCK ─────────────────────────────────────────────────────────
//
// CAMPAIGN-11 measured what happens without this. On 2026-08-05, with a CORRECT current recovery
// code: the hub firewalled off returned the customer "this code does not open your package" in
// 0.0556 s, and this agent stopped returned the same in 0.0299 s — against ~1.0 s for a genuine
// unseal. Neither attempted one. The failure path had exactly two branches, both of them statements
// about the customer's code, and `rerr` was never inspected.
//
// The rule this type exists to enforce: **the customer is blamed only after a real attempt refused
// their code.** Everything else — including anything we cannot classify — says something else.
// RecoveryRefusal is the agent's refusal of an unlock, carrying the STATUS as a value so callers
// classify on it rather than on the sentence. The message keeps `refusalError`'s shape so operator
// logs read as they did.
type RecoveryRefusal struct {
Status int
Reason string
}
func (e *RecoveryRefusal) Error() string {
reason := e.Reason
if reason == "" {
reason = "(no reason in agent response)"
}
return fmt.Sprintf("agentapi: POST /escrow/recover-offsite-password: HTTP %d: %s", e.Status, reason)
}
// RecoveryFailure is what went wrong, as far as it can be known.
type RecoveryFailure int
const (
// RecoveryUnknown — the cause could not be determined. **The safe default**, and deliberately the
// zero value: a new status, a transport shape nobody anticipated, or an agent too old to
// distinguish fetch from refusal all land here, and none of them may blame the customer.
RecoveryUnknown RecoveryFailure = iota
// RecoveryHubUnreachable — the agent answered, and it could not FETCH the sealed package: the hub
// refused, was unreachable, or recovery is not configured on this agent. **The code was not used.**
RecoveryHubUnreachable
// RecoveryAskedAndRefused — the bundle was fetched and the code did not open it. The ONLY class
// from which the customer may be told to check their typing.
RecoveryAskedAndRefused
// RecoveryNoBundle — the hub holds no sealed package for this host at all.
RecoveryNoBundle
// RecoveryBundleTooOld — the bundle opened but predates the repository-password field.
RecoveryBundleTooOld
// RecoveryAgentUnreachable — the machine's own in-house service never answered, so there is no
// agent verdict at all. **The code was not used.** Distinct from RecoveryHubUnreachable because
// it is a different fault, with different words and a different remedy.
RecoveryAgentUnreachable
// RecoveryCodeOpensRetained — the code was used, it WORKED, and it opened a RETAINED earlier
// package rather than the one currently held (R-311, agent >= v0.129.0).
//
// **The customer is not at fault here and must not be told they might be.** This class exists
// because until 2026-08-12 this situation and a mistype were indistinguishable: both fail closed
// against the current package, and nothing ever tried the retained ones. The screen said as much
// out loud — a true sentence about our own incuriosity that a customer reads as a statement about
// their code.
RecoveryCodeOpensRetained
)
// ClassifyRecoveryFailure maps an unlock error to its class, from the VALUE and never the text.
//
// ⚠ `trustRefusal` is the agent-version gate and it is not optional. An agent older than v0.126.0
// answers **400 for BOTH** a fetch failure and a wrong code, so a 400 from one cannot be read as
// "the code was refused" — it means "one of two things, and we cannot tell which". Pass false there
// and the 400 degrades to RecoveryUnknown, which is neutral. That degradation is the point: it is
// safe, it is silent, and it heals itself when the agent updates.
// ⚠ `trustRetained` is the R-311 twin of `trustRefusal` and is separate on purpose: the two gates
// name different agent versions (v0.126.0 and v0.129.0) and a box can sit between them. Passing
// `trustRefusal` for both would let a v0.126128 agent's unexpected 422 be read as a verdict it
// cannot produce.
func ClassifyRecoveryFailure(err error, trustRefusal, trustRetained bool) RecoveryFailure {
if err == nil {
return RecoveryUnknown
}
var ref *RecoveryRefusal
if !errors.As(err, &ref) {
// Not a refusal at all — the request never produced an agent verdict (dial failure, TLS,
// timeout, or the channel could not be built). The machine could not even ASK its own service,
// which is a different sentence from "the hub was unreachable" and a different thing to fix.
return RecoveryAgentUnreachable
}
switch ref.Status {
case http.StatusBadGateway, http.StatusServiceUnavailable, http.StatusGatewayTimeout:
// 502 is agent >= v0.126.0's "the sealed bundle could not be fetched". 503 is its
// "recovery is not configured on this agent (no hub client)". Neither used the code.
return RecoveryHubUnreachable
case http.StatusNotFound:
return RecoveryNoBundle
case http.StatusConflict:
return RecoveryBundleTooOld
case http.StatusUnprocessableEntity:
// R-311. Gated on the SAME trust flag as 400, and for the mirror-image reason: an agent that
// predates the retained lookup cannot emit 422 at all, so a 422 from anywhere else is a shape
// we did not design and must not be read as a statement about the customer's code.
if trustRetained {
return RecoveryCodeOpensRetained
}
return RecoveryUnknown
case http.StatusBadRequest:
if trustRefusal {
return RecoveryAskedAndRefused
}
return RecoveryUnknown
default:
return RecoveryUnknown
}
}
// String names the class for the operator log. The customer never sees these words.
func (f RecoveryFailure) String() string {
switch f {
case RecoveryHubUnreachable:
return "hub-unreachable"
case RecoveryAgentUnreachable:
return "agent-unreachable"
case RecoveryAskedAndRefused:
return "asked-and-refused"
case RecoveryNoBundle:
return "no-bundle"
case RecoveryBundleTooOld:
return "bundle-too-old"
case RecoveryCodeOpensRetained:
return "code-opens-retained"
default:
return "unknown"
}
}