From 987bd26acc0d9e706779e0b3bcd1008e7d937345 Mon Sep 17 00:00:00 2001 From: bigboateng Date: Tue, 11 Aug 2026 22:25:07 +0100 Subject: [PATCH] Preserve fresh-clone runtime bootstrap --- .github/tests/test_repository_contract.py | 45 ++++++++++- boatstack/internal/plant/observer.go | 42 ++++++++-- boatstack/internal/plant/observer_test.go | 78 +++++++++++++++++++ install.ps1 | 2 +- install.sh | 2 +- ...026-08-11-fresh-clone-runtime-bootstrap.md | 6 ++ 6 files changed, 166 insertions(+), 9 deletions(-) create mode 100644 release-notes/2026-08-11-fresh-clone-runtime-bootstrap.md diff --git a/.github/tests/test_repository_contract.py b/.github/tests/test_repository_contract.py index 9d81c09d..7d90921d 100644 --- a/.github/tests/test_repository_contract.py +++ b/.github/tests/test_repository_contract.py @@ -430,18 +430,59 @@ def test_offline_installer_initializes_updates_and_guards_through_kernel(self) - "BOATSTACK_BINARY": str(self.old_helper), "BOATSTACK_BINARY_SHA256": digest, "BOATSTACK_INSTALL_DIR": str(install_dir), + "BOATSTACK_HOME": str(root / "home"), "BOATSTACK_CONFIG": str(CONFIG), "BOATSTACK_STATE_ROOT": str(root / "state"), "BOATSTACK_ACTOR": "contract", "BOATSTACK_VERSION": "contract-v2", } ) - self.run_command("bash", REPO / "install.sh", cwd=repository, env=env) + installed = self.run_command("bash", REPO / "install.sh", cwd=repository, env=env) + self.assertIn(".boatstack/runtime.json", installed.stdout) launcher = install_dir / "boatstack" self.assertTrue(launcher.is_file()) self.assertFalse(launcher.is_symlink()) self.assertTrue((repository / ".boatstack" / "project.json").is_file()) + self.run_command("git", "add", ".", cwd=repository) + self.run_command("git", "commit", "-m", "install Boatstack", cwd=repository) + clone = root / "clone" + self.run_command("git", "clone", str(repository), str(clone)) + fresh = json.loads( + self.run_command( + launcher, "init", "--repo", clone, "--human", "contract", + "--param", f"config_path={clone / '.boatstack' / 'project.json'}", + "--format", "json", env=env, + ).stdout + ) + self.assertEqual(fresh["receipt"]["transition_id"], "installation.initialize") + fresh_doctor = json.loads( + self.run_command(launcher, "doctor", "--repo", clone, env=env).stdout + ) + self.assertTrue(fresh_doctor["doctor"]["healthy"]) + + invalid_clone = root / "invalid-clone" + self.run_command("git", "clone", str(repository), str(invalid_clone)) + invalid_pin_path = invalid_clone / ".boatstack" / "runtime.json" + invalid_pin = json.loads(invalid_pin_path.read_text()) + invalid_pin["version"] = "v-conflicting" + invalid_pin_path.write_text(json.dumps(invalid_pin, indent=2) + "\n") + state_before = { + path.relative_to(root / "state"): path.read_bytes() + for path in (root / "state").rglob("*") if path.is_file() + } + refused = self.run_command( + self.old_helper, "init", "--repo", invalid_clone, "--human", "contract", + "--param", f"config_path={invalid_clone / '.boatstack' / 'project.json'}", + "--format", "json", env=env, expected=1, + ) + self.assertIn("not admissible", refused.stderr) + state_after = { + path.relative_to(root / "state"): path.read_bytes() + for path in (root / "state").rglob("*") if path.is_file() + } + self.assertEqual(state_after, state_before) + doctor = json.loads( self.run_command(launcher, "doctor", "--repo", repository, env=env).stdout ) @@ -639,11 +680,13 @@ def test_installers_are_checksum_first_and_kernel_owned(self) -> None: "BOATSTACK_BINARY_SHA256", "sha256sum", "shasum -a 256", '"$runtime" init', "update_arguments=(", '"$runtime" "${update_arguments[@]}"', "BOATSTACK_ACCEPT_PROGRAM_CHANGE", "--accept-program-change", + ".boatstack/runtime.json", ): self.assertIn(expected, shell) for expected in ( "BOATSTACK_BINARY_SHA256", "Get-FileHash", "$Runtime init", "$Runtime update", "BOATSTACK_ACCEPT_PROGRAM_CHANGE", "--accept-program-change", + ".boatstack\\runtime.json", ): self.assertIn(expected, powershell) self.assertNotIn("--repair", shell) diff --git a/boatstack/internal/plant/observer.go b/boatstack/internal/plant/observer.go index 7e454f42..fc84ee2d 100644 --- a/boatstack/internal/plant/observer.go +++ b/boatstack/internal/plant/observer.go @@ -50,7 +50,7 @@ func (o Observer) Observe(ctx context.Context, request ports.ObservationRequest) return model.Observation{}, err } now := o.clock.Now().UTC() - state, stateEvidence, err := o.readState(layout.StatePath, current, now) + state, stateEvidence, stateExists, err := o.readState(layout.StatePath, current, now) if err != nil { return model.Observation{}, err } @@ -104,6 +104,36 @@ func (o Observer) Observe(ctx context.Context, request ports.ObservationRequest) } if pinExists { runtimeState = model.RuntimeConflicting + if !stateExists && state.Runtime == model.RuntimeAbsent { + pinRaw, readPinErr := os.ReadFile(pinPath) + if readPinErr != nil { + return model.Observation{}, readPinErr + } + pin, decodePinErr := boatstackruntime.DecodePin(pinRaw) + if decodePinErr == nil && pin.StateSchemaVersion == durable.StateSchemaVersion && + pin.Version == current.RuntimeVersion && pin.SHA256 == current.RuntimeFingerprint { + home, homeErr := boatstackruntime.Home("") + if homeErr != nil { + return model.Observation{}, homeErr + } + runtimePath, pathErr := boatstackruntime.ExecutablePath(home, pin.Identity()) + if pathErr == nil { + evidence, fingerprint, exists, runtimeErr := fileEvidence(runtimePath, "runtime", now) + if runtimeErr != nil { + return model.Observation{}, runtimeErr + } + runtimeEvidence = append(runtimeEvidence, evidence) + switch { + case !exists: + runtimeState = model.RuntimeStale + case boatstackruntime.VerifyExecutable(runtimePath, pin.Identity()) != nil || fingerprint != pin.SHA256: + runtimeState = model.RuntimeStale + default: + runtimeState = model.RuntimeAbsent + } + } + } + } } } else if !pinExists { runtimeState = model.RuntimeAbsent @@ -336,21 +366,21 @@ func doublestarMatch(pattern, name string) (bool, error) { return compiled.MatchString(filepath.ToSlash(name)), nil } -func (o Observer) readState(path string, invocation model.InvocationContext, now time.Time) (durable.State, []model.Evidence, error) { +func (o Observer) readState(path string, invocation model.InvocationContext, now time.Time) (durable.State, []model.Evidence, bool, error) { raw, err := os.ReadFile(path) if err != nil { if os.IsNotExist(err) { fingerprint := hashBytes([]byte("absent:" + path + ":" + invocation.RepositoryID + ":" + invocation.WorktreeID)) evidence := []model.Evidence{{Source: path, Fingerprint: fingerprint, ObservedAt: now}} - return durable.Default(invocation, now), evidence, nil + return durable.Default(invocation, now), evidence, false, nil } - return durable.State{}, nil, fmt.Errorf("read durable state: %w", err) + return durable.State{}, nil, false, fmt.Errorf("read durable state: %w", err) } state, err := durable.DecodeState(raw) if err != nil { - return durable.State{}, nil, fmt.Errorf("decode durable state: %w", err) + return durable.State{}, nil, false, fmt.Errorf("decode durable state: %w", err) } - return state, []model.Evidence{{Source: path, Fingerprint: hashBytes(raw), ObservedAt: now}}, nil + return state, []model.Evidence{{Source: path, Fingerprint: hashBytes(raw), ObservedAt: now}}, true, nil } func (o Observer) gitEvidence(ctx context.Context, repository string, now time.Time) ([]model.Evidence, string, string, error) { diff --git a/boatstack/internal/plant/observer_test.go b/boatstack/internal/plant/observer_test.go index 1b4003b8..44bf9598 100644 --- a/boatstack/internal/plant/observer_test.go +++ b/boatstack/internal/plant/observer_test.go @@ -109,6 +109,84 @@ func TestObserverBindsVerifiedRuntimeToExecutingBinary(t *testing.T) { } } +func TestObserverAdmitsExactCommittedPinOnlyForAbsentState(t *testing.T) { + // control-law: exact-committed-pin-is-bootstrap-evidence-not-controller-state + repository := t.TempDir() + runGit(t, repository, "init", "-q") + runGit(t, repository, "config", "user.email", "boatstack@example.invalid") + runGit(t, repository, "config", "user.name", "Boatstack Test") + if err := os.WriteFile(filepath.Join(repository, "README.md"), []byte("fixture\n"), 0o644); err != nil { + t.Fatal(err) + } + runGit(t, repository, "add", "README.md") + runGit(t, repository, "commit", "-q", "-m", "fixture") + + resolver, err := NewResolver(t.TempDir()) + if err != nil { + t.Fatal(err) + } + home := t.TempDir() + t.Setenv(boatstackruntime.HomeEnvironment, home) + identity := boatstackruntime.Identity{Version: resolver.runtimeVersion, SHA256: resolver.runtimeFingerprint, SourceRevision: "fixture-revision"} + installed, err := boatstackruntime.InstallExecutable(resolver.runtimePath, home, identity) + if err != nil { + t.Fatal(err) + } + resolver.runtimePath = installed + invocation, err := resolver.ResolveInvocation(context.Background(), repository, "cli", "fresh-clone") + if err != nil { + t.Fatal(err) + } + pinRaw, err := boatstackruntime.EncodePin(boatstackruntime.NewPin(identity, strings.Repeat("a", 64), durable.StateSchemaVersion)) + if err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Dir(boatstackruntime.PinPath(repository)), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(boatstackruntime.PinPath(repository), pinRaw, 0o644); err != nil { + t.Fatal(err) + } + observer, err := NewObserver(resolver, observerClock{now: time.Unix(200, 0).UTC()}) + if err != nil { + t.Fatal(err) + } + observed, err := observer.Observe(context.Background(), ports.ObservationRequest{Invocation: invocation}) + if err != nil { + t.Fatal(err) + } + if observed.Runtime.Value != model.RuntimeAbsent { + t.Fatalf("exact pin with absent controller state observed as %s, want absent", observed.Runtime.Value) + } + if err := os.Remove(installed); err != nil { + t.Fatal(err) + } + observed, err = observer.Observe(context.Background(), ports.ObservationRequest{Invocation: invocation}) + if err != nil { + t.Fatal(err) + } + if observed.Runtime.Value != model.RuntimeStale { + t.Fatalf("pin with missing immutable runtime observed as %s, want stale", observed.Runtime.Value) + } + + conflicting := boatstackruntime.NewPin(identity, strings.Repeat("a", 64), durable.StateSchemaVersion) + conflicting.Version = "v-conflicting" + conflictingRaw, err := boatstackruntime.EncodePin(conflicting) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(boatstackruntime.PinPath(repository), conflictingRaw, 0o644); err != nil { + t.Fatal(err) + } + observed, err = observer.Observe(context.Background(), ports.ObservationRequest{Invocation: invocation}) + if err != nil { + t.Fatal(err) + } + if observed.Runtime.Value != model.RuntimeConflicting { + t.Fatalf("candidate-mismatched pin observed as %s, want conflicting", observed.Runtime.Value) + } +} + func TestDoubleStarMatchesRootAndNestedPaths(t *testing.T) { for _, test := range []struct { pattern string diff --git a/install.ps1 b/install.ps1 index e4bdd9a6..1cdbf8f5 100644 --- a/install.ps1 +++ b/install.ps1 @@ -117,7 +117,7 @@ try { Copy-Item -LiteralPath $Candidate -Destination $StagedLauncher Move-Item -LiteralPath $StagedLauncher -Destination $Launcher -Force Write-Host "Boatstack V2 installed at $Runtime" - Write-Host "Review and commit $Repository\.boatstack\project.json and the generated host skills" + Write-Host "Review and commit $Repository\.boatstack\project.json, $Repository\.boatstack\runtime.json, and the generated host skills" Write-Host "Run: $Launcher doctor --repo `"$Repository`" --format text" } finally { Remove-Item -LiteralPath $Temporary -Recurse -Force -ErrorAction SilentlyContinue diff --git a/install.sh b/install.sh index 2ec82426..4127ad6f 100755 --- a/install.sh +++ b/install.sh @@ -128,5 +128,5 @@ install -m 0755 "$candidate" "$launcher_staged" mv -f "$launcher_staged" "$install_dir/boatstack" echo "Boatstack V2 installed at $runtime" -echo "Review and commit $repository/.boatstack/project.json and the generated host skills" +echo "Review and commit $repository/.boatstack/project.json, $repository/.boatstack/runtime.json, and the generated host skills" echo "Run: $install_dir/boatstack doctor --repo $repository --format text" diff --git a/release-notes/2026-08-11-fresh-clone-runtime-bootstrap.md b/release-notes/2026-08-11-fresh-clone-runtime-bootstrap.md new file mode 100644 index 00000000..b444107d --- /dev/null +++ b/release-notes/2026-08-11-fresh-clone-runtime-bootstrap.md @@ -0,0 +1,6 @@ +### Initialize fresh clones from an exact runtime pin + +Fresh clones can now use their committed runtime pin to initialize missing +machine-local controller state. Boatstack verifies that the pin matches the +executing candidate and immutable artifact before initialization; malformed, +mismatched, missing, or corrupted runtime evidence still fails closed.