Skip to content

Add per-service user environment variable overrides - #23312

Merged
MikeMcQuaid merged 1 commit into
Homebrew:mainfrom
IngmarStein:service-env-overrides
Aug 1, 2026
Merged

Add per-service user environment variable overrides#23312
MikeMcQuaid merged 1 commit into
Homebrew:mainfrom
IngmarStein:service-env-overrides

Conversation

@IngmarStein

Copy link
Copy Markdown
Contributor
  • Have you followed our Contributing guidelines?
  • Have you checked for other open Pull Requests for the same change?
  • Have you explained what your changes do? Performance claims (e.g. "this is faster") must include Hyperfine benchmarks.
  • Have you explained why you'd like these changes included, not just what they do?
  • For bug fixes, have you given step-by-step brew commands to reproduce the bug?
  • Have you written new tests (excluding integration tests)? Here's an example.
  • Have you successfully run brew lgtm (style, typechecking and tests) locally?

  • AI was used to generate or assist with generating this PR.

This PR was developed with Claude Code (claude-opus-5). AI assistance was used for code exploration, understanding the service plist generation architecture, and drafting the implementation. All AI-generated code was reviewed, typechecked with Sorbet, linted with RuboCop, and verified with the full test suite via brew lgtm --online before submission.


What this does

Adds support for per-service user environment variable overrides via a simple file convention. When brew services start/restart generates a plist or systemd unit, it now checks for $HOMEBREW_USER_CONFIG_HOME/services/<formula>.env (defaults to ~/.homebrew/services/<formula>.env) and merges any KEY=value pairs into the EnvironmentVariables of the generated service file.

Why

The problem: macOS launchd doesn't run a login shell, so environment variables set in .zprofile/.bash_profile are invisible to Homebrew services. Users currently have no supported way to set e.g. OLLAMA_HOST for brew services-managed plists — manually editing the plist is overwritten on every brew services restart, and launchctl setenv doesn't survive reboots without additional machinery.

This has been discussed at length in Homebrew/discussions#6196 since May 2025 with no resolution.

Real-world impact: 51 formulae in homebrew-core use environment_variables in their service blocks. Many hardcode values users need to change:

  • ollama — users want to set OLLAMA_HOST, OLLAMA_ORIGINS, etc. but the formula hardcodes OLLAMA_FLASH_ATTENTION and OLLAMA_KV_CACHE_TYPE
  • teslamate — maintains a wrapper script solely to source #{etc}/teslamate.env before running. With this feature, teslamate could drop the wrapper entirely
  • launch_socket_server — hardcodes LAUNCH_PROGRAM_TCP_ADDRESS: "127.0.0.1:8080" with no way to override
  • hbase — 11 hardcoded env vars for JVM tuning, log paths, and PID directories
  • caddy/vulcain — force XDG_DATA_HOME and HOME to specific paths
  • postgresql@15–18 — force LC_ALL: "en_US.UTF-8" across all four versioned formulae

How it works

The env file location follows the existing ~/.homebrew/ user config pattern (trust.json, livecheck_watchlist.txt, brew.env):

~/.homebrew/services/ollama.env

Format is KEY=value, one per line, with # comments:

# Set Ollama to listen on all interfaces
OLLAMA_HOST=0.0.0.0
OLLAMA_ORIGINS=*

User overrides take precedence over formula-defined variables. The file is read at plist/systemd unit generation time (every start/restart), so changes take effect on the next brew services restart. The file survives brew upgrade because it lives outside the keg.

Implementation

Three files changed in core, two test files updated:

  • service.rb — three new methods: effective_environment_variables (merges formula vars + user overrides), user_env_override_path, and load_user_env_overrides (reads and parses the env file, ignoring comments and blank lines)
  • formula_wrapper.rb — new public service_content method that regenerates plist/systemd content (so the env merge happens on every start/restart, not just on install)
  • cli.rbinstall_service_file now uses service_content instead of reading the pre-generated keg file, so user overrides are always applied
  • 6 new test cases covering: no file, merge, precedence, comments/blank lines, to_plist output, to_systemd_unit output
  • 2 existing tests updated to stub the new service_content method

Copilot AI review requested due to automatic review settings July 26, 2026 14:26

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds support for per-service user environment variable overrides when generating launchd plists and systemd units, so brew services start/restart can incorporate user-provided KEY=value pairs from a config file under HOMEBREW_USER_CONFIG_HOME/services/.

Changes:

  • Add Service#effective_environment_variables and env override file parsing/lookup in service.rb, and apply the merged env to plist/systemd output.
  • Add FormulaWrapper#service_content and update services/cli.rb to install regenerated content rather than reading the pre-generated file.
  • Extend/update specs to cover env override merging and to stub the new service_content API.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
Library/Homebrew/service.rb Implements env override file loading/merging and threads merged env into plist/systemd generation.
Library/Homebrew/services/formula_wrapper.rb Adds service_content to regenerate service definitions at start/restart time.
Library/Homebrew/services/cli.rb Switches service file installation to use regenerated content (and applies sudo-user plist edits to that content).
Library/Homebrew/test/service_spec.rb Adds coverage for env override parsing/merge and inclusion in plist/systemd output.
Library/Homebrew/test/services/cli_spec.rb Updates doubles/stubs to account for the new service_content method.
Comments suppressed due to low confidence (1)

Library/Homebrew/service.rb:564

  • to_systemd_unit calls effective_environment_variables twice, which duplicates the override-file read/parse work. Cache the hash in a local variable before checking present? and building Environment= entries.
      if effective_environment_variables.present?
        options += effective_environment_variables.map do |k, v|
          "Environment=\"#{k}=#{v}\""
        end
      end

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread Library/Homebrew/service.rb Outdated
Comment thread Library/Homebrew/service.rb Outdated

@MikeMcQuaid MikeMcQuaid left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I appreciate this is a common problem but I'm not sure this is the best way to solve it.

As per the discussion: did you try this? https://apple.stackexchange.com/questions/64916/defining-environment-variables-with-launchd-launchctl

What other approaches did you consider and try/reject?

Does this work equally well for launchctl and systemd?

@IngmarStein

This comment was marked as low quality.

@MikeMcQuaid

Copy link
Copy Markdown
Member

@IngmarStein did you write your last response or did an LLM?

@IngmarStein

Copy link
Copy Markdown
Contributor Author

I did the macOS archaeology myself, but let Claude summarize the findings - it's just more eloquent than me.
My human take is: there were many approaches that worked over the last decade, but none that I know of works with any modern macOS version.
I know that generating the launchd plist files with user-overrides is a significant change over copying the precreated files from the keg. I created this PR so that we have one concrete approach to discuss. Totally open to alternatives!

@MikeMcQuaid

Copy link
Copy Markdown
Member

let Claude summarize the findings - it's just more eloquent than me.

It's not. Please don't do that 😁.

Can I get your full answers to those questions written as a human instead? Feel free to use Claude but I want to read your interoperation of what it said, not the other way around.

@IngmarStein

Copy link
Copy Markdown
Contributor Author

Please don't do that 😁.

I actually didn't mean to - it was just a bit eager operating the gh CLI itself :) Apologies.

So here's the gist of it:

  • config files like /etc/launchd.conf, ~/.launchd.conf, or ~/.MacOSX/environment.plist which allowed to set global or per-user environment variables no longer work (this has been the case for many years already). https://apple.stackexchange.com/questions/64916/defining-environment-variables-with-launchd-launchctl mentions that: "However Apple deprecated this functionality years ago. Please skip to the "dynamic solution" farther down."
  • launchctl config user is deliberately limited to PATH. The man page says: "NOTE: This facility cannot be used to set general environment variables for all services within the domain. It is intentionally scoped to the PATH environment variable and nothing else for security reasons.
  • launchctl setenv only affects the current boot session and there's an inherent race condition when trying to use it for other login items (man page: "Specify an environment variable to be set on all future processes launched by launchd in the caller's context." - afaik, we can't guarantee that it runs before other services).

The only feasible approach today is brew services start --file but that requires users to copy the brew-generated plist and keep it in sync with upstream changes. And they must remember to use that same file also for brew services restart --file.

People are creating creating workarounds like https://github.com/ntrlmt/ollama-brew-service-setup, but I think brew could provide a more generic solution.

@MikeMcQuaid

Copy link
Copy Markdown
Member

@IngmarStein Ok, thanks!

Repeating questions above:

  • What other approaches did you consider and try/reject?
  • Does this work equally well for launchctl and systemd?

A change necessary, if not done already:

  • This should either not be permitted at all for root-owned services for now or require it to be under /root
  • Ensure these files are not group or world writable or symlinked to locations that are before usage.

And a new one:

  • What any additional security implications of this? Answer this yourself but this would be a good one to fire e.g. Fable Max at first 😉

@IngmarStein

Copy link
Copy Markdown
Contributor Author

@MikeMcQuaid

What other approaches did you consider and try/reject?

I started with the idea of the global env file (HOMEBREW_SERVICES_ENV_FILE) as in the original discussion. While simple, I discarded it because it is too coarse-grained - it wouldn't allow two services to have different values for the same variable.

I also discarded an ad-hoc --env flag (as in brew services start ollama --env OLLAMA_HOST=0.0.0.0) which may be good for one-off debugging but doesn't solve persistence.

I also thought about placing environment files in $(brew --prefix)/etc/<formula>/service.env or $(brew --prefix)/etc/<formula>.env but discarded that location because of possible collisions with formula-provided files.

I discarded $(brew --prefix)/var/homebrew/services/<formula>.env as an alternative because that doesn't allow for user-level overrides. I think it should be possible for multiple users to run services with their own settings.

A brew services env subcommand could complement the proposed solution:

brew services env ollama --set OLLAMA_HOST=0.0.0.0
brew services env ollama --list
brew services env ollama --unset OLLAMA_HOST

But I left this out of this PR to focus on the key part.

Does this work equally well for launchctl and systemd?

I believe it should, yes.

This should either not be permitted at all for root-owned services for now or require it to be under /root

Isn't that already the case? HOMEBREW_USER_CONFIG_HOME is derived from HOME, i.e. /root or /var/root,

Ensure these files are not group or world writable or symlinked to locations that are before usage.

Done!

What any additional security implications of this? Answer this yourself but this would be a good one to fire e.g. Fable Max at first 😉

Here's the Opus 5 assessment:

No new security implications beyond what the user can already do by manually editing plists in ~/Library/LaunchAgents/. The key points:

- Same trust domain: the env file lives in ~/.homebrew/, which requires the same permissions as the LaunchAgents directory the user already controls
- No path traversal: formula names are strictly constrained, preventing ../ injection
- No shell injection: values go directly into plist <string> elements, never through a shell
- Root services: bin/brew sets HOMEBREW_USER_CONFIG_HOME from the invoking user's home, and the resulting plist in /Library/LaunchDaemons/ is root-owned — no privilege boundary is crossed

I can't think of anything else.

@MikeMcQuaid

Copy link
Copy Markdown
Member

Thanks!

I believe it should, yes.

Which did you personally verify this on: launchd or systemd?

Isn't that already the case? HOMEBREW_USER_CONFIG_HOME is derived from HOME, i.e. /root or /var/root,

HOME isn't always set to /root when using sudo.

Here's the Opus 5 assessment:

I'm not going to read this. Don't give me the AI assessment, give me your assessment based on reading the AI assessment and rewritten into words you fully agree with.

This is strike two: if I have to point this out again I'm going to have to close this PR I'm afraid.

It's your job to read and interpret the output of your AI, not mine. If I have to read the output from your AI, I may as well close this out and just prompt it myself.

@IngmarStein

Copy link
Copy Markdown
Contributor Author

Which did you personally verify this on: launchd or systemd?

launchd. I don't have a Linux box around to test. There are unit tests for system and the general approach is identical.

HOME isn't always set to /root when using sudo.

Ah, sudo -E, you're right. I added a file ownership check (the environment file must be owned by root if brew runs as root). Thanks!

I'm not going to read this. Don't give me the AI assessment, give me your assessment based on reading the AI assessment and rewritten into words you fully agree with.

I should have changed the order. My assessment ("I can't think of anything else.") was right below the quote which I meant as an appendix as you asked for it.

The main thing that changes is that previously, brew only copied the launchd/systemd config file from a keg and now it generates those at the time brew services runs (similar to the existing brew.env) and as part of that, it merges in content from other files. We made sure that those other files don't have too wide write permissions, must be owned as root when run as root, and symlinks are resolved. Can you think of other attack vectors?

@MikeMcQuaid

Copy link
Copy Markdown
Member

Thanks @IngmarStein!

The main thing that changes is that previously, brew only copied the launchd/systemd config file from a keg and now it generates those at the time brew services runs (similar to the existing brew.env) and as part of that, it merges in content from other files. We made sure that those other files don't have too wide write permissions, must be owned as root when run as root, and symlinks are resolved. Can you think of other attack vectors?

I can't but pulling in @p-linnane @woodruffw @andrew for @Homebrew/security for their thoughts here.

@p-linnane

Copy link
Copy Markdown
Contributor

I think the root-service path needs changes before merge. realpath follows symlinks. It does not prevent TOCTOU. With caller-influenced HOME or XDG state, an override can select a root-owned KEY=value file and republish its contents in a mode-0644 service definition. The file can also be replaced between the ownership check and each_line.

I reproduced both paths on the current PR head. Overrides should be disabled for root services.

@MikeMcQuaid

Copy link
Copy Markdown
Member

Overrides should be disabled for root services.

Yes, I think this is a safer starting point.

@IngmarStein

Copy link
Copy Markdown
Contributor Author

Makes sense. I've disabled overrides for root services.

@MikeMcQuaid MikeMcQuaid left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks! A few notes.

Comment thread Library/Homebrew/cmd/services.rb Outdated
Comment thread Library/Homebrew/services/cli.rb Outdated
Comment thread Library/Homebrew/services/formula_wrapper.rb Outdated
Comment thread Library/Homebrew/service.rb Outdated
Comment thread Library/Homebrew/service.rb Outdated
Comment thread Library/Homebrew/service.rb Outdated
Comment thread Library/Homebrew/service.rb Outdated
Comment thread Library/Homebrew/service.rb Outdated
Comment thread Library/Homebrew/service.rb Outdated
@IngmarStein

Copy link
Copy Markdown
Contributor Author

Done.

@MikeMcQuaid MikeMcQuaid left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks! One more note. Can you also rebase and squash into a single commit?

Comment thread Library/Homebrew/service.rb Outdated
@IngmarStein
IngmarStein force-pushed the service-env-overrides branch from 96d8f2d to 5158b1a Compare July 31, 2026 19:45
Services can now read environment variable overrides from
$HOMEBREW_USER_CONFIG_HOME/services/<formula>.env (defaults to
~/.homebrew/services/<formula>.env).  The file uses KEY=value
format, one per line, with # comments.  User overrides take
precedence over formula-defined variables and are merged into
the generated plist/systemd unit on every start/restart.

This gives users a persistent way to customise service
environment variables that survives package upgrades.

Disabled for root services to avoid TOCTOU risks with
symlink-swapping and caller-influenced HOME/XDG state.
Non-root services validate that the override file is not
world-writable or group-writable, and warn on unparseable
lines.

Feature is documented in the "brew services --help" output.
@IngmarStein
IngmarStein force-pushed the service-env-overrides branch from 5158b1a to 9a239b2 Compare July 31, 2026 19:51
@IngmarStein

Copy link
Copy Markdown
Contributor Author

@MikeMcQuaid sure thing, done! Can I do something about the check-commit-format error? The message (HttpError: Resource not accessible by integration) doesn't make it obvious what the issue is about.

@MikeMcQuaid MikeMcQuaid left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good, thanks!

@MikeMcQuaid
MikeMcQuaid enabled auto-merge August 1, 2026 06:53
@MikeMcQuaid
MikeMcQuaid added this pull request to the merge queue Aug 1, 2026
Merged via the queue into Homebrew:main with commit b921d44 Aug 1, 2026
41 of 43 checks passed
Yuxin-Qiao added a commit to Yuxin-Qiao/brew that referenced this pull request Aug 3, 2026
Restore the pre-Homebrew#23312 behaviour where `brew services start`/restart
copied the service file installed by the formula into the keg instead of
regenerating it from the `service do` block. Formulas that ship their
own plist or systemd unit only define `name` (without `run`), so
regenerating the file produces an invalid one with an empty
ProgramArguments/ExecStart, and `launchctl bootstrap` fails with exit
code 5.

Only regenerate the service file when the formula defines a run
command; otherwise fall back to the installed file. This matches the
documented contract in the Formula Cookbook and the logic already used
by FormulaInstaller#install_service.

Fixes Homebrew#23408
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants