From bf74a0e44983743a11fc3b526ee37dad02eadba6 Mon Sep 17 00:00:00 2001 From: Luther Monson Date: Wed, 8 Jul 2026 00:04:06 -0700 Subject: [PATCH] fix(native): use host Homebrew (read-only) instead of empty per-job prefix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Native macOS jobs were given a per-job Homebrew prefix (HOMEBREW_PREFIX=/homebrew) with an empty Cellar, and only the host's brew *binaries* were symlinked into PATH — not the formula metadata. So `brew list`-style checks (e.g. `spc doctor`, which php-sdk's macOS build runs) queried the empty per-job Cellar, concluded nothing was installed, and tried to (re)install formulae the host already had — which then failed because the sandbox denies writes to /opt/homebrew. Point HOMEBREW_PREFIX/HOMEBREW_CELLAR at the host's real /opt/homebrew instead. Jobs now SEE the host's installed formulae (so tool checks pass) but still cannot mutate them (the sandbox write-deny stays), which is how a normal shared macOS runner behaves. Drops the now-pointless per-job homebrew dir + symlink step. Verified: a php-sdk macOS SDK build now runs green end-to-end on the native path (spc doctor / LLVM / build libphp.a / package / upload), where it previously failed at deps setup. Note: this makes the host's Homebrew the source of truth for native build tooling — deps must be installed AND kept current on the host, since jobs can't `brew install`/upgrade. Follow-up: a host-provisioning dep list + periodic `brew upgrade` so upstream formula bumps don't regress builds. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01ChVRC9ZrvQarrctDwSy1S5 --- pkg/native/native_darwin.go | 44 ++++++++------------------------ pkg/native/native_darwin_test.go | 6 ++--- 2 files changed, 13 insertions(+), 37 deletions(-) diff --git a/pkg/native/native_darwin.go b/pkg/native/native_darwin.go index 8e30795..ead4b63 100644 --- a/pkg/native/native_darwin.go +++ b/pkg/native/native_darwin.go @@ -220,8 +220,6 @@ func New(dataDir, jobID, jitConfig, runnerSrc string, log *slog.Logger) (*Runner filepath.Join(jobDir, "tmp"), filepath.Join(jobDir, "work"), filepath.Join(jobDir, "runner"), - filepath.Join(jobDir, "homebrew", "bin"), - filepath.Join(jobDir, "homebrew", "Cellar"), filepath.Join(jobDir, "keychain"), } for _, d := range dirs { @@ -264,25 +262,25 @@ func (r *Runner) Start(ctx context.Context) error { // Non-fatal: jobs that don't need signing will work fine } - // Symlink Homebrew binaries from host - if err := symlinkHomebrew(filepath.Join(r.jobDir, "homebrew", "bin")); err != nil { - r.log.Warn("failed to symlink homebrew binaries", "error", err) - // Non-fatal: host may not have Homebrew installed - } - // Build environment homeDir := filepath.Join(r.jobDir, "home") tmpDir := filepath.Join(r.jobDir, "tmp") workDir := filepath.Join(r.jobDir, "work") - brewDir := filepath.Join(r.jobDir, "homebrew") + // Use the host's real Homebrew (read-only: the sandbox denies writes to + // /opt/homebrew). Pointing HOMEBREW_PREFIX/CELLAR at a per-job empty prefix + // made `brew list`-style checks (e.g. spc doctor) see nothing installed, so + // tools tried to (re)install formulae the host already has — then failed on + // the write-deny. Sharing the host prefix read-only lets jobs USE the + // pre-installed deps without being able to mutate them. + const hostBrewPrefix = "/opt/homebrew" env := []string{ "HOME=" + homeDir, "TMPDIR=" + tmpDir, "RUNNER_WORK_FOLDER=" + workDir, - "PATH=" + filepath.Join(brewDir, "bin") + ":/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin", - "HOMEBREW_PREFIX=" + brewDir, - "HOMEBREW_CELLAR=" + filepath.Join(brewDir, "Cellar"), + "PATH=" + hostBrewPrefix + "/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin", + "HOMEBREW_PREFIX=" + hostBrewPrefix, + "HOMEBREW_CELLAR=" + hostBrewPrefix + "/Cellar", "HOMEBREW_TEMP=" + tmpDir, "LANG=en_US.UTF-8", } @@ -545,28 +543,6 @@ func GenerateSandboxProfile(jobDir, dataDir string) string { `, homeDir, absDataDir, absJobDir) } -// symlinkHomebrew creates symlinks from /opt/homebrew/bin/* into the -// per-job homebrew bin directory, giving jobs read access to pre-installed -// tools while keeping their own installs isolated. -func symlinkHomebrew(destBin string) error { - const hostBin = "/opt/homebrew/bin" - entries, err := os.ReadDir(hostBin) - if err != nil { - return fmt.Errorf("reading %s: %w", hostBin, err) - } - for _, e := range entries { - src := filepath.Join(hostBin, e.Name()) - dst := filepath.Join(destBin, e.Name()) - if err := os.Symlink(src, dst); err != nil { - // Skip if symlink already exists - if !os.IsExist(err) { - return fmt.Errorf("symlinking %s: %w", e.Name(), err) - } - } - } - return nil -} - // copyRunnerFiles copies the runner directory to the per-job location. // Uses hard links for efficiency, falling back to full copy on error. func copyRunnerFiles(src, dst string) error { diff --git a/pkg/native/native_darwin_test.go b/pkg/native/native_darwin_test.go index e22d0a9..b20407b 100644 --- a/pkg/native/native_darwin_test.go +++ b/pkg/native/native_darwin_test.go @@ -112,14 +112,14 @@ func TestNewCreatesWorkspace(t *testing.T) { t.Fatalf("New() error: %v", err) } - // Verify expected directories exist + // Verify expected directories exist. Note: there is no per-job "homebrew" + // dir — native jobs use the host's shared /opt/homebrew (read-only) so tool + // checks like `spc doctor` see the host's installed formulae. expectedDirs := []string{ "home", "tmp", "work", "runner", - filepath.Join("homebrew", "bin"), - filepath.Join("homebrew", "Cellar"), "keychain", } for _, d := range expectedDirs {