Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 <path>`.

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
Expand Down
Original file line number Diff line number Diff line change
@@ -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`.
97 changes: 82 additions & 15 deletions labs/12-product-engineering-loop/product-engineering-loop/attach.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"fmt"
"os"
"path/filepath"
"strings"
)

// Attach, detach, and status operations for Detached Supervision. Attaching a
Expand All @@ -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.
Expand All @@ -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}
}
Expand Down Expand Up @@ -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())
Expand All @@ -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)
Expand All @@ -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(),
}
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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"`
}

Expand All @@ -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
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading