package agentapi import ( "context" "encoding/json" "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 } if rerr := refusalError("/escrow/recover-offsite-password", status, env); rerr != nil { return "", "", rerr } 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 }