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 ) // 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. func ClassifyRecoveryFailure(err error, trustRefusal 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.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" default: return "unknown" } }