region HA 2b: the pass finds an invitation read-only, CheckConfirmed unexported, logs say refused or unknown
The invited path read the invitation and then called Invite, which is get-or-create, on the plain store: an invitation gone by Invite's own Get was minted again with no membership read. The pass now reads it once with enrollmentNonce, which never writes, so Invite runs only on the bound store after a read. CheckConfirmed had no production caller and is unexported. A check that failed before any read is not logged as a membership read, and every logged outcome says refused or unknown.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
5 files changed,
+154
−54
internal/hostactors/autoenroll.go+29 −21internal/hostactors/autoenroll_test.go+101 −9internal/hostactors/enrollment_confirmation.go+5 −12internal/hostactors/membershipread_test.go+17 −10internal/provision/testdata/plan14-root-reads-region-ha-2b.tsv+2 −2
diff --git a/internal/hostactors/autoenroll.go b/internal/hostactors/autoenroll.go| index 365058d56..b8be2e78f 100644 |
| --- a/internal/hostactors/autoenroll.go |
| +++ b/internal/hostactors/autoenroll.go |
| @@ -25,7 +25,7 @@ import ( |
| // |
| // 1. no invitation: begin an enrollment attempt (BeginEnrollmentAttempt), |
| // then, on the next pass, confirm the instance's group membership with |
| -// the console's own read (CheckConfirmed, Members) and invite the box |
| +// the console's own read (checkConfirmed, Members) and invite the box |
| // (Invite) while that read is under a minute old; |
| // 2. invited: wait for the box to arm its guard and publish its |
| // guard-ready report (VerifyReport, the same check membership.go makes |
| @@ -200,23 +200,28 @@ func (e *AutoEnroller) step(ctx context.Context, o Observed, floor hostfloor.Ver |
| } |
| in := EnrollmentRequest{Reference: ref, Namespace: e.Namespace, Label: ref, Region: name, Pool: e.Pool, AWSRegion: region.AWSRegion} |
| - _, _, err = d.Store.Get(ctx, "control/host-actor-invitations/"+ref+".json") |
| + // The invitation is only read here, once, and verified for this |
| + // record (enrollmentNonce writes nothing). Invite is get-or-create, so |
| + // it runs only on the bound store after a membership read |
| + // (confirmAndWrite): an invitation that vanished between two reads is |
| + // never minted again without one. |
| + nonce, err := d.enrollmentNonce(ctx, in, raw) |
| invited := err == nil |
| - if err != nil && !errors.Is(err, os.ErrNotExist) { |
| + switch { |
| + case err == nil, errors.Is(err, os.ErrNotExist): |
| + case errors.Is(err, ErrRoster): |
| + return Observed{}, fmt.Sprintf("its invitation is not for this record: %v", err) |
| + default: |
| return Observed{}, fmt.Sprintf("its invitation could not be read: %v", err) |
| } |
| if invited { |
| - inv, err := d.Invite(ctx, e.Signer, ref, raw) |
| - if err != nil { |
| - return Observed{}, fmt.Sprintf("its invitation is not for this record: %v", err) |
| - } |
| // The report is read under the reference derived from the |
| // attempt's proven instance (PrivateBoxReport, Group set). |
| rep, err := e.Evidence.BoxReport(ctx, ref, Box{Namespace: e.Namespace, Label: ref, Group: region.GroupName, Cloud: ev.Cloud}) |
| if err != nil { |
| return Observed{}, "waiting for its guard-ready report" |
| } |
| - if err := VerifyReport(ctx, e.Evidence, d.PublicKey, floor, rep, inv.Enrollment, instance, now); err != nil { |
| + if err := VerifyReport(ctx, e.Evidence, d.PublicKey, floor, rep, nonce, instance, now); err != nil { |
| return Observed{}, "waiting for its guard-ready report" |
| } |
| } |
| @@ -284,9 +289,8 @@ func (e *AutoEnroller) confirmAndWrite(ctx context.Context, o Observed, v boxid. |
| } |
| return next, fmt.Sprintf("enrolled in %s with the group's role, %s", name, RoleName(region.Role)), nil |
| } |
| - // The check is CheckConfirmed's own, made here through its unexported |
| - // core so the write is bound to the confirmation that read built, never |
| - // to one rebuilt from its exported evidence. |
| + // checkConfirmed returns the confirmation its read built, and the |
| + // write is bound to that, never to one rebuilt from exported evidence. |
| _, c, err := d.checkConfirmed(ctx, o, v, in, raw, instance, e.Members, e.Now) |
| ev := c.evidence() |
| if err != nil { |
| @@ -313,14 +317,22 @@ func (e *AutoEnroller) confirmAndWrite(ctx context.Context, o Observed, v boxid. |
| // logRead records one membership read: the box, its attempt, its instance, |
| // the cloud's two request ids and the read's time, and what came of it |
| -// (C2's audit: the roster has no field for them). A call that failed |
| -// before the cloud answered has no request ids and says so. |
| +// (C2's audit: the roster has no field for them): done on success, |
| +// "refused: " and the reason for the cloud's positive answer that counts |
| +// against the attempt (ErrNotMember, ErrMembershipStale), and "unknown: " |
| +// and the reason for everything else. A call that failed before the cloud |
| +// answered has no request ids and says so. A check that failed before |
| +// any read was made (no read time) is not a membership read and is not |
| +// logged here. |
| func (e *AutoEnroller) logRead(in EnrollmentRequest, instance string, ev MembershipEvidence, done string, err error) { |
| + if ev.ObservedAt.IsZero() { |
| + return |
| + } |
| outcome := done |
| if err != nil { |
| - outcome = "not " + done + ": " + err.Error() |
| - if done == "" { |
| - outcome = err.Error() |
| + outcome = "unknown: " + err.Error() |
| + if errors.Is(err, ErrNotMember) || errors.Is(err, ErrMembershipStale) { |
| + outcome = "refused: " + err.Error() |
| } |
| } |
| e.logOutcome(in, instance, ev, outcome) |
| @@ -336,11 +348,7 @@ func (e *AutoEnroller) logOutcome(in EnrollmentRequest, instance string, ev Memb |
| if len(ev.RequestIDs) > 0 { |
| ids = strings.Join(ev.RequestIDs, ", ") |
| } |
| - at := "never" |
| - if !ev.ObservedAt.IsZero() { |
| - at = ev.ObservedAt.Format(time.RFC3339) |
| - } |
| - e.Logf("console: automatic box enrollment: membership read for %s (attempt %s, instance %s, request ids %s, read at %s): %s", in.Reference, in.AttemptID, instance, ids, at, outcome) |
| + e.Logf("console: automatic box enrollment: membership read for %s (attempt %s, instance %s, request ids %s, read at %s): %s", in.Reference, in.AttemptID, instance, ids, ev.ObservedAt.Format(time.RFC3339), outcome) |
| } |
| // groupRegion is the one roster region whose group could have launched |
diff --git a/internal/hostactors/autoenroll_test.go b/internal/hostactors/autoenroll_test.go| index 99baa9636..7789ad3ff 100644 |
| --- a/internal/hostactors/autoenroll_test.go |
| +++ b/internal/hostactors/autoenroll_test.go |
| @@ -5,6 +5,7 @@ import ( |
| "encoding/json" |
| "errors" |
| "fmt" |
| + "os" |
| "strings" |
| "testing" |
| "time" |
| @@ -78,18 +79,28 @@ func (w *enrollWorld) guardReady(t *testing.T, enrollment string, ready bool) { |
| func (w *enrollWorld) invitation(t *testing.T) (Invitation, bool) { |
| t.Helper() |
| - if _, _, err := w.d.Store.Get(context.Background(), "control/host-actor-invitations/"+newRef+".json"); err != nil { |
| + ctx := context.Background() |
| + wire, _, err := w.d.Store.Get(ctx, "control/host-actor-invitations/"+newRef+".json") |
| + if errors.Is(err, os.ErrNotExist) { |
| return Invitation{}, false |
| } |
| - raw, err := boxid.Load(context.Background(), w.d.Store, newRef) |
| if err != nil { |
| t.Fatal(err) |
| } |
| - // Invite returns the stored invitation, verified, and writes nothing. |
| - got, err := w.d.Invite(context.Background(), w.sg, newRef, raw) |
| + var got Invitation |
| + if err := release.VerifyRootEnvelope(w.d.PublicKey, wire, &got); err != nil { |
| + t.Fatal(err) |
| + } |
| + // Read only, never Invite (get-or-create): the invitation verifies |
| + // for the box's record, and nothing is written. |
| + raw, err := boxid.Load(ctx, w.d.Store, newRef) |
| if err != nil { |
| t.Fatal(err) |
| } |
| + nonce, err := w.d.enrollmentNonce(ctx, EnrollmentRequest{Reference: newRef, Namespace: "platform"}, raw) |
| + if err != nil || nonce != got.Enrollment { |
| + t.Fatalf("invitation %+v: %v", got, err) |
| + } |
| return got, true |
| } |
| @@ -222,7 +233,7 @@ func TestAMembershipReadThatFailsKeepsTheAttempt(t *testing.T) { |
| // C2: the invitation is written only while the console's read is under a |
| // minute old. Here every look at the clock takes 31 seconds: the read is |
| -// fresh when CheckConfirmed has it, and over a minute old by the time the |
| +// fresh when checkConfirmed has it, and over a minute old by the time the |
| // invitation would be written. That is unknown: the attempt and its wait |
| // stand. |
| func TestAnInvitationNeedsAReadUnderAMinuteOldAtTheWrite(t *testing.T) { |
| @@ -654,8 +665,8 @@ func TestAutomaticEnrollmentRefusesWhatItCannotProve(t *testing.T) { |
| // S5: every membership read is logged with the box, its attempt, its |
| // instance and the request ids the read's own confirmation kept, whatever |
| -// came of it: a failed read (the ids the cloud gave, "none" for a call it |
| -// never answered), a refusal, and an invitation. |
| +// came of it: a failed read, unknown (the ids the cloud gave, "none" for a |
| +// call it never answered), a refusal, and an invitation. |
| func TestEveryMembershipReadIsLoggedWithItsIds(t *testing.T) { |
| w := newEnrollWorld(t, "") |
| w.pass(t) |
| @@ -666,10 +677,23 @@ func TestEveryMembershipReadIsLoggedWithItsIds(t *testing.T) { |
| w.cloud.err, w.cloud.failIDs = nil, nil |
| w.cloud.members["i-new"] = member("routers-uk") |
| w.pass(t) |
| - if len(w.logs) != 2 || !strings.Contains(w.logs[0], head+"asg-req, none, read at ") || !strings.Contains(w.logs[0], "could not be read") || |
| - !strings.Contains(w.logs[1], head+"asg-req, ec2-req, read at ") || !strings.Contains(w.logs[1], "not a current member") { |
| + if len(w.logs) != 2 || !strings.Contains(w.logs[0], head+"asg-req, none, read at ") || !strings.Contains(w.logs[0], "): unknown: "+ErrMembershipUnknown.Error()) || |
| + !strings.Contains(w.logs[1], head+"asg-req, ec2-req, read at ") || !strings.Contains(w.logs[1], "): refused: "+ErrNotMember.Error()) { |
| t.Fatalf("logs %q", w.logs) |
| } |
| + // A check that failed before any read was made is not a membership |
| + // read, and is not logged as one. |
| + w.e.logRead(EnrollmentRequest{Reference: newRef, AttemptID: id}, "i-new", MembershipEvidence{}, "invited", ErrRoster) |
| + if len(w.logs) != 2 { |
| + t.Fatalf("a check with no read was logged: %q", w.logs[2:]) |
| + } |
| + // A describe that took a minute is a refusal too. |
| + x := newEnrollWorld(t, "") |
| + x.pass(t) |
| + x.cloud.during = func() { x.clock = x.clock.Add(time.Minute) } |
| + if got := x.pass(t); !strings.Contains(got, "not confirmed") || len(x.logs) != 1 || !strings.Contains(x.logs[0], "): refused: "+ErrMembershipStale.Error()) { |
| + t.Fatalf("stale: %q, logs %q", got, x.logs) |
| + } |
| v := newEnrollWorld(t, "") |
| v.pass(t) |
| if got := v.pass(t); got != "invited" { |
| @@ -732,3 +756,71 @@ func TestAStoreErrorIsUnknownAndKeepsTheAttempt(t *testing.T) { |
| }) |
| } |
| } |
| + |
| +// vanishingStore answers the next Get of key, once armed, with the stored |
| +// object and then removes it, as an invitation deleted just after the |
| +// pass read it would be. It records each Put of key in events. |
| +type vanishingStore struct { |
| + release.Store |
| + key string |
| + armed bool |
| + events *[]string |
| +} |
| + |
| +func (s *vanishingStore) Get(ctx context.Context, key string) ([]byte, string, error) { |
| + body, etag, err := s.Store.Get(ctx, key) |
| + if s.armed && key == s.key && err == nil { |
| + s.armed = false |
| + if err := s.Store.Delete(ctx, key); err != nil { |
| + return nil, "", err |
| + } |
| + } |
| + return body, etag, err |
| +} |
| + |
| +func (s *vanishingStore) Put(ctx context.Context, key string, body []byte, ifMatch string) (string, error) { |
| + if key == s.key { |
| + *s.events = append(*s.events, "put") |
| + } |
| + return s.Store.Put(ctx, key, body, ifMatch) |
| +} |
| + |
| +// An invitation is never written without a membership read just before |
| +// it. Here the invitation vanishes right after the pass reads it: that |
| +// pass waits for the box's report and writes nothing, and the next one |
| +// reads membership and only then invites again. |
| +func TestAVanishedInvitationIsNeverWrittenWithoutARead(t *testing.T) { |
| + w := newEnrollWorld(t, "") |
| + w.pass(t) |
| + var events []string |
| + w.cloud.during = func() { events = append(events, "read") } |
| + key := "control/host-actor-invitations/" + newRef + ".json" |
| + vs := &vanishingStore{Store: w.e.Directory.Store, key: key, events: &events} |
| + w.e.Directory.Store = vs |
| + if got := w.pass(t); got != "invited" { |
| + t.Fatal(got) |
| + } |
| + vs.armed = true |
| + if got := w.pass(t); got != "waiting for its guard-ready report" { |
| + t.Fatalf("read, then vanished: %q", got) |
| + } |
| + if _, ok := w.invitation(t); ok { |
| + t.Fatal("the invitation was written again in the pass that read it") |
| + } |
| + if got := w.pass(t); got != "invited" { |
| + t.Fatalf("after it vanished: %q", got) |
| + } |
| + if got := w.pass(t); got != "waiting for its guard-ready report" { |
| + t.Fatalf("invited again: %q", got) |
| + } |
| + last := "" |
| + for _, ev := range events { |
| + if ev == "put" && last != "read" { |
| + t.Fatalf("an invitation Put with no membership read just before it: %v", events) |
| + } |
| + last = ev |
| + } |
| + if fmt.Sprint(events) != "[read put read put]" { |
| + t.Fatalf("events %v", events) |
| + } |
| +} |
diff --git a/internal/hostactors/enrollment_confirmation.go b/internal/hostactors/enrollment_confirmation.go| index 893b71ba5..e49ce7baa 100644 |
| --- a/internal/hostactors/enrollment_confirmation.go |
| +++ b/internal/hostactors/enrollment_confirmation.go |
| @@ -46,22 +46,15 @@ func (d Directory) EnrollAutomatic(ctx context.Context, sg release.Signer, o Obs |
| return next, c.evidence(), nil |
| } |
| -// CheckConfirmed is EnrollAutomatic's admission check, made without |
| +// checkConfirmed is EnrollAutomatic's admission check, made without |
| // writing: the attempt in the roster matches the request and the record, |
| // the request's reference is the instance's group reference |
| // (GroupBoxReference), and a describe made now confirms the attempt's |
| // instance is a current member of the region's group. It returns in |
| -// pinned to that instance and what the read saw. The console's enrollment |
| -// pass asks it immediately before it invites a box, and makes the |
| -// invitation under the read's deadline (autoenroll.go). |
| -func (d Directory) CheckConfirmed(ctx context.Context, o Observed, v boxid.Verifier, in EnrollmentRequest, raw []byte, instance string, r MembershipReader, now func() time.Time) (EnrollmentRequest, MembershipEvidence, error) { |
| - pinned, c, err := d.checkConfirmed(ctx, o, v, in, raw, instance, r, now) |
| - if err != nil { |
| - return EnrollmentRequest{}, c.evidence(), err |
| - } |
| - return pinned, c.evidence(), nil |
| -} |
| - |
| +// pinned to that instance and the confirmation the read built, which |
| +// never leaves this package. The console's enrollment pass asks it |
| +// immediately before it invites a box, and makes the invitation on the |
| +// store writeBound binds to that confirmation (autoenroll.go). |
| func (d Directory) checkConfirmed(ctx context.Context, o Observed, v boxid.Verifier, in EnrollmentRequest, raw []byte, instance string, r MembershipReader, now func() time.Time) (EnrollmentRequest, membershipConfirmation, error) { |
| if err := validateScope(o.Roster, Scope{Namespace: in.Namespace, Regions: []string{in.Region}, Pools: []string{in.Pool}}); err != nil { |
| return EnrollmentRequest{}, membershipConfirmation{}, err |
diff --git a/internal/hostactors/membershipread_test.go b/internal/hostactors/membershipread_test.go| index 18e8d1bdf..beb2f38cf 100644 |
| --- a/internal/hostactors/membershipread_test.go |
| +++ b/internal/hostactors/membershipread_test.go |
| @@ -109,7 +109,7 @@ func TestEnrollAutomaticReadsMembershipItself(t *testing.T) { |
| if _, _, err := w.enroll(); !errors.Is(err, ErrRoster) || len(w.cloud.calls) != 1 { |
| t.Fatalf("replay: %v, calls %v", err, w.cloud.calls) |
| } |
| - if _, _, err := w.d.CheckConfirmed(context.Background(), next, w.v, w.in, w.raw, "i-new", w.cloud, w.now); !errors.Is(err, ErrRoster) { |
| + if _, _, err := w.d.checkConfirmed(context.Background(), next, w.v, w.in, w.raw, "i-new", w.cloud, w.now); !errors.Is(err, ErrRoster) { |
| t.Fatalf("replayed check: %v", err) |
| } |
| } |
| @@ -128,7 +128,7 @@ func TestEnrollAutomaticRefusesANonMember(t *testing.T) { |
| } { |
| t.Run(name, func(t *testing.T) { |
| w := newMemberWorld(t, m) |
| - if _, _, err := w.d.CheckConfirmed(context.Background(), w.o, w.v, w.in, w.raw, "i-new", w.cloud, w.now); !errors.Is(err, ErrNotMember) { |
| + if _, _, err := w.d.checkConfirmed(context.Background(), w.o, w.v, w.in, w.raw, "i-new", w.cloud, w.now); !errors.Is(err, ErrNotMember) { |
| t.Fatalf("check: %v", err) |
| } |
| if _, _, err := w.enroll(); !errors.Is(err, ErrNotMember) { |
| @@ -179,7 +179,7 @@ func TestAMembershipReadThatIsNotAnAnswerIsUnknown(t *testing.T) { |
| t.Run(name, func(t *testing.T) { |
| w := newMemberWorld(t, member("routers-ie")) |
| edit(w.cloud) |
| - if _, _, err := w.d.CheckConfirmed(context.Background(), w.o, w.v, w.in, w.raw, "i-new", w.cloud, w.now); !errors.Is(err, ErrMembershipUnknown) || errors.Is(err, ErrNotMember) { |
| + if _, _, err := w.d.checkConfirmed(context.Background(), w.o, w.v, w.in, w.raw, "i-new", w.cloud, w.now); !errors.Is(err, ErrMembershipUnknown) || errors.Is(err, ErrNotMember) { |
| t.Fatalf("check: %v", err) |
| } |
| _, _, err := w.enroll() |
| @@ -207,7 +207,8 @@ func TestAFailedReadKeepsTheIdsTheCloudGave(t *testing.T) { |
| t.Run(name, func(t *testing.T) { |
| w := newMemberWorld(t, member("routers-ie")) |
| w.cloud.err, w.cloud.failIDs = errors.New("UnauthorizedOperation"), ids |
| - _, ev, err := w.d.CheckConfirmed(context.Background(), w.o, w.v, w.in, w.raw, "i-new", w.cloud, w.now) |
| + _, c, err := w.d.checkConfirmed(context.Background(), w.o, w.v, w.in, w.raw, "i-new", w.cloud, w.now) |
| + ev := c.evidence() |
| if !errors.Is(err, ErrMembershipUnknown) || !reflect.DeepEqual(ev.RequestIDs, ids) || !ev.ObservedAt.Equal(w.clock) { |
| t.Fatalf("check: %v, evidence %+v", err, ev) |
| } |
| @@ -215,7 +216,7 @@ func TestAFailedReadKeepsTheIdsTheCloudGave(t *testing.T) { |
| if !errors.Is(err, ErrMembershipUnknown) || !reflect.DeepEqual(ev.RequestIDs, ids) { |
| t.Fatalf("enroll: %v, evidence %+v", err, ev) |
| } |
| - c, err := confirmMembership(context.Background(), w.cloud, w.o.Roster.Regions["ie"], "i-new", w.now) |
| + c, err = confirmMembership(context.Background(), w.cloud, w.o.Roster.Regions["ie"], "i-new", w.now) |
| if !errors.Is(err, ErrMembershipUnknown) || !reflect.DeepEqual(c.requestIDs, ids) { |
| t.Fatalf("confirmMembership: %v, %v", err, c.requestIDs) |
| } |
| @@ -260,11 +261,17 @@ func TestEnrollAutomaticRefusesAReadOlderThanAMinuteAtTheWrite(t *testing.T) { |
| // No exported identifier of this package carries a membership |
| // confirmation: the only one is built inside it, from a reader's answer. |
| func TestNoExportedPathTakesAConfirmation(t *testing.T) { |
| - for _, name := range []string{"EnrollAutomatic", "CheckConfirmed"} { |
| - m, ok := reflect.TypeOf(Directory{}).MethodByName(name) |
| - if !ok { |
| - t.Fatalf("no %s", name) |
| - } |
| + // Every exported method of Directory, EnrollAutomatic among them: the |
| + // admission check itself (checkConfirmed) is not exported. |
| + if _, ok := reflect.TypeOf(Directory{}).MethodByName("EnrollAutomatic"); !ok { |
| + t.Fatal("no EnrollAutomatic") |
| + } |
| + if _, ok := reflect.TypeOf(Directory{}).MethodByName("CheckConfirmed"); ok { |
| + t.Fatal("the admission check is exported again") |
| + } |
| + for j := 0; j < reflect.TypeOf(Directory{}).NumMethod(); j++ { |
| + m := reflect.TypeOf(Directory{}).Method(j) |
| + name := m.Name |
| for i := 0; i < m.Type.NumIn(); i++ { |
| in := m.Type.In(i) |
| if strings.Contains(strings.ToLower(in.Name()), "confirmation") || in == reflect.TypeOf(MembershipEvidence{}) || in == reflect.TypeOf(Membership{}) { |
diff --git a/internal/provision/testdata/plan14-root-reads-region-ha-2b.tsv b/internal/provision/testdata/plan14-root-reads-region-ha-2b.tsv| index 0e496e2a7..faf32cdd5 100644 |
| --- a/internal/provision/testdata/plan14-root-reads-region-ha-2b.tsv |
| +++ b/internal/provision/testdata/plan14-root-reads-region-ha-2b.tsv |
| @@ -2,7 +2,7 @@ |
| # Task 2: host.env from the deployment's parameters (internal/hostenv: hostenv.go, the layout, assembly and rendering, with an allowlist of names, shared values written create-only and the group-box marker added on write; cli.go, carlos system host-env write and seed; write_linux.go, the write through directories held by descriptor, and write_other.go, its refusal off Linux; awsssm.go, the SSM adapter) and the SSM SDK in go.mod. Three new root-read units. runHostEnv: seed's --from file. Ruled authority A,J4 on conditions (no box write to the parameters; allowlisted names, safe values, atomic write, checked parent; authority-selecting values cross-checked against the roster at the agent's use). writeHostEnv and checkedDir (Linux): write's fixed path, /etc/carlos/host.env, reached only through /etc and /etc/carlos opened O_NOFOLLOW|O_DIRECTORY and checked by fstat (root's, writable by no group or other), the file written 0600 in that directory, synced, renamed and the directory synced; ruled A,J4 under census-ruling-region-ha-2b, as the checked parent runHostEnv's condition asks for. The legacy seal stands. |
| # Task 3: carlos system install-channel (internal/host installchannel.go, installchannel_linux.go): a fresh box installs its signed channel release into both protected slots through verifyPointer, hostfloor.CheckImage and stageCandidate, all unchanged, and on every run hands each directory on the agent's cache path (system, system/router and the version's) to the service user through bootstrap's own descriptor-anchored open, refusing a symlinked parent. One new root-read unit, channelInstall.run, whose one read site opens root's apply lock (systemApplier's lockPath). Ruled authority F,J4. stageCandidate, accepted-legacy, is called and not changed. The legacy seal stands. Fix rounds 1 and 2: more root-read units. slotState (Linux): each protected slot opened with O_NOFOLLOW and hashed from the descriptor against the signed artifact's sha256, to refuse a slot holding a build this command did not install. removeBeneath (Linux): removes the agent's cache entry only by openat2 and unlinkat on descriptors anchored at the router directory, opened with no symlink followed, never by path name, and refuses without openat2. var openat2: the unix.Openat2 seam for the test that it refuses. All ruled F,J4 on the same footing as channelInstall.run. |
| # Task 4: group membership, read by the console (internal/hostactors membershipread.go: MembershipReader, the unexported confirmation and the write bound, the single enforcement point of the 60-second deadline, each store call cut off at 15 seconds; enrollment_confirmation.go: EnrollAutomatic and CheckConfirmed make the read themselves and write on the bound store, and the box-written confirmation object is removed; groupref.go: GroupBoxReference; private.go: a group box's report read under the reference the console derives; awsmembership, the Auto Scaling and EC2 describe adapter) and the Auto Scaling SDK in go.mod. No root-read unit is added or changed: the read is the cloud's, through the reader. Envelope-only, and the seal stands. The record-write census gains deadlineStore.Put (nonrecord, ruled). |
| -# Task 5: the console's automatic enrollment pass (internal/hostactors autoenroll.go: AutoEnroller: an enrolling write the deadline left unknown is settled by the next pass's roster read; internal/console enrollpass.go, started in StartClaimRecoveryPass with region HA on, and awsmembership.go, its membership reader). One new root-read unit, AutoEnroller.inputs, whose two read sites are the signed roster (Directory.Read) and the host floor (hostfloor.Read), in the console. Ruled accepted-legacy (J4 root-input). Invite, BeginEnrollmentAttempt and checkMembership, accepted-legacy, are called and not changed. The seal moves. |
| +# Task 5: the console's automatic enrollment pass (internal/hostactors autoenroll.go: AutoEnroller: an enrolling write the deadline left unknown is settled by the next pass's roster read; internal/console enrollpass.go, started in StartClaimRecoveryPass with region HA on, and awsmembership.go, its membership reader). One new root-read unit, AutoEnroller.inputs, whose two read sites are the signed roster (Directory.Read) and the host floor (hostfloor.Read), in the console. Ruled accepted-legacy (J4 root-input). Invite (only on the bound store, after a membership read), enrollmentNonce (the read-only invitation check), BeginEnrollmentAttempt and checkMembership, accepted-legacy, are called and not changed. Fix round 1: CheckConfirmed is unexported (enrollment_confirmation.go), the pass binds the invitation to the confirmation checkConfirmed returns, and an invited box is found with enrollmentNonce, never Invite. The seal moves. |
| @coverage . 3678cfe22582d35fe0d8e93d3fed898cdffa6fb2ce2d81e0252c8a8da77993b3 |
| @coverage internal/hostenv 45d9d4b90a5d441020f5ba4573821ca92701246c80b32d6f1823665fb0f65208 |
| internal/hostenv/cli.go runHostEnv authority A,J4 rev3=census-ruling-region-ha-2b;basis=carlos system host-env: write runs as root at a box's start-up and writes /etc/carlos/host.env, 0600, whole (temporary file and rename), from allowlisted parameters with safe values only, refusing a missing required value; its one read site is seed's --from file, an operator's host.env whose values go only to the parameter store; write's path is fixed and reached only through writeHostEnv file:ReadFile@1500 df4470176b229b311927ff0ac4f268d354c3a2357cac4300ac0992d2ebb90899 |
| @@ -13,7 +13,7 @@ internal/host/installchannel.go channelInstall.run authority F,J4 rev3=census-ru |
| internal/host/installchannel_linux.go slotState authority F,J4 rev3=census-ruling-region-ha-2b;basis=carlos system install-channel (root, Linux): opens one protected slot with O_NOFOLLOW (a symlink is never followed), fstats the descriptor and hashes from it, to compare with the sha256 of the signed, verified artifact; a slot holding anything else, a symlink or a non-regular file is refused and left as it is, and nothing in it is executed file:Open@66,file:Stat@388 bc533a29f92ae95d31123b551354ab9f8960576e5ffef1bd6a74a06df0b57a24 |
| internal/host/installchannel_linux.go var openat2 authority F,J4 rev3=census-ruling-region-ha-2b;basis=carlos system install-channel (root, Linux): unix.Openat2 held in a variable so a test can make it fail; removeBeneath is its only user and has no path-based fallback when it fails file:Openat2@14 6081e86f758f4ae3948ec2d4f4c037888e1362f122a410c110e31290ba914816 |
| internal/host/installchannel_linux.go removeBeneath authority F,J4 rev3=census-ruling-region-ha-2b;basis=carlos system install-channel (root, Linux): removes the agent's cache entry for the version, after a run that made it, only through the router directory held by descriptor (opened with no symlink anywhere on its path); its version directory is opened beneath it with RESOLVE_BENEATH|RESOLVE_NO_SYMLINKS and everything under it is unlinked by descriptor; a symlink is unlinked, never entered; when openat2 is unavailable or refuses, the removal refuses and leaves the tree, with no path-based fallback; its one read site lists the held directory's names file:Readdirnames@381 42732bb7b400abdb7f18ddbf5aacdfadc9496d8941e774dc01a471bf15c35f69 |
| -@coverage internal/hostactors cdea6818082bf1e6b9c291b82369cf69b6193aa48b5275a7501c18891b0b8fa9 |
| +@coverage internal/hostactors 8777d00e51be1d185bb9f16018c7a26cd7e1d8887044e9bdc9370613be372545 |
| @coverage internal/hostactors/awsmembership ff967730ddb8f17acb73e3351590ba11695e82341b195539da6fbd856ac39175 |
| @coverage internal/console 80fa5bba8bd23325ababaedc2b8ce810b25161c93a81c6d97dd7033bcc5df103 |
| @legacy 2f4ca2a51661d8aae1e0f38a41efdcc9df9f1dd75411ca6742d6ee3ae71f1756 internal/hostactors/autoenroll.go AutoEnroller.inputs legacy accepted-legacy accepted-legacy;rev3=J4;basis=root-input;census-ruling-region-ha-2b file:Read@102,file:Read@305 c0ae921ee9d8df9de403d488d9708c565d659a7bf880199313f50bdc7c8f8127 |