From 70a56dd7e6b45ebfa8324a84bee5cc29b5cb87ed Mon Sep 17 00:00:00 2001 From: CL Kao Date: Fri, 12 Jun 2026 23:01:36 -0700 Subject: [PATCH 1/2] release: brew cask depends_on agentsview; safehouse stays a caveat Both generated Homebrew casks (spacedock stable + spacedock@next edge) now declare `dependencies: - cask: agentsview` in .goreleaser.yaml's homebrew_casks block, which goreleaser renders to `depends_on cask: ["agentsview"]` so brew installs agentsview (it powers /spacedock:survey) alongside the launcher. agentsview is dropped from each caveats "not installed by brew" list; safehouse stays a caveat because it is not a brew package and has no depends_on target to resolve. internal/release/cask_dependencies_test.go proves each rendered cask carries the agentsview dependency and a caveats block that names safehouse but not agentsview. Primary proof renders the real Ruby via `goreleaser release --snapshot --skip=publish --clean`; a config-parse fallback asserts the same properties when goreleaser cannot run in the environment. Co-Authored-By: Claude Opus 4.8 (1M context) --- .goreleaser.yaml | 16 +- internal/release/cask_dependencies_test.go | 171 +++++++++++++++++++++ 2 files changed, 183 insertions(+), 4 deletions(-) create mode 100644 internal/release/cask_dependencies_test.go diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 65806b6f..e73b8d0b 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -113,10 +113,15 @@ homebrew_casks: homepage: https://github.com/spacedock-dev/spacedock description: Workflow launcher and first-officer dispatch for agentic dev license: Apache-2.0 + # agentsview is a core Homebrew cask (Homebrew/homebrew-cask), so brew + # resolves and installs it alongside the launcher — it powers + # /spacedock:survey. safehouse stays a caveat (below): it is not a brew + # package, so there is no depends_on target to resolve. + dependencies: + - cask: agentsview caveats: | - Recommended companions (not installed by brew): + Recommended companion (not installed by brew): safehouse — sandboxes agent runs (https://agent-safehouse.dev) - agentsview — powers /spacedock:survey hooks: # The binary is adhoc/linker-signed only (not notarized), so the cask # download carries com.apple.quarantine and Gatekeeper kills the first @@ -139,10 +144,13 @@ homebrew_casks: homepage: https://github.com/spacedock-dev/spacedock description: Workflow launcher and first-officer dispatch for agentic dev (edge channel) license: Apache-2.0 + # Same companion split as the stable cask: agentsview is a brew-installable + # cask dependency; safehouse is not a brew package and stays a caveat. + dependencies: + - cask: agentsview caveats: | - Recommended companions (not installed by brew): + Recommended companion (not installed by brew): safehouse — sandboxes agent runs (https://agent-safehouse.dev) - agentsview — powers /spacedock:survey hooks: post: install: | diff --git a/internal/release/cask_dependencies_test.go b/internal/release/cask_dependencies_test.go new file mode 100644 index 00000000..b94beb5d --- /dev/null +++ b/internal/release/cask_dependencies_test.go @@ -0,0 +1,171 @@ +// ABOUTME: Proves the release-rendered Homebrew casks declare agentsview as a +// ABOUTME: cask dependency and keep safehouse (only) in the caveats companions list. +package release + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "gopkg.in/yaml.v3" +) + +// the two casks .goreleaser.yaml's homebrew_casks block emits per release, and +// the rendered .rb filename goreleaser writes each into. +var renderedCasks = []struct { + name string + file string +}{ + {name: "spacedock", file: "spacedock.rb"}, + {name: "spacedock@next", file: "spacedock@next.rb"}, +} + +// TestRenderedCaskDependsOnAgentsview locks AC-1 + AC-2: a release-rendered cask +// must carry a `depends_on cask:` stanza naming agentsview (AC-1), and its +// caveats must name safehouse but NOT agentsview — agentsview moved from a +// caveat to a real dependency (AC-2). Both rendered casks (stable + edge) are +// checked. The expected values (agentsview is a cask dep; caveats keeps +// safehouse, drops agentsview) come from the AC, not from the file under test — +// the rendered .rb is goreleaser OUTPUT, so this reds if the dependency is +// dropped or mis-spelled or the caveat regresses. +// +// Primary proof renders the real Ruby via goreleaser snapshot. When goreleaser +// cannot run in the environment (not on PATH, sandbox denies its caches), the +// test falls back to parsing .goreleaser.yaml's homebrew_casks declarations, +// which assert the same two properties at the config layer. +func TestRenderedCaskDependsOnAgentsview(t *testing.T) { + casks, rendered := renderCasks(t) + if !rendered { + t.Log("goreleaser render unavailable; falling back to .goreleaser.yaml config parse") + assertCaskConfig(t) + return + } + for _, c := range renderedCasks { + body := casks[c.name] + if body == "" { + t.Fatalf("rendered cask %q (%s) not found in goreleaser output", c.name, c.file) + } + assertRenderedCask(t, c.name, body) + } +} + +// assertRenderedCask checks one rendered cask's Ruby body against AC-1 and AC-2. +func assertRenderedCask(t *testing.T, name, body string) { + t.Helper() + + if !strings.Contains(body, "depends_on cask:") { + t.Errorf("rendered cask %q has no `depends_on cask:` stanza:\n%s", name, body) + } + if !strings.Contains(body, `"agentsview"`) { + t.Errorf("rendered cask %q does not name agentsview as a dependency:\n%s", name, body) + } + + caveats := caveatsBlock(body) + if caveats == "" { + t.Fatalf("rendered cask %q has no caveats block to inspect:\n%s", name, body) + } + if !strings.Contains(caveats, "safehouse") { + t.Errorf("rendered cask %q caveats dropped safehouse (still not brew-installable):\n%s", name, caveats) + } + if strings.Contains(caveats, "agentsview") { + t.Errorf("rendered cask %q still lists agentsview in caveats; it moved to depends_on:\n%s", name, caveats) + } +} + +// caveatsBlock extracts the heredoc body of a `caveats <<~EOS … EOS` stanza so +// the agentsview/safehouse assertions inspect only the companions text and not, +// say, a homepage URL elsewhere in the cask. +func caveatsBlock(body string) string { + start := strings.Index(body, "caveats <<~EOS") + if start < 0 { + return "" + } + rest := body[start:] + nl := strings.Index(rest, "\n") + if nl < 0 { + return "" + } + rest = rest[nl+1:] + end := strings.Index(rest, "EOS") + if end < 0 { + return "" + } + return rest[:end] +} + +// renderCasks runs `goreleaser release --snapshot --skip=publish --clean` from +// the repo root and returns each rendered cask's Ruby body keyed by cask name. +// rendered=false (the caller falls back to the config parse) when goreleaser is +// not on PATH or the render fails for an environmental reason — goreleaser is a +// release dependency, but CI sandboxes can deny the gem/module caches it needs. +func renderCasks(t *testing.T) (map[string]string, bool) { + t.Helper() + if _, err := exec.LookPath("goreleaser"); err != nil { + return nil, false + } + repoRoot := filepath.Join("..", "..") + cmd := exec.Command("goreleaser", "release", "--snapshot", "--skip=publish", "--clean") + cmd.Dir = repoRoot + if out, err := cmd.CombinedOutput(); err != nil { + t.Logf("goreleaser snapshot render failed (falling back to config parse): %v\n%s", err, out) + return nil, false + } + + casks := map[string]string{} + for _, c := range renderedCasks { + data, err := os.ReadFile(filepath.Join(repoRoot, "dist", "homebrew", "Casks", c.file)) + if err != nil { + t.Fatalf("goreleaser rendered no %s: %v", c.file, err) + } + casks[c.name] = string(data) + } + return casks, true +} + +// goreleaserCaskConfig is the slice of homebrew_casks fields the config-parse +// fallback inspects: the declared cask-on-cask dependencies and the caveats text. +type goreleaserCaskConfig struct { + HomebrewCasks []struct { + Name string `yaml:"name"` + Caveats string `yaml:"caveats"` + Dependencies []struct { + Cask string `yaml:"cask"` + Formula string `yaml:"formula"` + } `yaml:"dependencies"` + } `yaml:"homebrew_casks"` +} + +// assertCaskConfig is the fallback proof: it parses .goreleaser.yaml's +// homebrew_casks declarations and asserts the same AC-1/AC-2 properties the +// render check proves on the emitted Ruby — each cask declares a `cask: +// agentsview` dependency and a caveats block that keeps safehouse but drops +// agentsview. +func assertCaskConfig(t *testing.T) { + t.Helper() + var cfg goreleaserCaskConfig + if err := yaml.Unmarshal([]byte(readGoreleaserConfig(t)), &cfg); err != nil { + t.Fatalf("parse .goreleaser.yaml: %v", err) + } + if len(cfg.HomebrewCasks) != 2 { + t.Fatalf("expected 2 homebrew_casks (stable + edge), got %d", len(cfg.HomebrewCasks)) + } + for _, cask := range cfg.HomebrewCasks { + hasAgentsview := false + for _, dep := range cask.Dependencies { + if dep.Cask == "agentsview" { + hasAgentsview = true + } + } + if !hasAgentsview { + t.Errorf("cask %q does not declare `cask: agentsview` as a dependency", cask.Name) + } + if !strings.Contains(cask.Caveats, "safehouse") { + t.Errorf("cask %q caveats dropped safehouse:\n%s", cask.Name, cask.Caveats) + } + if strings.Contains(cask.Caveats, "agentsview") { + t.Errorf("cask %q still lists agentsview in caveats; it moved to a dependency:\n%s", cask.Name, cask.Caveats) + } + } +} From d6f41496b6734a816572737653c8eecd5d846015 Mon Sep 17 00:00:00 2001 From: CL Kao Date: Sat, 13 Jun 2026 07:49:36 -0700 Subject: [PATCH 2/2] release: correct safehouse caveat to its brew install command; fix install-hint URL safehouse IS brew-installable, but only from a third-party tap (eugene1g/safehouse/agent-safehouse). A cask depends_on formula on that tap-qualified name renders, but Homebrew 6.0 tap-trust refuses to auto-load an untrusted third-party tap reached transitively, so a fresh install would fail rather than pull safehouse silently. Keep safehouse a caveat, but correct it from the false 'not installed by brew' to its real brew install command, in both the stable and edge casks. Correct the matching code install-hint, which cited the outdated github.com/anthropics/safehouse URL. agentsview stays the only real depends_on cask. The render test now also guards that no cask ships a depends_on formula and that the caveat carries the install command; a safehouse-hint unit test pins the corrected hint string. Co-Authored-By: Claude Opus 4.8 (1M context) --- .goreleaser.yaml | 16 +++-- internal/release/cask_dependencies_test.go | 84 +++++++++++++++++----- internal/safehouse/safehouse.go | 2 +- internal/safehouse/safehouse_test.go | 14 +++- 4 files changed, 90 insertions(+), 26 deletions(-) diff --git a/.goreleaser.yaml b/.goreleaser.yaml index e73b8d0b..a0f44793 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -115,13 +115,16 @@ homebrew_casks: license: Apache-2.0 # agentsview is a core Homebrew cask (Homebrew/homebrew-cask), so brew # resolves and installs it alongside the launcher — it powers - # /spacedock:survey. safehouse stays a caveat (below): it is not a brew - # package, so there is no depends_on target to resolve. + # /spacedock:survey. safehouse stays a caveat (below): it lives in a + # third-party tap (eugene1g/safehouse), which Homebrew 6.0 tap-trust refuses + # to auto-load as a transitive depends_on, so we name the install command + # rather than declare a dependency that would break the fresh-install path. dependencies: - cask: agentsview caveats: | - Recommended companion (not installed by brew): + Recommended companion (install separately): safehouse — sandboxes agent runs (https://agent-safehouse.dev) + brew install eugene1g/safehouse/agent-safehouse hooks: # The binary is adhoc/linker-signed only (not notarized), so the cask # download carries com.apple.quarantine and Gatekeeper kills the first @@ -145,12 +148,15 @@ homebrew_casks: description: Workflow launcher and first-officer dispatch for agentic dev (edge channel) license: Apache-2.0 # Same companion split as the stable cask: agentsview is a brew-installable - # cask dependency; safehouse is not a brew package and stays a caveat. + # cask dependency; safehouse lives in a third-party tap that 6.0 tap-trust + # won't auto-load as a transitive dep, so it stays a caveat naming its + # install command. dependencies: - cask: agentsview caveats: | - Recommended companion (not installed by brew): + Recommended companion (install separately): safehouse — sandboxes agent runs (https://agent-safehouse.dev) + brew install eugene1g/safehouse/agent-safehouse hooks: post: install: | diff --git a/internal/release/cask_dependencies_test.go b/internal/release/cask_dependencies_test.go index b94beb5d..ed9a932d 100644 --- a/internal/release/cask_dependencies_test.go +++ b/internal/release/cask_dependencies_test.go @@ -1,5 +1,6 @@ // ABOUTME: Proves the release-rendered Homebrew casks declare agentsview as a -// ABOUTME: cask dependency and keep safehouse (only) in the caveats companions list. +// ABOUTME: cask dependency, declare no safehouse formula dependency, and keep +// ABOUTME: safehouse in the caveats with its brew install command. package release import ( @@ -23,18 +24,20 @@ var renderedCasks = []struct { } // TestRenderedCaskDependsOnAgentsview locks AC-1 + AC-2: a release-rendered cask -// must carry a `depends_on cask:` stanza naming agentsview (AC-1), and its -// caveats must name safehouse but NOT agentsview — agentsview moved from a -// caveat to a real dependency (AC-2). Both rendered casks (stable + edge) are -// checked. The expected values (agentsview is a cask dep; caveats keeps -// safehouse, drops agentsview) come from the AC, not from the file under test — -// the rendered .rb is goreleaser OUTPUT, so this reds if the dependency is -// dropped or mis-spelled or the caveat regresses. +// must carry a `depends_on cask:` stanza naming agentsview (AC-1); must carry NO +// `depends_on formula:` for the tap-qualified safehouse formula (the cross-tap +// dep the spike refuted); and its caveats must name safehouse with its +// `brew install eugene1g/safehouse/agent-safehouse` command and canonical site, +// but NOT agentsview, which is a dependency (AC-2). Both rendered casks (stable + +// edge) are checked. The expected values come from the AC, not from the file +// under test — the rendered .rb is goreleaser OUTPUT, so this reds if the +// dependency is dropped or mis-spelled, if the refuted formula dep is added, or +// if the caveat regresses. // // Primary proof renders the real Ruby via goreleaser snapshot. When goreleaser // cannot run in the environment (not on PATH, sandbox denies its caches), the // test falls back to parsing .goreleaser.yaml's homebrew_casks declarations, -// which assert the same two properties at the config layer. +// which assert the same properties at the config layer. func TestRenderedCaskDependsOnAgentsview(t *testing.T) { casks, rendered := renderCasks(t) if !rendered { @@ -62,18 +65,63 @@ func assertRenderedCask(t *testing.T, name, body string) { t.Errorf("rendered cask %q does not name agentsview as a dependency:\n%s", name, body) } + // safehouse must NOT be a formula dependency: a cask depends_on formula on + // the tap-qualified eugene1g/safehouse/agent-safehouse renders, but Homebrew + // 6.0 tap-trust refuses to auto-load that untrusted third-party tap as a + // transitive dep — the spike refuted it, so it must not ship. goreleaser + // emits a formula dep as a `formula: [...]` key inside the depends_on stanza; + // the tap-qualified name legitimately appears in the caveats install command, + // so the depends_on-stanza form is the precise signal. + if depBlock := dependsOnBlock(body); strings.Contains(depBlock, "formula:") { + t.Errorf("rendered cask %q carries a formula dependency in its depends_on stanza; the cross-tap safehouse dep was refuted and must not ship:\n%s", name, depBlock) + } + caveats := caveatsBlock(body) if caveats == "" { t.Fatalf("rendered cask %q has no caveats block to inspect:\n%s", name, body) } - if !strings.Contains(caveats, "safehouse") { - t.Errorf("rendered cask %q caveats dropped safehouse (still not brew-installable):\n%s", name, caveats) - } + assertSafehouseCaveat(t, name, caveats) if strings.Contains(caveats, "agentsview") { t.Errorf("rendered cask %q still lists agentsview in caveats; it moved to depends_on:\n%s", name, caveats) } } +// assertSafehouseCaveat pins AC-2's corrected caveat content: safehouse stays a +// caveat, but with its real third-party-tap install command and canonical site, +// and the stale "not installed by brew" phrasing gone (safehouse IS brew- +// installable, just not as a transitive depends_on). +func assertSafehouseCaveat(t *testing.T, name, caveats string) { + t.Helper() + if !strings.Contains(caveats, "safehouse") { + t.Errorf("cask %q caveats dropped safehouse:\n%s", name, caveats) + } + if !strings.Contains(caveats, "https://agent-safehouse.dev") { + t.Errorf("cask %q caveats does not name the canonical safehouse site:\n%s", name, caveats) + } + if !strings.Contains(caveats, "brew install eugene1g/safehouse/agent-safehouse") { + t.Errorf("cask %q caveats does not name safehouse's brew install command:\n%s", name, caveats) + } + if strings.Contains(caveats, "not installed by brew") { + t.Errorf("cask %q caveats still says safehouse is \"not installed by brew\"; it is brew-installable from its tap:\n%s", name, caveats) + } +} + +// dependsOnBlock extracts the `depends_on …` stanza of a rendered cask up to the +// blank line that separates it from the next stanza, so a `formula:` check sees +// only the dependency declaration and not the tap-qualified name that also +// appears in the caveats install command. +func dependsOnBlock(body string) string { + start := strings.Index(body, "depends_on") + if start < 0 { + return "" + } + rest := body[start:] + if end := strings.Index(rest, "\n\n"); end >= 0 { + return rest[:end] + } + return rest +} + // caveatsBlock extracts the heredoc body of a `caveats <<~EOS … EOS` stanza so // the agentsview/safehouse assertions inspect only the companions text and not, // say, a homepage URL elsewhere in the cask. @@ -140,8 +188,9 @@ type goreleaserCaskConfig struct { // assertCaskConfig is the fallback proof: it parses .goreleaser.yaml's // homebrew_casks declarations and asserts the same AC-1/AC-2 properties the // render check proves on the emitted Ruby — each cask declares a `cask: -// agentsview` dependency and a caveats block that keeps safehouse but drops -// agentsview. +// agentsview` dependency, declares NO formula dependency (the refuted cross-tap +// safehouse dep), and a caveats block that keeps the corrected safehouse install +// line but drops agentsview. func assertCaskConfig(t *testing.T) { t.Helper() var cfg goreleaserCaskConfig @@ -157,13 +206,14 @@ func assertCaskConfig(t *testing.T) { if dep.Cask == "agentsview" { hasAgentsview = true } + if dep.Formula != "" { + t.Errorf("cask %q declares a formula dependency %q; the cross-tap safehouse dep was refuted and must not ship", cask.Name, dep.Formula) + } } if !hasAgentsview { t.Errorf("cask %q does not declare `cask: agentsview` as a dependency", cask.Name) } - if !strings.Contains(cask.Caveats, "safehouse") { - t.Errorf("cask %q caveats dropped safehouse:\n%s", cask.Name, cask.Caveats) - } + assertSafehouseCaveat(t, cask.Name, cask.Caveats) if strings.Contains(cask.Caveats, "agentsview") { t.Errorf("cask %q still lists agentsview in caveats; it moved to a dependency:\n%s", cask.Name, cask.Caveats) } diff --git a/internal/safehouse/safehouse.go b/internal/safehouse/safehouse.go index ac9be0a0..21b3b4b2 100644 --- a/internal/safehouse/safehouse.go +++ b/internal/safehouse/safehouse.go @@ -12,7 +12,7 @@ import ( // installHint is the pinned, actionable stderr message emitted when a workdir // carries a .safehouse profile but the safehouse binary is not resolvable. const installHint = "Spacedock: this directory has a .safehouse profile but the `safehouse` binary was not found on PATH. " + - "Install safehouse (https://github.com/anthropics/safehouse) or remove .safehouse to launch without it." + "Install safehouse (brew install eugene1g/safehouse/agent-safehouse; https://agent-safehouse.dev) or remove .safehouse to launch without it." // Present reports whether a .safehouse profile exists in workdir. A regular file // or a directory both count (os.Stat truthiness) — the profile may be either. diff --git a/internal/safehouse/safehouse_test.go b/internal/safehouse/safehouse_test.go index 6cc759e0..36f2abf7 100644 --- a/internal/safehouse/safehouse_test.go +++ b/internal/safehouse/safehouse_test.go @@ -6,6 +6,7 @@ import ( "errors" "os" "path/filepath" + "strings" "testing" ) @@ -53,15 +54,22 @@ func TestAvailableResolvable(t *testing.T) { } // TestAvailableNotFound: a lookPath that fails → not ok, with a pinned install -// hint naming the safehouse binary for stderr. +// hint that cites safehouse's real third-party-tap brew install command and its +// canonical site, and never the outdated github.com/anthropics/safehouse URL. func TestAvailableNotFound(t *testing.T) { look := func(string) (string, error) { return "", errors.New("not found") } ok, hint := Available(look) if ok { t.Fatalf("Available ok=true, want false when lookPath fails") } - if hint == "" { - t.Fatalf("Available hint empty, want a pinned install hint") + if !strings.Contains(hint, "brew install eugene1g/safehouse/agent-safehouse") { + t.Errorf("Available hint %q does not name the brew install command", hint) + } + if !strings.Contains(hint, "https://agent-safehouse.dev") { + t.Errorf("Available hint %q does not name the canonical safehouse site", hint) + } + if strings.Contains(hint, "github.com/anthropics/safehouse") { + t.Errorf("Available hint %q still cites the outdated github.com/anthropics/safehouse URL", hint) } }