75b3b39254
gates / gates (push) Successful in 54s
Unreleased; ships with tomorrow's release. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
295 lines
13 KiB
Go
295 lines
13 KiB
Go
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
|
||
// RecoveryOlderUnchecked — the code did not open the current package, no earlier package opened it, and
|
||
// NOT every earlier package the hub holds was tried (R-304, agent 424). So whether the code is wrong is
|
||
// NOT known. The customer must not be told to check their typing as if it were.
|
||
RecoveryOlderUnchecked
|
||
)
|
||
|
||
// 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.126–128 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.StatusFailedDependency:
|
||
// R-304. NOT version-gated, deliberately — unlike 400 and 422 above. Those two make a claim about the
|
||
// customer's code (wrong / correct), so a status we did not design must not be read as one. This class
|
||
// claims only „not everything was checked; contact support", which is the same direction as
|
||
// RecoveryUnknown: misreading a stray 424 here can never accuse anyone.
|
||
return RecoveryOlderUnchecked
|
||
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"
|
||
case RecoveryOlderUnchecked:
|
||
return "older-unchecked"
|
||
default:
|
||
return "unknown"
|
||
}
|
||
}
|