diff --git a/labs/12-product-engineering-loop/boatstack-distribution/GETTING_STARTED.md b/labs/12-product-engineering-loop/boatstack-distribution/GETTING_STARTED.md index d3f20374..52357f8f 100644 --- a/labs/12-product-engineering-loop/boatstack-distribution/GETTING_STARTED.md +++ b/labs/12-product-engineering-loop/boatstack-distribution/GETTING_STARTED.md @@ -277,12 +277,23 @@ Boatstack files. Attach the repository, then install the developer-level guard once per coding agent: ```bash -boatstack-helper attach --repo . --mode detached +boatstack-helper attach \ + --repo . \ + --mode detached \ + --config /stationkeep/task/project.json boatstack-helper activate --repo . ``` -`attach` inspects the repository and writes the controller state and a binding to the external -control root, leaving the working tree byte-for-byte unchanged. `activate` merges a +The external file uses the normal Boatstack project-config schema. This is useful when the +repository root does not describe the project you want to supervise, such as a package inside +a monorepo. `attach` validates the file, copies its exact bytes into the external control root, +and binds their SHA-256 to detached status and generated provenance. Boatstack never writes the +file into the repository or `.git`. A changed detached copy blocks resume until you restore the +exact bytes or explicitly reattach with `--force --config `. + +Without `--config`, `attach` keeps the existing repository discovery behavior. In either mode it +writes the controller state and binding only to the external control root, leaving the working +tree byte-for-byte unchanged. `activate` merges a developer-level ambient guard into each agent's global configuration; that guard enforces Boatstack only on repositories you have attached and is a no-op everywhere else, and it never removes your own hooks. Use `activate --print` to review the exact per-agent configuration diff --git a/labs/12-product-engineering-loop/boatstack-distribution/release-notes/2026-08-05-detached-external-config.md b/labs/12-product-engineering-loop/boatstack-distribution/release-notes/2026-08-05-detached-external-config.md new file mode 100644 index 00000000..f04a6a11 --- /dev/null +++ b/labs/12-product-engineering-loop/boatstack-distribution/release-notes/2026-08-05-detached-external-config.md @@ -0,0 +1,3 @@ +### Detached attachment accepts an external project configuration + +Detached Supervision can now validate and copy a normal Boatstack project configuration from an explicit `--config` path. Its exact SHA-256 is bound to detached status and generated provenance, and any later drift blocks resume without writing configuration into the repository or `.git`. diff --git a/labs/12-product-engineering-loop/product-engineering-loop/attach.go b/labs/12-product-engineering-loop/product-engineering-loop/attach.go index fbd43c8e..b076d7e6 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/attach.go +++ b/labs/12-product-engineering-loop/product-engineering-loop/attach.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "path/filepath" + "strings" ) // Attach, detach, and status operations for Detached Supervision. Attaching a @@ -15,8 +16,9 @@ import ( // AttachOptions requests a detached attachment. StateRoot, when set, overrides the // external control-state root for this process (the CLI wires --state-root to it). type AttachOptions struct { - Repo string - Force bool + Repo string + ConfigPath string + Force bool } // AttachResult is the deterministic outcome of an attach request. @@ -28,10 +30,50 @@ type AttachResult struct { RepoRoot string `json:"repo_root,omitempty"` ControlRoot string `json:"control_root,omitempty"` WorktreeID string `json:"worktree_id,omitempty"` + ConfigSHA256 string `json:"config_sha256,omitempty"` Reason string `json:"reason"` FeatureMigrations []DetachedFeatureMigration `json:"feature_migrations,omitempty"` } +func loadDetachedAttachConfig(root, explicitPath string) (ProjectConfig, []byte, error) { + if strings.TrimSpace(explicitPath) == "" { + configPath := filepath.Join(root, sourceConfigName) + config, raw, err := LoadConfig(configPath) + if os.IsNotExist(err) { + config = defaultConfig(root, detectTestCommand(root)) + raw, err = MarshalJSON(config) + } + return config, raw, err + } + absolute, err := filepath.Abs(explicitPath) + if err != nil { + return ProjectConfig{}, nil, err + } + inputInfo, err := os.Lstat(absolute) + if err != nil { + return ProjectConfig{}, nil, fmt.Errorf("external project configuration is missing or unreadable: %w", err) + } + if inputInfo.Mode()&os.ModeSymlink != 0 { + return ProjectConfig{}, nil, fmt.Errorf("external project configuration must be a regular non-symlink file") + } + resolved, err := filepath.EvalSymlinks(absolute) + if err != nil { + return ProjectConfig{}, nil, fmt.Errorf("external project configuration is missing or unreadable: %w", err) + } + info, err := os.Lstat(resolved) + if err != nil || !info.Mode().IsRegular() { + return ProjectConfig{}, nil, fmt.Errorf("external project configuration must be a readable regular file") + } + common, err := gitCommonDir(root) + if err != nil { + return ProjectConfig{}, nil, err + } + if pathWithin(root, resolved) || pathWithin(common, resolved) { + return ProjectConfig{}, nil, fmt.Errorf("external project configuration must be outside the repository and its Git directory") + } + return LoadConfig(resolved) +} + func blockedAttach(reason string) AttachResult { return AttachResult{SchemaVersion: detachedSchemaVersion, VerificationStatus: "BLOCKED", Reason: reason} } @@ -63,17 +105,11 @@ func AttachDetached(opts AttachOptions) (AttachResult, error) { ctx := detachedContextFromIdentity(stateRoot, identity) - // Prefer the repository's declared source configuration during explicit - // reattachment. Falling back to discovery is valid only when no source exists. - configPath := filepath.Join(root, sourceConfigName) - config, rawConfig, err := LoadConfig(configPath) - if os.IsNotExist(err) { - config = defaultConfig(root, detectTestCommand(root)) - rawConfig, err = MarshalJSON(config) - } + config, rawConfig, err := loadDetachedAttachConfig(root, opts.ConfigPath) if err != nil { - return blockedAttach("Boatstack could not load the repository source configuration: " + err.Error()), nil + return blockedAttach("Boatstack could not load the detached project configuration: " + err.Error()), nil } + configSHA256 := SHA256Bytes(rawConfig) imports, migrationResults, migrationErr := planDetachedFeatureImports(root, ctx) if migrationErr != nil { result := blockedAttach("Boatstack refused detached feature migration: " + migrationErr.Error()) @@ -94,7 +130,11 @@ func AttachDetached(opts AttachOptions) (AttachResult, error) { if err := writeExport(ctx.controlRoot, bundle.Files, nil); err != nil { return blockedAttach("Boatstack could not write the controller bundle: " + err.Error()), nil } - if err := os.WriteFile(ctx.SourceConfigPath(), rawConfig, 0o644); err != nil { + sourcePath, err := newControllerPath(ctx.controlRoot, ctx.SourceConfigPath()) + if err != nil { + return blockedAttach(err.Error()), nil + } + if err := atomicWrite(sourcePath.path, rawConfig); err != nil { return blockedAttach(err.Error()), nil } migrationResults, err = applyDetachedFeatureImports(imports, migrationResults) @@ -111,6 +151,7 @@ func AttachDetached(opts AttachOptions) (AttachResult, error) { GitCommonIdentity: identity.GitCommonIdentity, InitialCommit: identity.InitialCommit, NormalizedOrigin: identity.NormalizedOrigin, + ConfigSHA256: configSHA256, CreatedByVersion: Version, CreatedAt: nowRFC3339(), } @@ -121,7 +162,7 @@ func AttachDetached(opts AttachOptions) (AttachResult, error) { if err := os.MkdirAll(filepath.Dir(bindingPath(stateRoot, identity.RepoID)), 0o755); err != nil { return blockedAttach(err.Error()), nil } - if err := os.WriteFile(bindingPath(stateRoot, identity.RepoID), bindingRaw, 0o644); err != nil { + if err := atomicWrite(bindingPath(stateRoot, identity.RepoID), bindingRaw); err != nil { return blockedAttach(err.Error()), nil } registry.Repositories[root] = identity.RepoID @@ -147,6 +188,7 @@ func AttachDetached(opts AttachOptions) (AttachResult, error) { RepoRoot: root, ControlRoot: ctx.controlRoot, WorktreeID: identity.WorktreeID, + ConfigSHA256: configSHA256, FeatureMigrations: migrationResults, Reason: "Attached Boatstack in detached mode. The repository was not modified; all controller state lives under the external control root.", }, nil @@ -223,6 +265,7 @@ type DetachedStatusResult struct { RepoRoot string `json:"repo_root,omitempty"` ControlRoot string `json:"control_root,omitempty"` WorktreeID string `json:"worktree_id,omitempty"` + ConfigSHA256 string `json:"config_sha256,omitempty"` Reason string `json:"reason"` } @@ -241,14 +284,38 @@ func DetachedStatus(repoPath string) (DetachedStatusResult, error) { }, nil } if verifyErr != nil { + configSHA256 := "" + stateRoot, rootErr := detachedStateRoot() + if rootErr == nil { + registry, registryErr := loadRegistry(stateRoot) + if registryErr == nil { + if binding, bindingErr := loadBinding(stateRoot, registry.Repositories[root]); bindingErr == nil { + configSHA256 = binding.ConfigSHA256 + } + } + } return DetachedStatusResult{ SchemaVersion: detachedSchemaVersion, Attached: true, Verified: false, Mode: string(SupervisionDetached), - RepoRoot: root, Reason: verifyErr.Error(), + RepoID: ctx.RepoID, RepoRoot: root, ControlRoot: ctx.controlRoot, WorktreeID: ctx.WorktreeID, + ConfigSHA256: configSHA256, Reason: verifyErr.Error(), }, nil } return DetachedStatusResult{ SchemaVersion: detachedSchemaVersion, Attached: true, Verified: true, Mode: string(SupervisionDetached), RepoID: ctx.RepoID, RepoRoot: ctx.RepoRoot, ControlRoot: ctx.controlRoot, WorktreeID: ctx.WorktreeID, - Reason: "This repository is attached in detached mode and its binding verifies.", + ConfigSHA256: bindingConfigSHA256(ctx), + Reason: "This repository is attached in detached mode and its binding verifies.", }, nil } + +func bindingConfigSHA256(ctx WorkspaceContext) string { + stateRoot, err := detachedStateRoot() + if err != nil { + return "" + } + binding, err := loadBinding(stateRoot, ctx.RepoID) + if err != nil { + return "" + } + return binding.ConfigSHA256 +} diff --git a/labs/12-product-engineering-loop/product-engineering-loop/cmd/boatstack-helper/main.go b/labs/12-product-engineering-loop/product-engineering-loop/cmd/boatstack-helper/main.go index dd0d116e..0f73885b 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/cmd/boatstack-helper/main.go +++ b/labs/12-product-engineering-loop/product-engineering-loop/cmd/boatstack-helper/main.go @@ -99,6 +99,7 @@ func attachCommand(arguments []string) int { repo := flags.String("repo", ".", "repository to attach") mode := flags.String("mode", "detached", "supervision mode; only \"detached\" is supported by attach") stateRoot := flags.String("state-root", "", "external control-state root (overrides the default user state directory)") + config := flags.String("config", "", "external project configuration to validate and copy into detached control state") force := flags.Bool("force", false, "re-attach even if the repository is already attached") if err := flags.Parse(arguments); err != nil { return 2 @@ -107,7 +108,7 @@ func attachCommand(arguments []string) int { return fail(fmt.Errorf("attach supports only --mode detached")) } applyStateRoot(*stateRoot) - result, err := boatstack.AttachDetached(boatstack.AttachOptions{Repo: *repo, Force: *force}) + result, err := boatstack.AttachDetached(boatstack.AttachOptions{Repo: *repo, ConfigPath: *config, Force: *force}) if err != nil { return fail(err) } diff --git a/labs/12-product-engineering-loop/product-engineering-loop/cmd/boatstack-helper/main_test.go b/labs/12-product-engineering-loop/product-engineering-loop/cmd/boatstack-helper/main_test.go index 0d548cc2..a16f0dc2 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/cmd/boatstack-helper/main_test.go +++ b/labs/12-product-engineering-loop/product-engineering-loop/cmd/boatstack-helper/main_test.go @@ -49,6 +49,42 @@ func TestBootstrapFailureUsesBlockingExitCode(t *testing.T) { } } +// control-law: detached-config-input-stays-outside-plant +func TestAttachCommandAcceptsExternalConfigFlag(t *testing.T) { + repo := t.TempDir() + commands := [][]string{ + {"git", "-C", repo, "init", "-b", "main"}, + {"git", "-C", repo, "config", "user.name", "Boatstack Test"}, + {"git", "-C", repo, "config", "user.email", "boatstack@example.invalid"}, + } + for _, arguments := range commands { + if output, err := exec.Command(arguments[0], arguments[1:]...).CombinedOutput(); err != nil { + t.Fatalf("%v: %v: %s", arguments, err, output) + } + } + if err := os.WriteFile(filepath.Join(repo, "README.md"), []byte("# app\n"), 0o644); err != nil { + t.Fatal(err) + } + for _, arguments := range [][]string{{"git", "-C", repo, "add", "README.md"}, {"git", "-C", repo, "commit", "-m", "initial"}} { + if output, err := exec.Command(arguments[0], arguments[1:]...).CombinedOutput(); err != nil { + t.Fatalf("%v: %v: %s", arguments, err, output) + } + } + configPath := filepath.Join(t.TempDir(), "project.json") + config := []byte(`{"schema_version":1,"project":{"name":"works-yield","commands":{"test":"pnpm test"}}}` + "\n") + if err := os.WriteFile(configPath, config, 0o644); err != nil { + t.Fatal(err) + } + stateRoot := t.TempDir() + var code int + output := captureStdout(t, func() { + code = attachCommand([]string{"--repo", repo, "--mode", "detached", "--config", configPath, "--state-root", stateRoot}) + }) + if code != 0 || !strings.Contains(output, `"verification_status": "VERIFIED"`) || !strings.Contains(output, `"config_sha256":`) { + t.Fatalf("attach --config failed: code=%d output=%s", code, output) + } +} + // captureStdout runs fn with os.Stdout redirected and returns what it printed, // so read-only CLI verbs can be asserted without polluting test output. func captureStdout(t *testing.T, fn func()) string { diff --git a/labs/12-product-engineering-loop/product-engineering-loop/delivery.go b/labs/12-product-engineering-loop/product-engineering-loop/delivery.go index ae2f1d34..2c9dc2f4 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/delivery.go +++ b/labs/12-product-engineering-loop/product-engineering-loop/delivery.go @@ -300,7 +300,11 @@ func deliveryDefinitions(plan map[string]any) ([]DeliverySlice, error) { } func deliveryStateDirectory(repo string) (string, error) { - return WorkspaceFor(repo).DeliveryDir() + ctx, err := ResolveWorkspaceContext(repo) + if err != nil { + return "", err + } + return ctx.DeliveryDir() } func deliveryStatePath(repo, feature string) (string, error) { @@ -1386,7 +1390,11 @@ func IgnoreDelivery(repo, feature string) (bool, error) { if err != nil { return false, err } - configPath := WorkspaceFor(resolved).ProjectConfigPath() + ctx, err := ResolveWorkspaceContext(resolved) + if err != nil { + return false, err + } + configPath := ctx.ProjectConfigPath() config, _, err := LoadConfig(configPath) if err != nil { return false, err diff --git a/labs/12-product-engineering-loop/product-engineering-loop/detached.go b/labs/12-product-engineering-loop/product-engineering-loop/detached.go index 3a724bfd..55624ea7 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/detached.go +++ b/labs/12-product-engineering-loop/product-engineering-loop/detached.go @@ -18,8 +18,12 @@ const ( // stateRootEnv overrides the external control-state root. Tests inject a temp // directory through it so they never read or write a real home directory. stateRootEnv = "BOATSTACK_STATE_ROOT" - // detachedSchemaVersion versions the registry and binding records. - detachedSchemaVersion = 1 + // detachedSchemaVersion versions the public detached status and binding + // records. Version 2 binds the exact detached project configuration bytes. + detachedSchemaVersion = 2 + // The registry remains a path-to-repository index. Configuration provenance + // belongs to the authoritative per-repository binding, not this index. + detachedRegistrySchemaVersion = 1 // repoIDLength is the hex width of a repository identity key. repoIDLength = 16 // worktreeIDLength is the hex width of a per-worktree identity key. @@ -150,6 +154,7 @@ type DetachedBinding struct { GitCommonIdentity string `json:"git_common_identity"` InitialCommit string `json:"initial_commit"` NormalizedOrigin string `json:"normalized_origin"` + ConfigSHA256 string `json:"config_sha256"` CreatedByVersion string `json:"created_by_version"` CreatedAt string `json:"created_at"` } @@ -173,7 +178,7 @@ func bindingPath(stateRoot, repoID string) string { } func loadRegistry(stateRoot string) (detachedRegistry, error) { - registry := detachedRegistry{SchemaVersion: detachedSchemaVersion, Repositories: map[string]string{}} + registry := detachedRegistry{SchemaVersion: detachedRegistrySchemaVersion, Repositories: map[string]string{}} raw, err := os.ReadFile(registryPath(stateRoot)) if err != nil { if os.IsNotExist(err) { @@ -191,7 +196,7 @@ func loadRegistry(stateRoot string) (detachedRegistry, error) { } func saveRegistry(stateRoot string, registry detachedRegistry) error { - registry.SchemaVersion = detachedSchemaVersion + registry.SchemaVersion = detachedRegistrySchemaVersion raw, err := MarshalJSON(registry) if err != nil { return err @@ -231,6 +236,55 @@ func bindingMatchesIdentity(binding DetachedBinding, identity RepoIdentity) bool return true } +type detachedGeneratedLock struct { + ConfigSHA256 string `json:"config_sha256"` + Files map[string]string `json:"files"` +} + +// verifyDetachedConfiguration proves that the authoritative source copy, its +// generated snapshot, and the generated runtime configuration still describe +// the exact bytes accepted at attachment. +// control-law: detached-config-digest-gates-resume +func verifyDetachedConfiguration(ctx WorkspaceContext, binding DetachedBinding) error { + if binding.SchemaVersion != detachedSchemaVersion { + return fmt.Errorf("detached binding schema_version %d is unsupported; reattach with `boatstack-helper attach --repo %s --mode detached --force --config `", binding.SchemaVersion, ctx.RepoRoot) + } + if strings.TrimSpace(binding.ConfigSHA256) == "" { + return fmt.Errorf("detached binding is missing config_sha256; reattach with `boatstack-helper attach --repo %s --mode detached --force --config `", ctx.RepoRoot) + } + sourceSHA, err := SHA256File(ctx.SourceConfigPath()) + if err != nil { + return fmt.Errorf("detached project configuration is missing or unreadable: %w", err) + } + if sourceSHA != binding.ConfigSHA256 { + return fmt.Errorf("detached project configuration drifted from bound SHA-256 %s; restore the exact attached bytes or reattach with `boatstack-helper attach --repo %s --mode detached --force --config `", binding.ConfigSHA256, ctx.RepoRoot) + } + lockPath := filepath.Join(ctx.GeneratedRoot(), "generated.lock.json") + lockRaw, err := os.ReadFile(lockPath) + if err != nil { + return fmt.Errorf("detached generated configuration snapshot is missing or unreadable: %w", err) + } + var lock detachedGeneratedLock + if err := DecodeJSON("verify detached generated configuration snapshot", lockPath, lockRaw, &lock); err != nil { + return err + } + if lock.ConfigSHA256 != binding.ConfigSHA256 { + return fmt.Errorf("detached generated configuration snapshot does not match bound SHA-256 %s", binding.ConfigSHA256) + } + expectedProjectSHA := lock.Files[productLoopDirName+"/project.json"] + if expectedProjectSHA == "" { + return fmt.Errorf("detached generated configuration snapshot does not bind %s/project.json", productLoopDirName) + } + projectSHA, err := SHA256File(ctx.ProjectConfigPath()) + if err != nil { + return fmt.Errorf("detached generated project configuration is missing or unreadable: %w", err) + } + if projectSHA != expectedProjectSHA { + return fmt.Errorf("detached generated project configuration drifted from its snapshot") + } + return nil +} + // detachedContextFor returns the detached WorkspaceContext for repo when the // repository is attached and its binding verifies. ok is false for an unattached // repository (the caller should use the embedded layout). err is non-nil only for @@ -265,13 +319,17 @@ func detachedContextFor(repo string) (ctx WorkspaceContext, ok bool, err error) return WorkspaceContext{}, true, fmt.Errorf("detached binding cannot be verified: %w", idErr) } binding, bindErr := loadBinding(stateRoot, repoID) + ctx = detachedContextFromIdentity(stateRoot, identity) if bindErr != nil { - return WorkspaceContext{}, true, fmt.Errorf("detached binding for %s is missing or unreadable: %w", repo, bindErr) + return ctx, true, fmt.Errorf("detached binding for %s is missing or unreadable: %w", repo, bindErr) } if !bindingMatchesIdentity(binding, identity) { - return WorkspaceContext{}, true, fmt.Errorf("detached binding does not match this repository's identity; reattach with `boatstack-helper attach` or migrate the binding") + return ctx, true, fmt.Errorf("detached binding does not match this repository's identity; reattach with `boatstack-helper attach` or migrate the binding") + } + if configErr := verifyDetachedConfiguration(ctx, binding); configErr != nil { + return ctx, true, configErr } - return detachedContextFromIdentity(stateRoot, identity), true, nil + return ctx, true, nil } // detachedContextFromIdentity builds the detached WorkspaceContext for a resolved diff --git a/labs/12-product-engineering-loop/product-engineering-loop/detached_external_config_conformance_test.go b/labs/12-product-engineering-loop/product-engineering-loop/detached_external_config_conformance_test.go new file mode 100644 index 00000000..0bf64cd5 --- /dev/null +++ b/labs/12-product-engineering-loop/product-engineering-loop/detached_external_config_conformance_test.go @@ -0,0 +1,328 @@ +package boatstack + +import ( + "encoding/json" + "os" + "path/filepath" + "sort" + "strings" + "testing" +) + +func externalConfigFixture(t *testing.T, name, command string) (string, []byte) { + t.Helper() + directory := t.TempDir() + path := filepath.Join(directory, "project.json") + raw := []byte(`{"schema_version":1,"project":{"name":"` + name + `","commands":{"test":"` + command + `"}}}` + "\n") + if err := os.WriteFile(path, raw, 0o644); err != nil { + t.Fatal(err) + } + return path, raw +} + +func filesystemSnapshot(t *testing.T, root string) string { + t.Helper() + entries := []string{} + err := filepath.WalkDir(root, func(path string, entry os.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + relative, err := filepath.Rel(root, path) + if err != nil { + return err + } + info, err := entry.Info() + if err != nil { + return err + } + line := filepath.ToSlash(relative) + " " + info.Mode().String() + if info.Mode().IsRegular() { + digest, err := SHA256File(path) + if err != nil { + return err + } + line += " " + digest + } else if info.Mode()&os.ModeSymlink != 0 { + target, err := os.Readlink(path) + if err != nil { + return err + } + line += " " + target + } + entries = append(entries, line) + return nil + }) + if err != nil { + t.Fatal(err) + } + sort.Strings(entries) + return strings.Join(entries, "\n") +} + +// control-law: detached-config-digest-gates-resume +func TestDetachedAttachAcceptsExternalConfigWithoutPlantWrites(t *testing.T) { + repo := detachedTestRepo(t, "https://github.com/acme/external-config.git") + configPath, raw := externalConfigFixture(t, "works-yield", "pnpm --filter @works/yield-web test") + before := filesystemSnapshot(t, repo) + + result, err := AttachDetached(AttachOptions{Repo: repo, ConfigPath: configPath}) + if err != nil || result.VerificationStatus != "VERIFIED" { + t.Fatalf("attach: %+v %v", result, err) + } + if after := filesystemSnapshot(t, repo); after != before { + t.Fatal("external configuration attachment changed repository or Git bytes") + } + wantSHA := SHA256Bytes(raw) + if result.SchemaVersion != detachedSchemaVersion || result.ConfigSHA256 != wantSHA { + t.Fatalf("attach digest = %q schema=%d, want %q schema=%d", result.ConfigSHA256, result.SchemaVersion, wantSHA, detachedSchemaVersion) + } + ctx := WorkspaceFor(repo) + copied, err := os.ReadFile(ctx.SourceConfigPath()) + if err != nil || string(copied) != string(raw) { + t.Fatalf("external source copy = %q, %v", copied, err) + } + generated, _, err := LoadConfig(ctx.ProjectConfigPath()) + if err != nil || generated.Project.Name != "works-yield" || generated.Project.Commands["test"] != "pnpm --filter @works/yield-web test" { + t.Fatalf("generated config did not preserve supplied values: %+v %v", generated, err) + } + stateRoot, _ := detachedStateRoot() + binding, err := loadBinding(stateRoot, result.RepoID) + if err != nil || binding.ConfigSHA256 != wantSHA || binding.SchemaVersion != detachedSchemaVersion { + t.Fatalf("binding did not capture config digest: %+v %v", binding, err) + } + lockRaw, err := os.ReadFile(filepath.Join(ctx.GeneratedRoot(), "generated.lock.json")) + if err != nil { + t.Fatal(err) + } + var lock detachedGeneratedLock + if err := json.Unmarshal(lockRaw, &lock); err != nil || lock.ConfigSHA256 != wantSHA { + t.Fatalf("generated lock did not capture config digest: %+v %v", lock, err) + } + status, _ := DetachedStatus(repo) + if !status.Verified || status.ConfigSHA256 != wantSHA || status.SchemaVersion != detachedSchemaVersion { + t.Fatalf("detached status did not bind config digest: %+v", status) + } + + // Attachment is copy-based. Later changes to the input path are not live + // configuration changes and cannot alter the bound detached snapshot. + if err := os.WriteFile(configPath, []byte(`{"schema_version":2}`), 0o644); err != nil { + t.Fatal(err) + } + status, _ = DetachedStatus(repo) + if !status.Verified || status.ConfigSHA256 != wantSHA { + t.Fatalf("changing original input changed detached attachment: %+v", status) + } +} + +// control-law: detached-config-input-stays-outside-plant +func TestDetachedAttachRejectsInvalidOrNonExternalConfigBeforeWrites(t *testing.T) { + tests := []struct { + name string + build func(*testing.T, string) string + want string + }{ + {name: "missing", build: func(t *testing.T, _ string) string { return filepath.Join(t.TempDir(), "missing.json") }, want: "missing or unreadable"}, + {name: "malformed", build: func(t *testing.T, _ string) string { + p := filepath.Join(t.TempDir(), "project.json") + _ = os.WriteFile(p, []byte("{\n"), 0o644) + return p + }, want: "parse JSON"}, + {name: "newer schema", build: func(t *testing.T, _ string) string { + p := filepath.Join(t.TempDir(), "project.json") + _ = os.WriteFile(p, []byte(`{"schema_version":2,"project":{"name":"x","commands":{"test":"true"}}}`), 0o644) + return p + }, want: "newer Boatstack"}, + {name: "repository local", build: func(t *testing.T, repo string) string { + p := filepath.Join(repo, "project.json") + _ = os.WriteFile(p, []byte(`{"schema_version":1,"project":{"name":"x","commands":{"test":"true"}}}`), 0o644) + return p + }, want: "outside the repository"}, + {name: "git local", build: func(t *testing.T, repo string) string { + gitDir, _ := gitCommonDir(repo) + p := filepath.Join(gitDir, "project.json") + _ = os.WriteFile(p, []byte(`{"schema_version":1,"project":{"name":"x","commands":{"test":"true"}}}`), 0o644) + return p + }, want: "outside the repository"}, + {name: "symlink", build: func(t *testing.T, _ string) string { + real, _ := externalConfigFixture(t, "x", "true") + link := filepath.Join(t.TempDir(), "project.json") + _ = os.Symlink(real, link) + return link + }, want: "non-symlink"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + repo := detachedTestRepo(t, "https://github.com/acme/reject-"+strings.ReplaceAll(test.name, " ", "-")+".git") + path := test.build(t, repo) + result, err := AttachDetached(AttachOptions{Repo: repo, ConfigPath: path}) + if err != nil || result.VerificationStatus != "BLOCKED" || !strings.Contains(result.Reason, test.want) { + t.Fatalf("result = %+v, err=%v, want %q", result, err, test.want) + } + stateRoot, _ := detachedStateRoot() + if _, statErr := os.Stat(stateRoot); !os.IsNotExist(statErr) { + t.Fatalf("rejected config wrote detached state: %v", statErr) + } + }) + } +} + +func assertDetachedDriftBlocked(t *testing.T, repo, want string) { + t.Helper() + status, err := DetachedStatus(repo) + if err != nil || !status.Attached || status.Verified || status.ConfigSHA256 == "" || !strings.Contains(status.Reason, want) { + t.Fatalf("status did not report %q drift: %+v %v", want, status, err) + } + next, err := ResolveNext(repo, "") + if err != nil || next.VerificationStatus != "BLOCKED" || next.NextOperation != "attach" { + t.Fatalf("next-status did not fail closed: %+v %v", next, err) + } + recovery, err := ResolveRecovery(RecoveryStatusOptions{Repo: repo, Message: "fix", SourceStage: "ci"}) + if err != nil || recovery.VerificationStatus != "BLOCKED" { + t.Fatalf("recovery-status did not fail closed: %+v %v", recovery, err) + } + preflight := CheckRunPreflight(repo, "") + if preflight.VerificationStatus != "BLOCKED" || preflight.Relation != "DETACHED_CONFIG_DRIFT" { + t.Fatalf("run-preflight did not fail closed: %+v", preflight) + } + if _, err := LoadDeliveryState(repo, "missing-feature"); err == nil || !strings.Contains(err.Error(), want) { + t.Fatalf("delivery state bypassed detached verification: %v", err) + } +} + +// control-law: detached-config-digest-gates-resume +func TestDetachedConfigDriftBlocksResumeAndRestoresExactly(t *testing.T) { + repo := detachedTestRepo(t, "https://github.com/acme/drift.git") + configPath, _ := externalConfigFixture(t, "drift", "true") + result, err := AttachDetached(AttachOptions{Repo: repo, ConfigPath: configPath}) + if err != nil || result.VerificationStatus != "VERIFIED" { + t.Fatalf("attach: %+v %v", result, err) + } + ctx := WorkspaceFor(repo) + + sourcePath := ctx.SourceConfigPath() + source, _ := os.ReadFile(sourcePath) + if err := os.WriteFile(sourcePath, append(source, ' '), 0o644); err != nil { + t.Fatal(err) + } + invalidateWorkspaceCache() + assertDetachedDriftBlocked(t, repo, "drifted from bound SHA-256") + if WorkspaceFor(repo).ProjectConfigPath() != ctx.ProjectConfigPath() { + t.Fatal("unverified attachment redirected controller paths into repository") + } + if err := os.WriteFile(sourcePath, source, 0o644); err != nil { + t.Fatal(err) + } + invalidateWorkspaceCache() + if status, _ := DetachedStatus(repo); !status.Verified { + t.Fatalf("exact source restoration did not recover verification: %+v", status) + } + + projectPath := ctx.ProjectConfigPath() + project, _ := os.ReadFile(projectPath) + if err := os.WriteFile(projectPath, append(project, ' '), 0o644); err != nil { + t.Fatal(err) + } + invalidateWorkspaceCache() + assertDetachedDriftBlocked(t, repo, "generated project configuration drifted") + if err := os.WriteFile(projectPath, project, 0o644); err != nil { + t.Fatal(err) + } + + lockPath := filepath.Join(ctx.GeneratedRoot(), "generated.lock.json") + lock, _ := os.ReadFile(lockPath) + var changed map[string]any + if err := json.Unmarshal(lock, &changed); err != nil { + t.Fatal(err) + } + changed["config_sha256"] = strings.Repeat("0", 64) + changedRaw, _ := MarshalJSON(changed) + if err := os.WriteFile(lockPath, changedRaw, 0o644); err != nil { + t.Fatal(err) + } + invalidateWorkspaceCache() + assertDetachedDriftBlocked(t, repo, "snapshot does not match") + if err := os.WriteFile(lockPath, lock, 0o644); err != nil { + t.Fatal(err) + } + invalidateWorkspaceCache() + if status, _ := DetachedStatus(repo); !status.Verified { + t.Fatalf("exact generated-state restoration did not recover verification: %+v", status) + } +} + +// control-law: detached-config-digest-gates-resume +func TestDetachedConfigDriftBlocksMutationAndPublicationBypasses(t *testing.T) { + repo := detachedTestRepo(t, "https://github.com/acme/drift-bypass.git") + embeddedFeatureForDetach(t, repo, "feature-one", "") + configPath, _ := externalConfigFixture(t, "drift-bypass", "true") + result, err := AttachDetached(AttachOptions{Repo: repo, ConfigPath: configPath}) + if err != nil || result.VerificationStatus != "VERIFIED" { + t.Fatalf("attach: %+v %v", result, err) + } + ctx := WorkspaceFor(repo) + source, _ := os.ReadFile(ctx.SourceConfigPath()) + if err := os.WriteFile(ctx.SourceConfigPath(), append(source, ' '), 0o644); err != nil { + t.Fatal(err) + } + invalidateWorkspaceCache() + before := filesystemSnapshot(t, ctx.controlRoot) + planPath := filepath.Join(ctx.FeatureDir("feature-one"), "plan.md") + + checks := []struct { + name string + run func() error + }{ + {name: "activation", run: func() error { + return ActivatePlan(ActivationOptions{ + PlanPath: planPath, OutDir: filepath.Join(ctx.FeatureDir("feature-one"), "compiled"), + OutputPath: filepath.Join(ctx.FeatureDir("feature-one"), "plan.lock.json"), SourceCommit: "test", + }) + }}, + {name: "repair", run: func() error { + _, _, err := RecordChangeObservation(ChangeObservationOptions{Repo: repo, Feature: "feature-one", Classification: "implementation_repair", Message: "fix", SourceStage: "ci"}) + return err + }}, + {name: "gate", run: func() error { + _, err := RecordDeliveryGate(DeliveryGateOptions{Repo: repo, Feature: "feature-one", SliceID: "delivery", Gate: "test", Status: "PASS"}) + return err + }}, + {name: "pr-context", run: func() error { + _, err := PreparePRContext(PRContextOptions{Repo: repo}) + return err + }}, + {name: "publish", run: func() error { + _, err := PublishPR(PRPublishOptions{Repo: repo, PreviewPath: "missing.md", ExpectedFingerprint: "missing", Action: "open"}) + return err + }}, + {name: "operation", run: func() error { + _, err := PrepareOperation(OperationPrepareOptions{Repo: repo, Kind: "test", Target: "target", PackageFingerprint: "package", ExpectedPostcondition: "done", RetryClass: "ATOMIC_LOCAL"}) + return err + }}, + } + for _, check := range checks { + t.Run(check.name, func(t *testing.T) { + if err := check.run(); err == nil || !strings.Contains(err.Error(), "drifted from bound SHA-256") { + t.Fatalf("entry point did not reach detached config boundary: %v", err) + } + }) + } + if after := filesystemSnapshot(t, ctx.controlRoot); after != before { + t.Fatal("blocked resume path mutated detached controller state") + } +} + +// control-law: detached-config-rebinding-requires-explicit-force +func TestDetachedForceReattachRebindsExternalConfig(t *testing.T) { + repo := detachedTestRepo(t, "https://github.com/acme/rebind.git") + firstPath, firstRaw := externalConfigFixture(t, "first", "true") + first, _ := AttachDetached(AttachOptions{Repo: repo, ConfigPath: firstPath}) + secondPath, secondRaw := externalConfigFixture(t, "second", "pnpm test") + blocked, _ := AttachDetached(AttachOptions{Repo: repo, ConfigPath: secondPath}) + if blocked.VerificationStatus != "BLOCKED" { + t.Fatalf("reattach without force succeeded: %+v", blocked) + } + second, err := AttachDetached(AttachOptions{Repo: repo, ConfigPath: secondPath, Force: true}) + if err != nil || second.VerificationStatus != "VERIFIED" || second.ConfigSHA256 != SHA256Bytes(secondRaw) || second.ConfigSHA256 == SHA256Bytes(firstRaw) || second.ConfigSHA256 == first.ConfigSHA256 { + t.Fatalf("forced reattach did not rebind config: first=%+v second=%+v err=%v", first, second, err) + } +} diff --git a/labs/12-product-engineering-loop/product-engineering-loop/insight_conformance_test.go b/labs/12-product-engineering-loop/product-engineering-loop/insight_conformance_test.go index c051c17a..87410f01 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/insight_conformance_test.go +++ b/labs/12-product-engineering-loop/product-engineering-loop/insight_conformance_test.go @@ -14,14 +14,7 @@ func configureInsights(t *testing.T, repo string, terminal DeliveryTerminal) Wor t.Helper() stateRoot := t.TempDir() t.Setenv("BOATSTACK_STATE_ROOT", stateRoot) - if _, err := AttachDetached(AttachOptions{Repo: repo}); err != nil { - t.Fatal(err) - } - ctx := WorkspaceFor(repo) - config, _, err := LoadConfig(ctx.ProjectConfigPath()) - if err != nil { - t.Fatal(err) - } + config := testConfig() config.Insights = &InsightPolicy{ Enabled: true, CaptureMode: "manual", ValueMap: "required", SuggestFeatures: true, EvaluateOnPR: true, PendingFrontier: true, CompletionMode: "human_confirmed", @@ -33,10 +26,14 @@ func configureInsights(t *testing.T, repo string, terminal DeliveryTerminal) Wor if err != nil { t.Fatal(err) } - if err := os.WriteFile(ctx.ProjectConfigPath(), value, 0o600); err != nil { + configPath := filepath.Join(t.TempDir(), "project.json") + if err := os.WriteFile(configPath, value, 0o600); err != nil { t.Fatal(err) } - return ctx + if _, err := AttachDetached(AttachOptions{Repo: repo, ConfigPath: configPath}); err != nil { + t.Fatal(err) + } + return WorkspaceFor(repo) } func configureEmbeddedInsights(t *testing.T, repo string, terminal DeliveryTerminal) WorkspaceContext { diff --git a/labs/12-product-engineering-loop/product-engineering-loop/next.go b/labs/12-product-engineering-loop/product-engineering-loop/next.go index ede85a47..2d461dce 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/next.go +++ b/labs/12-product-engineering-loop/product-engineering-loop/next.go @@ -314,6 +314,11 @@ func ResolveNext(repoPath, explicitFeature string) (result NextStatus, resultErr if err != nil { return NextStatus{}, err } + if _, workspaceErr := ResolveWorkspaceContext(repo); workspaceErr != nil { + status := blockedNextStatus("INVALID_STATE", "attach", workspaceErr.Error()) + status.SupervisionMode = string(SupervisionDetached) + return status, nil + } defer func() { ctx, ok, verifyErr := detachedContextFor(repo) if verifyErr != nil { diff --git a/labs/12-product-engineering-loop/product-engineering-loop/operation.go b/labs/12-product-engineering-loop/product-engineering-loop/operation.go index 7f8f4dfe..91a37044 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/operation.go +++ b/labs/12-product-engineering-loop/product-engineering-loop/operation.go @@ -117,7 +117,11 @@ func operationTimestamp() string { // so bumping the version cleanly orphans the legacy clone-shared "v1" ledger for // every worktree — including main — instead of silently inheriting its receipts. func operationDirectory(repo string) (string, error) { - return WorkspaceFor(repo).OperationDir() + ctx, err := ResolveWorkspaceContext(repo) + if err != nil { + return "", err + } + return ctx.OperationDir() } // pruneLegacyOperationLedger removes the pre-isolation clone-shared "v1" ledger @@ -285,6 +289,9 @@ func PrepareOperation(options OperationPrepareOptions) (OperationReceipt, error) if err != nil { return OperationReceipt{}, err } + if _, err := ResolveWorkspaceContext(repo); err != nil { + return OperationReceipt{}, err + } // Retention is best-effort and never prevents a new supervised operation. _ = compactOperations(repo) kind := strings.TrimSpace(options.Kind) @@ -353,6 +360,9 @@ func AuthorizeOperation(repoPath, id, packageFingerprint, authorizationFingerpri if err != nil { return OperationReceipt{}, err } + if _, err := ResolveWorkspaceContext(repo); err != nil { + return OperationReceipt{}, err + } var result OperationReceipt err = withOperationLock(repo, id, func() error { receipt, loadErr := loadOperation(repo, id) @@ -390,6 +400,9 @@ func BeginOperation(repoPath, id, attemptKey, tool string) (OperationBeginResult if err != nil { return OperationBeginResult{}, err } + if _, err := ResolveWorkspaceContext(repo); err != nil { + return OperationBeginResult{}, err + } attemptKey = strings.TrimSpace(attemptKey) if attemptKey == "" { return OperationBeginResult{}, fmt.Errorf("operation attempt key is required") @@ -465,6 +478,9 @@ func reconcileSucceededInstallUpdate(repoPath, id, detail, evidence string) (Ope if err != nil { return OperationReceipt{}, err } + if _, err := ResolveWorkspaceContext(repo); err != nil { + return OperationReceipt{}, err + } var result OperationReceipt err = withOperationLock(repo, id, func() error { receipt, loadErr := loadOperation(repo, id) @@ -496,6 +512,9 @@ func completeOperation(repoPath, id, leaseToken, attemptKey, outcome, detail, ev if err != nil { return OperationReceipt{}, err } + if _, err := ResolveWorkspaceContext(repo); err != nil { + return OperationReceipt{}, err + } var result OperationReceipt err = withOperationLock(repo, id, func() error { receipt, loadErr := loadOperation(repo, id) @@ -570,6 +589,9 @@ func RecordOperationReconciliation(repoPath, id, result, detail, evidence string if err != nil { return OperationReceipt{}, err } + if _, err := ResolveWorkspaceContext(repo); err != nil { + return OperationReceipt{}, err + } var output OperationReceipt err = withOperationLock(repo, id, func() error { receipt, loadErr := loadOperation(repo, id) @@ -658,6 +680,9 @@ func ResolveOperationStatus(repoPath, id string) (OperationStatusResult, error) if err != nil { return OperationStatusResult{}, err } + if _, err := ResolveWorkspaceContext(repo); err != nil { + return OperationStatusResult{}, err + } if strings.TrimSpace(id) != "" { receipt, loadErr := refreshExpiredOperation(repo, strings.TrimSpace(id)) if loadErr != nil { diff --git a/labs/12-product-engineering-loop/product-engineering-loop/paths.go b/labs/12-product-engineering-loop/product-engineering-loop/paths.go index 1551334a..9d4cb777 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/paths.go +++ b/labs/12-product-engineering-loop/product-engineering-loop/paths.go @@ -117,13 +117,11 @@ func (w WorkspaceContext) sharedOwnedPath(target string) (controllerPath, error) } // WorkspaceFor returns the resolver for a repository. It consults the external -// attachment registry and returns a detached context when the repository is -// attached and its binding verifies; otherwise it returns the embedded layout. -// An attached-but-unverifiable repository resolves to embedded here (best effort, -// so deep path callers stay total) — the fail-closed denial with a bounded -// recovery action is raised at the safety and CLI entry points via -// ResolveWorkspaceContext. Results are cached per input path; attach/detach -// invalidate the cache. +// attachment registry and returns a detached context whenever the repository is +// attached. Verification failures do not redirect paths into the repository: +// strict operational entry points deny through ResolveWorkspaceContext, while +// best-effort path projection remains external. Results are cached per input +// path; attach/detach invalidate the cache. func WorkspaceFor(repo string) WorkspaceContext { workspaceCacheMu.Lock() if cached, ok := workspaceCache[repo]; ok { @@ -133,7 +131,7 @@ func WorkspaceFor(repo string) WorkspaceContext { workspaceCacheMu.Unlock() resolved := embeddedWorkspace(repo) - if ctx, ok, err := detachedContextFor(repo); ok && err == nil { + if ctx, ok, _ := detachedContextFor(repo); ok { resolved = ctx } @@ -183,8 +181,8 @@ func ResolveControllerRepository(path string) (string, error) { return "", err } for repo := range registry.Repositories { - ctx, ok, verifyErr := detachedContextFor(repo) - if !ok || verifyErr != nil { + ctx, ok, _ := detachedContextFor(repo) + if !ok { continue } if pathWithin(ctx.ExportRoot(), path) { diff --git a/labs/12-product-engineering-loop/product-engineering-loop/plan.go b/labs/12-product-engineering-loop/product-engineering-loop/plan.go index d2887cd2..e99e41d1 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/plan.go +++ b/labs/12-product-engineering-loop/product-engineering-loop/plan.go @@ -1105,15 +1105,19 @@ type ActivationOptions struct { } func ActivatePlan(options ActivationOptions) error { - check, err := CheckPlan(options.PlanPath) + repo, err := ResolveControllerRepository(filepath.Dir(options.PlanPath)) if err != nil { return err } - repo, err := ResolveControllerRepository(filepath.Dir(options.PlanPath)) + ctx, err := ResolveWorkspaceContext(repo) + if err != nil { + return err + } + check, err := CheckPlan(options.PlanPath) if err != nil { return err } - config, _, err := LoadConfig(WorkspaceFor(repo).ProjectConfigPath()) + config, _, err := LoadConfig(ctx.ProjectConfigPath()) if err != nil { return fmt.Errorf("plan activation requires a valid Boatstack project configuration: %w", err) } diff --git a/labs/12-product-engineering-loop/product-engineering-loop/pr.go b/labs/12-product-engineering-loop/product-engineering-loop/pr.go index cce3533c..e8278fbe 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/pr.go +++ b/labs/12-product-engineering-loop/product-engineering-loop/pr.go @@ -710,6 +710,10 @@ func PreparePRContext(options PRContextOptions) (PRContext, error) { if err != nil { return PRContext{}, err } + ctx, err := ResolveWorkspaceContext(repo) + if err != nil { + return PRContext{}, err + } if strings.TrimSpace(options.Feature) == "" { active, activeErr := ActiveManagedDeliveries(repo) if activeErr != nil { @@ -723,7 +727,7 @@ func PreparePRContext(options PRContextOptions) (PRContext, error) { if err != nil || head == "" { return PRContext{}, fmt.Errorf("PR preparation requires a named branch") } - configPath := WorkspaceFor(repo).ProjectConfigPath() + configPath := ctx.ProjectConfigPath() config, _, err := LoadConfig(configPath) if err != nil { return PRContext{}, fmt.Errorf("PR preparation requires a valid Boatstack project configuration: %w", err) @@ -1123,6 +1127,9 @@ func CheckPRPreview(repoPath, previewPath string) (PRPreview, PRContext, error) if err != nil { return PRPreview{}, PRContext{}, err } + if _, err := ResolveWorkspaceContext(repo); err != nil { + return PRPreview{}, PRContext{}, err + } if !filepath.IsAbs(previewPath) { previewPath = filepath.Join(repo, filepath.FromSlash(previewPath)) } diff --git a/labs/12-product-engineering-loop/product-engineering-loop/recovery.go b/labs/12-product-engineering-loop/product-engineering-loop/recovery.go index 95e5e19b..fab3b03e 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/recovery.go +++ b/labs/12-product-engineering-loop/product-engineering-loop/recovery.go @@ -389,6 +389,9 @@ func ResolveRecovery(options RecoveryStatusOptions) (RecoveryStatus, error) { if err != nil { return RecoveryStatus{}, err } + if _, workspaceErr := ResolveWorkspaceContext(repo); workspaceErr != nil { + return blockedRecovery(workspaceErr.Error()), nil + } if strings.TrimSpace(options.Message) == "" || strings.TrimSpace(options.SourceStage) == "" { return RecoveryStatus{}, fmt.Errorf("recovery status requires the exact message and source stage") } diff --git a/labs/12-product-engineering-loop/product-engineering-loop/run.go b/labs/12-product-engineering-loop/product-engineering-loop/run.go index 5185caf0..5d485394 100644 --- a/labs/12-product-engineering-loop/product-engineering-loop/run.go +++ b/labs/12-product-engineering-loop/product-engineering-loop/run.go @@ -107,6 +107,9 @@ func CheckRunPreflight(repoPath, explicitFeature string) RunPreflight { if err != nil { return blockedRunPreflight("", "", "", "INVALID_REPOSITORY", err.Error()) } + if _, workspaceErr := ResolveWorkspaceContext(repo); workspaceErr != nil { + return blockedRunPreflight("", "", "", "DETACHED_CONFIG_DRIFT", workspaceErr.Error()) + } if !fileExists(WorkspaceFor(repo).ProjectConfigPath()) { return blockedRunPreflight("", "", "", "NOT_INITIALIZED", "This repository has no Boatstack project installation to run.") }