diff --git a/.github/tests/test_repository_contract.py b/.github/tests/test_repository_contract.py index 9b8a200..c5b76c0 100644 --- a/.github/tests/test_repository_contract.py +++ b/.github/tests/test_repository_contract.py @@ -102,8 +102,8 @@ def setUpClass(cls) -> None: "boatstack-helper-old.exe" if os.name == "nt" else "boatstack-helper-old" ) for output, version, source in ( - (cls.helper, "v0.7.contract-new", "b" * 40), - (cls.old_helper, "v0.7.contract-old", "a" * 40), + (cls.helper, "v9.9.10", "b" * 40), + (cls.old_helper, "v9.9.9", "a" * 40), ): ldflags = " ".join( ( @@ -884,7 +884,7 @@ def test_offline_installer_initializes_updates_and_guards_through_kernel(self) - ) self.assertTrue(updated["doctor"]["healthy"]) pin = json.loads((repository / ".boatstack" / "runtime.json").read_text()) - self.assertEqual(pin["version"], "v0.7.contract-new") + self.assertEqual(pin["version"], "v9.9.10") self.assertEqual(pin["sha256"], hashlib.sha256(self.helper.read_bytes()).hexdigest()) self.assertNotIn("path", pin) events = [json.loads(line) for line in self.run_command( @@ -900,6 +900,210 @@ def test_offline_installer_initializes_updates_and_guards_through_kernel(self) - self.assertTrue(event["committed_effects"]) self.assertEqual(event["verification"]["result"], "satisfied") + def test_installer_download_and_checksum_failures_are_actionable_and_non_mutating(self) -> None: + # control-law: installer-failure-identifies-exact-artifact-and-does-not-mutate-repository + if os.name == "nt": + self.skipTest("the repository contract job exercises the POSIX installer") + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + repository = root / "repo" + repository.mkdir() + self.init_repository(repository) + fake_bin = root / "fake-bin" + fake_bin.mkdir() + curl = fake_bin / "curl" + curl.write_text("#!/bin/sh\nexit 22\n") + curl.chmod(0o755) + env = dict(os.environ) + env.update( + { + "PATH": f"{fake_bin}{os.pathsep}{env['PATH']}", + "BOATSTACK_REPO": str(repository), + "BOATSTACK_HOME": str(root / "home"), + "BOATSTACK_INSTALL_DIR": str(root / "bin"), + "BOATSTACK_VERSION": "v9.9.9", + } + ) + unavailable = self.run_command( + "bash", REPO / "install.sh", cwd=repository, env=env, expected=1, + ) + self.assertIn( + "BOATSTACK_RUNTIME_ARTIFACT_UNAVAILABLE: version=v9.9.9 asset=boatstack-helper_", + unavailable.stderr, + ) + self.assertEqual( + self.run_command("git", "status", "--porcelain", cwd=repository).stdout, + "", + ) + + self.assertFalse((root / "home").exists()) + self.assertFalse((root / "bin").exists()) + + checksum_env = dict(env) + checksum_env.update( + { + "BOATSTACK_BINARY": str(self.helper), + "BOATSTACK_BINARY_SHA256": "0" * 64, + } + ) + mismatch = self.run_command( + "bash", REPO / "install.sh", cwd=repository, env=checksum_env, expected=1, + ) + self.assertIn( + "BOATSTACK_RUNTIME_ARTIFACT_CHECKSUM_MISMATCH: version=v9.9.9", + mismatch.stderr, + ) + self.assertEqual( + self.run_command("git", "status", "--porcelain", cwd=repository).stdout, + "", + ) + + def test_installer_hydrates_exact_local_runtime_without_repository_state(self) -> None: + # control-law: approved-runtime-hydration-restores-only-trusted-runtime-storage + if os.name == "nt": + self.skipTest("the repository contract job exercises the POSIX installer") + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + repository = root / "repo" + repository.mkdir() + self.init_repository(repository) + version = self.run_command(self.helper, "version").stdout.strip() + digest = hashlib.sha256(self.helper.read_bytes()).hexdigest() + env = dict(os.environ) + env.update( + { + "BOATSTACK_REPO": str(repository), + "BOATSTACK_HOME": str(root / "home"), + "BOATSTACK_INSTALL_DIR": str(root / "bin"), + "BOATSTACK_MODE": "hydrate", + "BOATSTACK_VERSION": version, + "BOATSTACK_BINARY": str(self.helper), + "BOATSTACK_BINARY_SHA256": digest, + } + ) + + hydrated = self.run_command("bash", REPO / "install.sh", cwd=repository, env=env) + runtime = root / "home" / "runtimes" / f"{version}-{digest}" / "boatstack-runtime" + self.assertTrue(runtime.is_file()) + self.assertTrue((root / "bin" / "boatstack").is_file()) + self.assertNotIn("Review and commit", hydrated.stdout) + self.assertFalse((repository / ".boatstack").exists()) + self.assertFalse((repository / ".git" / "boatstack").exists()) + self.assertEqual( + self.run_command("git", "status", "--porcelain", cwd=repository).stdout, + "", + ) + + wrong_digest = dict(env) + wrong_digest.update( + { + "BOATSTACK_HOME": str(root / "digest-home"), + "BOATSTACK_INSTALL_DIR": str(root / "digest-bin"), + "BOATSTACK_EXPECTED_RUNTIME_SHA256": "0" * 64, + } + ) + digest_rejected = self.run_command( + "bash", REPO / "install.sh", cwd=repository, env=wrong_digest, expected=1, + ) + self.assertIn("BOATSTACK_RUNTIME_ARTIFACT_CHECKSUM_MISMATCH", digest_rejected.stderr) + self.assertFalse((root / "digest-home").exists()) + self.assertFalse((root / "digest-bin").exists()) + + wrong_version = dict(env) + wrong_version.update( + { + "BOATSTACK_HOME": str(root / "wrong-home"), + "BOATSTACK_INSTALL_DIR": str(root / "wrong-bin"), + "BOATSTACK_VERSION": "v9.9.9", + } + ) + rejected = self.run_command( + "bash", REPO / "install.sh", cwd=repository, env=wrong_version, expected=1, + ) + self.assertIn("Boatstack runtime version mismatch", rejected.stderr) + self.assertFalse((root / "wrong-home").exists()) + self.assertFalse((root / "wrong-bin").exists()) + + old_version = self.run_command(self.old_helper, "version").stdout.strip() + old_digest = hashlib.sha256(self.old_helper.read_bytes()).hexdigest() + older_runtime = dict(env) + older_runtime.update( + { + "BOATSTACK_HOME": str(root / "old-home"), + "BOATSTACK_INSTALL_DIR": str(root / "old-bin"), + "BOATSTACK_VERSION": old_version, + "BOATSTACK_BINARY": str(self.old_helper), + "BOATSTACK_BINARY_SHA256": old_digest, + "BOATSTACK_EXPECTED_RUNTIME_SHA256": old_digest, + } + ) + self.run_command("bash", REPO / "install.sh", cwd=repository, env=older_runtime) + restored = root / "old-home" / "runtimes" / f"{old_version}-{old_digest}" / "boatstack-runtime" + self.assertTrue(restored.is_file()) + self.assertFalse((repository / ".boatstack").exists()) + self.assertFalse((repository / ".git" / "boatstack").exists()) + + def test_launcher_renders_missing_runtime_as_text_and_json_before_state(self) -> None: + # control-law: launcher-diagnostic-precedes-runtime-dispatch-and-managed-state + with tempfile.TemporaryDirectory() as temp: + root = Path(temp) + repository = root / "repo" + repository.mkdir() + self.init_repository(repository) + runtime = b"required but absent" + pin = { + "schema_version": 1, + "version": "v9.9.9", + "sha256": hashlib.sha256(runtime).hexdigest(), + "source_revision": "9" * 40, + "program_fingerprint": "a" * 64, + "state_schema_version": 3, + } + pin_path = repository / ".boatstack" / "runtime.json" + pin_path.parent.mkdir() + pin_path.write_text(json.dumps(pin, indent=2) + "\n") + launcher = root / ("boatstack.exe" if os.name == "nt" else "boatstack") + launcher.write_bytes(self.helper.read_bytes()) + launcher.chmod(0o755) + env = dict(os.environ) + env.update( + { + "BOATSTACK_HOME": str(root / "runtime-home"), + "BOATSTACK_STATE_ROOT": str(root / "state-root"), + } + ) + + rendered = self.run_command( + launcher, "next", "--repo", repository, "--format", "json", + env=env, expected=1, + ) + diagnostic = json.loads(rendered.stderr) + self.assertEqual(diagnostic["schema"], "boatstack-bootstrap-diagnostic") + self.assertEqual(diagnostic["schema_revision"], 1) + self.assertEqual(diagnostic["code"], "BOATSTACK_RUNTIME_NOT_INSTALLED") + self.assertEqual(diagnostic["required_runtime"], { + "version": pin["version"], + "sha256": pin["sha256"], + "source_revision": pin["source_revision"], + }) + self.assertIn("/v9.9.10/install", diagnostic["recovery"]["command"]) + self.assertIn(pin["sha256"], diagnostic["recovery"]["command"]) + self.assertIn("BOATSTACK_MODE=hydrate", diagnostic["recovery"]["command"]) + self.assertNotIn("BOATSTACK_MODE=update", diagnostic["recovery"]["command"]) + self.assertTrue(diagnostic["recovery"]["requires_confirmation"]) + self.assertFalse(diagnostic["flow_run_created"]) + self.assertFalse(diagnostic["managed_state_changed"]) + + text = self.run_command( + launcher, "next", "--repo", repository, "--format", "text", + env=env, expected=1, + ) + self.assertIn("Blocked:", text.stderr) + self.assertIn("BOATSTACK_RUNTIME_NOT_INSTALLED", text.stderr) + self.assertIn("BOATSTACK_VERSION=v9.9.9", text.stderr) + self.assertFalse((root / "state-root").exists()) + self.assertFalse((repository / ".git" / "boatstack").exists()) + def test_program_changing_update_is_explicit_atomic_and_dormant_safe(self) -> None: # control-law: accepted-program-delta-atomically-pins-runtime-and-program if os.name == "nt": @@ -997,7 +1201,7 @@ def test_program_changing_update_is_explicit_atomic_and_dormant_safe(self) -> No env["BOATSTACK_ACCEPT_PROGRAM_CHANGE"] = "true" self.run_command("bash", REPO / "install.sh", cwd=repository, env=env) pin = json.loads((repository / ".boatstack" / "runtime.json").read_text()) - self.assertEqual(pin["version"], "v0.7.contract-new") + self.assertEqual(pin["version"], "v9.9.10") self.assertEqual(pin["sha256"], hashlib.sha256(self.helper.read_bytes()).hexdigest()) self.assertNotIn("path", pin) doctor = json.loads( diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e0063ca..297c72b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -184,6 +184,53 @@ jobs: $errors | ForEach-Object { Write-Error $_ } exit 1 } + - name: Prove PowerShell install and hydration boundaries + if: matrix.shard == '0' + shell: pwsh + run: | + $root = Join-Path $env:RUNNER_TEMP ("boatstack-installer-" + [guid]::NewGuid().ToString("N")) + $helper = (Resolve-Path boatstack/boatstack-helper.exe).Path + $version = (& $helper version).Trim() + $digest = (Get-FileHash -Algorithm SHA256 -LiteralPath $helper).Hash.ToLowerInvariant() + try { + foreach ($mode in @("hydrate", "install")) { + $repository = Join-Path $root "$mode-repository" + New-Item -ItemType Directory -Force -Path $repository | Out-Null + & git -C $repository init --initial-branch=main | Out-Null + & git -C $repository config user.email "installer-contract@example.invalid" + & git -C $repository config user.name "Installer Contract" + Set-Content -LiteralPath (Join-Path $repository "README.md") -Value "fixture" + & git -C $repository add README.md + & git -C $repository commit -m "Initialize fixture" | Out-Null + + $runtimeHome = Join-Path $root "$mode-home" + $installDir = Join-Path $root "$mode-bin" + $env:BOATSTACK_REPO = $repository + $env:BOATSTACK_HOME = $runtimeHome + $env:BOATSTACK_INSTALL_DIR = $installDir + $env:BOATSTACK_MODE = $mode + $env:BOATSTACK_VERSION = $version + $env:BOATSTACK_BINARY = $helper + $env:BOATSTACK_BINARY_SHA256 = $digest + $env:BOATSTACK_EXPECTED_RUNTIME_SHA256 = $digest + & ./install.ps1 + if ($LASTEXITCODE -ne 0) { throw "$mode installer failed" } + + $runtime = Join-Path $runtimeHome "runtimes\$version-$digest\boatstack-runtime.exe" + $launcher = Join-Path $installDir "boatstack.exe" + if (-not (Test-Path -LiteralPath $runtime -PathType Leaf)) { throw "$mode did not stage the runtime" } + if (-not (Test-Path -LiteralPath $launcher -PathType Leaf)) { throw "$mode did not stage the launcher" } + if ($mode -eq "hydrate") { + if (Test-Path -LiteralPath (Join-Path $repository ".boatstack")) { throw "hydrate changed repository state" } + if (Test-Path -LiteralPath (Join-Path $repository ".git\boatstack")) { throw "hydrate changed controller state" } + if (& git -C $repository status --porcelain) { throw "hydrate changed tracked repository files" } + } elseif (-not (Test-Path -LiteralPath (Join-Path $repository ".boatstack\runtime.json") -PathType Leaf)) { + throw "install did not initialize the repository runtime pin" + } + } + } finally { + Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue + } repository-contract: name: repository-contract diff --git a/boatstack/cmd/boatstack-helper/main.go b/boatstack/cmd/boatstack-helper/main.go index 0b25e19..31bca9a 100644 --- a/boatstack/cmd/boatstack-helper/main.go +++ b/boatstack/cmd/boatstack-helper/main.go @@ -81,7 +81,12 @@ func main() { if boatstackruntime.ShouldDispatch(os.Args[0]) { code, err := boatstackruntime.Dispatch(os.Args[1:]) if err != nil { - fmt.Fprintln(os.Stderr, "boatstack:", err) + rendered, renderErr := boatstackruntime.RenderBootstrapDiagnostic(os.Stderr, err, os.Args[1:]) + if renderErr != nil { + fmt.Fprintln(os.Stderr, "boatstack:", renderErr) + } else if !rendered { + fmt.Fprintln(os.Stderr, "boatstack:", err) + } os.Exit(1) } os.Exit(code) diff --git a/boatstack/flow/skillprojection/bootstrap.go b/boatstack/flow/skillprojection/bootstrap.go new file mode 100644 index 0000000..6ab2788 --- /dev/null +++ b/boatstack/flow/skillprojection/bootstrap.go @@ -0,0 +1,33 @@ +package skillprojection + +import "fmt" + +// BootstrapContract is shared by every generated Flow entry skill. It covers +// failures that occur before a repository Control Program can be loaded. +func BootstrapContract(installerVersion string) string { + return fmt.Sprintf(`Before starting the Flow, verify that the `+"`boatstack`"+` command is +available (`+"`command -v boatstack`"+` on POSIX or `+"`Get-Command boatstack`"+` in +PowerShell). If it is absent, read the exact committed +`+"`.boatstack/runtime.json`"+` regular file. Report +`+"`BOATSTACK_LAUNCHER_NOT_FOUND`"+`, the pinned version and SHA-256, and the +tag-specific installer command for the current platform: + +POSIX: +`+"`BOATSTACK_MODE=hydrate BOATSTACK_VERSION= BOATSTACK_EXPECTED_RUNTIME_SHA256= /bin/bash -c \"$(curl -fsSL https://raw.githubusercontent.com/operatorstack/boatstack/%s/install.sh)\"`"+` + +PowerShell: +`+"`$env:BOATSTACK_MODE='hydrate'; $env:BOATSTACK_VERSION=''; $env:BOATSTACK_EXPECTED_RUNTIME_SHA256=''; Invoke-RestMethod https://raw.githubusercontent.com/operatorstack/boatstack/%s/install.ps1 | Invoke-Expression`"+` + +Replace `+"``"+` and `+"``"+` only with the validated +values in the pin. The installer comes from Boatstack %s, the runtime version +that generated this skill, so it can hydrate older pinned runtime artifacts. +If the pin is absent or invalid, report `+"`BOATSTACK_RUNTIME_PIN_MISSING`"+` or +`+"`BOATSTACK_RUNTIME_PIN_INVALID`"+` and stop without guessing a version or +selecting `+"`latest`"+`. + +Display the installer command and ask for explicit approval. Never run it or +authorize installation on the user's behalf. A bootstrap failure creates no +Flow run ID. Preserve any Boatstack bootstrap diagnostic verbatim, including +stderr, and resume this same requested entry only after the user has installed +the exact runtime.`, installerVersion, installerVersion, installerVersion) +} diff --git a/boatstack/flow/skillprojection/bootstrap_test.go b/boatstack/flow/skillprojection/bootstrap_test.go new file mode 100644 index 0000000..dc77022 --- /dev/null +++ b/boatstack/flow/skillprojection/bootstrap_test.go @@ -0,0 +1,29 @@ +package skillprojection + +import ( + "strings" + "testing" +) + +func TestBootstrapContractFailsClosedBeforeFlowExecution(t *testing.T) { + // control-law: generated-skills-report-bootstrap-recovery-without-executing-it + contract := BootstrapContract("v9.9.10") + for _, expected := range []string{ + "command -v boatstack", "Get-Command boatstack", ".boatstack/runtime.json", + "BOATSTACK_LAUNCHER_NOT_FOUND", "BOATSTACK_RUNTIME_PIN_MISSING", + "BOATSTACK_RUNTIME_PIN_INVALID", "explicit approval", "Never run it", + "creates no\nFlow run ID", "resume this same requested entry", + "BOATSTACK_MODE=hydrate", "BOATSTACK_VERSION=", + "BOATSTACK_EXPECTED_RUNTIME_SHA256=", "/v9.9.10/install.sh", + } { + if !strings.Contains(contract, expected) { + t.Fatalf("bootstrap contract lacks %q", expected) + } + } + if strings.Contains(contract, "BOATSTACK_VERSION=latest") { + t.Fatal("bootstrap contract permits mutable latest selection") + } + if strings.Contains(contract, "BOATSTACK_MODE=update") { + t.Fatal("bootstrap contract permits repository mutation during runtime recovery") + } +} diff --git a/boatstack/flow/softwaredelivery/skills.go b/boatstack/flow/softwaredelivery/skills.go index 78fdfd0..b2a452c 100644 --- a/boatstack/flow/softwaredelivery/skills.go +++ b/boatstack/flow/softwaredelivery/skills.go @@ -7,6 +7,8 @@ import ( "strings" "github.com/operatorstack/boatstack/boatstack/controlprogram" + "github.com/operatorstack/boatstack/boatstack/flow/skillprojection" + "github.com/operatorstack/boatstack/boatstack/internal/buildinfo" ) func GenerateSkills(compiled controlprogram.Compiled, hosts []string) (map[string][]byte, error) { @@ -91,6 +93,8 @@ description: %q Run the repository-owned Flow %q entry %q until its marked target %q is reached. Boatstack does not interpret the entry name. +%s + Start with `+"`boatstack next --repo . --flow %s --entry %s --host %s --format json`"+`. Preserve the returned program fingerprint, entry, run ID, delivery, repository, worktree, host, actor, authority receipts, prescription, and receipts through @@ -106,7 +110,7 @@ background while input is missing. Never synthesize authority. Stop only when Boatstack reports the marked target, a typed blocker, refusal, unresolved recovery, or missing authority. This entry grants no merge or deploy authority. -`, slug, description, title(slug), compiled.Document.Program.ID, entry.ID, entry.Target, compiled.Document.Program.ID, entry.ID, host, delegation, supersession)) +`, slug, description, title(slug), compiled.Document.Program.ID, entry.ID, entry.Target, skillprojection.BootstrapContract(buildinfo.Version), compiled.Document.Program.ID, entry.ID, host, delegation, supersession)) } func targetEntrySkill(programID string, entries []controlprogram.Entry, target string) (string, bool) { diff --git a/boatstack/flow/softwaredelivery/skills_test.go b/boatstack/flow/softwaredelivery/skills_test.go index d23a04d..12521a2 100644 --- a/boatstack/flow/softwaredelivery/skills_test.go +++ b/boatstack/flow/softwaredelivery/skills_test.go @@ -35,7 +35,10 @@ func TestGeneratedSkillsProjectOnlyDeclaredEntriesWithHostParity(t *testing.T) { t.Fatal("Codex and Claude entry contracts differ") } value := string(codex) - for _, contract := range []string{"--flow product-delivery --entry run", "same run ID", "Nothing continues in the\nbackground", "no merge or deploy"} { + for _, contract := range []string{ + "--flow product-delivery --entry run", "same run ID", "Nothing continues in the\nbackground", "no merge or deploy", + "BOATSTACK_LAUNCHER_NOT_FOUND", ".boatstack/runtime.json", "Never run it", "creates no\nFlow run ID", + } { if !strings.Contains(value, contract) { t.Fatalf("generated skill lacks %q", contract) } diff --git a/boatstack/internal/runtime/bootstrap_diagnostic.go b/boatstack/internal/runtime/bootstrap_diagnostic.go new file mode 100644 index 0000000..d6704c2 --- /dev/null +++ b/boatstack/internal/runtime/bootstrap_diagnostic.go @@ -0,0 +1,144 @@ +package runtime + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "regexp" + "runtime" + "strings" + + "github.com/operatorstack/boatstack/boatstack/internal/buildinfo" +) + +const ( + BootstrapDiagnosticSchema = "boatstack-bootstrap-diagnostic" + BootstrapDiagnosticSchemaRevision = 1 + + CodeRuntimePinMissing = "BOATSTACK_RUNTIME_PIN_MISSING" + CodeRuntimePinInvalid = "BOATSTACK_RUNTIME_PIN_INVALID" + CodeRuntimeNotInstalled = "BOATSTACK_RUNTIME_NOT_INSTALLED" + CodeRuntimeInvalid = "BOATSTACK_RUNTIME_INVALID" + CodeRuntimeChecksumMismatch = "BOATSTACK_RUNTIME_CHECKSUM_MISMATCH" + CodeRuntimeArtifactUnavailable = "BOATSTACK_RUNTIME_ARTIFACT_UNAVAILABLE" + CodeRuntimeArtifactChecksumMismatch = "BOATSTACK_RUNTIME_ARTIFACT_CHECKSUM_MISMATCH" +) + +var publicReleasePattern = regexp.MustCompile(`^v[0-9]+\.[0-9]+\.[0-9]+(?:-[A-Za-z0-9][A-Za-z0-9.-]*)?$`) + +type BootstrapRequiredRuntime struct { + Version string `json:"version"` + SHA256 string `json:"sha256"` + SourceRevision string `json:"source_revision"` +} + +type BootstrapRecovery struct { + Action string `json:"action"` + Command string `json:"command,omitempty"` + RequiresConfirmation bool `json:"requires_confirmation"` +} + +type BootstrapDiagnostic struct { + Schema string `json:"schema"` + SchemaRevision int `json:"schema_revision"` + Kind string `json:"kind"` + Code string `json:"code"` + Message string `json:"message"` + Repository string `json:"repository"` + RequiredRuntime *BootstrapRequiredRuntime `json:"required_runtime,omitempty"` + Recovery *BootstrapRecovery `json:"recovery,omitempty"` + FlowRunCreated bool `json:"flow_run_created"` + ManagedStateChanged bool `json:"managed_state_changed"` + Cause error `json:"-"` +} + +func (d *BootstrapDiagnostic) Error() string { + if d == nil { + return "" + } + return d.Message +} + +func (d *BootstrapDiagnostic) Unwrap() error { + if d == nil { + return nil + } + return d.Cause +} + +func newBootstrapDiagnostic(code, message, repository string, cause error) *BootstrapDiagnostic { + return &BootstrapDiagnostic{ + Schema: BootstrapDiagnosticSchema, SchemaRevision: BootstrapDiagnosticSchemaRevision, + Kind: "blocked", Code: code, Message: message, Repository: repository, Cause: cause, + } +} + +func runtimeBootstrapDiagnostic(code, message, repository string, identity Identity, cause error) *BootstrapDiagnostic { + diagnostic := newBootstrapDiagnostic(code, message, repository, cause) + diagnostic.RequiredRuntime = &BootstrapRequiredRuntime{ + Version: identity.Version, SHA256: identity.SHA256, SourceRevision: identity.SourceRevision, + } + if code == CodeRuntimeNotInstalled && publicReleasePattern.MatchString(identity.Version) && publicReleasePattern.MatchString(buildinfo.Version) { + diagnostic.Recovery = &BootstrapRecovery{ + Action: "install-exact-runtime", Command: releaseInstallCommand(identity, buildinfo.Version), RequiresConfirmation: true, + } + } + return diagnostic +} + +func releaseInstallCommand(identity Identity, installerVersion string) string { + if runtime.GOOS == "windows" { + return fmt.Sprintf("$env:BOATSTACK_MODE='hydrate'; $env:BOATSTACK_VERSION='%s'; $env:BOATSTACK_EXPECTED_RUNTIME_SHA256='%s'; Invoke-RestMethod https://raw.githubusercontent.com/operatorstack/boatstack/%s/install.ps1 | Invoke-Expression", identity.Version, identity.SHA256, installerVersion) + } + return fmt.Sprintf("BOATSTACK_MODE=hydrate BOATSTACK_VERSION=%s BOATSTACK_EXPECTED_RUNTIME_SHA256=%s /bin/bash -c \"$(curl -fsSL https://raw.githubusercontent.com/operatorstack/boatstack/%s/install.sh)\"", identity.Version, identity.SHA256, installerVersion) +} + +func requestedJSON(arguments []string) bool { + for index := 0; index < len(arguments); index++ { + if arguments[index] == "--format" && index+1 < len(arguments) { + return arguments[index+1] == "json" + } + if strings.TrimPrefix(arguments[index], "--format=") != arguments[index] { + return strings.TrimPrefix(arguments[index], "--format=") == "json" + } + } + return false +} + +func RenderBootstrapDiagnostic(writer io.Writer, err error, arguments []string) (bool, error) { + var diagnostic *BootstrapDiagnostic + if !errors.As(err, &diagnostic) { + return false, nil + } + if requestedJSON(arguments) { + encoder := json.NewEncoder(writer) + encoder.SetIndent("", " ") + return true, encoder.Encode(diagnostic) + } + if _, writeErr := fmt.Fprintf(writer, "Blocked: %s\n\nCode: %s\n", diagnostic.Message, diagnostic.Code); writeErr != nil { + return true, writeErr + } + if diagnostic.RequiredRuntime != nil { + if _, writeErr := fmt.Fprintf(writer, "Required version: %s\nRequired SHA-256: %s\n", diagnostic.RequiredRuntime.Version, diagnostic.RequiredRuntime.SHA256); writeErr != nil { + return true, writeErr + } + } + if diagnostic.Recovery != nil && diagnostic.Recovery.Command != "" { + if _, writeErr := fmt.Fprintf(writer, "\nInstall the exact runtime after explicit approval:\n\n%s\n", diagnostic.Recovery.Command); writeErr != nil { + return true, writeErr + } + } + _, writeErr := fmt.Fprintln(writer, "\nNo Flow run was created and no managed state was changed.") + return true, writeErr +} + +type runtimeVerificationError struct { + code string + identity Identity + actual string + cause error +} + +func (e *runtimeVerificationError) Error() string { return e.cause.Error() } +func (e *runtimeVerificationError) Unwrap() error { return e.cause } diff --git a/boatstack/internal/runtime/dispatch.go b/boatstack/internal/runtime/dispatch.go index b77b749..5d20c43 100644 --- a/boatstack/internal/runtime/dispatch.go +++ b/boatstack/internal/runtime/dispatch.go @@ -1,6 +1,7 @@ package runtime import ( + "errors" "fmt" "os" "path/filepath" @@ -31,11 +32,11 @@ func ResolvePinnedExecutable(repository string) (string, Pin, error) { } raw, err := os.ReadFile(PinPath(repository)) if err != nil { - return "", Pin{}, fmt.Errorf("read repository runtime pin: %w", err) + return "", Pin{}, newBootstrapDiagnostic(CodeRuntimePinInvalid, "The repository Boatstack runtime pin cannot be read.", repository, err) } pin, err := DecodePin(raw) if err != nil { - return "", Pin{}, fmt.Errorf("decode repository runtime pin: %w", err) + return "", Pin{}, newBootstrapDiagnostic(CodeRuntimePinInvalid, "The repository Boatstack runtime pin is invalid.", repository, err) } home, err := Home("") if err != nil { @@ -46,6 +47,17 @@ func ResolvePinnedExecutable(repository string) (string, Pin, error) { return "", Pin{}, err } if err := VerifyExecutable(executable, pin.Identity()); err != nil { + var verification *runtimeVerificationError + if errors.As(err, &verification) { + message := "The repository-pinned Boatstack runtime is invalid." + switch verification.code { + case CodeRuntimeNotInstalled: + message = "The repository-pinned Boatstack runtime is not installed." + case CodeRuntimeChecksumMismatch: + message = "The repository-pinned Boatstack runtime checksum does not match." + } + return "", Pin{}, runtimeBootstrapDiagnostic(verification.code, message, repository, pin.Identity(), err) + } return "", Pin{}, err } return executable, pin, nil @@ -93,13 +105,13 @@ func findPinnedRepository(start string) (string, error) { return "", err } if _, err := os.Lstat(filepath.Join(current, ".git")); err == nil { - return "", fmt.Errorf("repository at %s has no Boatstack runtime pin; initialize it first", current) + return "", newBootstrapDiagnostic(CodeRuntimePinMissing, "The repository has no Boatstack runtime pin; a maintainer must initialize it.", current, nil) } else if !os.IsNotExist(err) { return "", err } parent := filepath.Dir(current) if parent == current { - return "", fmt.Errorf("no Boatstack runtime pin found from %s; initialize this repository first", start) + return "", newBootstrapDiagnostic(CodeRuntimePinMissing, "No Boatstack runtime pin was found; a maintainer must initialize the repository.", start, nil) } current = parent } diff --git a/boatstack/internal/runtime/identity.go b/boatstack/internal/runtime/identity.go index 11bbaa0..22a7ab7 100644 --- a/boatstack/internal/runtime/identity.go +++ b/boatstack/internal/runtime/identity.go @@ -162,20 +162,35 @@ func VerifyExecutable(path string, identity Identity) error { info, err := os.Lstat(path) if err != nil { if os.IsNotExist(err) { - return fmt.Errorf("pinned Boatstack runtime is not installed: %s", identity.Version+"@"+identity.SHA256) + return &runtimeVerificationError{ + code: CodeRuntimeNotInstalled, identity: identity, + cause: fmt.Errorf("pinned Boatstack runtime is not installed: %s", identity.Version+"@"+identity.SHA256), + } + } + return &runtimeVerificationError{ + code: CodeRuntimeInvalid, identity: identity, + cause: fmt.Errorf("inspect pinned Boatstack runtime: %w", err), } - return err } if info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() { - return fmt.Errorf("pinned Boatstack runtime must be an immutable regular file") + return &runtimeVerificationError{ + code: CodeRuntimeInvalid, identity: identity, + cause: fmt.Errorf("pinned Boatstack runtime must be an immutable regular file"), + } } raw, err := os.ReadFile(path) if err != nil { - return err + return &runtimeVerificationError{ + code: CodeRuntimeInvalid, identity: identity, + cause: fmt.Errorf("read pinned Boatstack runtime: %w", err), + } } sum := sha256.Sum256(raw) if actual := hex.EncodeToString(sum[:]); actual != identity.SHA256 { - return fmt.Errorf("pinned Boatstack runtime checksum mismatch: got %s, want %s", actual, identity.SHA256) + return &runtimeVerificationError{ + code: CodeRuntimeChecksumMismatch, identity: identity, actual: actual, + cause: fmt.Errorf("pinned Boatstack runtime checksum mismatch: got %s, want %s", actual, identity.SHA256), + } } return nil } diff --git a/boatstack/internal/runtime/runtime_test.go b/boatstack/internal/runtime/runtime_test.go index 523df84..c07a6df 100644 --- a/boatstack/internal/runtime/runtime_test.go +++ b/boatstack/internal/runtime/runtime_test.go @@ -1,15 +1,35 @@ package runtime import ( + "bytes" "crypto/sha256" "encoding/hex" + "encoding/json" + "errors" "fmt" "os" "path/filepath" "strings" "testing" + + "github.com/operatorstack/boatstack/boatstack/internal/buildinfo" ) +func requireBootstrapDiagnostic(t *testing.T, err error, code string) *BootstrapDiagnostic { + t.Helper() + var diagnostic *BootstrapDiagnostic + if !errors.As(err, &diagnostic) { + t.Fatalf("error %v is not a bootstrap diagnostic", err) + } + if diagnostic.Code != code { + t.Fatalf("diagnostic code = %q, want %q", diagnostic.Code, code) + } + if diagnostic.FlowRunCreated || diagnostic.ManagedStateChanged { + t.Fatalf("pre-runtime diagnostic claims mutation: %#v", diagnostic) + } + return diagnostic +} + func fixtureIdentity(version, source string, raw []byte) Identity { sum := sha256.Sum256(raw) return Identity{Version: version, SHA256: hex.EncodeToString(sum[:]), SourceRevision: source} @@ -91,8 +111,122 @@ func TestMissingPinnedRuntimeFailsClosedWithoutLatestFallback(t *testing.T) { missing := fixtureIdentity("v1.0.0", "missing-source", []byte("missing")) repository := t.TempDir() writePin(t, repository, missing) - if _, _, err := ResolvePinnedExecutable(repository); err == nil || !strings.Contains(err.Error(), "not installed") { - t.Fatalf("missing pin resolution error = %v", err) + _, _, err := ResolvePinnedExecutable(repository) + diagnostic := requireBootstrapDiagnostic(t, err, CodeRuntimeNotInstalled) + if diagnostic.RequiredRuntime == nil || diagnostic.RequiredRuntime.Version != missing.Version || diagnostic.RequiredRuntime.SHA256 != missing.SHA256 || diagnostic.RequiredRuntime.SourceRevision != missing.SourceRevision { + t.Fatalf("required runtime = %#v", diagnostic.RequiredRuntime) + } + if diagnostic.Recovery == nil || diagnostic.Recovery.Action != "install-exact-runtime" || !diagnostic.Recovery.RequiresConfirmation || !strings.Contains(diagnostic.Recovery.Command, "BOATSTACK_MODE") || !strings.Contains(diagnostic.Recovery.Command, "hydrate") || !strings.Contains(diagnostic.Recovery.Command, missing.Version) || strings.Contains(diagnostic.Recovery.Command, "latest") || strings.Contains(diagnostic.Recovery.Command, "BOATSTACK_MODE=update") { + t.Fatalf("recovery = %#v", diagnostic.Recovery) + } + if !strings.Contains(diagnostic.Recovery.Command, missing.SHA256) || !strings.Contains(diagnostic.Recovery.Command, "/"+buildinfo.Version+"/install") || strings.Contains(diagnostic.Recovery.Command, "/"+missing.Version+"/install") { + t.Fatalf("recovery is not bound to target digest and current installer: %s", diagnostic.Recovery.Command) + } + if _, statErr := os.Stat(filepath.Join(repository, ".git", "boatstack")); !os.IsNotExist(statErr) { + t.Fatalf("missing runtime created managed state: %v", statErr) + } +} + +func TestBootstrapDiagnosticsClassifyPinAndRuntimeFailures(t *testing.T) { + // control-law: every-pre-runtime-failure-is-typed-and-zero-mutation + home := t.TempDir() + t.Setenv(HomeEnvironment, home) + + t.Run("missing-pin", func(t *testing.T) { + repository := t.TempDir() + if err := os.Mkdir(filepath.Join(repository, ".git"), 0o755); err != nil { + t.Fatal(err) + } + _, _, err := ResolvePinnedExecutable(repository) + requireBootstrapDiagnostic(t, err, CodeRuntimePinMissing) + }) + + t.Run("invalid-pin", func(t *testing.T) { + repository := t.TempDir() + if err := os.MkdirAll(filepath.Join(repository, ".boatstack"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(PinPath(repository), []byte(`{"schema_version":1,"version":"latest"}`), 0o644); err != nil { + t.Fatal(err) + } + _, _, err := ResolvePinnedExecutable(repository) + requireBootstrapDiagnostic(t, err, CodeRuntimePinInvalid) + }) + + for _, test := range []struct { + name string + code string + make func(string) error + }{ + {name: "invalid-type", code: CodeRuntimeInvalid, make: func(path string) error { return os.MkdirAll(path, 0o755) }}, + {name: "checksum-mismatch", code: CodeRuntimeChecksumMismatch, make: func(path string) error { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + return os.WriteFile(path, []byte("wrong runtime"), 0o755) + }}, + } { + t.Run(test.name, func(t *testing.T) { + identity := fixtureIdentity("v1.2.3-"+test.name, "source", []byte("expected runtime")) + repository := t.TempDir() + writePin(t, repository, identity) + path, err := ExecutablePath(home, identity) + if err != nil { + t.Fatal(err) + } + if err := test.make(path); err != nil { + t.Fatal(err) + } + _, _, err = ResolvePinnedExecutable(repository) + diagnostic := requireBootstrapDiagnostic(t, err, test.code) + if diagnostic.Recovery != nil { + t.Fatalf("unsafe repair command for %s: %#v", test.name, diagnostic.Recovery) + } + }) + } +} + +func TestBootstrapDiagnosticRenderingPreservesOneEnvelope(t *testing.T) { + // control-law: text-and-json-hosts-receive-the-same-pre-runtime-decision + identity := fixtureIdentity("v1.2.3-rc.1", "source", []byte("runtime")) + diagnostic := runtimeBootstrapDiagnostic(CodeRuntimeNotInstalled, "The repository-pinned Boatstack runtime is not installed.", "/repo", identity, nil) + + var jsonOutput bytes.Buffer + rendered, err := RenderBootstrapDiagnostic(&jsonOutput, diagnostic, []string{"next", "--format", "json"}) + if err != nil || !rendered { + t.Fatalf("JSON render = rendered %t, err %v", rendered, err) + } + var decoded BootstrapDiagnostic + if err := json.Unmarshal(jsonOutput.Bytes(), &decoded); err != nil { + t.Fatal(err) + } + if decoded.Schema != BootstrapDiagnosticSchema || decoded.SchemaRevision != BootstrapDiagnosticSchemaRevision || decoded.Code != CodeRuntimeNotInstalled || decoded.Recovery == nil || !decoded.Recovery.RequiresConfirmation { + t.Fatalf("JSON diagnostic = %#v", decoded) + } + + var textOutput bytes.Buffer + rendered, err = RenderBootstrapDiagnostic(&textOutput, diagnostic, []string{"next", "--format=text"}) + if err != nil || !rendered { + t.Fatalf("text render = rendered %t, err %v", rendered, err) + } + for _, expected := range []string{"Blocked:", CodeRuntimeNotInstalled, identity.Version, identity.SHA256, "explicit approval", "No Flow run was created"} { + if !strings.Contains(textOutput.String(), expected) { + t.Fatalf("text diagnostic lacks %q: %s", expected, textOutput.String()) + } + } + + var unrelated bytes.Buffer + if rendered, err := RenderBootstrapDiagnostic(&unrelated, errors.New("ordinary"), nil); err != nil || rendered || unrelated.Len() != 0 { + t.Fatalf("ordinary error render = rendered %t, err %v, output %q", rendered, err, unrelated.String()) + } +} + +func TestUnreleasedRuntimeIdentityHasNoDownloadCommand(t *testing.T) { + // control-law: repository-pin-cannot-inject-an-installer-command + identity := fixtureIdentity("local-development", "source", []byte("runtime")) + diagnostic := runtimeBootstrapDiagnostic(CodeRuntimeNotInstalled, "missing", "/repo", identity, nil) + if diagnostic.Recovery != nil { + t.Fatalf("non-release identity produced recovery command: %#v", diagnostic.Recovery) } } @@ -111,9 +245,8 @@ func TestNestedUninitializedRepositoryCannotInheritParentPin(t *testing.T) { if err := os.MkdirAll(filepath.Join(nested, ".git"), 0o755); err != nil { t.Fatal(err) } - if _, _, err := ResolvePinnedExecutable(nested); err == nil || !strings.Contains(err.Error(), "has no Boatstack runtime pin") { - t.Fatalf("nested repository resolution error = %v", err) - } + _, _, err := ResolvePinnedExecutable(nested) + requireBootstrapDiagnostic(t, err, CodeRuntimePinMissing) } func TestRuntimeStoreIsImmutableAndScalesIndependentlyOfSelection(t *testing.T) { diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index afd151d..53d45da 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -14,16 +14,31 @@ Malformed or unsupported configuration is not treated as verified. ## Runtime is absent, stale, or wrong -Use the checksum-verifying installer in update mode. It durably installs the -exact version-and-digest runtime candidate, requests `installation.update`, and -changes the repository runtime pin only after the kernel verifies the -candidate. +The launcher reports pre-runtime failures before it loads a Flow or creates +managed state. With `--format json`, the diagnostic uses the versioned +`boatstack-bootstrap-diagnostic` envelope. Text mode prints the same stable +code, exact pinned version, and SHA-256. + +Use the checksum-verifying installer in `hydrate` mode. It durably restores the +exact version-and-digest runtime and launcher without changing repository or +controller state. ```sh -BOATSTACK_MODE=update BOATSTACK_VERSION= \ - /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/operatorstack/boatstack/main/install.sh)" +BOATSTACK_MODE=hydrate BOATSTACK_VERSION= \ + BOATSTACK_EXPECTED_RUNTIME_SHA256= \ + /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/operatorstack/boatstack//install.sh)" ``` +The launcher supplies all three exact values. The installer tag identifies the +current launcher release that supports hydration; it may restore an older +pinned runtime tag. The explicit SHA-256 must still match the repository pin. + +Display this command and obtain explicit approval before running it. Generated +Flow skills never run or authorize installation. If the `boatstack` launcher +itself is absent, they read the committed `.boatstack/runtime.json`, report +`BOATSTACK_LAUNCHER_NOT_FOUND`, and stop. An absent or invalid pin requires a +maintainer; never substitute `latest`. + Do not copy a helper from another worktree or select a “latest” cache slot. A missing pinned runtime fails closed; reinstall that exact release and checksum. diff --git a/install.ps1 b/install.ps1 index 42de744..bd39897 100644 --- a/install.ps1 +++ b/install.ps1 @@ -10,7 +10,7 @@ $Actor = if ($env:BOATSTACK_ACTOR) { $env:BOATSTACK_ACTOR } elseif ($env:USERNAM $InstallDir = if ($env:BOATSTACK_INSTALL_DIR) { $env:BOATSTACK_INSTALL_DIR } else { Join-Path $env:LOCALAPPDATA "Boatstack\bin" } $BoatstackHome = if ($env:BOATSTACK_HOME) { $env:BOATSTACK_HOME } else { Join-Path $env:LOCALAPPDATA "Boatstack" } -if ($Mode -notin @("install", "update")) { throw "Boatstack supports BOATSTACK_MODE=install or update" } +if ($Mode -notin @("install", "update", "hydrate")) { throw "Boatstack supports BOATSTACK_MODE=install, update, or hydrate" } $RepositoryOutput = & git -C $Repository rev-parse --show-toplevel $RepositoryStatus = $LASTEXITCODE if ($RepositoryStatus -ne 0 -or -not $RepositoryOutput) { throw "Boatstack installation requires a Git repository" } @@ -49,19 +49,31 @@ try { } else { "https://github.com/operatorstack/boatstack/releases/download/$Version" } - Invoke-WebRequest -UseBasicParsing -Uri "$Base/$Asset" -OutFile $Candidate - $ChecksumPath = Join-Path $Temporary "$Asset.sha256" - Invoke-WebRequest -UseBasicParsing -Uri "$Base/$Asset.sha256" -OutFile $ChecksumPath + try { + Invoke-WebRequest -UseBasicParsing -Uri "$Base/$Asset" -OutFile $Candidate + $ChecksumPath = Join-Path $Temporary "$Asset.sha256" + Invoke-WebRequest -UseBasicParsing -Uri "$Base/$Asset.sha256" -OutFile $ChecksumPath + } catch { + throw "BOATSTACK_RUNTIME_ARTIFACT_UNAVAILABLE: version=$Version asset=$Asset" + } $Expected = ((Get-Content -LiteralPath $ChecksumPath -Raw).Trim() -split '\s+')[0].ToLowerInvariant() } $Actual = (Get-FileHash -Algorithm SHA256 -LiteralPath $Candidate).Hash.ToLowerInvariant() - if ($Actual -ne $Expected) { throw "Boatstack runtime checksum mismatch" } + if ($Actual -ne $Expected) { + throw "BOATSTACK_RUNTIME_ARTIFACT_CHECKSUM_MISMATCH: version=$Version asset=$Asset expected=$Expected actual=$Actual" + } + if ($env:BOATSTACK_EXPECTED_RUNTIME_SHA256 -and $Actual -ne $env:BOATSTACK_EXPECTED_RUNTIME_SHA256.ToLowerInvariant()) { + throw "BOATSTACK_RUNTIME_ARTIFACT_CHECKSUM_MISMATCH: version=$Version asset=$Asset expected=$($env:BOATSTACK_EXPECTED_RUNTIME_SHA256) actual=$Actual" + } $CandidateVersionOutput = & $Candidate version if ($LASTEXITCODE -ne 0 -or -not $CandidateVersionOutput) { throw "Boatstack runtime did not report its version identity" } $CandidateVersion = ($CandidateVersionOutput -join "`n").Trim() $SafeVersion = [regex]::Replace($CandidateVersion, '[^A-Za-z0-9._-]', '-') if (-not $SafeVersion -or $SafeVersion -ne $CandidateVersion) { throw "Boatstack runtime reported an invalid version identity" } + if ($Mode -eq "hydrate" -and $Version -ne "latest" -and $CandidateVersion -ne $Version) { + throw "Boatstack runtime version mismatch: requested=$Version actual=$CandidateVersion" + } $RuntimeDirectory = Join-Path $BoatstackHome ("runtimes\$SafeVersion-$Actual") New-Item -ItemType Directory -Force -Path $RuntimeDirectory | Out-Null $Runtime = Join-Path $RuntimeDirectory "boatstack-runtime.exe" @@ -101,7 +113,7 @@ try { [System.IO.File]::WriteAllText($ConfigSource, $ConfigText, [System.Text.UTF8Encoding]::new($false)) } & $Runtime init --repo $Repository --human $Actor --param "config_path=$ConfigSource" --format text - } else { + } elseif ($Mode -eq "update") { $AcceptProgramChange = @() if ($env:BOATSTACK_ACCEPT_PROGRAM_CHANGE -eq "true") { $AcceptProgramChange = @("--accept-program-change") @@ -109,7 +121,7 @@ try { & $Runtime update --repo $Repository --human $Actor ` --param "runtime_sha256=$Actual" @AcceptProgramChange --format json } - if ($LASTEXITCODE -ne 0) { throw "Boatstack kernel rejected installation" } + if ($Mode -ne "hydrate" -and $LASTEXITCODE -ne 0) { throw "Boatstack kernel rejected installation" } New-Item -ItemType Directory -Force -Path $InstallDir | Out-Null $Launcher = Join-Path $InstallDir "boatstack.exe" @@ -117,7 +129,9 @@ try { Copy-Item -LiteralPath $Candidate -Destination $StagedLauncher Move-Item -LiteralPath $StagedLauncher -Destination $Launcher -Force Write-Host "Boatstack installed at $Runtime" - Write-Host "Review and commit $Repository\.boatstack\project.json, $Repository\.boatstack\runtime.json, and the generated host skills" + if ($Mode -ne "hydrate") { + 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 fbe2934..0cffe97 100755 --- a/install.sh +++ b/install.sh @@ -14,8 +14,8 @@ boatstack_home="${BOATSTACK_HOME:-${XDG_DATA_HOME:-${HOME}/.local/share}/boatsta config_source="${BOATSTACK_CONFIG:-}" case "$mode" in - install|update) ;; - *) echo "Boatstack supports BOATSTACK_MODE=install or update" >&2; exit 2 ;; + install|update|hydrate) ;; + *) echo "Boatstack supports BOATSTACK_MODE=install, update, or hydrate" >&2; exit 2 ;; esac repository="$(git -C "$repository" rev-parse --show-toplevel)" @@ -58,8 +58,14 @@ else else base="https://github.com/operatorstack/boatstack/releases/download/$version" fi - curl --fail --silent --show-error --location "$base/$asset" --output "$candidate" - curl --fail --silent --show-error --location "$base/$asset.sha256" --output "$temporary/$asset.sha256" + if ! curl --fail --silent --show-error --location "$base/$asset" --output "$candidate"; then + echo "BOATSTACK_RUNTIME_ARTIFACT_UNAVAILABLE: version=$version asset=$asset" >&2 + exit 1 + fi + if ! curl --fail --silent --show-error --location "$base/$asset.sha256" --output "$temporary/$asset.sha256"; then + echo "BOATSTACK_RUNTIME_ARTIFACT_UNAVAILABLE: version=$version asset=$asset.sha256" >&2 + exit 1 + fi expected="$(awk '{print $1}' "$temporary/$asset.sha256")" fi @@ -68,12 +74,23 @@ if command -v sha256sum >/dev/null 2>&1; then else actual="$(shasum -a 256 "$candidate" | awk '{print $1}')" fi -[[ "$actual" == "$expected" ]] || { echo "Boatstack runtime checksum mismatch" >&2; exit 1; } +[[ "$actual" == "$expected" ]] || { + echo "BOATSTACK_RUNTIME_ARTIFACT_CHECKSUM_MISMATCH: version=$version asset=$asset expected=$expected actual=$actual" >&2 + exit 1 +} +if [[ -n "${BOATSTACK_EXPECTED_RUNTIME_SHA256:-}" && "$actual" != "$BOATSTACK_EXPECTED_RUNTIME_SHA256" ]]; then + echo "BOATSTACK_RUNTIME_ARTIFACT_CHECKSUM_MISMATCH: version=$version asset=$asset expected=$BOATSTACK_EXPECTED_RUNTIME_SHA256 actual=$actual" >&2 + exit 1 +fi chmod 0755 "$candidate" candidate_version="$("$candidate" version)" safe_version="${candidate_version//[^a-zA-Z0-9._-]/-}" [[ -n "$safe_version" && "$safe_version" == "$candidate_version" ]] || { echo "Boatstack runtime reported an invalid version identity" >&2; exit 1; } +if [[ "$mode" == hydrate && "$version" != latest && "$candidate_version" != "$version" ]]; then + echo "Boatstack runtime version mismatch: requested=$version actual=$candidate_version" >&2 + exit 1 +fi runtime_dir="$boatstack_home/runtimes/${safe_version}-${actual}" runtime="$runtime_dir/boatstack-runtime" mkdir -p "$runtime_dir" @@ -110,7 +127,7 @@ if [[ "$mode" == install ]]; then printf '%s\n' "{\"schema_version\":2,\"project\":{\"name\":\"repository\",\"default_branch\":\"$json_default_branch\",\"commands\":{}},\"policy\":{\"plan_approval\":\"human\",\"visual_evidence\":\"optional\"},\"hosts\":[\"cli\",\"cursor\",\"codex\",\"claude\",\"gemini\",\"mcp\"]}" > "$config_source" fi "$runtime" init --repo "$repository" --human "$actor" --param "config_path=$config_source" --format text -else +elif [[ "$mode" == update ]]; then update_arguments=( update --repo "$repository" --human "$actor" --param "runtime_sha256=$actual" @@ -128,5 +145,7 @@ install -m 0755 "$candidate" "$launcher_staged" mv -f "$launcher_staged" "$install_dir/boatstack" echo "Boatstack installed at $runtime" -echo "Review and commit $repository/.boatstack/project.json, $repository/.boatstack/runtime.json, and the generated host skills" +if [[ "$mode" != hydrate ]]; then + echo "Review and commit $repository/.boatstack/project.json, $repository/.boatstack/runtime.json, and the generated host skills" +fi echo "Run: $install_dir/boatstack doctor --repo $repository --format text" diff --git a/release-notes/2026-08-14-runtime-bootstrap-errors.md b/release-notes/2026-08-14-runtime-bootstrap-errors.md new file mode 100644 index 0000000..71d1b9b --- /dev/null +++ b/release-notes/2026-08-14-runtime-bootstrap-errors.md @@ -0,0 +1,3 @@ +### Report actionable runtime bootstrap failures + +Boatstack now reports typed missing-runtime diagnostics with the exact pinned release and a confirmation-gated installation command. Generated Flow skills preserve the blocker and never install automatically.