Skip to content

Commit af3538d

Browse files
committed
Add as-console-user command
- Support MDM/Munki/Jamf runs where the parent process is root but Homebrew must execute as the logged-in macOS console user. - Keep the nested operation constrained to `HOMEBREW_BREW_FILE` so this helper cannot become a general-purpose root command launcher. - Share `macos_user.sh` with installer scripts so no-user and package plist fallback behaviour cannot drift between install paths. - Stage `macos_user.sh` for `pkgbuild` because `preinstall` needs the resolver before the Homebrew payload has been installed.
1 parent 6c8be83 commit af3538d

8 files changed

Lines changed: 348 additions & 17 deletions

File tree

.github/workflows/release.yml

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ on:
77
- "**"
88
paths:
99
- .github/workflows/release.yml
10+
- Library/Homebrew/utils/macos_user.sh
1011
- package/**/*
1112
workflow_dispatch:
1213
inputs:
@@ -143,13 +144,24 @@ jobs:
143144
- name: Open macOS keychain
144145
run: security list-keychain -d user -s "${RUNNER_TEMP}/${TEMPORARY_KEYCHAIN_FILE}"
145146

147+
- name: Prepare Homebrew installer scripts
148+
run: |
149+
installer_scripts="${RUNNER_TEMP}/homebrew-installer-scripts"
150+
rm -rvf "${installer_scripts}"
151+
mkdir -vp "${installer_scripts}"
152+
cp -vp brew/package/scripts/preinstall \
153+
brew/package/scripts/postinstall \
154+
brew/Library/Homebrew/utils/macos_user.sh \
155+
"${installer_scripts}/"
156+
echo "HOMEBREW_INSTALLER_SCRIPTS=${installer_scripts}" >> "${GITHUB_ENV}"
157+
146158
- name: Build Homebrew installer component package
147159
env:
148160
HOMEBREW_VERSION: ${{ steps.homebrew-version.outputs.version }}
149161
# Note: `Library/Homebrew/test/support/fixtures/` contains unsigned
150162
# binaries so it needs to be excluded from notarization.
151163
run: pkgbuild --root brew
152-
--scripts brew/package/scripts
164+
--scripts "${HOMEBREW_INSTALLER_SCRIPTS}"
153165
--identifier sh.brew.homebrew
154166
--version "${HOMEBREW_VERSION}"
155167
--install-location /opt/homebrew

Library/Homebrew/brew.sh

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,10 @@ check-run-command-as-root() {
239239
[[ -f /run/.containerenv ]] && return
240240
[[ -f /proc/1/cgroup ]] && grep -E "azpl_job|actions_job|docker|garden|kubepods" -q /proc/1/cgroup && return
241241

242+
# `brew as-console-user` is run by root-owned MDM/Munki/Jamf workflows so it
243+
# can immediately dispatch the requested Homebrew command as the console user.
244+
[[ "${HOMEBREW_COMMAND}" == "as-console-user" ]] && return
245+
242246
# `brew services` may need `sudo` for system-wide daemons.
243247
if [[ "${HOMEBREW_COMMAND}" == "services" ]]
244248
then
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# typed: strict
2+
# frozen_string_literal: true
3+
4+
require "abstract_command"
5+
require "shell_command"
6+
7+
module Homebrew
8+
module Cmd
9+
class AsConsoleUser < AbstractCommand
10+
include ShellCommand
11+
12+
cmd_args do
13+
usage_banner <<~EOS
14+
`as-console-user` <command> [<args> ...]
15+
16+
Run a Homebrew command as the active macOS console user.
17+
18+
This is intended for MDM, Munki and Jamf workflows where `brew` is
19+
invoked as root but Homebrew operations should run as the logged-in
20+
console user. The nested command is always dispatched through
21+
`HOMEBREW_BREW_FILE`.
22+
EOS
23+
24+
named_args min: 1
25+
end
26+
end
27+
end
28+
end
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
# Documentation defined in Library/Homebrew/cmd/as-console-user.rb
2+
3+
# `HOMEBREW_*` variables are set by brew.sh before sourcing this command.
4+
# shellcheck disable=SC2154
5+
homebrew-as-console-user() {
6+
case "${1:-}" in
7+
--help | -h | --usage | "-?")
8+
"${HOMEBREW_BREW_FILE}" help as-console-user
9+
return
10+
;;
11+
*) ;;
12+
esac
13+
14+
if [[ "$#" -eq 0 ]]
15+
then
16+
"${HOMEBREW_BREW_FILE}" help as-console-user
17+
return 1
18+
fi
19+
20+
[[ -n "${HOMEBREW_MACOS}" ]] || odie "\`brew as-console-user\` is only supported on macOS."
21+
22+
# `HOMEBREW_LIBRARY` is set by brew.sh, so ShellCheck cannot follow it.
23+
# shellcheck disable=SC1091
24+
source "${HOMEBREW_LIBRARY}/Homebrew/utils/macos_user.sh"
25+
26+
local console_user
27+
console_user="$(homebrew-console-user)" || odie "No supported macOS console user is logged in."
28+
29+
local console_home
30+
console_home="$(homebrew-user-home "${console_user}")" ||
31+
odie "Could not determine home directory for console user: ${console_user}"
32+
33+
(
34+
cd "${console_home}" &>/dev/null || odie "Failed to cd to ${console_home}!"
35+
36+
sudo -H -u "${console_user}" /usr/bin/env -i \
37+
"HOME=${console_home}" \
38+
"USER=${console_user}" \
39+
"LOGNAME=${console_user}" \
40+
"PWD=${console_home}" \
41+
"PATH=/usr/bin:/bin:/usr/sbin:/sbin" \
42+
"${HOMEBREW_BREW_FILE}" "$@"
43+
)
44+
}
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
# typed: false
2+
# frozen_string_literal: true
3+
4+
require "open3"
5+
6+
require "cmd/shared_examples/args_parse"
7+
require "cmd/as-console-user"
8+
9+
RSpec.describe Homebrew::Cmd::AsConsoleUser do
10+
let(:as_console_user_script) { HOMEBREW_LIBRARY_PATH/"cmd/as-console-user.sh" }
11+
let(:repository_root) { HOMEBREW_LIBRARY_PATH.parent.parent }
12+
let(:test_root) { mktmpdir }
13+
let(:macos_user_script) { repository_root/"Library/Homebrew/utils/macos_user.sh" }
14+
15+
let(:macos_env) do
16+
{
17+
"HOMEBREW_BREW_FILE" => "brew",
18+
"HOMEBREW_LIBRARY" => (repository_root/"Library").to_s,
19+
"HOMEBREW_MACOS" => "1",
20+
}
21+
end
22+
23+
it_behaves_like "parseable arguments"
24+
25+
def run_as_console_user_shell(script, env = {})
26+
Bundler.with_unbundled_env do
27+
Open3.capture3(env, "/bin/bash", "-c", script)
28+
end
29+
end
30+
31+
it "prints help and fails when no command is provided" do
32+
stdout, stderr, status = run_as_console_user_shell(
33+
<<~SH,
34+
source "#{as_console_user_script}"
35+
brew() { printf '%s\\n' "$*" >&2; }
36+
homebrew-as-console-user
37+
SH
38+
"HOMEBREW_BREW_FILE" => "brew",
39+
)
40+
41+
expect(status.exitstatus).to eq 1
42+
expect(stdout).to be_empty
43+
expect(stderr).to eq("help as-console-user\n")
44+
end
45+
46+
it "rejects a root console user" do
47+
stdout, stderr, status = run_as_console_user_shell(
48+
<<~SH,
49+
source "#{as_console_user_script}"
50+
odie() { echo "Error: $*" >&2; exit 1; }
51+
stat() { printf 'root\\n'; }
52+
homebrew-as-console-user install wget
53+
SH
54+
macos_env,
55+
)
56+
57+
expect(status.exitstatus).to eq 1
58+
expect(stdout).to be_empty
59+
expect(stderr).to eq("Error: No supported macOS console user is logged in.\n")
60+
end
61+
62+
it "rejects a loginwindow console user" do
63+
stdout, stderr, status = run_as_console_user_shell(
64+
<<~SH,
65+
source "#{as_console_user_script}"
66+
odie() { echo "Error: $*" >&2; exit 1; }
67+
stat() { printf 'loginwindow\\n'; }
68+
homebrew-as-console-user install wget
69+
SH
70+
macos_env,
71+
)
72+
73+
expect(status.exitstatus).to eq 1
74+
expect(stdout).to be_empty
75+
expect(stderr).to eq("Error: No supported macOS console user is logged in.\n")
76+
end
77+
78+
it "rejects non-macOS systems" do
79+
stdout, stderr, status = run_as_console_user_shell(
80+
<<~SH,
81+
source "#{as_console_user_script}"
82+
odie() { echo "Error: $*" >&2; exit 1; }
83+
homebrew-as-console-user install wget
84+
SH
85+
"HOMEBREW_BREW_FILE" => "brew",
86+
)
87+
88+
expect(status.exitstatus).to eq 1
89+
expect(stdout).to be_empty
90+
expect(stderr).to eq("Error: `brew as-console-user` is only supported on macOS.\n")
91+
end
92+
93+
it "uses the package user plist before the console user" do
94+
homebrew_pkg_user_plist = test_root/".homebrew_pkg_user.plist"
95+
homebrew_pkg_user_plist.write "plist"
96+
97+
stdout, stderr, status = run_as_console_user_shell(
98+
<<~SH,
99+
source "#{macos_user_script}"
100+
defaults() { printf 'munki\\n'; }
101+
stat() { printf 'root\\n'; }
102+
homebrew-package-user
103+
SH
104+
"HOMEBREW_PKG_USER_PLIST" => homebrew_pkg_user_plist.to_s,
105+
)
106+
107+
expect(status.success?).to be true
108+
expect(stdout).to eq("munki\n")
109+
expect(stderr).to be_empty
110+
end
111+
112+
it "falls back to the console user without a package user plist" do
113+
stdout, stderr, status = run_as_console_user_shell <<~SH
114+
source "#{macos_user_script}"
115+
stat() { printf 'mike\\n'; }
116+
homebrew-package-user
117+
SH
118+
119+
expect(status.success?).to be true
120+
expect(stdout).to eq("mike\n")
121+
expect(stderr).to be_empty
122+
end
123+
124+
it "rejects package user lookup without a package user or console user" do
125+
stdout, stderr, status = run_as_console_user_shell <<~SH
126+
source "#{macos_user_script}"
127+
stat() { printf 'root\\n'; }
128+
homebrew-package-user
129+
SH
130+
131+
expect(status.exitstatus).to eq 1
132+
expect(stdout).to be_empty
133+
expect(stderr).to be_empty
134+
end
135+
136+
it "dispatches the nested brew command as the console user" do
137+
args_file = test_root/"sudo-args.txt"
138+
console_home = test_root/"console-home"
139+
console_home.mkpath
140+
141+
stdout, stderr, status = run_as_console_user_shell(
142+
<<~SH,
143+
source "#{as_console_user_script}"
144+
odie() { echo "Error: $*" >&2; exit 1; }
145+
stat() { printf 'mike\\n'; }
146+
id() { printf 'mike:*:501:20::0:0:Mike:#{console_home}:/bin/zsh\\n'; }
147+
sudo() {
148+
printf 'cwd=%s\\n' "$PWD" > "#{args_file}"
149+
printf '%s\\n' "$@" >> "#{args_file}"
150+
return 42
151+
}
152+
homebrew-as-console-user upgrade git --minimum-version=2.50.1
153+
SH
154+
macos_env.merge("HOMEBREW_BREW_FILE" => "/opt/homebrew/bin/brew"),
155+
)
156+
157+
expect(status.exitstatus).to eq 42
158+
expect(stdout).to be_empty
159+
expect(stderr).to be_empty
160+
expect(args_file.read).to eq <<~EOS
161+
cwd=#{console_home}
162+
-H
163+
-u
164+
mike
165+
/usr/bin/env
166+
-i
167+
HOME=#{console_home}
168+
USER=mike
169+
LOGNAME=mike
170+
PWD=#{console_home}
171+
PATH=/usr/bin:/bin:/usr/sbin:/sbin
172+
/opt/homebrew/bin/brew
173+
upgrade
174+
git
175+
--minimum-version=2.50.1
176+
EOS
177+
end
178+
end
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
# Used by `brew as-console-user` and macOS installer package scripts.
2+
# Keep this standalone: package scripts source it before Homebrew is installed.
3+
4+
# Print the active macOS console user, or fail for login-window/system users.
5+
homebrew-console-user() {
6+
local console_user
7+
console_user="$(stat -f "%Su" /dev/console 2>/dev/null)" || return 1
8+
9+
case "${console_user}" in
10+
"" | root | loginwindow | _mbsetupuser)
11+
return 1
12+
;;
13+
*) ;;
14+
esac
15+
16+
echo "${console_user}"
17+
}
18+
19+
# Print a user's home directory from the local account database.
20+
homebrew-user-home() {
21+
local user_record
22+
user_record="$(id -P "$1" 2>/dev/null)" || return 1
23+
user_record="${user_record%:*}"
24+
user_record="${user_record##*:}"
25+
[[ -n "${user_record}" ]] || return 1
26+
27+
echo "${user_record}"
28+
}
29+
30+
# Print the package install user, preferring MDM's plist override.
31+
homebrew-package-user() {
32+
local homebrew_pkg_user_plist="${HOMEBREW_PKG_USER_PLIST:-/var/tmp/.homebrew_pkg_user.plist}"
33+
if [[ -f "${homebrew_pkg_user_plist}" ]]
34+
then
35+
local homebrew_pkg_user
36+
if homebrew_pkg_user="$(defaults read "${homebrew_pkg_user_plist}" HOMEBREW_PKG_USER 2>/dev/null)" &&
37+
[[ -n "${homebrew_pkg_user}" ]]
38+
then
39+
echo "${homebrew_pkg_user}"
40+
return
41+
fi
42+
fi
43+
44+
# Fall back to the active console user when MDM has not specified one.
45+
homebrew-console-user
46+
}

package/scripts/postinstall

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,18 @@
55
# $4 System root directory (unused)
66
set -euo pipefail
77

8+
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" &>/dev/null && pwd -P)"
9+
if [[ -f "${script_dir}/macos_user.sh" ]]
10+
then
11+
# `pkgbuild` stages this helper beside the package scripts.
12+
# shellcheck disable=SC1091
13+
source "${script_dir}/macos_user.sh"
14+
else
15+
# This fallback keeps direct local execution from the repository working.
16+
# shellcheck disable=SC1091
17+
source "${script_dir}/../../Library/Homebrew/utils/macos_user.sh"
18+
fi
19+
820
# disable analytics while installing
921
export HOMEBREW_NO_ANALYTICS_THIS_RUN=1
1022
export HOMEBREW_NO_ANALYTICS_MESSAGE_OUTPUT=1
@@ -72,14 +84,12 @@ fi
7284
mkdir -vp Caskroom Cellar Frameworks etc include lib opt sbin share var/homebrew/linked
7385

7486
# optionally define an install user at /var/tmp/.homebrew_pkg_user.plist
75-
homebrew_pkg_user_plist="/var/tmp/.homebrew_pkg_user.plist"
76-
if [[ -f "${homebrew_pkg_user_plist}" ]] && [[ -n $(defaults read "${homebrew_pkg_user_plist}" HOMEBREW_PKG_USER) ]]
77-
then
78-
homebrew_pkg_user=$(defaults read /var/tmp/.homebrew_pkg_user HOMEBREW_PKG_USER)
7987
# otherwise, get valid logged in user
80-
else
81-
homebrew_pkg_user=$(echo "show State:/Users/ConsoleUser" | scutil | awk '/Name :/ { print $3 }')
82-
fi
88+
homebrew_pkg_user="$(homebrew-package-user)" ||
89+
{
90+
echo "No valid user for Homebrew installation. Log in before install or specify an install user."
91+
exit 1
92+
}
8393

8494
# set permissions
8595
chmod ug=rwx Caskroom Cellar Frameworks bin etc include lib opt sbin share var var/homebrew var/homebrew/linked
@@ -93,7 +103,11 @@ else
93103
fi
94104

95105
# move API cache to ~/Library/Caches/Homebrew
96-
user_home_dir=$(dscl . read /Users/"${homebrew_pkg_user}" NFSHomeDirectory | awk '{ print $2 }')
106+
user_home_dir="$(homebrew-user-home "${homebrew_pkg_user}")" ||
107+
{
108+
echo "No home directory for Homebrew installation user: ${homebrew_pkg_user}"
109+
exit 1
110+
}
97111
user_cache_dir="${user_home_dir}/Library/Caches/Homebrew"
98112
user_api_cache_dir="${user_cache_dir}/api"
99113
mkdir -vp "${user_api_cache_dir}"

0 commit comments

Comments
 (0)