diff --git a/boatstack/hydrate_runtime_test.go b/boatstack/hydrate_runtime_test.go index 2228d9b..6ecf005 100644 --- a/boatstack/hydrate_runtime_test.go +++ b/boatstack/hydrate_runtime_test.go @@ -4,6 +4,7 @@ import ( "encoding/json" "os" "path/filepath" + "runtime" "strings" "testing" ) @@ -51,6 +52,103 @@ func TestRunHydrateRuntimePopulatesSlotIdempotentlyWithoutTouchingCommittedState } } +// control-law: tracked-launcher-selects-only-the-pinned-runtime +// Positive and relation conformance: detached hydration must publish the +// Git-common bootstrap consumed by tracked launchers and the external shared +// runtime consumed by supervision-aware worktree activation. +func TestRunHydrateRuntimePopulatesDetachedBootstrapAndSharedSlots(t *testing.T) { + t.Setenv(stateRootEnv, t.TempDir()) + invalidateWorkspaceCache() + repo := runtimeTestRepo(t) + result, err := AttachDetached(AttachOptions{Repo: repo, BinaryPath: os.Args[0]}) + if err != nil || result.VerificationStatus != "VERIFIED" { + t.Fatalf("attach failed: %v %+v", err, result) + } + sharedBinary, _, err := sharedRuntimePaths(repo, Version, SourceCommit) + if err != nil { + t.Fatal(err) + } + bootstrapBinary, _, err := bootstrapRuntimePaths(repo, Version, SourceCommit) + if err != nil { + t.Fatal(err) + } + if filepath.Clean(sharedBinary) == filepath.Clean(bootstrapBinary) { + t.Fatalf("detached shared and bootstrap slots unexpectedly alias: %s", sharedBinary) + } + for _, path := range []string{filepath.Dir(sharedBinary), filepath.Dir(bootstrapBinary), filepath.Join(repo, ".product-loop", "bin")} { + if err := os.RemoveAll(path); err != nil { + t.Fatal(err) + } + } + before := readGeneratedLockBytes(t, repo) + + if err := RunHydrateRuntime(repo); err != nil { + t.Fatalf("detached hydration failed: %v", err) + } + for name, path := range map[string]string{"Git-common bootstrap": bootstrapBinary, "detached shared runtime": sharedBinary} { + if _, err := os.Stat(path); err != nil { + t.Fatalf("%s was not populated: %v", name, err) + } + } + if err := verifyLocalRuntime(repo); err != nil { + t.Fatalf("detached hydration did not activate the local runtime: %v", err) + } + if after := readGeneratedLockBytes(t, repo); string(before) != string(after) { + t.Fatalf("detached hydration mutated committed generated.lock.json") + } + if err := RunHydrateRuntime(repo); err != nil { + t.Fatalf("second detached hydration was not idempotent: %v", err) + } +} + +// control-law: tracked-launcher-selects-only-the-pinned-runtime +// Negative, bypass, and failure-state conformance: an unsafe detached shared +// path must fail before publishing an admissible bootstrap or local runtime. +func TestRunHydrateRuntimeRejectsUnsafeDetachedSharedSlotBeforeBootstrap(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("symlink creation requires privileges not guaranteed on Windows CI") + } + t.Setenv(stateRootEnv, t.TempDir()) + invalidateWorkspaceCache() + repo := runtimeTestRepo(t) + result, err := AttachDetached(AttachOptions{Repo: repo, BinaryPath: os.Args[0]}) + if err != nil || result.VerificationStatus != "VERIFIED" { + t.Fatalf("attach failed: %v %+v", err, result) + } + sharedBinary, _, err := sharedRuntimePaths(repo, Version, SourceCommit) + if err != nil { + t.Fatal(err) + } + bootstrapBinary, _, err := bootstrapRuntimePaths(repo, Version, SourceCommit) + if err != nil { + t.Fatal(err) + } + for _, path := range []string{filepath.Dir(sharedBinary), filepath.Dir(bootstrapBinary), filepath.Join(repo, ".product-loop", "bin")} { + if err := os.RemoveAll(path); err != nil { + t.Fatal(err) + } + } + if err := os.MkdirAll(filepath.Dir(filepath.Dir(sharedBinary)), 0o755); err != nil { + t.Fatal(err) + } + if err := os.Symlink(t.TempDir(), filepath.Dir(sharedBinary)); err != nil { + t.Fatal(err) + } + + err = RunHydrateRuntime(repo) + if err == nil || !strings.Contains(err.Error(), "symlink") { + t.Fatalf("expected unsafe detached slot refusal, got %v", err) + } + for name, path := range map[string]string{ + "bootstrap runtime": bootstrapBinary, + "worktree runtime": filepath.Join(repo, ".product-loop", "bin", helperName()), + } { + if _, statErr := os.Lstat(path); !os.IsNotExist(statErr) { + t.Fatalf("failed hydration partially published %s: %v", name, statErr) + } + } +} + // TestRunHydrateRuntimeRefusesRunningVersusPinMismatch pins the incident- // prevention invariant: hydration must never populate a version-keyed slot with // a binary whose identity disagrees with the worktree's committed pin. diff --git a/boatstack/init.go b/boatstack/init.go index 8720af0..381d47f 100644 --- a/boatstack/init.go +++ b/boatstack/init.go @@ -566,7 +566,7 @@ func RunInit(options InitOptions) (returnErr error) { } } }() - if _, err := installSharedRuntime(helperSource, repo, config.Integrations); err != nil { + if _, err := installCommandRuntime(helperSource, repo, config.Integrations); err != nil { return fmt.Errorf("cannot install the repository-family Boatstack runtime: %w", err) } var states map[string]IntegrationState @@ -644,7 +644,7 @@ func RunInit(options InitOptions) (returnErr error) { if err := initCheckpoint("helper-written"); err != nil { return fmt.Errorf("initialization checkpoint helper-written: %w", err) } - if _, err := installSharedRuntime(helperSource, repo, states); err != nil { + if _, err := installCommandRuntime(helperSource, repo, states); err != nil { return fmt.Errorf("cannot finalize the repository-family Boatstack runtime: %w", err) } if err := writeInstallLock(repo, binaryPath, binaryHash, states); err != nil { diff --git a/boatstack/launcher_test.go b/boatstack/launcher_test.go index f3b5d49..d5d9a77 100644 --- a/boatstack/launcher_test.go +++ b/boatstack/launcher_test.go @@ -148,6 +148,53 @@ func TestTrackedLauncherActivatesFreshLinkedWorktreeWithoutHookTrust(t *testing. } } +// control-law: tracked-launcher-selects-only-the-pinned-runtime +// Relation conformance for the detached failure mode: tracked launcher -> exact +// hydrate operation -> Git-common bootstrap -> detached shared activation -> +// verified local command dispatch. +func TestTrackedLauncherHydratesDetachedRepositoryThroughGitCommonBootstrap(t *testing.T) { + t.Setenv(stateRootEnv, t.TempDir()) + invalidateWorkspaceCache() + primary, _ := launcherTestRepository(t) + helper := buildLauncherTestHelper(t) + result, err := AttachDetached(AttachOptions{Repo: primary, BinaryPath: helper}) + if err != nil || result.VerificationStatus != "VERIFIED" { + t.Fatalf("attach failed: %v %+v", err, result) + } + sharedBinary, _, err := sharedRuntimePaths(primary, Version, SourceCommit) + if err != nil { + t.Fatal(err) + } + bootstrapBinary, _, err := bootstrapRuntimePaths(primary, Version, SourceCommit) + if err != nil { + t.Fatal(err) + } + for _, path := range []string{filepath.Dir(sharedBinary), filepath.Dir(bootstrapBinary), filepath.Join(primary, ".product-loop", "bin")} { + if err := os.RemoveAll(path); err != nil { + t.Fatal(err) + } + } + hydrate := quotedLiteral(t, helper) + " hydrate-runtime --repo " + quotedLiteral(t, primary) + if runtime.GOOS == "windows" { + hydrate = "& " + hydrate + } + command := launcherCommand(primary, "version") + command.Dir = primary + command.Env = append(os.Environ(), "BOATSTACK_HYDRATE_COMMAND="+hydrate) + value, runErr := command.CombinedOutput() + if runErr != nil || !strings.Contains(string(value), Version) { + t.Fatalf("detached launcher hydration failed: %v\n%s", runErr, value) + } + for name, path := range map[string]string{"Git-common bootstrap": bootstrapBinary, "detached shared runtime": sharedBinary} { + if _, err := os.Stat(path); err != nil { + t.Fatalf("%s missing after launcher hydration: %v", name, err) + } + } + if err := verifyLocalRuntime(primary); err != nil { + t.Fatalf("launcher did not dispatch through a verified local runtime: %v", err) + } +} + func runErrString(err error, output []byte) string { if err == nil { return string(output) diff --git a/boatstack/paths.go b/boatstack/paths.go index fc08c2c..69def04 100644 --- a/boatstack/paths.go +++ b/boatstack/paths.go @@ -386,3 +386,23 @@ func (w WorkspaceContext) RuntimeDir(version, sourceCommit string) (string, erro } return filepath.Join(base, "runtimes", version, sourceCommit, platformKey()), nil } + +// BootstrapRuntimeDir holds the exact runtime used by tracked launchers and +// repository guards before supervision mode can be resolved by trusted Go code. +// It is always Git-common, including for detached supervision. The bootstrap +// helper then activates the mode-aware shared runtime through HydrateWorktree. +func (w WorkspaceContext) BootstrapRuntimeDir(version, sourceCommit string) (string, error) { + version, err := safeCacheSegment(version, "Boatstack version") + if err != nil { + return "", err + } + sourceCommit, err = safeCacheSegment(sourceCommit, "source commit") + if err != nil { + return "", err + } + common, err := gitCommonDir(w.RepoRoot) + if err != nil { + return "", err + } + return filepath.Join(common, controlDirName, "runtimes", version, sourceCommit, platformKey()), nil +} diff --git a/boatstack/references/artifacts.md b/boatstack/references/artifacts.md index 44f7456..9edb323 100644 --- a/boatstack/references/artifacts.md +++ b/boatstack/references/artifacts.md @@ -151,6 +151,7 @@ clone, `external` outside the repository (Detached Supervision). | flow-logs | runtime-worktree | per-worktree | flow | | guard-denial-ledger | runtime-worktree | per-worktree | safety-hook, ambient-safety-hook | | runtime-slots | runtime-shared | git-common | init, update, hydrate-runtime | +| runtime-bootstrap-slots | runtime-shared | git-common | init, update, hydrate-runtime | | mutation-receipts | runtime-shared | git-common | activate-plan, undo | | update-previews | runtime-shared | git-common | prepare-update-pr, publish-update-pr | | repair-receipts | runtime-shared | git-common | update | diff --git a/boatstack/runtime_cache.go b/boatstack/runtime_cache.go index dd9deaf..8e4456a 100644 --- a/boatstack/runtime_cache.go +++ b/boatstack/runtime_cache.go @@ -112,6 +112,29 @@ func sharedRuntimeOwnedPaths(repo, version, sourceCommit string) (controllerPath return binary, manifest, err } +func bootstrapRuntimePaths(repo, version, sourceCommit string) (string, string, error) { + binary, manifest, err := bootstrapRuntimeOwnedPaths(repo, version, sourceCommit) + return binary.path, manifest.path, err +} + +func bootstrapRuntimeOwnedPaths(repo, version, sourceCommit string) (controllerPath, controllerPath, error) { + ctx := WorkspaceFor(repo) + directory, err := ctx.BootstrapRuntimeDir(version, sourceCommit) + if err != nil { + return controllerPath{}, controllerPath{}, err + } + common, err := gitCommonDir(repo) + if err != nil { + return controllerPath{}, controllerPath{}, err + } + binary, err := newControllerPath(common, filepath.Join(directory, helperName())) + if err != nil { + return controllerPath{}, controllerPath{}, err + } + manifest, err := newControllerPath(common, filepath.Join(directory, "runtime.lock.json")) + return binary, manifest, err +} + func atomicWriteMode(path string, content []byte, mode fs.FileMode) error { directory := filepath.Dir(path) if err := os.MkdirAll(directory, 0o755); err != nil { @@ -154,6 +177,31 @@ func installSharedRuntime(source, repo string, integrations map[string]Integrati return writeRuntimeSlot(source, binaryPath, manifestPath, integrations) } +// installCommandRuntime publishes the exact runtime needed by both sides of +// tracked command activation. The mode-aware shared slot is installed first so +// the Git-common bootstrap is never made admissible before it can activate the +// worktree-local helper. Embedded mode uses one physical slot; detached mode +// deliberately uses an external shared slot plus a Git-common bootstrap slot. +// control-law: tracked-launcher-selects-only-the-pinned-runtime +func installCommandRuntime(source, repo string, integrations map[string]IntegrationState) (runtimeManifest, error) { + sharedManifest, err := installSharedRuntime(source, repo, integrations) + if err != nil { + return runtimeManifest{}, err + } + sharedBinary, _, err := sharedRuntimePaths(repo, Version, SourceCommit) + if err != nil { + return runtimeManifest{}, err + } + bootstrapBinary, bootstrapLock, err := bootstrapRuntimeOwnedPaths(repo, Version, SourceCommit) + if err != nil { + return runtimeManifest{}, err + } + if filepath.Clean(sharedBinary) == bootstrapBinary.path { + return sharedManifest, nil + } + return writeRuntimeSlot(source, bootstrapBinary, bootstrapLock, integrations) +} + // installDetachedRuntime populates a detached repository's external shared-runtime // slot from the running helper, so the developer-level ambient guard has a stable // helper to invoke. Unlike installSharedRuntime it scopes the symlink check to the @@ -369,7 +417,7 @@ func HydrateWorktree(repoPath string) error { // this, the running binary equals the repo's committed pin by construction. The // verifyGeneratedRuntime gate refuses to populate a slot for any other version, // so hydration can never write a mislabeled runtime (the taxweave incident's -// invariant), and installSharedRuntime's own post-write verify+rollback is the +// invariant), and installCommandRuntime's own post-write verify+rollback is the // backstop. The operation is idempotent and safe under concurrent first use. func RunHydrateRuntime(repoPath string) error { repo, err := ResolveRepository(repoPath) @@ -387,8 +435,8 @@ func RunHydrateRuntime(repoPath string) error { if err != nil { return fmt.Errorf("load project configuration for runtime hydration: %w", err) } - if _, err := installSharedRuntime(source, repo, config.Integrations); err != nil { - return fmt.Errorf("populate the repository-family Boatstack runtime: %w", err) + if _, err := installCommandRuntime(source, repo, config.Integrations); err != nil { + return fmt.Errorf("populate the Boatstack command runtime: %w", err) } return HydrateWorktree(repo) } diff --git a/boatstack/statemap.go b/boatstack/statemap.go index 3ca1c86..1d79a6b 100644 --- a/boatstack/statemap.go +++ b/boatstack/statemap.go @@ -236,6 +236,17 @@ func StateRegistry() []StateEntry { return filepath.Join(base, "runtime.lock.json"), nil }, }, + { + Name: "runtime-bootstrap-slots", Class: ClassRuntimeShared, Partition: "git-common", Gitignored: true, GuardProtected: true, + OwnerVerbs: []string{"init", "update", "hydrate-runtime"}, + Sample: func(w WorkspaceContext) (string, error) { + base, err := w.BootstrapRuntimeDir("v0.0.0", "0000000") + if err != nil { + return "", err + } + return filepath.Join(base, "runtime.lock.json"), nil + }, + }, { Name: "mutation-receipts", Class: ClassRuntimeShared, Partition: "git-common", Gitignored: true, GuardProtected: true, OwnerVerbs: []string{"activate-plan", "undo"}, diff --git a/boatstack/statemap_conformance_test.go b/boatstack/statemap_conformance_test.go index 8871331..b435a41 100644 --- a/boatstack/statemap_conformance_test.go +++ b/boatstack/statemap_conformance_test.go @@ -72,6 +72,9 @@ func TestEveryWorkspaceResolverIsDeclared(t *testing.T) { "RuntimeDir": {resolve(func() (string, error) { return w.RuntimeDir("v0.0.0", "0000000") }), ClassRuntimeShared}, + "BootstrapRuntimeDir": {resolve(func() (string, error) { + return w.BootstrapRuntimeDir("v0.0.0", "0000000") + }), ClassRuntimeShared}, } for name, want := range resolverOutputs { diff --git a/release-notes/2026-08-09-detached-launcher-bootstrap.md b/release-notes/2026-08-09-detached-launcher-bootstrap.md new file mode 100644 index 0000000..92e0ca1 --- /dev/null +++ b/release-notes/2026-08-09-detached-launcher-bootstrap.md @@ -0,0 +1,2 @@ +### Restore tracked commands in detached repositories +Tracked Boatstack launchers now hydrate their exact pinned runtime correctly when a repository uses detached supervision, so a missing local helper no longer leaves updates stuck in a recovery loop.