Add per-service user environment variable overrides - #23312
Conversation
There was a problem hiding this comment.
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_variablesand env override file parsing/lookup inservice.rb, and apply the merged env to plist/systemd output. - Add
FormulaWrapper#service_contentand updateservices/cli.rbto install regenerated content rather than reading the pre-generated file. - Extend/update specs to cover env override merging and to stub the new
service_contentAPI.
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_unitcallseffective_environment_variablestwice, which duplicates the override-file read/parse work. Cache the hash in a local variable before checkingpresent?and buildingEnvironment=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.
MikeMcQuaid
left a comment
There was a problem hiding this comment.
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?
This comment was marked as low quality.
This comment was marked as low quality.
|
@IngmarStein did you write your last response or did an LLM? |
|
I did the macOS archaeology myself, but 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. |
I actually didn't mean to - it was just a bit eager operating the So here's the gist of it:
The only feasible approach today is People are creating creating workarounds like https://github.com/ntrlmt/ollama-brew-service-setup, but I think |
|
@IngmarStein Ok, thanks! Repeating questions above:
A change necessary, if not done already:
And a new one:
|
I started with the idea of the global env file ( I also discarded an ad-hoc I also thought about placing environment files in I discarded A But I left this out of this PR to focus on the key part.
I believe it should, yes.
Isn't that already the case?
Done!
Here's the Opus 5 assessment: I can't think of anything else. |
|
Thanks!
Which did you personally verify this on: launchd or systemd?
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. |
launchd. I don't have a Linux box around to test. There are unit tests for system and the general approach is identical.
Ah,
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, |
|
Thanks @IngmarStein!
I can't but pulling in @p-linnane @woodruffw @andrew for @Homebrew/security for their thoughts here. |
|
I think the root-service path needs changes before merge. I reproduced both paths on the current PR head. Overrides should be disabled for root services. |
Yes, I think this is a safer starting point. |
|
Makes sense. I've disabled overrides for root services. |
|
Done. |
MikeMcQuaid
left a comment
There was a problem hiding this comment.
Thanks! One more note. Can you also rebase and squash into a single commit?
96d8f2d to
5158b1a
Compare
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.
5158b1a to
9a239b2
Compare
|
@MikeMcQuaid sure thing, done! Can I do something about the |
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
For bug fixes, have you given step-by-stepbrewcommands to reproduce the bug?brew lgtm(style, typechecking and tests) locally?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 --onlinebefore submission.What this does
Adds support for per-service user environment variable overrides via a simple file convention. When
brew services start/restartgenerates a plist or systemd unit, it now checks for$HOMEBREW_USER_CONFIG_HOME/services/<formula>.env(defaults to~/.homebrew/services/<formula>.env) and merges anyKEY=valuepairs into theEnvironmentVariablesof the generated service file.Why
The problem: macOS
launchddoesn't run a login shell, so environment variables set in.zprofile/.bash_profileare invisible to Homebrew services. Users currently have no supported way to set e.g.OLLAMA_HOSTforbrew services-managed plists — manually editing the plist is overwritten on everybrew services restart, andlaunchctl setenvdoesn'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_variablesin theirserviceblocks. Many hardcode values users need to change:OLLAMA_HOST,OLLAMA_ORIGINS, etc. but the formula hardcodesOLLAMA_FLASH_ATTENTIONandOLLAMA_KV_CACHE_TYPEsource #{etc}/teslamate.envbefore running. With this feature, teslamate could drop the wrapper entirelyLAUNCH_PROGRAM_TCP_ADDRESS: "127.0.0.1:8080"with no way to overrideXDG_DATA_HOMEandHOMEto specific pathsLC_ALL: "en_US.UTF-8"across all four versioned formulaeHow it works
The env file location follows the existing
~/.homebrew/user config pattern (trust.json,livecheck_watchlist.txt,brew.env):Format is
KEY=value, one per line, with#comments: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 nextbrew services restart. The file survivesbrew upgradebecause 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, andload_user_env_overrides(reads and parses the env file, ignoring comments and blank lines)formula_wrapper.rb— new publicservice_contentmethod that regenerates plist/systemd content (so the env merge happens on every start/restart, not just on install)cli.rb—install_service_filenow usesservice_contentinstead of reading the pre-generated keg file, so user overrides are always appliedservice_contentmethod