carlos / platform Public

WIP claim Plan 14 track E checkpoint 48 (not for merge)

4a: production unit binding and program checks; systemd startup identity race fixed; TCP, socket and backing=unit lifecycles beside an unaffected claimed sibling.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Paul Campbell pushed by claude agent, operated by paul@keymail.dev 59fcf3f49094a515186dcbbf715dc86a885f9da4 parent dd5357e
20 files changed, +1129 −26
  • internal/host/claimlaunch_authority.go +3 −0
  • internal/host/claimlaunch_production.go +3 −0
  • internal/host/claimlaunch_root.go +1 −0
  • internal/host/claimlaunch_root_test.go +4 −1
  • internal/host/claimunit_binding.go +30 −2
  • internal/host/claimunit_definition.go +51 −0
  • internal/host/claimunit_definition_test.go +43 −0
  • internal/host/claimunit_dispatch.go +123 −0
  • internal/host/claimunit_dispatch_test.go +89 −0
  • internal/host/claimunit_production.go +120 −0
  • internal/host/claimunit_program.go +99 −0
  • internal/host/claimunit_program_test.go +88 −0
  • internal/host/plan14_bound_sibling_root_linux_test.go +158 −0
  • internal/host/plan14_installed_launch_root_linux_test.go +169 −10
  • internal/host/plan14_root_generations_test.go +15 −1
  • internal/instancectl/claimexec.go +39 −4
  • internal/instancectl/claimexec_linux.go +5 −2
  • internal/instancectl/claimexec_startup_linux_test.go +68 −0
  • internal/instancectl/claimunit.go +1 −1
  • internal/provision/testdata/plan14-root-reads-e.tsv +20 −5

CI passed — run details

CI log
=== gate ===
cd launchctx && go vet ./... && go test ./... -count=1
ok  	amadan.net/carlos/platform.git/launchctx	0.011s
?   	amadan.net/carlos/platform.git/launchctx/controlname	[no test files]
?   	amadan.net/carlos/platform.git/launchctx/wireid	[no test files]
go vet ./...
gofmt -l .: clean
go test ./... -count=1 -timeout 30m
ok  	github.com/carlosframework/platform	298.463s
?   	github.com/carlosframework/platform/cmd/changelog	[no test files]
ok  	github.com/carlosframework/platform/cmd/console	2.867s
ok  	github.com/carlosframework/platform/cmd/moveprobe	93.330s
ok  	github.com/carlosframework/platform/cmd/zddprobe	0.722s
ok  	github.com/carlosframework/platform/internal/activator	250.030s
ok  	github.com/carlosframework/platform/internal/agentsock	0.021s
ok  	github.com/carlosframework/platform/internal/alerting	0.007s
ok  	github.com/carlosframework/platform/internal/analytics	0.019s
ok  	github.com/carlosframework/platform/internal/appconfig	2.565s
ok  	github.com/carlosframework/platform/internal/apperr	0.099s
ok  	github.com/carlosframework/platform/internal/applog	0.169s
ok  	github.com/carlosframework/platform/internal/awsinv	0.004s
ok  	github.com/carlosframework/platform/internal/boxchoice	0.006s
ok  	github.com/carlosframework/platform/internal/boxid	0.848s
?   	github.com/carlosframework/platform/internal/boxid/boxidtest	[no test files]
?   	github.com/carlosframework/platform/internal/boxid/internal/signtest	[no test files]
ok  	github.com/carlosframework/platform/internal/boxvolumes	0.016s
ok  	github.com/carlosframework/platform/internal/caps	0.012s
ok  	github.com/carlosframework/platform/internal/certwarm	0.002s
ok  	github.com/carlosframework/platform/internal/cgroup	0.736s
ok  	github.com/carlosframework/platform/internal/changelog	0.345s
ok  	github.com/carlosframework/platform/internal/clamd	0.057s
?   	github.com/carlosframework/platform/internal/cliflag	[no test files]
ok  	github.com/carlosframework/platform/internal/console	391.672s
?   	github.com/carlosframework/platform/internal/controlname	[no test files]
?   	github.com/carlosframework/platform/internal/controlsock	[no test files]
ok  	github.com/carlosframework/platform/internal/dbdoor	5.973s
ok  	github.com/carlosframework/platform/internal/dbdoor/dbdoortest	0.010s
ok  	github.com/carlosframework/platform/internal/dbverify	0.004s
ok  	github.com/carlosframework/platform/internal/dnsrec	0.004s
ok  	github.com/carlosframework/platform/internal/docsite	0.007s
ok  	github.com/carlosframework/platform/internal/domains	2.393s
ok  	github.com/carlosframework/platform/internal/doorkey	0.007s
ok  	github.com/carlosframework/platform/internal/economics	0.007s
ok  	github.com/carlosframework/platform/internal/edge	26.947s
?   	github.com/carlosframework/platform/internal/edgeproof	[no test files]
ok  	github.com/carlosframework/platform/internal/edgesfile	0.008s
ok  	github.com/carlosframework/platform/internal/fleetapi	0.133s
ok  	github.com/carlosframework/platform/internal/gate	1.655s
ok  	github.com/carlosframework/platform/internal/genselect	0.014s
ok  	github.com/carlosframework/platform/internal/geo	0.020s
ok  	github.com/carlosframework/platform/internal/geoip	0.030s
ok  	github.com/carlosframework/platform/internal/historyreadback	0.178s
ok  	github.com/carlosframework/platform/internal/host	145.429s
ok  	github.com/carlosframework/platform/internal/hostactors	0.523s
ok  	github.com/carlosframework/platform/internal/hostbox	2.381s
ok  	github.com/carlosframework/platform/internal/hostclaim	0.503s
ok  	github.com/carlosframework/platform/internal/hostevidence	0.005s
ok  	github.com/carlosframework/platform/internal/hostexec	4.996s
ok  	github.com/carlosframework/platform/internal/hostfence	0.052s
ok  	github.com/carlosframework/platform/internal/hostfiles	10.437s
ok  	github.com/carlosframework/platform/internal/hostfloor	0.012s
ok  	github.com/carlosframework/platform/internal/hostguard	1.024s
ok  	github.com/carlosframework/platform/internal/hostops	1.579s
?   	github.com/carlosframework/platform/internal/hostops/fencerelease	[no test files]
ok  	github.com/carlosframework/platform/internal/hostops/internal/fencerelease	0.002s
ok  	github.com/carlosframework/platform/internal/hostquarantine	0.002s
ok  	github.com/carlosframework/platform/internal/hostrelocate	0.469s
ok  	github.com/carlosframework/platform/internal/hostresolve	0.132s
ok  	github.com/carlosframework/platform/internal/hostwrite	0.002s
ok  	github.com/carlosframework/platform/internal/infraview	0.002s
ok  	github.com/carlosframework/platform/internal/instancectl	5.235s
ok  	github.com/carlosframework/platform/internal/instanceexec	2.749s
ok  	github.com/carlosframework/platform/internal/instancefs	0.008s
ok  	github.com/carlosframework/platform/internal/instancekey	0.002s
ok  	github.com/carlosframework/platform/internal/instanceupdates	0.007s
ok  	github.com/carlosframework/platform/internal/layout	1.504s
ok  	github.com/carlosframework/platform/internal/lease	9.792s
ok  	github.com/carlosframework/platform/internal/lease/leasetest	0.006s
ok  	github.com/carlosframework/platform/internal/ledger	0.013s
ok  	github.com/carlosframework/platform/internal/mail	0.160s
ok  	github.com/carlosframework/platform/internal/maintenance	0.082s
ok  	github.com/carlosframework/platform/internal/maintenanceexec	5.502s
ok  	github.com/carlosframework/platform/internal/maintenanceguard	0.068s
ok  	github.com/carlosframework/platform/internal/metrics	0.004s
ok  	github.com/carlosframework/platform/internal/moves	21.058s
ok  	github.com/carlosframework/platform/internal/moves/archive	0.779s
ok  	github.com/carlosframework/platform/internal/objstore	0.173s
ok  	github.com/carlosframework/platform/internal/ops	0.177s
ok  	github.com/carlosframework/platform/internal/park	0.204s
ok  	github.com/carlosframework/platform/internal/privatefile	0.004s
ok  	github.com/carlosframework/platform/internal/provision	17.584s
ok  	github.com/carlosframework/platform/internal/publicrelease	0.007s
?   	github.com/carlosframework/platform/internal/regionname	[no test files]
ok  	github.com/carlosframework/platform/internal/registry	5.490s
ok  	github.com/carlosframework/platform/internal/relay	7.240s
ok  	github.com/carlosframework/platform/internal/release	22.194s
ok  	github.com/carlosframework/platform/internal/rollout	3.796s
ok  	github.com/carlosframework/platform/internal/rolloutwords	0.003s
ok  	github.com/carlosframework/platform/internal/rootaws	0.007s
ok  	github.com/carlosframework/platform/internal/rootparticipation	0.007s
ok  	github.com/carlosframework/platform/internal/rootstate	0.026s
ok  	github.com/carlosframework/platform/internal/runs	0.575s
ok  	github.com/carlosframework/platform/internal/s3compat	0.005s
ok  	github.com/carlosframework/platform/internal/safefile	0.004s
ok  	github.com/carlosframework/platform/internal/schedule	2.530s
ok  	github.com/carlosframework/platform/internal/seeds	0.012s
ok  	github.com/carlosframework/platform/internal/sim/hostlife	9.810s
ok  	github.com/carlosframework/platform/internal/sim/litestreamfacts	4.005s
ok  	github.com/carlosframework/platform/internal/status	0.039s
ok  	github.com/carlosframework/platform/internal/steering	0.006s
ok  	github.com/carlosframework/platform/internal/stopevidence	0.189s
ok  	github.com/carlosframework/platform/internal/svcrole	0.002s
ok  	github.com/carlosframework/platform/internal/telemetry	0.005s
ok  	github.com/carlosframework/platform/internal/termfence	0.004s
ok  	github.com/carlosframework/platform/internal/tunnelclient	0.004s
ok  	github.com/carlosframework/platform/internal/unixdial	0.003s
ok  	github.com/carlosframework/platform/internal/updatewords	0.004s
ok  	github.com/carlosframework/platform/internal/usage	0.004s
ok  	github.com/carlosframework/platform/internal/version	0.002s
ok  	github.com/carlosframework/platform/internal/vet	1.537s
=== cross ===
--- shard 0 of 5 ---
cross-compiling darwin/arm64
--- shard 1 of 5 ---
cross-compiling darwin/amd64
--- shard 2 of 5 ---
cross-compiling linux/arm64
linux/arm64 carlos binary: 47186082 bytes (ceiling 52000000)
--- shard 3 of 5 ---
cross-compiling windows/amd64
--- shard 4 of 5 ---
cross-compiling windows/arm64
=== minio-contract ===
minio contract: arm64, minio RELEASE.2025-09-07T16-13-09Z, mc RELEASE.2025-08-13T08-35-41Z
  minio: cached, digest verified
  mc: cached, digest verified
  minio healthy on 127.0.0.1:20999
Added `local` successfully.
Bucket created successfully `local/contract-bucket`.
Bucket created successfully `local/denied-bucket`.
Created policy `contract-only` successfully.
Added user `scopeduser` successfully.
Attached Policies: [contract-only]
To User: scopeduser
  buckets and scoped user ready
=== RUN   TestDeliveredContractMinio
=== RUN   TestDeliveredContractMinio/put
=== RUN   TestDeliveredContractMinio/get_round-trips_the_body
=== RUN   TestDeliveredContractMinio/list_shows_the_key
=== RUN   TestDeliveredContractMinio/head_and_list_agree_on_the_version
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_its_own_object
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/not_by_PutObjectTagging
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/and_not_by_x-amz-tagging_on_upload
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CreateMultipartUpload
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CopyObject_with_a_REPLACE_tagging_directive
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/but_multipart_itself_is_allowed
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_copy_itself_is_allowed
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/a_POST-policy_forged_tag_with_a_matching_version_is_still_not_a_verdict
=== RUN   TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_an_ordinary_tagged-with-something-else_upload_is_allowed
=== RUN   TestDeliveredContractMinio/delete_removes_it
=== RUN   TestDeliveredContractMinio/cross-bucket_denial
--- PASS: TestDeliveredContractMinio (0.02s)
    --- PASS: TestDeliveredContractMinio/put (0.00s)
    --- PASS: TestDeliveredContractMinio/get_round-trips_the_body (0.00s)
    --- PASS: TestDeliveredContractMinio/list_shows_the_key (0.00s)
    --- PASS: TestDeliveredContractMinio/head_and_list_agree_on_the_version (0.00s)
    --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/not_by_PutObjectTagging (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_its_own_object/and_not_by_x-amz-tagging_on_upload (0.00s)
    --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route (0.01s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CreateMultipartUpload (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/not_by_CopyObject_with_a_REPLACE_tagging_directive (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/but_multipart_itself_is_allowed (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_copy_itself_is_allowed (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/a_POST-policy_forged_tag_with_a_matching_version_is_still_not_a_verdict (0.00s)
        --- PASS: TestDeliveredContractMinio/the_app_cannot_tag_by_any_other_route/and_an_ordinary_tagged-with-something-else_upload_is_allowed (0.00s)
    --- PASS: TestDeliveredContractMinio/delete_removes_it (0.00s)
    --- PASS: TestDeliveredContractMinio/cross-bucket_denial (0.00s)
PASS
ok  	github.com/carlosframework/platform/internal/objstore	0.024s
=== RUN   TestT4StorageProbeMinio
2026/10/15 17:31:01 moves: T4: STORE: list: operation error S3: ListObjectsV2, https response error StatusCode: 403, RequestID: 18DEC43B9153F953, HostID: 7c420053e0fbcd7f41653fdd92ffa2802c903d05303b1aa15adc2e8bb65d183f, api error SignatureDoesNotMatch: The request signature we calculated does not match the signature you provided. Check your key and signing method.
--- PASS: TestT4StorageProbeMinio (0.01s)
PASS
ok  	github.com/carlosframework/platform/internal/host	0.021s
minio contract: PASS
diff --git a/internal/host/claimlaunch_authority.go b/internal/host/claimlaunch_authority.go
index 0df2243ff..31bc1eafe 100644
--- a/internal/host/claimlaunch_authority.go
+++ b/internal/host/claimlaunch_authority.go
@@ -48,6 +48,9 @@ func (h *ClaimLaunchRoot) launchSpec(q hostbox.RoleLaunchRequest) (instancectl.L
return instancectl.Launch{}, hostbox.ErrRoleLaunchShape
}
t := q.Reservation.Target
+ if launch, bound, err := h.boundLaunchSpec(q); bound || err != nil {
+ return launch, err
+ }
paths, err := claimIsolatedPaths(map[string]string{"data": h.DataRoot}, t.Host, ClaimLaunchInputs{DBPath: provision.NestedDBPath(h.DataRoot, t.Host), Limit: cgroup.Limit{MemoryMax: q.MemoryMax}})
if err != nil {
return instancectl.Launch{}, err
diff --git a/internal/host/claimlaunch_production.go b/internal/host/claimlaunch_production.go
index 2ef8eee94..e4b297b91 100644
--- a/internal/host/claimlaunch_production.go
+++ b/internal/host/claimlaunch_production.go
@@ -76,6 +76,9 @@ func WithProductionClaimLaunch(ctx context.Context, handler http.Handler) (http.
wrapped.Launch.history = root.history
wrapped.Launch.generations = productionRootGenerations(a.Claims, a.ManagedRoots["opt"])
wrapped.Launch.installedProgram = wrapped.Launch.rootInstalledProgram
+ if err := bindProductionClaimUnits(ctx, &wrapped, a); err != nil {
+ return nil, err
+ }
return wrapped, nil
}
diff --git a/internal/host/claimlaunch_root.go b/internal/host/claimlaunch_root.go
index 086cc4b39..1848f1760 100644
--- a/internal/host/claimlaunch_root.go
+++ b/internal/host/claimlaunch_root.go
@@ -62,6 +62,7 @@ type ClaimLaunchObserved struct {
// roster identity, its local launch journal and its own view of the kernel.
// A request only names which reservation to run.
type ClaimLaunchRoot struct {
+ unitProgram func(claimUnitShape) (claimUnitProgram, error)
unitBindings *claimUnitBindings
history rootHistoryAccess
generations *rootGenerationSource
diff --git a/internal/host/claimlaunch_root_test.go b/internal/host/claimlaunch_root_test.go
index 95bbcd0f0..5e468eff0 100644
--- a/internal/host/claimlaunch_root_test.go
+++ b/internal/host/claimlaunch_root_test.go
@@ -132,8 +132,11 @@ type launchFixture struct {
}
func newLaunchFixture(t *testing.T, op hostclaim.Op) *launchFixture {
+ return newLaunchFixtureHost(t, op, "shop.example")
+}
+
+func newLaunchFixtureHost(t *testing.T, op hostclaim.Op, host string) *launchFixture {
t.Helper()
- host := "shop.example"
boot, err := execlease.BootID()
if err != nil {
t.Skip("root launch requires Linux")
diff --git a/internal/host/claimunit_binding.go b/internal/host/claimunit_binding.go
index 90979cac6..8221dc55b 100644
--- a/internal/host/claimunit_binding.go
+++ b/internal/host/claimunit_binding.go
@@ -21,6 +21,7 @@ import (
type claimUnitShape struct {
Host, Account, App, Kind, Unit, Backing, Socket, Addr, DBPath string
+ Program string `json:",omitempty"`
}
type claimUnitBinding struct {
@@ -37,6 +38,9 @@ type claimUnitBindings struct {
}
func (s claimUnitShape) valid() bool {
+ if s.Program != "" && !claimHexDigest(s.Program) {
+ return false
+ }
if !provision.ValidHostShape(s.Host) || !release.ValidAccount(s.Account) || !release.ValidApp(s.App) || s.Kind != "instance" || !instancectl.ValidBoundClaimUnit(s.Unit) {
return false
}
@@ -61,11 +65,15 @@ func (s claimUnitShape) valid() bool {
}
func (s claimUnitShape) route() registry.Route {
- return registry.Route{Host: s.Host, Account: s.Account, App: s.App, Kind: s.Kind, Unit: s.Unit, Backing: s.Backing, Socket: s.Socket, Addr: s.Addr, DBPath: s.DBPath}
+ unit := s.Unit
+ if s.Backing == registry.BackingUnit {
+ unit = ""
+ }
+ return registry.Route{Host: s.Host, Account: s.Account, App: s.App, Kind: s.Kind, Unit: unit, Backing: s.Backing, Socket: s.Socket, Addr: s.Addr, DBPath: s.DBPath}
}
func (s claimUnitShape) matches(r registry.Route) bool {
- return s.Host == r.Host && s.Account == r.Account && s.App == r.App && s.Kind == r.Kind && s.Unit == r.Unit && s.Backing == r.Backing && s.Socket == r.Socket && s.Addr == r.Addr && s.DBPath == r.DBPath && r.Addrs == "" && r.Tunnel == "" && r.Seed == "" && !r.Replica
+ return s.Host == r.Host && s.Account == r.Account && s.App == r.App && s.Kind == r.Kind && s.route().Unit == r.Unit && s.Backing == r.Backing && s.Socket == r.Socket && s.Addr == r.Addr && s.DBPath == r.DBPath && r.Addrs == "" && r.Tunnel == "" && r.Seed == "" && !r.Replica
}
func unitBindingOwner(in hostguard.RootInstallation) hostguard.RootInstallation {
@@ -142,6 +150,26 @@ func (s *claimUnitBindings) establish(ctx context.Context, host string) (claimUn
if err != nil {
return next, err
}
+ names, err := s.state.Names()
+ if err != nil {
+ return claimUnitBinding{}, err
+ }
+ for _, name := range names {
+ if name == "bindings" || name == host+".json" {
+ continue
+ }
+ otherHost, ok := strings.CutSuffix(name, ".json")
+ if !ok {
+ return claimUnitBinding{}, os.ErrPermission
+ }
+ other, err := s.read(otherHost)
+ if err != nil {
+ return claimUnitBinding{}, err
+ }
+ if other.Shape.Unit == next.Shape.Unit {
+ return claimUnitBinding{}, os.ErrPermission
+ }
+ }
old, err := s.read(host)
if err == nil {
if old != next {
diff --git a/internal/host/claimunit_definition.go b/internal/host/claimunit_definition.go
new file mode 100644
index 000000000..9c72d987b
--- /dev/null
+++ b/internal/host/claimunit_definition.go
@@ -0,0 +1,51 @@
+package host
+
+import (
+ "bytes"
+ "io"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "github.com/carlosframework/platform/internal/instancectl"
+ "github.com/carlosframework/platform/internal/provision"
+ "github.com/carlosframework/platform/internal/rootstate"
+)
+
+func claimBoundUnitDefinition(s claimUnitShape) ([]byte, error) {
+ if !s.valid() || !claimHexDigest(s.Program) {
+ return nil, os.ErrPermission
+ }
+ template, err := unitsFS.ReadFile("units/carlos-instance@.service")
+ if err != nil {
+ return nil, err
+ }
+ body := strings.ReplaceAll(strings.ReplaceAll(string(template), "%i", instancectl.Escape(provision.InstanceID(s.Host))), "%I", provision.InstanceID(s.Host))
+ // With no live root gate, even a direct systemctl restart must execute no
+ // tenant code. The temporary reservation drop-in replaces this command.
+ body = strings.Replace(body, "ExecStart="+instancectl.SystemBinary+" instance-exec run "+provision.InstanceID(s.Host), "ExecStart=/usr/bin/false", 1)
+ if s.Addr != "" {
+ body += "\n[Service]\nIPAddressAllow=localhost\n"
+ }
+ return []byte(body), nil
+}
+
+func verifyClaimBoundUnitDefinition(s claimUnitShape) error {
+ want, err := claimBoundUnitDefinition(s)
+ if err != nil {
+ return err
+ }
+ f, err := rootstate.OpenAuthorityFile(filepath.Join("/run/systemd/system", s.Unit))
+ if err != nil {
+ return err
+ }
+ defer f.Close()
+ got, err := io.ReadAll(io.LimitReader(f, rootstate.MaxRecord+1))
+ if err != nil {
+ return err
+ }
+ if !bytes.Equal(got, want) {
+ return os.ErrPermission
+ }
+ return nil
+}
diff --git a/internal/host/claimunit_definition_test.go b/internal/host/claimunit_definition_test.go
new file mode 100644
index 000000000..bffe259b6
--- /dev/null
+++ b/internal/host/claimunit_definition_test.go
@@ -0,0 +1,43 @@
+package host
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/instancectl"
+ "github.com/carlosframework/platform/internal/provision"
+)
+
+func TestPlan14BoundUnitDefinition(t *testing.T) {
+ _, _, shape := plan14UnitBindingFixture(t, "console.service", "", "")
+ shape.Program = strings.Repeat("a", 64)
+ for _, tcp := range []bool{true, false} {
+ if !tcp {
+ shape.Addr, shape.Socket = "", provision.NestedSocket(shape.Host)
+ }
+ raw, err := claimBoundUnitDefinition(shape)
+ if err != nil {
+ t.Fatal(err)
+ }
+ body := string(raw)
+ if !strings.Contains(body, "ExecStart=/usr/bin/false\n") || strings.Contains(body, "ExecStart=/opt/carlos/carlos instance-exec") || !strings.Contains(body, "Restart=no\n") || !strings.Contains(body, "Slice="+instancectl.SliceName(shape.Host)+"\n") || strings.Contains(body, "%I") || strings.Contains(body, "%i") {
+ t.Fatal("definition permits unreserved execution or foreign placement")
+ }
+ if strings.Contains(body, "IPAddressAllow=localhost") != tcp {
+ t.Fatal("loopback permission escaped named TCP scope")
+ }
+ for _, setting := range []string{"ProtectControlGroups=yes", "PrivatePIDs=yes", "NoNewPrivileges=yes", "CapabilityBoundingSet=", "TemporaryFileSystem=/opt/carlos:ro"} {
+ if !strings.Contains(body, setting+"\n") {
+ t.Fatal("missing containment", setting)
+ }
+ }
+ }
+ shape.Host = "woodstar-media.example"
+ raw, err := claimBoundUnitDefinition(shape)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if !strings.Contains(string(raw), "Slice="+instancectl.SliceName(shape.Host)+"\n") {
+ t.Fatal("named unit used unescaped slice")
+ }
+}
diff --git a/internal/host/claimunit_dispatch.go b/internal/host/claimunit_dispatch.go
new file mode 100644
index 000000000..133940340
--- /dev/null
+++ b/internal/host/claimunit_dispatch.go
@@ -0,0 +1,123 @@
+package host
+
+import (
+ "context"
+ "errors"
+ "os"
+
+ "github.com/carlosframework/platform/internal/instancectl"
+)
+
+// Placement comes from retained root state. Configuration may deny a new
+// start, but removing it cannot retarget cleanup to the ordinary run unit.
+type claimUnitDispatch struct {
+ base instancectl.ClaimUnits
+ bindings *claimUnitBindings
+ shapes func() (map[string]claimUnitShape, error)
+ definition func(claimUnitShape) error
+}
+
+func (d claimUnitDispatch) selected(host string) (instancectl.ClaimUnits, error) {
+ u := d.base
+ if d.bindings == nil || d.shapes == nil {
+ return u, os.ErrPermission
+ }
+ b, err := d.bindings.read(host)
+ if err == nil {
+ u.BoundHost, u.BoundUnit = host, b.Shape.Unit
+ return u, nil
+ }
+ if !errors.Is(err, os.ErrNotExist) {
+ return u, err
+ }
+ shapes, err := d.shapes()
+ if err != nil {
+ return u, err
+ }
+ if _, configured := shapes[host]; configured {
+ return u, os.ErrPermission
+ }
+ return u, nil
+}
+
+func (d claimUnitDispatch) forStart(ctx context.Context, host string) (instancectl.ClaimUnits, error) {
+ u, err := d.selected(host)
+ if err != nil {
+ return u, err
+ }
+ if u.BoundUnit != "" {
+ shapes, err := d.shapes()
+ if err != nil {
+ return u, err
+ }
+ b, err := d.bindings.check(ctx, host, nil)
+ if err != nil {
+ return u, err
+ }
+ if shapes[host] != b.Shape {
+ return u, os.ErrPermission
+ }
+ if d.definition == nil {
+ return u, os.ErrPermission
+ }
+ if err := d.definition(b.Shape); err != nil {
+ return u, err
+ }
+ }
+ return u, nil
+}
+
+func (d claimUnitDispatch) Placement(host string) (string, string, error) {
+ u, err := d.selected(host)
+ if err != nil {
+ return "", "", err
+ }
+ return u.Placement(host)
+}
+
+func (d claimUnitDispatch) StartReserved(ctx context.Context, in instancectl.ClaimUnit, l instancectl.Launch, p instancectl.ClaimExecPermit, check func() error) (instancectl.ClaimUnitStarted, error) {
+ u, err := d.forStart(ctx, in.Host)
+ if err != nil {
+ return instancectl.ClaimUnitStarted{}, err
+ }
+ if check == nil {
+ return instancectl.ClaimUnitStarted{}, os.ErrPermission
+ }
+ return u.StartReserved(ctx, in, l, p, func() error {
+ again, err := d.forStart(ctx, in.Host)
+ if err != nil {
+ return err
+ }
+ if again.BoundHost != u.BoundHost || again.BoundUnit != u.BoundUnit {
+ return os.ErrPermission
+ }
+ return check()
+ })
+}
+
+func (d claimUnitDispatch) Same(ctx context.Context, started instancectl.ClaimUnitStarted) error {
+ // The caller retained the selected placement in the launch grant. Same
+ // checks that exact invocation without taking a new route from the registry.
+ return d.base.Same(ctx, started)
+}
+
+func (d claimUnitDispatch) Stop(context.Context, string) error {
+ // Dispatch cannot guess which invocation a bare host means.
+ return os.ErrPermission
+}
+
+func (d claimUnitDispatch) StopInvocation(ctx context.Context, host, invocation string) error {
+ u, err := d.selected(host)
+ if err != nil {
+ return err
+ }
+ return u.StopInvocation(ctx, host, invocation)
+}
+
+func (d claimUnitDispatch) Gone(ctx context.Context, host, invocation string) error {
+ u, err := d.selected(host)
+ if err != nil {
+ return err
+ }
+ return u.Gone(ctx, host, invocation)
+}
diff --git a/internal/host/claimunit_dispatch_test.go b/internal/host/claimunit_dispatch_test.go
new file mode 100644
index 000000000..6daa8ace6
--- /dev/null
+++ b/internal/host/claimunit_dispatch_test.go
@@ -0,0 +1,89 @@
+package host
+
+import (
+ "context"
+ "os"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/hostguard"
+ "github.com/carlosframework/platform/internal/instancectl"
+)
+
+func TestPlan14UnitDispatch(t *testing.T) {
+ b, in, shape := plan14UnitBindingFixture(t, "console.service", "", "")
+ configured := map[string]claimUnitShape{in.Host: shape}
+ d := claimUnitDispatch{bindings: b, shapes: func() (map[string]claimUnitShape, error) { return configured, nil }}
+ if _, _, err := d.Placement(in.Host); err == nil {
+ t.Fatal("configured unit without retained binding fell through")
+ }
+ if _, err := b.establish(t.Context(), in.Host); err != nil {
+ t.Fatal(err)
+ }
+ unit, group, err := d.Placement(in.Host)
+ if err != nil || unit != shape.Unit || group != instancectl.BoundClaimUnitControlGroup(in.Host, shape.Unit) {
+ t.Fatal(unit, group, err)
+ }
+ unit, group, err = d.Placement("sibling.example")
+ if err != nil || unit != instancectl.UnitName(instancectl.RoleRun, "sibling.example") || group != instancectl.ClaimUnitControlGroup("sibling.example") {
+ t.Fatal(unit, group, err)
+ }
+ delete(configured, in.Host)
+ if _, err := d.forStart(t.Context(), in.Host); err == nil {
+ t.Fatal("removed configuration allowed start")
+ }
+ unit, _, err = d.Placement(in.Host)
+ if err != nil || unit != shape.Unit {
+ t.Fatal("cleanup lost retained placement", unit, err)
+ }
+ configured[in.Host] = shape
+ changed := in
+ changed.Stamp++
+ b.installation = func(context.Context, string) (hostguard.RootInstallation, error) { return changed, nil }
+ if _, err := d.forStart(t.Context(), in.Host); err == nil {
+ t.Fatal("successor inherited placement")
+ }
+ b.state.Write(in.Host+".json", []byte("broken"))
+ if _, _, err := d.Placement(in.Host); err == nil {
+ t.Fatal("corruption fell through")
+ }
+ d.shapes = func() (map[string]claimUnitShape, error) { return nil, os.ErrPermission }
+ if _, _, err := d.Placement("sibling.example"); err == nil {
+ t.Fatal("unreadable configuration inferred absence")
+ }
+}
+
+func TestPlan14BackingUnitRegistryShape(t *testing.T) {
+ b, in, s := plan14UnitBindingFixture(t, "carlos-app@shop.example.service", "unit", "")
+ if _, err := b.establish(t.Context(), in.Host); err != nil {
+ t.Fatal(err)
+ }
+ r := s.route()
+ if r.Unit != "" {
+ t.Fatal("canonical backing route invents a named unit", r.Unit)
+ }
+ if _, err := b.check(t.Context(), in.Host, &r); err != nil {
+ t.Fatal(err)
+ }
+ r.Unit = s.Unit
+ if _, err := b.check(t.Context(), in.Host, &r); err == nil {
+ t.Fatal("accepted backing-to-named shape change")
+ }
+}
+
+func TestPlan14UnitBindingRetainedOwnership(t *testing.T) {
+ b, in, shape := plan14UnitBindingFixture(t, "console.service", "", "")
+ if _, err := b.establish(t.Context(), in.Host); err != nil {
+ t.Fatal(err)
+ }
+ other := in
+ other.Host, other.Record = "sibling.example", "apps/bab/shop/instances/sibling.example.json"
+ shape.Host = other.Host
+ b.installation = func(context.Context, string) (hostguard.RootInstallation, error) { return other, nil }
+ b.canonical = func(context.Context, string) (claimUnitShape, error) { return shape, nil }
+ if _, err := b.establish(t.Context(), other.Host); err == nil {
+ t.Fatal("root config reassigned a retained unit to a sibling")
+ }
+ if _, err := b.read(other.Host); !os.IsNotExist(err) {
+ t.Fatal("created conflicting owner", err)
+ }
+}
diff --git a/internal/host/claimunit_production.go b/internal/host/claimunit_production.go
new file mode 100644
index 000000000..8dc7ac57d
--- /dev/null
+++ b/internal/host/claimunit_production.go
@@ -0,0 +1,120 @@
+package host
+
+import (
+ "context"
+ "errors"
+ "os"
+ "sort"
+
+ "github.com/carlosframework/platform/internal/hostguard"
+ "github.com/carlosframework/platform/internal/instancectl"
+ "github.com/carlosframework/platform/internal/rootstate"
+)
+
+var openClaimUnitBindings = func() (rootstate.Store, error) {
+ return rootstate.Open("/var/lib/carlos-claims/unit-bindings", true, false)
+}
+
+var productionClaimUnitShapes = func() (map[string]claimUnitShape, error) {
+ shapes, err := readClaimUnitShapes("/etc/carlos/claim-units.json")
+ if errors.Is(err, os.ErrNotExist) {
+ return map[string]claimUnitShape{}, nil
+ }
+ return shapes, err
+}
+
+// Called only in the census-gated root control startup. The root configuration
+// chooses unit placement; the existing root installation chooses its owner.
+func bindProductionClaimUnits(ctx context.Context, h *ClaimRootWithLaunch, a *ClaimAssembly) error {
+ guard := h.Launch.Guard
+ if guard.Root == nil || guard.Claims == nil {
+ return os.ErrPermission
+ }
+ base, ok := h.Launch.Units.(instancectl.ClaimUnits)
+ if !ok {
+ return os.ErrPermission
+ }
+ shapes, err := productionClaimUnitShapes()
+ if err != nil {
+ return err
+ }
+ for _, shape := range shapes {
+ if !claimHexDigest(shape.Program) {
+ return os.ErrPermission
+ }
+ if _, err := readClaimUnitProgram(shape); err != nil {
+ return err
+ }
+ }
+ state, err := openClaimUnitBindings()
+ if err != nil {
+ return err
+ }
+ b := &claimUnitBindings{state: state}
+ b.canonical = func(ctx context.Context, host string) (claimUnitShape, error) {
+ current, err := productionClaimUnitShapes()
+ if err != nil {
+ return claimUnitShape{}, err
+ }
+ shape, ok := current[host]
+ if !ok {
+ return claimUnitShape{}, os.ErrPermission
+ }
+ return shape, nil
+ }
+ b.installation = func(ctx context.Context, host string) (hostguard.RootInstallation, error) {
+ captured, err := guard.Capture(host)
+ if err != nil {
+ return hostguard.RootInstallation{}, err
+ }
+ in, err := guard.Root.Installation(ctx, host, captured)
+ if err != nil {
+ return in, err
+ }
+ observed, err := guard.Claims.Read(ctx, host)
+ if err != nil {
+ return in, err
+ }
+ if observed.Absent || observed.Claim == nil || observed.Claim.Holder == nil {
+ return in, os.ErrPermission
+ }
+ holder := observed.Claim.Holder
+ if holder.Incarnation != in.Incarnation || holder.Stamp != in.Stamp || holder.Account != in.Account || holder.App != in.App || holder.Key != in.Record {
+ return in, os.ErrPermission
+ }
+ return in, nil
+ }
+ hosts := make([]string, 0, len(shapes))
+ for host := range shapes {
+ hosts = append(hosts, host)
+ }
+ sort.Strings(hosts)
+ for _, host := range hosts {
+ if err := guard.WithLocal(ctx, host, func() error { _, err := b.establish(ctx, host); return err }); err != nil {
+ state.Close()
+ return err
+ }
+ }
+ dispatch := claimUnitDispatch{base: base, bindings: b, shapes: productionClaimUnitShapes, definition: verifyClaimBoundUnitDefinition}
+ h.Launch.Units, h.Launch.unitBindings = dispatch, b
+ h.Launch.unitProgram = readClaimUnitProgram
+ kernel, ok := h.Root.Stop.Coordinator.Kernel.(claimUnitStopKernel)
+ if !ok {
+ state.Close()
+ return os.ErrPermission
+ }
+ kernel.Units = dispatch
+ h.Root.Stop.Coordinator.Kernel = kernel
+ if h.Root.executionRuntime != nil {
+ h.Root.executionRuntime.root = &h.Root
+ }
+ closePrevious := a.Close
+ a.Close = func(ctx context.Context) error {
+ var err error
+ if closePrevious != nil {
+ err = closePrevious(ctx)
+ }
+ return errors.Join(err, state.Close())
+ }
+ return nil
+}
diff --git a/internal/host/claimunit_program.go b/internal/host/claimunit_program.go
new file mode 100644
index 000000000..cac2be0d8
--- /dev/null
+++ b/internal/host/claimunit_program.go
@@ -0,0 +1,99 @@
+package host
+
+import (
+ "bytes"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "io"
+ "os"
+ "path/filepath"
+ "slices"
+ "strings"
+
+ "github.com/carlosframework/platform/internal/hostbox"
+ "github.com/carlosframework/platform/internal/instancectl"
+ "github.com/carlosframework/platform/internal/provision"
+ "github.com/carlosframework/platform/internal/rootstate"
+)
+
+type claimUnitProgram struct {
+ Schema int
+ Host string
+ Args, Env []string
+}
+
+func readClaimUnitProgram(shape claimUnitShape) (claimUnitProgram, error) {
+ if !provision.ValidHostShape(shape.Host) {
+ return claimUnitProgram{}, os.ErrPermission
+ }
+ f, err := rootstate.OpenAuthorityFile(filepath.Join("/etc/carlos/claim-units", shape.Host+".json"))
+ if err != nil {
+ return claimUnitProgram{}, err
+ }
+ defer f.Close()
+ raw, err := io.ReadAll(io.LimitReader(f, rootstate.MaxRecord+1))
+ if err != nil {
+ return claimUnitProgram{}, err
+ }
+ return decodeClaimUnitProgram(raw, shape)
+}
+
+func decodeClaimUnitProgram(raw []byte, shape claimUnitShape) (claimUnitProgram, error) {
+ var p claimUnitProgram
+ sum := sha256.Sum256(raw)
+ dec := json.NewDecoder(bytes.NewReader(raw))
+ dec.DisallowUnknownFields()
+ if len(raw) > rootstate.MaxRecord || hex.EncodeToString(sum[:]) != shape.Program || dec.Decode(&p) != nil || dec.Decode(new(any)) != io.EOF || p.Schema != 1 || p.Host != shape.Host || len(p.Args) > 256 || len(p.Env) > 1024 {
+ return p, os.ErrPermission
+ }
+ for _, arg := range p.Args {
+ if strings.ContainsRune(arg, 0) {
+ return p, os.ErrPermission
+ }
+ }
+ seen := map[string]bool{}
+ for _, env := range p.Env {
+ key, _, ok := strings.Cut(env, "=")
+ if !ok || key == "" || strings.ContainsRune(env, 0) || seen[key] {
+ return p, os.ErrPermission
+ }
+ seen[key] = true
+ }
+ return p, nil
+}
+
+func (h *ClaimLaunchRoot) boundLaunchSpec(q hostbox.RoleLaunchRequest) (instancectl.Launch, bool, error) {
+ var launch instancectl.Launch
+ host := q.Reservation.Target.Host
+ unit, _, err := claimLaunchPlacement(h.Units, host)
+ if err != nil {
+ return launch, false, err
+ }
+ if unit == instancectl.UnitName(instancectl.RoleRun, host) {
+ return launch, false, nil
+ }
+ if h.unitBindings == nil {
+ return launch, true, os.ErrPermission
+ }
+ b, err := h.unitBindings.read(host)
+ if err != nil {
+ return launch, true, err
+ }
+ // Older bindings only describe placement and retain the ordinary isolated
+ // launch contract. Production configuration requires a program digest.
+ if b.Shape.Program == "" {
+ return launch, false, nil
+ }
+ if b.Retiring || b.Retired || b.Shape.Unit != unit || h.unitProgram == nil {
+ return launch, true, os.ErrPermission
+ }
+ p, err := h.unitProgram(b.Shape)
+ if err != nil {
+ return launch, true, err
+ }
+ if !slices.Equal(p.Args, q.Spec.Args) || !slices.Equal(p.Env, q.Spec.Env) {
+ return launch, true, os.ErrPermission
+ }
+ return instancectl.Launch{Schema: instancectl.LaunchSchema, Host: host, Argv: append([]string{filepath.Join(instancectl.LiveDir, host)}, p.Args...), Env: append([]string(nil), p.Env...)}, true, nil
+}
diff --git a/internal/host/claimunit_program_test.go b/internal/host/claimunit_program_test.go
new file mode 100644
index 000000000..e93ddae6b
--- /dev/null
+++ b/internal/host/claimunit_program_test.go
@@ -0,0 +1,88 @@
+package host
+
+import (
+ "context"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "testing"
+
+ "github.com/carlosframework/platform/internal/hostclaim"
+ "github.com/carlosframework/platform/internal/hostguard"
+)
+
+func TestPlan14BoundProgram(t *testing.T) {
+ f, _, _ := generationLaunchFixture(t)
+ b, _, _ := plan14UnitBindingFixture(t, "console.service", "", "")
+ in, err := f.root.Guard.Root.Installation(t.Context(), f.target.Host, hostguard.Captured{Incarnation: f.target.Incarnation, Stamp: f.target.Stamp})
+ if err != nil {
+ t.Fatal(err)
+ }
+ p := claimUnitProgram{Schema: 1, Host: in.Host, Args: []string{"console", "-listen", "127.0.0.1:8090"}, Env: []string{"CONSOLE_ORIGIN=https://shop.example"}}
+ raw, _ := json.Marshal(p)
+ sum := sha256.Sum256(raw)
+ s := claimUnitShape{Host: in.Host, Account: in.Account, App: in.App, Kind: "instance", Unit: "console.service", Addr: "127.0.0.1:8090", Program: hex.EncodeToString(sum[:])}
+ b.installation = func(context.Context, string) (hostguard.RootInstallation, error) { return in, nil }
+ b.canonical = func(context.Context, string) (claimUnitShape, error) { return s, nil }
+ if _, err := b.establish(t.Context(), in.Host); err != nil {
+ t.Fatal(err)
+ }
+ f.root.unitBindings = b
+ f.root.unitProgram = func(shape claimUnitShape) (claimUnitProgram, error) { return decodeClaimUnitProgram(raw, shape) }
+ f.root.Units = plan14NamedLaunchUnits{f.units, in.Host, s.Unit}
+ f.req.Spec.Args, f.req.Spec.Env = p.Args, p.Env
+ launch, err := f.root.launchSpec(f.req)
+ if err != nil || len(launch.Argv) != 4 || launch.Argv[1] != "console" {
+ t.Fatal(launch, err)
+ }
+ f.units.started.Unit, f.units.started.ControlGroup, _ = f.root.Units.(plan14NamedLaunchUnits).Placement(in.Host)
+ f.req.Reservation.Group.RelativePath = f.units.started.ControlGroup
+ inputs, err := f.req.Spec.Hash()
+ if err != nil {
+ t.Fatal(err)
+ }
+ f.req.Reservation.Inputs = inputs
+ f.claims.set(func(c *hostclaim.Claim) { c.Holder.WakeRetry.Attempt.LaunchInputs = inputs })
+ f.units.beforeCheck = func() { raw = append(raw, ' ') }
+ if _, err := f.root.Serve(t.Context(), f.peer, f.req); err == nil {
+ t.Fatal("changed program at exec accepted")
+ }
+ if n, _ := f.units.counts(); n != 0 {
+ t.Fatal("changed program executed")
+ }
+ raw, _ = json.Marshal(p)
+ f.req.Spec.Env = []string{"CONSOLE_ORIGIN=https://forged.example"}
+ if _, err := f.root.launchSpec(f.req); err == nil {
+ t.Fatal("service-selected environment accepted")
+ }
+ f.req.Spec.Env = p.Env
+ f.req.Spec.Args = []string{"shell"}
+ if _, err := f.root.launchSpec(f.req); err == nil {
+ t.Fatal("service-selected command accepted")
+ }
+ f.req.Spec.Args = p.Args
+ raw = append(raw, ' ')
+ if _, err := f.root.launchSpec(f.req); err == nil {
+ t.Fatal("changed canonical bytes accepted")
+ }
+ f.root.unitProgram = nil
+ if _, err := f.root.launchSpec(f.req); err == nil {
+ t.Fatal("missing program reader fell through")
+ }
+}
+
+func TestPlan14BoundProgramDecode(t *testing.T) {
+ for _, p := range []claimUnitProgram{
+ {Schema: 2, Host: "shop.example"},
+ {Schema: 1, Host: "sibling.example"},
+ {Schema: 1, Host: "shop.example", Args: []string{"bad\x00arg"}},
+ {Schema: 1, Host: "shop.example", Env: []string{"A=1", "A=2"}},
+ {Schema: 1, Host: "shop.example", Env: []string{"INVALID"}},
+ } {
+ raw, _ := json.Marshal(p)
+ sum := sha256.Sum256(raw)
+ if _, err := decodeClaimUnitProgram(raw, claimUnitShape{Host: "shop.example", Program: hex.EncodeToString(sum[:])}); err == nil {
+ t.Fatal("invalid program accepted", p)
+ }
+ }
+}
diff --git a/internal/host/plan14_bound_sibling_root_linux_test.go b/internal/host/plan14_bound_sibling_root_linux_test.go
new file mode 100644
index 000000000..3b95485eb
--- /dev/null
+++ b/internal/host/plan14_bound_sibling_root_linux_test.go
@@ -0,0 +1,158 @@
+//go:build linux && rootrunner
+
+package host
+
+import (
+ "context"
+ "crypto/ed25519"
+ "crypto/sha256"
+ "encoding/hex"
+ "net"
+ "net/http"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/platform/internal/hostbox"
+ "github.com/carlosframework/platform/internal/hostclaim"
+ execlease "github.com/carlosframework/platform/internal/hostexec"
+ "github.com/carlosframework/platform/internal/instancectl"
+ "github.com/carlosframework/platform/internal/provision"
+ "github.com/carlosframework/platform/internal/release"
+ "github.com/carlosframework/platform/internal/rootstate"
+)
+
+func plan14BoundSibling(t *testing.T, body []byte, ctl func(context.Context, ...string) (string, error), uid, gid int, lock *release.ReleaseExclusion) func() {
+ t.Helper()
+ f, disk, rec := generationLaunchFixtureHost(t, "sibling.example")
+ host := rec.Host
+ for _, dir := range []string{provision.InstanceDir("/data", host), provision.InstanceRunDir(host)} {
+ if err := os.MkdirAll(dir, 0755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.Chown(dir, uid, gid); err != nil {
+ t.Fatal(err)
+ }
+ }
+ template, err := unitsFS.ReadFile("units/carlos-instance@.service")
+ if err != nil {
+ t.Fatal(err)
+ }
+ // Match the named test's dependency and output adapters.
+ lines := plan14RunnerUnitLines(template)
+ path := "/run/systemd/system/carlos-instance@.service"
+ if err := os.WriteFile(path, []byte(lines), 0644); err != nil {
+ t.Fatal(err)
+ }
+ t.Cleanup(func() {
+ ctl(context.Background(), "stop", instancectl.UnitName(instancectl.RoleRun, host))
+ ctl(context.Background(), "stop", instancectl.SliceName(host))
+ os.Remove(path)
+ os.RemoveAll(filepath.Join("/run/systemd/system", instancectl.UnitName(instancectl.RoleRun, host)+".d"))
+ os.RemoveAll(filepath.Join("/run/systemd/system", instancectl.SliceName(host)+".d"))
+ ctl(context.Background(), "daemon-reload")
+ })
+ if _, err := ctl(t.Context(), "daemon-reload"); err != nil {
+ t.Fatal(err)
+ }
+ store := release.NewDirStore(t.TempDir())
+ rec.Schema = 1
+ plan14PutRecord(t, store, rec)
+ pub, priv, err := ed25519.GenerateKey(nil)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := release.StageArtifact("/opt/carlos", rec.Account, "sibling", rec.App, body); err != nil {
+ t.Fatal(err)
+ }
+ m := release.Manifest{App: rec.App, Version: "sibling", Kind: "binary", Artifacts: map[string]release.Artifact{release.AdoptPlatform(): {}}}
+ if err := release.Ship(t.Context(), store, rec.Account, m, map[string][]byte{release.AdoptPlatform(): body}); err != nil {
+ t.Fatal(err)
+ }
+ if err := release.Promote(t.Context(), store, priv, rec.Account, rec.App, "canary/console", "sibling"); err != nil {
+ t.Fatal(err)
+ }
+ exe := release.VersionPath("/opt/carlos", rec.Account, "sibling", rec.App)
+ file, err := rootstate.OpenTrustedFile(exe)
+ if err != nil {
+ t.Fatal(err)
+ }
+ id, err := rootstate.Identity(file)
+ file.Close()
+ if err != nil {
+ t.Fatal(err)
+ }
+ sum := sha256.Sum256(body)
+ f.req.Executable, f.req.Spec.Executable, f.req.Spec.Digest = exe, exe, hex.EncodeToString(sum[:])
+ f.req.Reservation.Executable = hostbox.LaunchFile{Device: id.Device, Inode: id.Inode}
+ socket := filepath.Join(provision.InstanceRunDir(host), "app.sock")
+ f.req.Spec.Args = []string{"--socket", socket, "--db", provision.NestedDBPath("/data", host)}
+ f.req.Spec.Env = []string{"CARLOS_ADMIN_TOKEN=test", "PLAN14_TENANT_SOCKET=" + socket}
+ inputs, err := f.req.Spec.Hash()
+ if err != nil {
+ t.Fatal(err)
+ }
+ f.req.Reservation.Inputs = inputs
+ f.claims.set(func(c *hostclaim.Claim) { c.Holder.WakeRetry.Attempt.LaunchInputs = inputs })
+ f.root.Clock, f.root.DataRoot = execlease.SystemClock{}, "/data"
+ f.root.ReadyBudget = 10 * time.Second
+ f.root.Units = instancectl.ClaimUnits{Systemctl: ctl, CgroupRoot: "/sys/fs/cgroup", ExeRoot: "/run/carlos-control/exe", UnitRoot: "/run/systemd/system", DataRoot: "/data"}
+ f.root.Kernel = linuxClaimLaunchKernel{OptRoot: "/opt/carlos", CgroupRoot: "/sys/fs/cgroup", ProcRoot: "/proc"}
+ f.root.generations = &rootGenerationSource{store: store, optDir: "/opt/carlos", pub: func() (ed25519.PublicKey, error) { return pub, nil }, open: func() (rootstate.Store, error) { return disk, nil }, openFile: rootstate.OpenTrustedFile}
+ var started hostbox.RoleLaunchResponse
+ if err := lock.With(t.Context(), func(ctx context.Context) error {
+ var err error
+ started, err = f.root.Serve(ctx, f.peer, f.req)
+ return err
+ }); err != nil {
+ t.Fatal("sibling launch", err)
+ }
+ pin := readGenerationLaunch(t, disk)
+ t.Cleanup(func() {
+ q := f.req
+ q.Op = hostbox.RoleLaunchStop
+ q.Spec, q.Executable, q.MemoryMax = nil, "", 0
+ if _, err := f.root.Serve(context.Background(), f.peer, q); err != nil {
+ t.Error("sibling stop", err)
+ }
+ })
+ return func() {
+ t.Helper()
+ if got := readGenerationLaunch(t, disk); got != pin {
+ t.Fatal("sibling pin changed")
+ }
+ g := started.Grant
+ if err := f.root.Units.Same(t.Context(), instancectl.ClaimUnitStarted{Unit: g.Unit, InvocationID: g.UnitInvocationID, MainPID: g.PID, ControlGroup: g.Group.RelativePath}); err != nil {
+ t.Fatal("sibling invocation changed", err)
+ }
+ transport := &http.Transport{DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
+ return (&net.Dialer{}).DialContext(ctx, "unix", socket)
+ }}
+ defer transport.CloseIdleConnections()
+ resp, err := (&http.Client{Transport: transport}).Get("http://sibling/")
+ if err != nil {
+ t.Fatal("sibling stopped serving", err)
+ }
+ resp.Body.Close()
+ if resp.StatusCode != 200 {
+ t.Fatal(resp.Status)
+ }
+ }
+}
+
+func plan14RunnerUnitLines(template []byte) string {
+ var lines []string
+ for _, line := range strings.Split(string(template), "\n") {
+ key, _, _ := strings.Cut(line, "=")
+ switch key {
+ case "BindsTo", "Requires", "After":
+ continue
+ case "StandardOutput":
+ line = "StandardOutput=journal"
+ }
+ lines = append(lines, line)
+ }
+ return strings.Join(lines, "\n")
+}
diff --git a/internal/host/plan14_installed_launch_root_linux_test.go b/internal/host/plan14_installed_launch_root_linux_test.go
index a10a33daf..5ae46f185 100644
--- a/internal/host/plan14_installed_launch_root_linux_test.go
+++ b/internal/host/plan14_installed_launch_root_linux_test.go
@@ -33,7 +33,22 @@ func plan14InstalledTenant() {
if socket == "" {
os.Exit(2)
}
- ln, err := net.Listen("unix", socket)
+ network := "unix"
+ if addr := os.Getenv("PLAN14_TENANT_ADDR"); addr != "" {
+ network, socket = "tcp", addr
+ }
+ if network == "unix" {
+ // The payload owns its socket, like an ordinary Unix HTTP server. A
+ // process killed at exit may leave that socket pathname behind.
+ if st, err := os.Lstat(socket); err == nil {
+ if st.Mode()&os.ModeSocket == 0 || os.Remove(socket) != nil {
+ os.Exit(1)
+ }
+ } else if !os.IsNotExist(err) {
+ os.Exit(1)
+ }
+ }
+ ln, err := net.Listen(network, socket)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
@@ -53,7 +68,26 @@ func TestPlan14NamedClaimLaunchRootRunner(t *testing.T) {
}
}
+// This witness permits loopback HTTP between the runner and its named TCP
+// payloads. The payloads make no remote network requests.
+func TestPlan14BoundRouteProgramRootRunner(t *testing.T) {
+ for _, unit := range []string{"tcp", "socket-program", "backing-program"} {
+ t.Run(unit, func(t *testing.T) { plan14InstalledClaimLaunch(t, unit) })
+ }
+}
+
func plan14InstalledClaimLaunch(t *testing.T, boundUnit string) {
+ programMode := boundUnit == "tcp" || boundUnit == "socket-program" || boundUnit == "backing-program"
+ tcpMode := boundUnit == "tcp" || boundUnit == "backing-program"
+ if boundUnit == "tcp" {
+ boundUnit = "plan14-tcp.service"
+ }
+ if boundUnit == "socket-program" {
+ boundUnit = "plan14-socket.service"
+ }
+ if boundUnit == "backing-program" {
+ boundUnit = "backing"
+ }
if dir := os.Getenv("PLAN14_INSTALLED_RETIRE_CHILD"); dir != "" {
plan14InstalledRetire(t, dir)
return
@@ -93,6 +127,21 @@ func plan14InstalledClaimLaunch(t *testing.T, boundUnit string) {
t.Setenv("CARLOS_DATA_DIR", "/data")
t.Setenv("CARLOS_OPT_DIR", "/opt/carlos")
host := f.target.Host
+ var endpoint string
+ if tcpMode {
+ ln, err := net.Listen("tcp", "127.0.0.1:0")
+ if err != nil {
+ t.Fatal(err)
+ }
+ endpoint = ln.Addr().String()
+ ln.Close()
+ }
+ program := claimUnitProgram{Schema: 1, Host: host, Args: []string{"plan14-tenant"}, Env: []string{"PLAN14_TENANT_SOCKET=" + filepath.Join(provision.InstanceRunDir(host), "app.sock")}}
+ if tcpMode {
+ program.Env = append(program.Env, "PLAN14_TENANT_ADDR="+endpoint)
+ }
+ programRaw, _ := json.Marshal(program)
+ programSum := sha256.Sum256(programRaw)
if boundUnit == "backing" {
boundUnit = "carlos-app@" + host + ".service"
}
@@ -118,9 +167,16 @@ func plan14InstalledClaimLaunch(t *testing.T, boundUnit string) {
continue
case "StandardOutput":
line = "StandardOutput=journal"
+ case "ExecStart":
+ if programMode {
+ line = "ExecStart=/usr/bin/false"
+ }
}
lines = append(lines, line)
}
+ if tcpMode {
+ lines = append(lines, "[Service]", "IPAddressAllow=localhost")
+ }
unit := instancectl.UnitName(instancectl.RoleRun, host)
unitPath := "/run/systemd/system/carlos-instance@.service"
if boundUnit != "" {
@@ -216,6 +272,29 @@ func plan14InstalledClaimLaunch(t *testing.T, boundUnit string) {
if strings.HasPrefix(boundUnit, "carlos-app@") {
shape.Backing = "unit"
}
+ if programMode {
+ shape.Program = hex.EncodeToString(programSum[:])
+ if tcpMode {
+ shape.Addr, shape.Socket = endpoint, ""
+ }
+ if err := os.MkdirAll("/etc/carlos/claim-units", 0700); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join("/etc/carlos/claim-units", host+".json"), programRaw, 0600); err != nil {
+ t.Fatal(err)
+ }
+ programPath := filepath.Join("/etc/carlos/claim-units", host+".json")
+ if err := os.Chmod(programPath, 0666); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := readClaimUnitProgram(shape); err == nil {
+ t.Fatal("service-writable program accepted")
+ }
+ if err := os.Chmod(programPath, 0600); err != nil {
+ t.Fatal(err)
+ }
+ f.root.unitProgram = readClaimUnitProgram
+ }
f.root.unitBindings = &claimUnitBindings{state: bindings, installation: func(context.Context, string) (hostguard.RootInstallation, error) { return old, nil }, canonical: func(context.Context, string) (claimUnitShape, error) { return shape, nil }}
if _, err := f.root.unitBindings.establish(t.Context(), host); err != nil {
t.Fatal(err)
@@ -231,6 +310,26 @@ func plan14InstalledClaimLaunch(t *testing.T, boundUnit string) {
}
return ""
}(), Systemctl: ctl, CgroupRoot: "/sys/fs/cgroup", ExeRoot: "/run/carlos-control/exe", UnitRoot: "/run/systemd/system", DataRoot: "/data", WaitBudget: 20 * time.Second}
+ if programMode {
+ base := f.root.Units.(instancectl.ClaimUnits)
+ base.BoundHost, base.BoundUnit = "", ""
+ binding, err := f.root.unitBindings.read(host)
+ if err != nil {
+ t.Fatal(err)
+ }
+ f.root.Units = claimUnitDispatch{base: base, bindings: f.root.unitBindings, shapes: func() (map[string]claimUnitShape, error) { return map[string]claimUnitShape{host: binding.Shape}, nil }, definition: func(s claimUnitShape) error {
+ // Dependencies and output are runner adapters; all isolation settings
+ // and the permanent no-reservation refusal remain in the unit.
+ raw, err := os.ReadFile(unitPath)
+ if err != nil {
+ return err
+ }
+ if string(raw) != strings.Join(lines, "\n") {
+ return os.ErrPermission
+ }
+ return nil
+ }}
+ }
f.root.Kernel = linuxClaimLaunchKernel{OptRoot: "/opt/carlos", CgroupRoot: "/sys/fs/cgroup", ProcRoot: "/proc"}
store := release.NewDirStore(filepath.Join(dir, "deployment"))
rec.Schema = 1
@@ -268,6 +367,9 @@ func plan14InstalledClaimLaunch(t *testing.T, boundUnit string) {
f.req.Spec.Digest = hex.EncodeToString(digest[:])
f.req.Spec.Args = []string{"--socket", filepath.Join(provision.InstanceRunDir(host), "app.sock"), "--db", provision.NestedDBPath("/data", host)}
f.req.Spec.Env = []string{"CARLOS_ADMIN_TOKEN=test", "PLAN14_TENANT_SOCKET=" + filepath.Join(provision.InstanceRunDir(host), "app.sock")}
+ if programMode {
+ f.req.Spec.Args, f.req.Spec.Env = program.Args, program.Env
+ }
inputs, err := f.req.Spec.Hash()
if err != nil {
t.Fatal(err)
@@ -276,6 +378,11 @@ func plan14InstalledClaimLaunch(t *testing.T, boundUnit string) {
f.claims.set(func(c *hostclaim.Claim) { c.Holder.WakeRetry.Attempt.LaunchInputs = inputs })
f.root.generations = &rootGenerationSource{store: store, optDir: "/opt/carlos", pub: func() (ed25519.PublicKey, error) { return pub, nil }, open: func() (rootstate.Store, error) { return rootstate.Open(filepath.Join(dir, "generations"), true, false) }, openFile: rootstate.OpenTrustedFile}
lock := &release.ReleaseExclusion{Open: func() (rootstate.Store, error) { return rootstate.Open(filepath.Join(dir, "release"), true, false) }}
+ checkSibling := func() {}
+ if programMode {
+ checkSibling = plan14BoundSibling(t, body, ctl, uid, gid, lock)
+ checkSibling()
+ }
if err := f.req.Validate(); err != nil {
t.Fatal("request", err)
}
@@ -296,6 +403,9 @@ func plan14InstalledClaimLaunch(t *testing.T, boundUnit string) {
t.Fatalf("production launch: %v\n%s", err, logs)
}
transport := &http.Transport{DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
+ if tcpMode {
+ return (&net.Dialer{}).DialContext(ctx, "tcp", endpoint)
+ }
return (&net.Dialer{}).DialContext(ctx, "unix", filepath.Join(provision.InstanceRunDir(host), "app.sock"))
}}
defer transport.CloseIdleConnections()
@@ -309,6 +419,7 @@ func plan14InstalledClaimLaunch(t *testing.T, boundUnit string) {
if resp.StatusCode != 200 {
t.Fatal(resp.Status)
}
+ checkSibling()
generations, err := rootstate.Open(filepath.Join(dir, "generations"), false, false)
if err != nil {
t.Fatal(err)
@@ -360,6 +471,50 @@ func plan14InstalledClaimLaunch(t *testing.T, boundUnit string) {
if _, err := f.root.Serve(t.Context(), f.peer, stop); err != nil {
t.Fatal("production stop", err)
}
+ if programMode {
+ ctl(t.Context(), "start", unit)
+ time.Sleep(100 * time.Millisecond)
+ state, err := ctl(t.Context(), "show", "-p", "MainPID", "--value", unit)
+ if err != nil || state != "0" {
+ t.Fatal("unreserved restart executed", state, err)
+ }
+ ctl(t.Context(), "stop", unit)
+ next := *f.req.Reservation
+ next.ID, next.InvocationID = "attempt-2:main", "inv-2"
+ f.req.Reservation = &next
+ f.claims.set(func(c *hostclaim.Claim) {
+ c.Holder.WakeRetry.AttemptsReserved++
+ c.Holder.WakeRetry.Attempt.ID = "attempt-2"
+ c.Holder.WakeRetry.Attempt.InvocationID = "inv-2"
+ })
+ var restarted hostbox.RoleLaunchResponse
+ if err := lock.With(t.Context(), func(ctx context.Context) error {
+ var err error
+ restarted, err = f.root.Serve(ctx, f.peer, f.req)
+ return err
+ }); err != nil {
+ logs, _ := exec.Command("journalctl", "-u", unit, "--no-pager", "-n", "100").CombinedOutput()
+ state, _ := ctl(t.Context(), "show", "-p", "ActiveState,SubState,InvocationID,ControlGroup,ExecStart,Result", unit)
+ t.Fatalf("reserved restart: %v\n%s\n%s", err, state, logs)
+ }
+ if restarted.Grant.UnitInvocationID == response.Grant.UnitInvocationID {
+ t.Fatal("restart reused invocation")
+ }
+ resp, err := client.Get("http://tenant/")
+ if err != nil {
+ t.Fatal("restarted endpoint", err)
+ }
+ resp.Body.Close()
+ transport.CloseIdleConnections()
+ if resp.StatusCode != 200 {
+ t.Fatal(resp.Status)
+ }
+ stop.Reservation = &next
+ if _, err := f.root.Serve(t.Context(), f.peer, stop); err != nil {
+ t.Fatal("restarted stop", err)
+ }
+ }
+ checkSibling()
if err := journal.CloseWriters(t.Context(), f.target); err != nil {
t.Fatal(err)
}
@@ -381,6 +536,7 @@ func plan14InstalledClaimLaunch(t *testing.T, boundUnit string) {
} else {
t.Logf("%s", output)
}
+ checkSibling()
}
func plan14InstalledRetire(t *testing.T, dir string) {
@@ -401,13 +557,14 @@ func plan14InstalledRetire(t *testing.T, dir string) {
defer generations.Close()
ledger := &release.RootGenerations{Store: generations}
records, err := ledger.Reservations(t.Context())
- if err != nil || len(records) != 2 {
+ if err != nil || len(records) < 2 {
t.Fatal(records, err)
}
- var v1, v3 release.GenerationReservation
+ var v1s []release.GenerationReservation
+ var v3 release.GenerationReservation
for _, r := range records {
if r.Binding.Version == "v1" {
- v1 = r
+ v1s = append(v1s, r)
} else {
v3 = r
}
@@ -417,12 +574,14 @@ func plan14InstalledRetire(t *testing.T, dir string) {
}
verifier := generationRetirement{guard: hostguard.Guard{DataDir: "/data", LockDir: filepath.Join(dir, "retire-locks"), Root: hostguard.NewRootAuthority(state)}, journal: journal, kernel: productionGenerationAbsence}
lock := &release.ReleaseExclusion{Open: func() (rootstate.Store, error) { return rootstate.Open(filepath.Join(dir, "release"), false, false) }}
- if err := lock.With(t.Context(), func(ctx context.Context) error { return verifier.retire(ctx, ledger, v1) }); err != nil {
- t.Fatal(err)
- }
- released, err := ledger.Read(v1.Binding)
- if err != nil || released.Retirement == "" {
- t.Fatal("exact pin not released", released, err)
+ for _, v1 := range v1s {
+ if err := lock.With(t.Context(), func(ctx context.Context) error { return verifier.retire(ctx, ledger, v1) }); err != nil {
+ t.Fatal(err)
+ }
+ released, err := ledger.Read(v1.Binding)
+ if err != nil || released.Retirement == "" {
+ t.Fatal("exact pin not released", released, err)
+ }
}
pending, err := ledger.Read(v3.Binding)
if err != nil || pending != v3 {
diff --git a/internal/host/plan14_root_generations_test.go b/internal/host/plan14_root_generations_test.go
index ff1827930..15d24415c 100644
--- a/internal/host/plan14_root_generations_test.go
+++ b/internal/host/plan14_root_generations_test.go
@@ -42,8 +42,12 @@ func (s *generationDisk) Write(k string, b []byte) error {
}
func generationLaunchFixture(t *testing.T) (*launchFixture, *generationDisk, provision.Record) {
+ return generationLaunchFixtureHost(t, "shop.example")
+}
+
+func generationLaunchFixtureHost(t *testing.T, host string) (*launchFixture, *generationDisk, provision.Record) {
t.Helper()
- f := newLaunchFixture(t, servingOp())
+ f := newLaunchFixtureHost(t, servingOp(), host)
store := release.NewDirStore(t.TempDir())
rec := provision.Record{Host: f.target.Host, Account: "bab", App: "shop", CreatedAt: time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC)}
plan14PutRecord(t, store, rec)
@@ -306,6 +310,10 @@ func TestPlan14GenerationReservationProcessCrash(t *testing.T) {
}
func TestPlan14GenerationProductionWiring(t *testing.T) {
+ oldOpen, oldShapes := openClaimUnitBindings, productionClaimUnitShapes
+ openClaimUnitBindings = func() (rootstate.Store, error) { return &rollbackMemory{files: map[string][]byte{}}, nil }
+ productionClaimUnitShapes = func() (map[string]claimUnitShape, error) { return map[string]claimUnitShape{}, nil }
+ t.Cleanup(func() { openClaimUnitBindings, productionClaimUnitShapes = oldOpen, oldShapes })
f, _, _ := generationLaunchFixture(t)
root := ClaimRootHTTP{Launches: f.journal, Stop: ClaimStopHTTP{Authenticate: func(context.Context, net.Conn) (StopPeer, error) { return f.peer, nil }, Coordinator: ClaimStopCoordinator{Kernel: stopKernelFunc(nil), Guard: f.root.Guard}}}
assembly := &ClaimAssembly{Claims: f.root.generations.store, ManagedRoots: map[string]string{"opt": f.root.generations.optDir, "data": "/data"}, Local: f.root.Local}
@@ -315,6 +323,12 @@ func TestPlan14GenerationProductionWiring(t *testing.T) {
t.Fatal(err)
}
launch := handler.(ClaimRootWithLaunch).Launch
+ if _, ok := launch.Units.(claimUnitDispatch); !ok || launch.unitBindings == nil {
+ t.Fatal("production unit dispatch missing")
+ }
+ if err := assembly.Close(t.Context()); err != nil {
+ t.Fatal(err)
+ }
if launch.generations == nil || launch.generations.open == nil || launch.generations.openFile == nil || launch.generations.pub == nil || launch.generations.store != assembly.Claims || launch.history == nil {
t.Fatal("production launch lacks a mandatory root reservation")
}
diff --git a/internal/instancectl/claimexec.go b/internal/instancectl/claimexec.go
index 6ff0c6992..f7009dcd6 100644
--- a/internal/instancectl/claimexec.go
+++ b/internal/instancectl/claimexec.go
@@ -8,6 +8,7 @@ import (
"net"
"os"
"path/filepath"
+ "strconv"
"time"
"github.com/carlosframework/platform/internal/provision"
@@ -90,7 +91,7 @@ func (u ClaimUnits) prepareExecGate(ctx context.Context, host string, p ClaimExe
return gate, nil
}
-func (g *claimExecGate) authorize(ctx context.Context, u ClaimUnits, s ClaimUnitStarted, check func() error) error {
+func (g *claimExecGate) authorize(ctx context.Context, u ClaimUnits, s *ClaimUnitStarted, check func() error) error {
deadline := time.Now().Add(u.budget())
if d, ok := ctx.Deadline(); ok && d.Before(deadline) {
deadline = d
@@ -110,10 +111,10 @@ func (g *claimExecGate) authorize(ctx context.Context, u ClaimUnits, s ClaimUnit
if err = conn.SetDeadline(deadline); err != nil {
return err
}
- if err = claimExecPeer(conn, s.MainPID); err != nil {
+ if err = u.pinExecPeer(ctx, conn, s); err != nil {
return err
}
- if err = u.Same(ctx, s); err != nil {
+ if err = u.Same(ctx, *s); err != nil {
return err
}
var asked ClaimExecPermit
@@ -127,9 +128,43 @@ func (g *claimExecGate) authorize(ctx context.Context, u ClaimUnits, s ClaimUnit
if err = check(); err != nil {
return err
}
- if err = u.Same(ctx, s); err != nil {
+ if err = u.Same(ctx, *s); err != nil {
return err
}
// One connection, one permit. Closing/restarting root cannot replay it.
return json.NewEncoder(conn).Encode(g.permit)
}
+
+// PrivatePIDs may replace systemd's setup PID with the namespace init PID.
+// Only this pre-exec handshake may pin that transition. The invocation and
+// cgroup must stay fixed, and systemd must identify the socket peer as MainPID.
+func (u ClaimUnits) pinExecPeer(ctx context.Context, conn net.Conn, s *ClaimUnitStarted) error {
+ for {
+ if err := ctx.Err(); err != nil {
+ return err
+ }
+ if err := claimExecPeer(conn, s.MainPID); err == nil {
+ return nil
+ }
+ state, err := u.show(ctx, s.Unit, "InvocationID", "MainPID", "ControlGroup", "ActiveState")
+ if err != nil {
+ return err
+ }
+ if state["InvocationID"] != s.InvocationID || state["ControlGroup"] != "/"+s.ControlGroup || state["ActiveState"] != "active" {
+ return ErrClaimUnit
+ }
+ pid, err := strconv.Atoi(state["MainPID"])
+ if err != nil || pid <= 0 {
+ return ErrClaimUnit
+ }
+ if err := claimExecPeer(conn, pid); err == nil {
+ s.MainPID = pid
+ return nil
+ }
+ select {
+ case <-ctx.Done():
+ return ctx.Err()
+ case <-time.After(u.poll()):
+ }
+ }
+}
diff --git a/internal/instancectl/claimexec_linux.go b/internal/instancectl/claimexec_linux.go
index dad2541e0..00688bf08 100644
--- a/internal/instancectl/claimexec_linux.go
+++ b/internal/instancectl/claimexec_linux.go
@@ -2,7 +2,10 @@
package instancectl
-import "net"
+import (
+ "fmt"
+ "net"
+)
func claimExecPeer(c net.Conn, pid int) error {
cred, err := peerCred(c)
@@ -10,7 +13,7 @@ func claimExecPeer(c net.Conn, pid int) error {
return err
}
if int(cred.Pid) != pid {
- return ErrClaimUnit
+ return fmt.Errorf("%w: executable gate peer %d differs from unit MainPID %d", ErrClaimUnit, cred.Pid, pid)
}
return nil
}
diff --git a/internal/instancectl/claimexec_startup_linux_test.go b/internal/instancectl/claimexec_startup_linux_test.go
new file mode 100644
index 000000000..629752715
--- /dev/null
+++ b/internal/instancectl/claimexec_startup_linux_test.go
@@ -0,0 +1,68 @@
+//go:build linux
+
+package instancectl
+
+import (
+ "context"
+ "fmt"
+ "net"
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+)
+
+func TestPlan14ExecPeerNamespaceTransition(t *testing.T) {
+ for _, attack := range []string{"none", "invocation", "group", "peer", "state"} {
+ t.Run(attack, func(t *testing.T) {
+ dir, err := os.MkdirTemp("", "ep")
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer os.RemoveAll(dir)
+ ln, err := net.Listen("unix", filepath.Join(dir, "s"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer ln.Close()
+ client, err := net.Dial("unix", filepath.Join(dir, "s"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer client.Close()
+ server, err := ln.Accept()
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer server.Close()
+ s := ClaimUnitStarted{Unit: "console.service", InvocationID: "inv", ControlGroup: "group", MainPID: os.Getpid() + 10000}
+ calls := 0
+ u := ClaimUnits{Poll: time.Millisecond, Systemctl: func(context.Context, ...string) (string, error) {
+ calls++
+ pid, inv, group, state := os.Getpid(), "inv", "/group", "active"
+ if calls == 1 || attack == "peer" {
+ pid += 10000
+ }
+ switch attack {
+ case "invocation":
+ inv = "replacement"
+ case "group":
+ group = "/sibling"
+ case "state":
+ state = "failed"
+ }
+ return fmt.Sprintf("MainPID=%d\nInvocationID=%s\nControlGroup=%s\nActiveState=%s\n", pid, inv, group, state), nil
+ }}
+ ctx, cancel := context.WithTimeout(t.Context(), 50*time.Millisecond)
+ defer cancel()
+ err = u.pinExecPeer(ctx, server, &s)
+ if attack == "none" {
+ if err != nil || s.MainPID != os.Getpid() || calls < 2 {
+ t.Fatal(s, calls, err)
+ }
+ } else if err == nil {
+ t.Fatal("accepted changed identity", attack)
+ }
+ })
+ }
+}
diff --git a/internal/instancectl/claimunit.go b/internal/instancectl/claimunit.go
index 9e0ec697e..b4a278e4f 100644
--- a/internal/instancectl/claimunit.go
+++ b/internal/instancectl/claimunit.go
@@ -217,7 +217,7 @@ func (u ClaimUnits) start(ctx context.Context, in ClaimUnit, launch *Launch, che
}
started := ClaimUnitStarted{Unit: unit, InvocationID: id, ControlGroup: group, MainPID: pid}
if gate != nil {
- if err := gate.authorize(ready, u, started, check); err != nil {
+ if err := gate.authorize(ready, u, &started, check); err != nil {
cleanup, cancel := context.WithTimeout(context.WithoutCancel(ctx), u.budget())
defer cancel()
if sameErr := u.Same(cleanup, started); sameErr != nil {
diff --git a/internal/provision/testdata/plan14-root-reads-e.tsv b/internal/provision/testdata/plan14-root-reads-e.tsv
index ec24b90d7..283b2bfee 100644
--- a/internal/provision/testdata/plan14-root-reads-e.tsv
+++ b/internal/provision/testdata/plan14-root-reads-e.tsv
@@ -54,7 +54,7 @@ internal/release/roothistory_replace.go replacementReconstruction authority E1 S
internal/release/signedhead.go StageSignedHead authority E1 Canonical route is supplied by the root unit verifier. Verifies signed channel, digest ratchet and manifest/artifact before staging; unavailable pin census never authorizes restaging differing bytes. Fixtures: TestPlan14Incident20261006WithLedger and TestPlan14SignedNamedRecoveryAuthority. registry:Account@202,registry:Account@302,registry:Account@403,registry:App@214,registry:App@415,registry:Channel@222,registry:Host@434 ca02f04e55556ba0233dfa0848b0c533a9d9aa06cad6abd33cfd2968b3bd838a
internal/rootstate/link_linux.go ReadTrustedLink authority A,E1 Checks the full root-owned parent chain before descriptor-based live-link reading. Fixture: TestPlan14TrustedLiveLink; privileged acceptance remains required. file:Open@156 90f8e8de4e2c71f07b5d749cbe4bb1c1dfa426576a82138823449f56fc9ec5ab
internal/rootstate/link_linux.go directory.readLink authority A,E1 Pins the symlink inode with O_PATH and checks owner/type/link count before readlinkat. Fixture: TestPlan14TrustedLiveLink. file:Fstat@356,file:Openat@183,file:Readlinkat@554 a8559fe63dce48830916004ecfe7fbbe5d419568c7daf7a3073cf1b5bb12c3d7
-@coverage internal/host f7295d9eaa466a1b8ca6daf9ec5176b604538815739fec2b12d0fe20b87b4f8b
+@coverage internal/host 20d945384e2acd968e65c69ad218b960dc1428993a3b281092aac9390a4b90a7
@coverage internal/release 329e742eddf1a5da3cd8f4a6b76a2eda2e38e5e6f0132d66e7b3830cfa95e77f
@coverage internal/rootstate a773d6dbc0e4150e0054f7a94d0e1493c88d0d57f4f4530138b3427e2c217bc5
internal/release/roothistory_status.go RootHistory.MigrationStatus authority E1 Exact completion binds the accepted authorization digest from durable private history to enrolled-root signed readback. Pending originals, changed evidence and canonical sources cannot supply retirement completion. Fixtures: TestPlan14HistoryReconstructionCompletion, TestPlan14HistoryRetirementCompletion, TestPlan14ReadReconstructionCompletion, TestPlan14ReadRetirementCompletion, TestPlan14RecoveryRetirementProductionCallback, TestPlan14RootHistoryReconstructionCrash, TestPlan14HistoryRetirementProcessCrash. file:Read@1062,file:Read@3891 f0ab7ad2d2d70f8c22ebfd575f5a1c6931f7eba30ccdba80ad43d0efffc88154
@@ -184,11 +184,11 @@ proof_cli.go runProof authority E1 E17: operator saves exact signed authorizatio
internal/historyreadback/readback.go RequestStatus authority E1 E17: operator saves exact signed authorization and challenge before delivery, resumes without renewal, and verifies enrolled-root exact completion. Existing proof branches are unchanged. Tests: TestPlan14HistoryOperatorWorkflow, TestPlan14HistoryOperatorConflicts, TestPlan14HistoryOperatorExactCompletion, TestPlan14HistoryWorkflowProcessCrash, TestPlan14HistoryCommandResume, TestPlan14HistorySavedFiles, TestPlan14HistoryCLIDispatch. d89052b0cdd12c8c8dcbc353e9ae995005caeb88d8e4c70974cb9e6f4bfd46d7
@coverage . dcc873ca955a35bf69ae09641d50576790123ec17aaf01637f0cfbbcf85530ce
@coverage internal/historyreadback eaca4d4caacf499db8a4c4e8044d565eec5bb42e0fc765b66f6c7559fbb04842
-internal/host/claimlaunch_production.go WithProductionClaimLaunch authority E1,E2 E18: claims-on launch records the canonical installation, deployment revision, signed build, executable and containing tree before start under release exclusion; both final spawn checks reread the same reservation and identities. Root process evidence never retires the reservation. This is the launch prerequisite only; complete pins and retirement remain pending. Fixtures: TestPlan14GenerationReservedBeforeClaimSpawn, TestPlan14GenerationLaunchRefusals, TestPlan14GenerationStartedSyncFailureRetainsPin, TestPlan14GenerationReservationProcessCrash, TestPlan14GenerationProductionWiring, TestPlan14GenerationReservationDurability, TestPlan14GenerationReservationConflicts, TestPlan14GenerationObservationKeepsReservation, TestPlan14GenerationRecordRefusals. 183f783e1998ffbe49fec813501dfcb9238ffae3b8a09dc545cf88d1b1609dd0
+internal/host/claimlaunch_production.go WithProductionClaimLaunch authority E1,E2 E18: claims-on launch records the canonical installation, deployment revision, signed build, executable and containing tree before start under release exclusion; both final spawn checks reread the same reservation and identities. Root process evidence never retires the reservation. This is the launch prerequisite only; complete pins and retirement remain pending. Fixtures: TestPlan14GenerationReservedBeforeClaimSpawn, TestPlan14GenerationLaunchRefusals, TestPlan14GenerationStartedSyncFailureRetainsPin, TestPlan14GenerationReservationProcessCrash, TestPlan14GenerationProductionWiring, TestPlan14GenerationReservationDurability, TestPlan14GenerationReservationConflicts, TestPlan14GenerationObservationKeepsReservation, TestPlan14GenerationRecordRefusals. PROPOSED E48: root; retained canonical unit and program ownership, checked root configuration, deny-only candidate matching, reservation-bearing per-host dispatch, and invocation/cgroup-bound executable peer confirmation. No no-census path is added. Full named-route admission, flat sockets and unit/env/timer retirement remain incomplete. d207171fd9e9ac2b056f246623da6e35ae80296590ebd018852415e4cf7a7133
internal/host/claimlaunch_root.go ClaimLaunchRoot.launchLocked authority E1,E2 E18: claims-on launch records the canonical installation, deployment revision, signed build, executable and containing tree before start under release exclusion; both final spawn checks reread the same reservation and identities. Root process evidence never retires the reservation. This is the launch prerequisite only; complete pins and retirement remain pending. Fixtures: TestPlan14GenerationReservedBeforeClaimSpawn, TestPlan14GenerationLaunchRefusals, TestPlan14GenerationStartedSyncFailureRetainsPin, TestPlan14GenerationReservationProcessCrash, TestPlan14GenerationProductionWiring, TestPlan14GenerationReservationDurability, TestPlan14GenerationReservationConflicts, TestPlan14GenerationObservationKeepsReservation, TestPlan14GenerationRecordRefusals. PROPOSED E47: root-owned immutable unit/shape binding, reserved launch placement and exact invocation stop; retained generation binding carries the canonical unit. Default unit paths remain unchanged. Production named-route wiring and unit/env/timer retirement are not complete. file:Read@2348,file:Read@2507,file:Read@707 8857198d935629758593ec447afac75c9b28ecaf09743bb8f304f93ea868c66e
internal/host/claimlaunch_root.go ClaimLaunchRoot.start authority E1,E2 E18: claims-on launch records the canonical installation, deployment revision, signed build, executable and containing tree before start under release exclusion; both final spawn checks reread the same reservation and identities. Root process evidence never retires the reservation. This is the launch prerequisite only; complete pins and retirement remain pending. Fixtures: TestPlan14GenerationReservedBeforeClaimSpawn, TestPlan14GenerationLaunchRefusals, TestPlan14GenerationStartedSyncFailureRetainsPin, TestPlan14GenerationReservationProcessCrash, TestPlan14GenerationProductionWiring, TestPlan14GenerationReservationDurability, TestPlan14GenerationReservationConflicts, TestPlan14GenerationObservationKeepsReservation, TestPlan14GenerationRecordRefusals. 78796fbb8f2516f96d1de84cfdd97f294cbcbd21ee63e8b47aa4e2fc2bad3980
internal/host/claimlaunch_root.go ClaimLaunchRoot.startRecorded authority E1,E2 E18: claims-on launch records the canonical installation, deployment revision, signed build, executable and containing tree before start under release exclusion; both final spawn checks reread the same reservation and identities. Root process evidence never retires the reservation. This is the launch prerequisite only; complete pins and retirement remain pending. Fixtures: TestPlan14GenerationReservedBeforeClaimSpawn, TestPlan14GenerationLaunchRefusals, TestPlan14GenerationStartedSyncFailureRetainsPin, TestPlan14GenerationReservationProcessCrash, TestPlan14GenerationProductionWiring, TestPlan14GenerationReservationDurability, TestPlan14GenerationReservationConflicts, TestPlan14GenerationObservationKeepsReservation, TestPlan14GenerationRecordRefusals. PROPOSED E47: root-owned immutable unit/shape binding, reserved launch placement and exact invocation stop; retained generation binding carries the canonical unit. Default unit paths remain unchanged. Production named-route wiring and unit/env/timer retirement are not complete. file:Read@2865 f0bc2975c60b0476090e265227b6428b3e7ab0bb41c6a0feb9a9a2244642fbc9
-internal/host/claimlaunch_root.go type ClaimLaunchRoot authority E1,E2 E18: claims-on launch records the canonical installation, deployment revision, signed build, executable and containing tree before start under release exclusion; both final spawn checks reread the same reservation and identities. Root process evidence never retires the reservation. This is the launch prerequisite only; complete pins and retirement remain pending. Fixtures: TestPlan14GenerationReservedBeforeClaimSpawn, TestPlan14GenerationLaunchRefusals, TestPlan14GenerationStartedSyncFailureRetainsPin, TestPlan14GenerationReservationProcessCrash, TestPlan14GenerationProductionWiring, TestPlan14GenerationReservationDurability, TestPlan14GenerationReservationConflicts, TestPlan14GenerationObservationKeepsReservation, TestPlan14GenerationRecordRefusals. PROPOSED E47: root-owned immutable unit/shape binding, reserved launch placement and exact invocation stop; retained generation binding carries the canonical unit. Default unit paths remain unchanged. Production named-route wiring and unit/env/timer retirement are not complete. bf0bd36504cca10325b1f10a6f1ed094bbaccfc1a0bf4391e006af53ae2a9f94
+internal/host/claimlaunch_root.go type ClaimLaunchRoot authority E1,E2 E18: claims-on launch records the canonical installation, deployment revision, signed build, executable and containing tree before start under release exclusion; both final spawn checks reread the same reservation and identities. Root process evidence never retires the reservation. This is the launch prerequisite only; complete pins and retirement remain pending. Fixtures: TestPlan14GenerationReservedBeforeClaimSpawn, TestPlan14GenerationLaunchRefusals, TestPlan14GenerationStartedSyncFailureRetainsPin, TestPlan14GenerationReservationProcessCrash, TestPlan14GenerationProductionWiring, TestPlan14GenerationReservationDurability, TestPlan14GenerationReservationConflicts, TestPlan14GenerationObservationKeepsReservation, TestPlan14GenerationRecordRefusals. PROPOSED E47: root-owned immutable unit/shape binding, reserved launch placement and exact invocation stop; retained generation binding carries the canonical unit. Default unit paths remain unchanged. Production named-route wiring and unit/env/timer retirement are not complete. PROPOSED E48: root; retained canonical unit and program ownership, checked root configuration, deny-only candidate matching, reservation-bearing per-host dispatch, and invocation/cgroup-bound executable peer confirmation. No no-census path is added. Full named-route admission, flat sockets and unit/env/timer retirement remain incomplete. f1a0ee3e297b8f85b63f0f5361d065d3cd21448552f1574ec54f6256409fe3f3
internal/host/rootgenerations_launch.go generationLaunchPermit.check authority E2 E18: claims-on launch records the canonical installation, deployment revision, signed build, executable and containing tree before start under release exclusion; both final spawn checks reread the same reservation and identities. Root process evidence never retires the reservation. This is the launch prerequisite only; complete pins and retirement remain pending. Fixtures: TestPlan14GenerationReservedBeforeClaimSpawn, TestPlan14GenerationLaunchRefusals, TestPlan14GenerationStartedSyncFailureRetainsPin, TestPlan14GenerationReservationProcessCrash, TestPlan14GenerationProductionWiring, TestPlan14GenerationReservationDurability, TestPlan14GenerationReservationConflicts, TestPlan14GenerationObservationKeepsReservation, TestPlan14GenerationRecordRefusals. 788e600dd37fec60c5b98dc42b23c182c337df064088a041e2e20b68573942b5
internal/host/rootgenerations_launch.go generationLaunchPermit.started authority E2 E18: claims-on launch records the canonical installation, deployment revision, signed build, executable and containing tree before start under release exclusion; both final spawn checks reread the same reservation and identities. Root process evidence never retires the reservation. This is the launch prerequisite only; complete pins and retirement remain pending. Fixtures: TestPlan14GenerationReservedBeforeClaimSpawn, TestPlan14GenerationLaunchRefusals, TestPlan14GenerationStartedSyncFailureRetainsPin, TestPlan14GenerationReservationProcessCrash, TestPlan14GenerationProductionWiring, TestPlan14GenerationReservationDurability, TestPlan14GenerationReservationConflicts, TestPlan14GenerationObservationKeepsReservation, TestPlan14GenerationRecordRefusals. PROPOSED E45: root uses retained reservation identities and live kernel containment checks; unrecognized processes still refuse. Current positive root acceptance requires rerun after proc-mount tightening. a7635d02d9cb682be64a0df3f8932336f5e874260898df9013ff7ffd7e3d7462
internal/host/rootgenerations_launch.go productionRootGenerations authority E2 E18: claims-on launch records the canonical installation, deployment revision, signed build, executable and containing tree before start under release exclusion; both final spawn checks reread the same reservation and identities. Root process evidence never retires the reservation. This is the launch prerequisite only; complete pins and retirement remain pending. Fixtures: TestPlan14GenerationReservedBeforeClaimSpawn, TestPlan14GenerationLaunchRefusals, TestPlan14GenerationStartedSyncFailureRetainsPin, TestPlan14GenerationReservationProcessCrash, TestPlan14GenerationProductionWiring, TestPlan14GenerationReservationDurability, TestPlan14GenerationReservationConflicts, TestPlan14GenerationObservationKeepsReservation, TestPlan14GenerationRecordRefusals. file:Open@346 a1132403ace7213de9e12720d5a393a3bb85045d1d2c53f0c37ea2ae8e933b04
@@ -391,7 +391,7 @@ internal/host/rootgeneration_containment_linux.go generationKernel.containedTask
internal/host/claimstop_run.go claimActivationReady authority E2 PROPOSED E45: root enforces census-gated release ordering, bounded acquisition and scoped recovery; registry timer primitives preserve supplied identity but producer integration remains pending. 82d893dc252c02813cd492f44dab8adc75b0e6fe5eaf4b7314c17759fdd5c83e
internal/host/rootreleaselock.go withClaimReleaseCensus authority E2 PROPOSED E45: root enforces census-gated release ordering, bounded acquisition and scoped recovery; registry timer primitives preserve supplied identity but producer integration remains pending. 35bcc5e18e89f9755cf1f749bbaa4cbf493380b75500183687476abd71c946b6
internal/rootparticipation/ledger.go IsAcquisitionRecovery authority E2 PROPOSED E45: root enforces census-gated release ordering, bounded acquisition and scoped recovery; registry timer primitives preserve supplied identity but producer integration remains pending. d71ec87152a9ba780aaf6e08fd54288bd6da40d61ad96495e7c3b29a0be200cd
-@coverage internal/instancectl e2a238c3bd1267e3084705ccb5b2e0bff4b3feed82accbb48de8bd4dd68b9e29
+@coverage internal/instancectl 9425fb28a2251848755a36fd8e68921c1ab0f72db21320493a15e6701a76180b
@coverage internal/instanceexec 1a4b9c7a5c57c99ecb39b56c724f08f7e0226dc4809f31000e49e4b38cb4d059
@fields A,Account,Accounts,AddedAt,Addr,Addrs,Alias,Aliases,Alive,AllowAbsent,App,Archives,Args,At,Attempts,B,Backing,Bucket,BuildState,Callback,CallbackError,CallbackFailed,Canary,Caps,CertExpiresAt,Changed,Channel,CheckedAt,ClaimIncarnation,ClaimOwner,ClaimOwnership,ClaimRowID,ClaimStamp,Class,Contents,CreatedAt,DBPath,DNSCheckedAt,DNSStatus,Deadline,Demand,Digest,DoneBefore,Due,DueAt,Env,EnvBefore,Environment,Error,ErrorKind,ExcludeHosts,ExpectGood,ExpectOp,Expected,F,Failed,FenceGen,Fleet,FleetBox,FleetBoxes,Fleets,From,FromDB,FromOp,FromSock,Gate,GoodDemand,GoodDigest,GoodVersion,Hash,Health,Held,Hibernate,HoldOp,Host,Hosts,ID,Id,IdValue,Idle,IdleSeconds,ImportGate,Journal,Keep,Kind,Label,LastAt,LastError,LastMs,LastStatus,Limit,MaxRun,MaxRunSeconds,Method,Ms,Name,Next,NextAt,NextDue,NextGood,NextValue,Now,Number,Number64,Observed,ObservedValue,Old,On,Op,Original,Owner,Ownership,P,Path,Paths,Platform,Pool,Prefix,Prev,PrevVersion,Provisioned,RecordKey,Region,Registration,Replica,Replicas,Replication,Retirement,Route,Routes,Row,Rows,Seed,SeedID,SeedSource,ServingVersion,Slug,Socket,Spec,Sqid,State,Status,StatusNamed,Steering,StringMap,T,TValue,Tags,Target,Targets,Text,Timer,Timers,Times,To,ToDB,ToOp,ToSock,TokenHash,Tunnel,Unit,UpdateSettings,Ver,Version,W,WakeIncarnation,Wildcards,closed,conn,connect,ctx,d,dec,done,enc,full,input,mu,output,path,remote,sql,wait
internal/host/agent.go runAgent authority B,C,D,F rev3=census-ruling-seeded-instances;basis=the agent (/opt/carlos/carlos agent, carlos-edge.service, user carlos): main's body with wireSeedPlacement called in the activator start's Configure step, before armMovesPass, and the startup sweep's result stored for the import rollback in Recover; read sites unchanged, offsets moved; main's B,C,D,F authority retained PROPOSED E46: captured claim selection and root journal mutation; root RPC outside service host exclusion; SQL/route data never grants root authority. Executing identity: service controller; root for inventory/retirement callbacks. file:Exec@13802,file:Open@731,registry:App@10065,whole:Route@10057,whole:Route@23109 098dab554d79d132385030a61a266947c39d91b9c2670a3be1141e7eacfbab72
@@ -434,8 +434,23 @@ internal/host/claimlaunch_root.go ClaimLaunchRoot.stop authority C,G,J4,E2 root-
internal/host/claimlaunch_root.go claimUnitStopKernel.settleUnits authority C,G,J4,E2 root-input;kept=C,G;scope=protected-decision-only PROPOSED E47: root-owned immutable unit/shape binding, reserved launch placement and exact invocation stop; retained generation binding carries the canonical unit. Default unit paths remain unchanged. Production named-route wiring and unit/env/timer retirement are not complete. file:Read@219 82c5a4ea6295e61d08f7a77a26e42040af6fff953265d8055d2454e28dff5ef1
internal/host/claimunit_binding.go claimUnitBindings.check authority E2 PROPOSED E47: root-owned immutable unit/shape binding, reserved launch placement and exact invocation stop; retained generation binding carries the canonical unit. Default unit paths remain unchanged. Production named-route wiring and unit/env/timer retirement are not complete. whole:Route@342,whole:Route@384 ade4c1bc0458015b450959ac11e28696c19491e24a76f334a5fb4f9c3ce8e767
internal/host/claimunit_binding.go claimUnitBindings.read authority E2 PROPOSED E47: root-owned immutable unit/shape binding, reserved launch placement and exact invocation stop; retained generation binding carries the canonical unit. Default unit paths remain unchanged. Production named-route wiring and unit/env/timer retirement are not complete. file:Read@211 3ce1ba6c82c41f1ad4ac005066746fcefa8a679ff587d07fa8794ca743a77685
-internal/host/claimunit_binding.go claimUnitShape.matches hint E2 PROPOSED E47: root-owned immutable unit/shape binding, reserved launch placement and exact invocation stop; retained generation binding carries the canonical unit. Default unit paths remain unchanged. Production named-route wiring and unit/env/timer retirement are not complete. registry:Account@98,registry:Addr@229,registry:Addrs@263,registry:App@120,registry:Backing@182,registry:DBPath@251,registry:Host@75,registry:Kind@139,registry:Replica@315,registry:Seed@298,registry:Socket@207,registry:Tunnel@280,registry:Unit@159 3846a08b4ea9297cb75bb7ebd25e07c9a63b8ada6a53ec8b49f2dce4e323ad6b
+internal/host/claimunit_binding.go claimUnitShape.matches hint E2 PROPOSED E47: root-owned immutable unit/shape binding, reserved launch placement and exact invocation stop; retained generation binding carries the canonical unit. Default unit paths remain unchanged. Production named-route wiring and unit/env/timer retirement are not complete. PROPOSED E48: root; retained canonical unit and program ownership, checked root configuration, deny-only candidate matching, reservation-bearing per-host dispatch, and invocation/cgroup-bound executable peer confirmation. No no-census path is added. Full named-route admission, flat sockets and unit/env/timer retirement remain incomplete. registry:Account@98,registry:Addr@237,registry:Addrs@271,registry:App@120,registry:Backing@190,registry:DBPath@259,registry:Host@75,registry:Kind@139,registry:Replica@323,registry:Seed@306,registry:Socket@215,registry:Tunnel@288,registry:Unit@149,registry:Unit@167 e0fd04d11a508d0d01830e7e646cc33d3673b5d1f9f25756981de70fcb61d46f
internal/host/claimunit_binding_source.go readClaimUnitShapes authority E2 PROPOSED E47: root-owned immutable unit/shape binding, reserved launch placement and exact invocation stop; retained generation binding carries the canonical unit. Default unit paths remain unchanged. Production named-route wiring and unit/env/timer retirement are not complete. file:ReadAll@188 77b2f5b6157118d78a66945e5bfc942d5ef485d2b7a39e52d3b28b8a5b10a0b9
internal/instancectl/claimexec.go ClaimUnits.prepareExecGate authority C,G,J2,J4,E2 root-input;kept=C,G;scope=protected-decision-only PROPOSED E47: root-owned immutable unit/shape binding, reserved launch placement and exact invocation stop; retained generation binding carries the canonical unit. Default unit paths remain unchanged. Production named-route wiring and unit/env/timer retirement are not complete. file:ReadFile@995 1b95e2b44b392d930ec8bd921b6d0382b47b3530a3d020646ced26e940e6f523
internal/instancectl/claimunit.go ClaimUnits.cgroupEmpty authority C,G,J2,J4,E2 root-input;kept=C,G;scope=protected-decision-only PROPOSED E47: root-owned immutable unit/shape binding, reserved launch placement and exact invocation stop; retained generation binding carries the canonical unit. Default unit paths remain unchanged. Production named-route wiring and unit/env/timer retirement are not complete. file:ReadFile@196 9e874fe959a3a381442e2900e8184f91ef803dd4f47839de8dbd6d3ab395d03e
internal/host/claimlaunch_authority.go ClaimLaunchRoot.authorize authority D,E2 Both root launch authorizations read root participation and match authenticated peer; TestPlan14ExecutionFinalLaunchUsesRootParticipation. PROPOSED E47: final named-unit launch authorization rechecks the retained root binding and refuses missing, retired, foreign or changed canonical shape. 9e00346c2012343327a230067f2e35f9f960569fbb3b6ee0dc401e7f566cbef6
+internal/host/claimlaunch_authority.go ClaimLaunchRoot.launchSpec authority E2 PROPOSED E48: root; retained canonical unit and program ownership, checked root configuration, deny-only candidate matching, reservation-bearing per-host dispatch, and invocation/cgroup-bound executable peer confirmation. No no-census path is added. Full named-route admission, flat sockets and unit/env/timer retirement remain incomplete. 4703055ca1e26021e448839b43b5e281a020e492aac61dadca12f03675a688a4
+internal/host/claimunit_binding.go claimUnitBindings.establish authority E2 PROPOSED E48: root; retained canonical unit and program ownership, checked root configuration, deny-only candidate matching, reservation-bearing per-host dispatch, and invocation/cgroup-bound executable peer confirmation. No no-census path is added. Full named-route admission, flat sockets and unit/env/timer retirement remain incomplete. 8001dce0f3c38b1c08845b8a536870e8d644114401670b8ca4950562b3c3517e
+internal/host/claimunit_binding.go claimUnitShape.valid authority E2 PROPOSED E48: root; retained canonical unit and program ownership, checked root configuration, deny-only candidate matching, reservation-bearing per-host dispatch, and invocation/cgroup-bound executable peer confirmation. No no-census path is added. Full named-route admission, flat sockets and unit/env/timer retirement remain incomplete. 725731eb019d6a4e612a7a94bcebc5619139405081053d725a4c67f763d536d4
+internal/host/claimunit_definition.go claimBoundUnitDefinition authority E2 PROPOSED E48: root; retained canonical unit and program ownership, checked root configuration, deny-only candidate matching, reservation-bearing per-host dispatch, and invocation/cgroup-bound executable peer confirmation. No no-census path is added. Full named-route admission, flat sockets and unit/env/timer retirement remain incomplete. file:ReadFile@165 2ef162905f3ca438ac3c186c92cd04059ba4deb07da29badd9d31a02d352ff00
+internal/host/claimunit_definition.go verifyClaimBoundUnitDefinition authority E2 PROPOSED E48: root; retained canonical unit and program ownership, checked root configuration, deny-only candidate matching, reservation-bearing per-host dispatch, and invocation/cgroup-bound executable peer confirmation. No no-census path is added. Full named-route admission, flat sockets and unit/env/timer retirement remain incomplete. file:ReadAll@285 107db8628ffc00a68a079040c507484372c36815e20de082f4964caac958597e
+internal/host/claimunit_dispatch.go claimUnitDispatch.StartReserved authority E2 PROPOSED E48: root; retained canonical unit and program ownership, checked root configuration, deny-only candidate matching, reservation-bearing per-host dispatch, and invocation/cgroup-bound executable peer confirmation. No no-census path is added. Full named-route admission, flat sockets and unit/env/timer retirement remain incomplete. 02dfbc7e31f9a13ba210d2bed3181da5e1874ae388c2d1060cbdb57c398a04e5
+internal/host/claimunit_dispatch.go claimUnitDispatch.forStart authority E2 PROPOSED E48: root; retained canonical unit and program ownership, checked root configuration, deny-only candidate matching, reservation-bearing per-host dispatch, and invocation/cgroup-bound executable peer confirmation. No no-census path is added. Full named-route admission, flat sockets and unit/env/timer retirement remain incomplete. ca62826e85ad57e88e68739cf35bee81c4caa0e541f1883d4440ebbdaefc4890
+internal/host/claimunit_dispatch.go claimUnitDispatch.selected authority E2 PROPOSED E48: root; retained canonical unit and program ownership, checked root configuration, deny-only candidate matching, reservation-bearing per-host dispatch, and invocation/cgroup-bound executable peer confirmation. No no-census path is added. Full named-route admission, flat sockets and unit/env/timer retirement remain incomplete. d167dd0c3f8f2b32995fd4d330e9fd3d15054e6abcbc77ce9408a0bf3b2c8196
+internal/host/claimunit_production.go bindProductionClaimUnits authority E2 PROPOSED E48: root; retained canonical unit and program ownership, checked root configuration, deny-only candidate matching, reservation-bearing per-host dispatch, and invocation/cgroup-bound executable peer confirmation. No no-census path is added. Full named-route admission, flat sockets and unit/env/timer retirement remain incomplete. file:Read@1270 bf9467dfc3f9819e7157fad6af0ae86c3d0bdb05f56fcd55fb68438e73699712
+internal/host/claimunit_production.go var openClaimUnitBindings authority E2 PROPOSED E48: root; retained canonical unit and program ownership, checked root configuration, deny-only candidate matching, reservation-bearing per-host dispatch, and invocation/cgroup-bound executable peer confirmation. No no-census path is added. Full named-route admission, flat sockets and unit/env/timer retirement remain incomplete. file:Open@70 f332e5eeef4116b6a9242f7f5c6c47bef78639a042ef0db3993a54dfae71963c
+internal/host/claimunit_program.go ClaimLaunchRoot.boundLaunchSpec authority E2 PROPOSED E48: root; retained canonical unit and program ownership, checked root configuration, deny-only candidate matching, reservation-bearing per-host dispatch, and invocation/cgroup-bound executable peer confirmation. No no-census path is added. Full named-route admission, flat sockets and unit/env/timer retirement remain incomplete. e80de9bd3222f06b6a7999ab9aa160a7a5edbb07f58eb7ba6ecba2ef77e66b7e
+internal/host/claimunit_program.go readClaimUnitProgram authority E2 PROPOSED E48: root; retained canonical unit and program ownership, checked root configuration, deny-only candidate matching, reservation-bearing per-host dispatch, and invocation/cgroup-bound executable peer confirmation. No no-census path is added. Full named-route admission, flat sockets and unit/env/timer retirement remain incomplete. file:ReadAll@353 28ee039bab9f2461f10797082d8cfafe751f094271f377d04565e410127808ec
+internal/instancectl/claimexec.go ClaimUnits.pinExecPeer authority E2 PROPOSED E48: root; retained canonical unit and program ownership, checked root configuration, deny-only candidate matching, reservation-bearing per-host dispatch, and invocation/cgroup-bound executable peer confirmation. No no-census path is added. Full named-route admission, flat sockets and unit/env/timer retirement remain incomplete. 6966c6e7ca873e312051e94c473f59331b33a1bf6b1017a2a83cd05c66ec01c4
+internal/instancectl/claimexec.go claimExecGate.authorize authority E2 PROPOSED E48: root; retained canonical unit and program ownership, checked root configuration, deny-only candidate matching, reservation-bearing per-host dispatch, and invocation/cgroup-bound executable peer confirmation. No no-census path is added. Full named-route admission, flat sockets and unit/env/timer retirement remain incomplete. 96964cb1eb39b1dd8ecbd47e9e6139e92a61bc72a83ebacfe9cf5886b6d08712
+internal/instancectl/claimunit.go ClaimUnits.start authority E2 PROPOSED E48: root; retained canonical unit and program ownership, checked root configuration, deny-only candidate matching, reservation-bearing per-host dispatch, and invocation/cgroup-bound executable peer confirmation. No no-census path is added. Full named-route admission, flat sockets and unit/env/timer retirement remain incomplete. 415efaf2a2a3b304cc9b623c19f0f772e941939322f5752eb8c74ed49e93b1dd