v0.89.0: pbsdr self-grant (R-22) + escrow config live-reload + agent-plane poke listener (Direction-2a)

- pbsdr: on a 403 pre-check (non-default storage id, no ACL yet) self-grant via the root wrapper then re-read, instead of aborting before the grant — closes F4/R-22. Red-proof TestSelfGrant_PreCheck403DoesNotAbortBeforeGrant.
- escrow preflight: late-bound CurrentPBSStorageID re-reads agent.json so a pbsdr-seeded pbs_storage_id flips the row green in-process (no restart). Red-proof TestEscrowPreflight_PBSStorageIDLiveReload.
- internal/poke: contentless UDP poke listener bound exclusively to the box WG /32 (port 51822), leading-edge debounced, fires the hub-loop out-of-band trigger for an immediate desired-state cycle. First slice of R-13. Red-proofs TestBindConfinement + TestDebounceCoalescesBurst.
This commit is contained in:
2026-07-16 22:47:22 +02:00
parent c040c180e9
commit a659e5dc09
11 changed files with 643 additions and 15 deletions
+18 -3
View File
@@ -34,8 +34,17 @@ import (
type EscrowCeremonyConfig struct {
// SudoPath is the sudo binary ("" → "sudo").
SudoPath string
// PBSStorageID is cfg.Escrow.PBSStorageID ("" = not configured — preflight red).
// PBSStorageID is cfg.Escrow.PBSStorageID at daemon-start ("" = not configured — preflight
// red). It is the FALLBACK snapshot; the live value is CurrentPBSStorageID when wired.
PBSStorageID string
// CurrentPBSStorageID, when set, is called at preflight time to read the LIVE
// escrow.pbs_storage_id. The pbsdr bridge SEEDS this key into agent.json on DR convergence
// (finishConverged → seedEscrowStorageID); a static snapshot taken at daemon start would then
// stay red until a service restart (v0.89.0 live-reload). The wired closure re-reads config
// from disk — exactly what the ceremony subprocess itself loads — so the preflight reflects the
// real state the bare `--selftest=escrow-create` one-liner will see. nil → the PBSStorageID
// snapshot is used (old wiring / tests).
CurrentPBSStorageID func() string
// HubConfigured: hub url + host id + api key all present (the --upload target).
HubConfigured bool
// DRConfigured answers "is the DR tier applied on this box?" (pbsdr.Manager.DRConfigured,
@@ -362,8 +371,14 @@ func (s *Server) handleEscrowPreflight(w http.ResponseWriter, r *http.Request, v
}
items := make([]item, 0, 6)
items = append(items, item{ID: "pbs_storage_id", OK: cfg.PBSStorageID != "",
Detail: map[bool]string{true: cfg.PBSStorageID, false: "escrow.pbs_storage_id not configured"}[cfg.PBSStorageID != ""]})
// Live-reload (v0.89.0): prefer the current on-disk value over the daemon-start snapshot, so a
// pbsdr convergence that just seeded escrow.pbs_storage_id flips this row green with no restart.
storageID := cfg.PBSStorageID
if cfg.CurrentPBSStorageID != nil {
storageID = cfg.CurrentPBSStorageID()
}
items = append(items, item{ID: "pbs_storage_id", OK: storageID != "",
Detail: map[bool]string{true: storageID, false: "escrow.pbs_storage_id not configured"}[storageID != ""]})
drOK := cfg.DRConfigured != nil && cfg.DRConfigured()
items = append(items, item{ID: "dr_tier", OK: drOK,
+78
View File
@@ -5,6 +5,8 @@ import (
"encoding/json"
"fmt"
"net/http"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
@@ -61,6 +63,82 @@ func startAndWait(t *testing.T, srv *Server, h http.Handler) {
}
}
// TestEscrowPreflight_PBSStorageIDLiveReload pins the v0.89.0 live-reload: the pbsdr bridge seeds
// escrow.pbs_storage_id into agent.json on DR convergence; the preflight must see that seed IN THE
// SAME PROCESS (no restart). The daemon-start snapshot (cfg.PBSStorageID) is empty; only the
// late-bound CurrentPBSStorageID resolver (re-reads disk) reflects the seed. RED-PROOF: the pre-fix
// preflight read cfg.PBSStorageID directly and ignored the resolver, so step 3 stays red → FAIL.
func TestEscrowPreflight_PBSStorageIDLiveReload(t *testing.T) {
dir := t.TempDir()
cfgPath := filepath.Join(dir, "agent.json")
// Pre-convergence: agent.json has no escrow.pbs_storage_id.
if err := os.WriteFile(cfgPath, []byte(`{"escrow":{"posture":"zero_knowledge"}}`), 0o600); err != nil {
t.Fatalf("seed config: %v", err)
}
readCurrent := func() string {
raw, err := os.ReadFile(cfgPath)
if err != nil {
return ""
}
var doc struct {
Escrow struct {
PBSStorageID string `json:"pbs_storage_id"`
} `json:"escrow"`
}
_ = json.Unmarshal(raw, &doc)
return doc.Escrow.PBSStorageID
}
srv := newEscrowTestServer(t, okRunner(nil))
srv.escrowCeremony.PBSStorageID = "" // daemon-start snapshot: empty (the pre-fix source)
srv.escrowCeremony.CurrentPBSStorageID = readCurrent // live resolver → current disk state
h := srv.Handler()
rowOK := func(t *testing.T) (bool, string) {
t.Helper()
w := do(t, h, "GET", "/escrow/preflight", "A", "")
if w.Code != http.StatusOK {
t.Fatalf("preflight status %d: %s", w.Code, w.Body.String())
}
var resp struct {
Data struct {
Items []struct {
ID string `json:"id"`
OK bool `json:"ok"`
Detail string `json:"detail"`
} `json:"items"`
} `json:"data"`
}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode preflight: %v (%s)", err, w.Body.String())
}
for _, it := range resp.Data.Items {
if it.ID == "pbs_storage_id" {
return it.OK, it.Detail
}
}
t.Fatal("pbs_storage_id row missing from preflight")
return false, ""
}
// 1. Before convergence: the row is RED.
if ok, _ := rowOK(t); ok {
t.Fatal("pbs_storage_id row is green before any seed")
}
// 2. Convergence seeds the id IN-PROCESS (simulating pbsdr finishConverged → seedEscrowStorageID).
if err := os.WriteFile(cfgPath, []byte(`{"escrow":{"posture":"zero_knowledge","pbs_storage_id":"felhom-offsite"}}`), 0o600); err != nil {
t.Fatalf("seed after convergence: %v", err)
}
// 3. Same process, NO restart: the row flips GREEN and reports the seeded id.
ok, detail := rowOK(t)
if !ok {
t.Fatal("pbs_storage_id row still red after the in-process seed — the preflight is not live-reloading (restart-only)")
}
if detail != "felhom-offsite" {
t.Fatalf("pbs_storage_id detail = %q, want the seeded id felhom-offsite", detail)
}
}
// Scenario A/D happy path: run → done → claim ONCE (R delivered, no-store) → 410 on re-claim,
// holder zeroed. R never appears in the start or status payloads.
func TestEscrowCeremony_OneShotClaim(t *testing.T) {