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
54 changes: 48 additions & 6 deletions lib/dev/agent_bootstrap.rb
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,17 @@ class StepFailedError < RuntimeError; end
# The sudoers drop-in carrying the one-way spawn edge.
SUDOERS_PATH = "/etc/sudoers.d/ai-flow-agent"

# Where brew lives on supported hosts, in discovery order (Apple
# Silicon, Intel mac, Linuxbrew).
BREW_LOCATIONS = T.let(
[
"/opt/homebrew/bin/brew",
"/usr/local/bin/brew",
"/home/linuxbrew/.linuxbrew/bin/brew",
].freeze,
T::Array[String],
)

# The shared DDC directory under the shared root. UE disables a shared
# cache store whose path is missing rather than creating it (probed on
# ue5-mac 5.8), so register provisions the leaf; cellbound-3d's committed
Expand Down Expand Up @@ -132,6 +143,8 @@ def quiet?(*cmd)
# @param shared_root [String] where the shared data root is provisioned
# @param home_dev [String] the per-user data dir migrated out of
# @param launch_agents_dir [String] where svc.sh installs runner plists
# @param brew_executable [String, nil] the host's brew binary (discovered
# from BREW_LOCATIONS; injectable for tests; nil = no brew edge)
sig do
params(
agent_user: String,
Expand All @@ -142,12 +155,14 @@ def quiet?(*cmd)
shared_root: String,
home_dev: String,
launch_agents_dir: String,
brew_executable: T.nilable(String),
).void
end
def initialize(agent_user: DEFAULT_AGENT_USER, runner_user: T.must(Etc.getpwuid(Process.uid)).name,
executor: Executor.new, out: $stdout, darwin: RUBY_PLATFORM.include?("darwin"),
shared_root: DataRoot::SHARED_ROOT, home_dev: File.expand_path(DataRoot::HOME_ROOT),
launch_agents_dir: File.join(Dir.home, "Library", "LaunchAgents"))
launch_agents_dir: File.join(Dir.home, "Library", "LaunchAgents"),
brew_executable: BREW_LOCATIONS.find { |path| File.exist?(path) })
@agent_user = agent_user
@runner_user = runner_user
@executor = executor
Expand All @@ -156,6 +171,7 @@ def initialize(agent_user: DEFAULT_AGENT_USER, runner_user: T.must(Etc.getpwuid(
@shared_root = shared_root
@home_dev = home_dev
@launch_agents_dir = launch_agents_dir
@brew_executable = brew_executable
end

# Converge the host-singular facts: agent user, group, sudoers edge,
Expand Down Expand Up @@ -237,19 +253,45 @@ def agent_config_content(existing)
# The sudoers drop-in content: the one-way NOPASSWD SETENV edge (env is
# allowlisted by the caller's --preserve-env, which is why SETENV is
# safe here), plus the agent-side umask defaults that keep agent-created
# dirs group-accessible for the dispatcher's cleanup and `git add`.
# dirs group-accessible for the dispatcher's cleanup and `git add`,
# plus the brew escalation edge (when the host has brew).
#
# @return [String]
sig { returns(String) }
def sudoers_content
<<~SUDOERS
#{@runner_user} ALL=(#{@agent_user}) NOPASSWD:SETENV: ALL
Defaults>#{@agent_user} env_reset, umask=0002, umask_override
SUDOERS
lines = [
"#{@runner_user} ALL=(#{@agent_user}) NOPASSWD:SETENV: ALL",
"Defaults>#{@agent_user} env_reset, umask=0002, umask_override",
]
edge = brew_edge
lines << edge if edge
"#{lines.join("\n")}\n"
end

private

# The brew escalation edge (the Homebrew single-user gap on cooperative
# machines): the agent may run exactly brew as the prefix owner,
# NOPASSWD, so `dev install-deps` converges formulae without a human
# even though the prefix belongs to the enrolling user. The owner is
# stat'd here at bootstrap time — never hardcoded — and re-running
# register re-converges the edge. Omitted when the host has no brew or
# the agent owns the prefix itself (nothing to escalate). Consumed by
# Dev::Deps::BrewIntegration's `sudo -n` escalation.
#
# @return [String, nil]
sig { returns(T.nilable(String)) }
def brew_edge
brew = @brew_executable
return nil unless brew && File.exist?(brew)

prefix = File.dirname(File.dirname(brew))
owner = T.must(Etc.getpwuid(File.stat(prefix).uid)).name
return nil if owner == @agent_user

"#{@agent_user} ALL=(#{owner}) NOPASSWD: #{brew}"
end

# @raise [UnsupportedPlatformError] off macOS
sig { void }
def assert_darwin!
Expand Down
96 changes: 85 additions & 11 deletions lib/dev/deps/brew_integration.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
# typed: strict
# frozen_string_literal: true

require "etc"
require "open3"
require "pathname"
require "uri"
Expand All @@ -15,6 +16,15 @@ module Deps
# install_all installs each formula/cask via brew. Registers taps
# (if configured) before the first install.
#
# Homebrew assumes exactly one non-root owner of exactly one prefix, so
# on cooperative shared machines (plans#26) a sandboxed agent user
# cannot write /opt/homebrew. Brew *writes* (tap, install) therefore
# escalate to the prefix owner via `sudo -n` when the prefix is not
# writable by the current user — the owner is stat'd at runtime, never
# hardcoded, and the NOPASSWD edge is converged by the agent host
# bootstrap (Dev::AgentBootstrap). On a single-user machine the prefix
# is writable and nothing changes.
#
# Env filtering (install vs skip based on ci/dev) is the caller's
# responsibility — only pass deps that should be installed.
class BrewIntegration < Integration
Expand All @@ -27,19 +37,24 @@ class TapRegistrationError < StandardError; end
# @param cache [Cache, nil] shared download cache
# @param taps [Array<Tap>] Homebrew taps to register before installing
# @param project_dir [String, Pathname, nil] project root for resolving file:// tap URLs
# @param brew_prefix [String, Pathname, nil] the Homebrew prefix
# (discovered via `brew --prefix` when nil; injectable for tests)
sig do
params(
repository: T.nilable(Repository),
cache: T.nilable(Cache),
taps: T::Array[Tap],
project_dir: T.nilable(T.any(String, Pathname)),
brew_prefix: T.nilable(T.any(String, Pathname)),
).void
end
def initialize(repository:, cache:, taps: [], project_dir: nil)
def initialize(repository:, cache:, taps: [], project_dir: nil, brew_prefix: nil)
super(repository:, cache:)
@taps = taps
@project_dir = T.let(project_dir ? Pathname(project_dir) : nil, T.nilable(Pathname))
@taps_registered = T.let(false, T::Boolean)
@brew_prefix = T.let(brew_prefix&.to_s, T.nilable(String))
@brew_prefix_resolved = T.let(!brew_prefix.nil?, T::Boolean)
end

# Install all brew dependencies. Registers taps on first call.
Expand Down Expand Up @@ -76,7 +91,8 @@ def ensure_taps_registered
@taps_registered = true
end

# Register a single Homebrew tap.
# Register a single Homebrew tap (escalated to the prefix owner when
# the prefix is not ours — see the class doc).
#
# @param tap [Tap] tap to register
# @raise [TapRegistrationError] if `brew tap` fails
Expand All @@ -86,15 +102,15 @@ def register_tap(tap)
url = tap.url
if tap.local? && project_dir && url
path = resolve_file_url(url, project_dir)
success = system("brew", "tap", tap.name, path)
raise TapRegistrationError, "brew tap #{tap.name} #{path} failed" unless success
success = system(*T.unsafe(escalation), "brew", "tap", tap.name, path)
raise TapRegistrationError, "brew tap #{tap.name} #{path} failed#{escalation_hint}" unless success
elsif url
url_str = url.to_s
success = system("brew", "tap", tap.name, url_str)
raise TapRegistrationError, "brew tap #{tap.name} #{url_str} failed" unless success
success = system(*T.unsafe(escalation), "brew", "tap", tap.name, url_str)
raise TapRegistrationError, "brew tap #{tap.name} #{url_str} failed#{escalation_hint}" unless success
else
success = system("brew", "tap", tap.name)
raise TapRegistrationError, "brew tap #{tap.name} failed" unless success
success = system(*T.unsafe(escalation), "brew", "tap", tap.name)
raise TapRegistrationError, "brew tap #{tap.name} failed#{escalation_hint}" unless success
end
end

Expand Down Expand Up @@ -163,15 +179,73 @@ def brew_installed?(name)
system("brew list #{name} >/dev/null 2>&1")
end

# Run `brew install` with the given spec.
# Run `brew install` with the given spec (escalated to the prefix
# owner when the prefix is not ours — see the class doc).
#
# @param name [String] dependency name (for error messages)
# @param spec [String] full install spec (e.g. "cmake@3.31.4")
# @raise [InstallError] if brew exits non-zero
sig { params(name: String, spec: String).void }
def run_brew_install(name, spec)
_out, err, status = T.unsafe(Open3).capture3("brew", "install", *spec.split)
raise InstallError, "brew install #{spec} failed: #{err}" unless status.success?
_out, err, status = T.unsafe(Open3).capture3(*escalation, "brew", "install", *spec.split)
return if status.success?

if sudo_refused?(err)
raise InstallError,
"brew install #{spec} needs the prefix owner and sudo -n was refused — " \
"the brew sudoers edge is missing on this host; run `dev runner register` to re-converge it"
end

raise InstallError, "brew install #{spec} failed: #{err}"
end

# argv prefix for brew write commands: empty when the prefix is
# writable by the current user, `sudo -n` to the stat'd prefix owner
# otherwise. -n so a missing sudoers edge fails fast instead of
# hanging on a password prompt no one is watching.
#
# @return [Array<String>]
sig { returns(T::Array[String]) }
def escalation
prefix = brew_prefix
return [] if prefix.nil? || File.writable?(prefix)

["sudo", "-n", "-u", T.must(Etc.getpwuid(File.stat(prefix).uid)).name]
end

# Remediation appended to escalated-write failures: the two host facts
# that break them (missing sudoers edge, a path the owner cannot read).
#
# @return [String] "" when not escalated
sig { returns(String) }
def escalation_hint
return "" if escalation.empty?

" (escalated to the brew prefix owner: ensure any local tap path is readable " \
"by them and the sudoers brew edge exists — run `dev runner register` to re-converge it)"
end

# The Homebrew prefix, resolved once: injected (tests), else asked of
# brew itself. nil when brew is absent — writes then run unescalated
# and surface brew's own error.
#
# @return [String, nil]
sig { returns(T.nilable(String)) }
def brew_prefix
return @brew_prefix if @brew_prefix_resolved

@brew_prefix_resolved = true
out, _err, status = Open3.capture3("brew", "--prefix")
@brew_prefix = (out.strip if status.success? && !out.strip.empty?)
rescue Errno::ENOENT
@brew_prefix = nil
end

# @param err [String] a brew invocation's stderr
# @return [Boolean] whether sudo -n refused for lack of a NOPASSWD rule
sig { params(err: String).returns(T::Boolean) }
def sudo_refused?(err)
err.include?("a password is required")
end
end
end
Expand Down
48 changes: 48 additions & 0 deletions test/dev/agent_bootstrap_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

require "test_helper"
require "dev/agent_bootstrap"
require "etc"
require "stringio"
require "tmpdir"

Expand Down Expand Up @@ -493,6 +494,53 @@ def converged_identity_executor
content.end_with?("\n")
end

test "sudoers content grants the agent a brew edge to the prefix owner" do
Given "a host with brew at a known prefix owned by the human"
dir = Dir.mktmpdir
brew = File.join(dir, "bin", "brew")
FileUtils.mkdir_p(File.dirname(brew))
File.write(brew, "")
owner = Etc.getpwuid(File.stat(dir).uid).name
content = Dev::AgentBootstrap.new(
runner_user: "human", darwin: true, brew_executable: brew,
).sudoers_content

Expect "the agent may run exactly brew as the prefix owner, NOPASSWD"
content.include?("ai-agent ALL=(#{owner}) NOPASSWD: #{brew}")

Cleanup
FileUtils.rm_rf(dir)
end

test "sudoers content omits the brew edge when brew is absent" do
Given "a host with no brew executable"
content = Dev::AgentBootstrap.new(
runner_user: "human", darwin: true,
brew_executable: File.join(Dir.mktmpdir, "no-brew"),
).sudoers_content

Expect "only the spawn edge and the umask defaults remain"
content.lines.size == 2
end

test "sudoers content omits the brew edge when the agent already owns the prefix" do
Given "a brew prefix owned by the agent user itself"
dir = Dir.mktmpdir
brew = File.join(dir, "bin", "brew")
FileUtils.mkdir_p(File.dirname(brew))
File.write(brew, "")
owner = Etc.getpwuid(File.stat(dir).uid).name
content = Dev::AgentBootstrap.new(
runner_user: "human", agent_user: owner, darwin: true, brew_executable: brew,
).sudoers_content

Expect "no self-edge is emitted"
!content.include?("NOPASSWD: #{brew}")

Cleanup
FileUtils.rm_rf(dir)
end

test "the agent user is a register-time parameter" do
Given "an overridden run-as user"
executor = RecordedBootstrapExecutor.new
Expand Down
Loading
Loading