Fix security issues, simplify app setup/updates, fix calibrate and some minor issues#446
Conversation
… timeouts only, tolerate stderr warnings
|
@actuallymentor ready for review |
| const line = util.format(...messages) + "\n"; | ||
| await fs.appendFile( `${ HOME }/.battery/gui.log`, line, 'utf8' ) |
There was a problem hiding this comment.
This change allows to have a nice output like:
Update details: {
stdout: '☑️ No updates found\n' +
'☑️ The existing battery visudo file is what it should be for version v1.3.2\n',
stderr: ''
}
not only in terminal but in gui.log as well. Version 1.3.2 logs stdout only. Btw, stdout and stderr are logged for errors as well, unless we decide to ignore resolved/rejected value.
| async function set_initial_interface() { | ||
|
|
||
| log( "Starting tray app" ) | ||
| log('\n===\n=== Starting tray app\n===\n') |
There was a problem hiding this comment.
Makes the new app session more visible in the gui.log
| local color=$1 | ||
|
|
||
| # Check whether user can run color changes without password (required for backwards compatibility) | ||
| if sudo -n smc -k ACLC -r &>/dev/null; then |
There was a problem hiding this comment.
This check is not needed anymore. The smc command just reads smc changing nothing
battery.sh
Outdated
| # Temporarily support the 'silent' setting for backward compatibility with older UI versions. | ||
| if [[ "$setting" == "silent" ]]; then | ||
| sudo -n $battery_binary update_silent | ||
| exit 0 | ||
| fi |
There was a problem hiding this comment.
Old GUI version actually works if the new battery.sh is already installed, but it cannot be used to update from current version. It will install the new battery script using current battery executable, but the next time Old GUI starts user will get an error alert. I'm gonna need one more commit to prevent it.
| $battery_binary maintain stop | ||
|
|
||
| # Disable charge blocker if enabled | ||
| $battery_binary adapter on |
There was a problem hiding this comment.
Not needed here, charging is restored below, don't do it twice.
| sudo chmod 755 $binfolder/smc | ||
| sudo chmod +x $binfolder/smc | ||
| echo "[ 3 ] Make sure $binfolder exists and owned by root" | ||
| sudo install -d -m 755 -o root -g wheel "$binfolder" |
There was a problem hiding this comment.
install is just a system utility which does the same job as cp/chown/chmod commands we deleted above and below.
There was a problem hiding this comment.
Ah this is dope, I didn't know that util. I see the default for -m is already 755 but I like it explicitly in here for clarity indeed
|
|
||
| touch $logfile | ||
| sudo chown $calling_user $logfile | ||
| sudo chmod 755 $logfile |
There was a problem hiding this comment.
Only executables and folders require 755. Others are good with just 644 which is read/write for owner and read for everyone else.
setup.sh
Outdated
|
|
||
| sudo chown $calling_user $binfolder/battery | ||
| # Fix permissions for 'create_daemon' action | ||
| echo "[ 7 ] Fix ownership and permissions for $(dirname "$launch_agent_plist")" |
There was a problem hiding this comment.
A user once had a root ownership on ~/Library/LaunchAgents folder (#420 (comment)). Not sure how that could happen, but lets make installer capable of fixing it.
setup.sh
Outdated
| echo "[ 6 ] Setting up visudo declarations" | ||
| sudo $batteryfolder/battery.sh visudo $USER | ||
| echo "[ 8 ] Setup visudo configuration" | ||
| sudo $binfolder/battery visudo $calling_user |
There was a problem hiding this comment.
visudo does not need $calling_user argument anymore. Will remove.
| mkdir -p $batteryfolder | ||
| curl -sS -o $batteryfolder/battery.sh https://raw.githubusercontent.com/actuallymentor/battery/main/battery.sh | ||
| echo -n "[ 1 ] Allocating temp folder: " | ||
| tempfolder="$(mktemp -d)" |
There was a problem hiding this comment.
mktemp is a standard system utility for temporary directory allocation, used extensively on macOS. The system owns the directory and will remove it at some point on startup if we don’t remove it ourselves.
aac6db3 to
acb0eac
Compare
|
@actuallymentor You asked to tag you when PR is ready. Now it is. |
|
@base47 I will try to make time this week to review in detail, in the meantime I'll ask the robot to do a first pass. A question at first glance: will we fuck over any other pieces of software by root owning the bin directory? It is not only our directory but a shared one. |
There was a problem hiding this comment.
Pull request overview
This PR targets the security vulnerability described in #443 by tightening ownership/permissions for privileged executables and adding a passwordless update_silent path for the GUI, while also fixing several CLI behaviors (charge loop, calibrate flow, adapter semantics) and improving GUI shell-exec robustness.
Changes:
- Harden installation/update scripts and sudoers rules to reduce local privilege-escalation risk and enable GUI “silent” updates.
- Refactor GUI shell execution helpers to preserve stdout/stderr, avoid treating stderr as fatal, and make timeouts explicit.
- Fix/adjust CLI actions (
charge,adapter,calibrate,--help/--version) and improve logging.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
| update.sh | Uses a mktemp temp dir and installs the updated battery script as root. |
| setup.sh | Installs smc/battery as root-owned and applies permissions/ownership changes + visudo setup. |
| battery.sh | Adds update_silent, hardcodes executable paths/URLs, fixes charge loop, updates calibrate flow, and tightens permission repair logic. |
| app/modules/battery.js | Reworks exec helpers, simplifies install/update decision logic, and improves online detection. |
| app/modules/helpers.js | Improves GUI log formatting by using util.format and preserving message structure. |
| app/modules/interface.js | Adjusts startup logging output formatting. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
update.sh
Outdated
| set -eu | ||
| sudo install -d -m 755 -o root -g wheel "$binfolder" | ||
| sudo install -m 755 -o root -g wheel "$updatefolder/battery.sh" "$binfolder/battery" | ||
| sudo chown root:wheel "$binfolder/smc" |
There was a problem hiding this comment.
sudo chown root:wheel "$binfolder/smc" will cause the update to fail (because set -e is enabled) on installations where /usr/local/bin/smc is missing or temporarily unavailable. Consider guarding this with an existence check and/or installing/updating smc as part of the update flow so battery update remains reliable.
| sudo chown root:wheel "$binfolder/smc" | |
| if [ -e "$binfolder/smc" ]; then sudo chown root:wheel "$binfolder/smc"; fi |
There was a problem hiding this comment.
When $binfolder/smc is missing the battery setup is broken. It would be misleading for update script to succeed like everything is fine. When Terminal users see the error, they are expected to run battery reinstall. GUI app will trigger reinstall on its startup. We can adopt suggested change, but I don't see a reason for that.
There was a problem hiding this comment.
the line changing smc ownership is not needed anymore and removed
setup.sh
Outdated
| @@ -33,44 +66,41 @@ unzip -qq $batteryfolder/repo.zip -d $batteryfolder | |||
| cp -r $batteryfolder/$in_zip_folder_name/* $batteryfolder | |||
| rm $batteryfolder/repo.zip | |||
There was a problem hiding this comment.
tempfolder=/Users/$calling_user/.battery-tmp is a predictable, user-writable location, but later the script runs sudo install ... "$batteryfolder/..." from inside that tree. This creates a local privilege-escalation opportunity via symlink/TOCTOU replacement of the downloaded artifacts before the sudo install steps. Use a mktemp -d temp directory with restrictive permissions (and ideally root-owned for the portion used as the sudo install source) and clean it up via trap.
There was a problem hiding this comment.
An unlikely scenario, but I agree. Will add mktemp -d to setup.sh
setup.sh
Outdated
| echo "[ 3 ] Make sure $binfolder exists and owned by root" | ||
| sudo install -d -m 755 -o root -g wheel "$binfolder" |
There was a problem hiding this comment.
sudo install -d -m 755 -o root -g wheel "$binfolder" will modify the ownership of the existing /usr/local/bin directory. On Intel/Homebrew setups that rely on user-owned /usr/local, this can break package installs/updates and other tooling. If the goal is to prevent replacement of battery executables, consider installing into a dedicated root-owned subdirectory (or only enforcing root ownership on the specific battery files + a dedicated parent dir) rather than taking ownership of the entire /usr/local/bin.
| echo "[ 3 ] Make sure $binfolder exists and owned by root" | |
| sudo install -d -m 755 -o root -g wheel "$binfolder" | |
| echo "[ 3 ] Make sure $binfolder exists" | |
| sudo install -d -m 755 "$binfolder" |
There was a problem hiding this comment.
Homebrew lives somewhere in /opt on Apple silicon macs and this app supports silicon macs only. But regarding some other 3pp software we don't even know exists, the Copilot's comment is true.
That means we are homeless now. Additionally, the change would require a modification of PATH variable for any possible shell used by users so the battery script can be launched without a full path.
There was a problem hiding this comment.
Battery executables were relocated to /usr/local/co.palokaj.battery in order to avoid conflicts with other apps. /usr/local is SIP protected by system which makes it the best choice (see this Discussion with Copilot for details).
The name for co.palokaj.battery folder was chosen the same as bundle id of the battery.app. It's purpose is to be unique to avoid conflicts with other software. Also, it has to indicate that it's a battery.app's folder, so when a user sees it in their PATH variable they don't freak out.
The executables are installed right inside /usr/local/co.palokaj.battery. There are no 'bin' or 'sbin' subfolders because they are unlikely to ever be needed.
Also, /usr/local/bin/battery and /usr/local/bin/smc are now symlinks, mostly for compatibility with current version of GUI app. The names are already claimed and having them does no harm.
Please, let me know if you prefer a different name for co.palokaj.battery folder.
There was a problem hiding this comment.
Name is fine for now. It is also an identity that links to my apple certificate that I use for signing. It's not user facing so it's fine
app/modules/battery.js
Outdated
| const path_fix = 'PATH=/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin' | ||
| const battery = `${ path_fix } battery` | ||
|
|
There was a problem hiding this comment.
The GUI still runs battery via PATH (const battery = ${ path_fix } battery``) while path_fix includes user-writable locations like `/opt/homebrew/bin`. This reintroduces PATH-based spoofing risk (a malicious `battery` earlier in PATH could be executed and then prompt for admin credentials / run privileged subcommands). Prefer invoking the absolute path (`/usr/local/bin/battery`) everywhere, and avoid inheriting `process.env.PATH` in `shell_options` for these calls.
There was a problem hiding this comment.
Now we are using a full executable path and minimal PATH. path_fix is not needed and removed.
| // exec_async_no_timeout(), exec_async() and exec_sudo_async() are async helper functions | ||
| // which run bash-shell commands. | ||
| // | ||
| // They provide a unified output contract: | ||
| // Fulfilled result: { stdout: string, stderr: string } | ||
| // Rejected result: 'Error' object having the following extra properties: | ||
| // - 'cmd': shell command string | ||
| // - 'code': shell exit code or 'ETIMEDOUT' | ||
| // - 'output': { stdout: string, stderr: string } | ||
| // |
There was a problem hiding this comment.
The comment/doc for the shell helpers says rejected results are Error objects with extra cmd and code properties, but the implementation only attaches output (and the timeout error sets code but not cmd). Either populate err.cmd consistently (and ensure err.code is always set for exec errors) or adjust the documented contract so downstream callers/logging can rely on it.
There was a problem hiding this comment.
Good catch. cmd and code are included in error objects by other means, that's what I see in logs. But I don't add them to timeout errors. Also, if the process is terminated by a signal there will be no code.
| fi | ||
| curl -sS https://raw.githubusercontent.com/actuallymentor/battery/main/setup.sh | bash | ||
| curl -sS "$github_url_setup_sh" | bash |
There was a problem hiding this comment.
The curl -sS "$github_url_setup_sh" | bash pipeline downloads and executes a remote shell script from GitHub without any integrity or authenticity verification. If the GitHub repository or the network path is compromised, an attacker can serve a modified setup.sh that runs arbitrary code (including invoking sudo) under the installing user's session. Replace this pattern by downloading to a file and verifying a pinned commit/tag or checksum before execution, or by executing only locally distributed installer scripts instead of live curl|bash.
There was a problem hiding this comment.
The described problem is not related to this PR and deserves a new one. But only if we decide to consider compromise of the GitHub repository or the HTTPS channel a threat in our security model.
If the actuallymentor's GitHub account doesn't change owner and users don't have state actors targeting them, everybody should be fine.
| if ! is_latest_version_installed; then | ||
| curl -sS "$github_url_update_sh" | bash | ||
| echo "✅ battery background script was updated to the latest version." | ||
| else |
There was a problem hiding this comment.
The update_silent action executes curl -sS "$github_url_update_sh" | bash as root via a passwordless sudoers rule, which downloads and runs an unpinned script from GitHub with full root privileges and no integrity verification. Compromise of the upstream repository or the HTTPS channel would immediately yield arbitrary root code execution on every client that invokes battery update_silent (including the GUI’s automatic background updates). Mitigate by avoiding curl|bash, pinning to immutable versions (e.g. commit SHA) and verifying checksums/signatures before executing any downloaded update logic, or by shipping and updating from locally stored, signed artifacts instead of live scripts.
There was a problem hiding this comment.
Signatures would resolve all listed threats (even though those threats are unlikely), I like the idea, but again, not in this PR.
No, it's owned by root according to the OS design. We are making sure it's owned by root because that's were 3pp software likes to install it's executables. If some broken 3pp installer changes We should be this paranoid because we add sudoers config file to the system; this implies huge responsibility. UPDATE: My statement above about |
|
I addressed Copilot's comments and did the full retest on Sequoia 15.7.3 and the latest Tahoe 26.3. The PR is ready. I tested it using my test branch which is rebased on top of this PR and modifies update URLs for GUI and background executables. Below I'm sharing my test list which describes in details the expected user experience in possible scenarios when updating to a newer version. The only potential inconvenience I know about is described in tests 4 and 5. |
|
Love this in depth and thoughtful work @base47! I'm happy to ship this. This requires the PR merged and a local build of the GUI on my part. I'm not sure what timezone you are in, but I'd like to make sure to do that while you are awake just on the off chance that an edge-case breaks things. Is there a day/time this week that would be a good target for me to merge, build, and upload? |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 6 out of 6 changed files in this pull request and generated 5 comments.
Comments suppressed due to low confidence (3)
update.sh:8
- The comment says the script is running as root, but this script explicitly supports non-root execution paths (e.g. GUI-launched updates and the reinstall trigger). Consider rewording to avoid implying EUID==0 is guaranteed so future maintainers don’t accidentally add root-only assumptions above the EUID checks.
# This script is running as root:
# - Reset PATH to safe defaults at the very beginning of the script.
# - Never include user-owned directories in PATH.
PATH=/usr/bin:/bin:/usr/sbin:/sbin
app/modules/battery.js:137
- Spelling typo in comment: “batery” -> “battery”.
// 'batery maintain' creates a child process, so when the command exits exec_async does not return.
// That's why here we use a timeout and wait for some time.
app/modules/helpers.js:3
- This file consistently uses single quotes and omits semicolons, but the new
utilrequire uses double quotes and a semicolon. If ESLint/style rules enforce the existing convention, this will fail linting. Align the new import line with the surrounding style.
const util = require("util");
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| const workers = [ exec_async_no_timeout( command ) ] | ||
| if ( timeout_in_ms > 0 ) { | ||
| workers.push( | ||
| wait(timeout_in_ms).then( () => { | ||
| const error = new Error( `${ command } timed out` ) | ||
| error.code = 'ETIMEDOUT' | ||
| error.cmd = command | ||
| error.output = { stdout: '', stderr: '' } | ||
| throw error; | ||
| }) | ||
| ); | ||
| } | ||
|
|
||
| return Promise.race( workers ) |
There was a problem hiding this comment.
exec_async() implements timeouts via Promise.race, but it does not terminate the underlying subprocess when the timeout wins. For commands like the online checks (curl), this can leave long-running/hung processes behind even though the caller has moved on. Prefer using child_process.exec’s built-in timeout/killSignal options or enforce timeouts at the command level (e.g. curl --max-time/--connect-timeout) so the process is actually stopped.
| const workers = [ exec_async_no_timeout( command ) ] | |
| if ( timeout_in_ms > 0 ) { | |
| workers.push( | |
| wait(timeout_in_ms).then( () => { | |
| const error = new Error( `${ command } timed out` ) | |
| error.code = 'ETIMEDOUT' | |
| error.cmd = command | |
| error.output = { stdout: '', stderr: '' } | |
| throw error; | |
| }) | |
| ); | |
| } | |
| return Promise.race( workers ) | |
| // If no timeout is requested, use the existing helper unchanged | |
| if ( timeout_in_ms <= 0 ) { | |
| return exec_async_no_timeout( command ) | |
| } | |
| // Use child_process.exec's built-in timeout/killSignal so the subprocess | |
| // is actually terminated when the timeout elapses. | |
| return new Promise( ( resolve, reject ) => { | |
| log( `Executing ${ command } with timeout ${ timeout_in_ms }ms` ) | |
| const options = { | |
| ...shell_options, | |
| timeout: timeout_in_ms, | |
| // Use SIGTERM so the child is terminated on timeout | |
| killSignal: 'SIGTERM' | |
| } | |
| exec( command, options, ( error, stdout, stderr ) => { | |
| const output = { stdout: stdout ?? '', stderr: stderr ?? '' } | |
| if ( error ) { | |
| // If this was caused by a timeout, normalize the code to ETIMEDOUT | |
| if ( error.killed && error.signal === options.killSignal ) { | |
| error.code = 'ETIMEDOUT' | |
| } else { | |
| error.code ??= ( error.signal ? 'SIGNAL' : 'UNKNOWN' ) | |
| } | |
| error.cmd ??= command | |
| error.output = output | |
| return reject( error ) | |
| } else { | |
| return resolve( output ) | |
| } | |
| } ) | |
| } ) |
There was a problem hiding this comment.
The behavior we have is exactly the one we need for 'battery maintain'.
Regarding hanging network requests, I consider it not a big deal. Also, the PR does not make the situation here worse comparing to what we already have in the latest version 1.3.2.
battery.sh
Outdated
| version_before="$($battery_binary version)" | ||
|
|
||
| if ! check_installation_integrity; then | ||
| echo -e "‼️ The battery installation seems to be broken. Forcing reinstall...\n" | ||
| $battery_binary reinstall silent | ||
| version_before="0" # Force restart maintenance process | ||
| else |
There was a problem hiding this comment.
In the update action, version_before is computed by executing $battery_binary version before check_installation_integrity runs. If the installation is partially broken (e.g. missing or non-executable battery binary), this call can fail/noise before you’ve decided to force reinstall. Consider moving the version_before call to after the integrity check (and only when $battery_binary is known to be runnable), or guard it so the reinstall path remains clean and reliable.
| version_before="$($battery_binary version)" | |
| if ! check_installation_integrity; then | |
| echo -e "‼️ The battery installation seems to be broken. Forcing reinstall...\n" | |
| $battery_binary reinstall silent | |
| version_before="0" # Force restart maintenance process | |
| else | |
| if ! check_installation_integrity; then | |
| echo -e "‼️ The battery installation seems to be broken. Forcing reinstall...\n" | |
| $battery_binary reinstall silent | |
| version_before="0" # Force restart maintenance process | |
| else | |
| # Installation looks healthy; safe to query current version | |
| version_before="$($battery_binary version)" |
| echo "[ 3 ] Writing script to $binfolder/battery" | ||
| sudo install -d -m 755 -o root -g wheel "$binfolder" | ||
| sudo install -m 755 -o root -g wheel "$updatefolder/battery.sh" "$binfolder/battery" | ||
|
|
||
| # Remove tempfiles | ||
| cd | ||
| rm -rf $tempfolder | ||
| echo "[ 3 ] Removed temporary folder" | ||
| echo "[ 4 ] Remove temporary folder" | ||
| rm -rf "$tempfolder"; | ||
|
|
||
| echo -e "\n🎉 Battery tool updated.\n" |
There was a problem hiding this comment.
The update flow doesn’t check the result of the sudo install ... steps. If install fails (e.g. permissions, disk full), the script will still print the success message and exit 0, leaving a partial update. Consider checking the exit status of the install commands (or enabling set -e / explicit || exit with a clear error message) so failures are surfaced.
There was a problem hiding this comment.
It would make sense to check for this kind of errors if we had a way to recover from them. So, the only problem we have is we can tell user that everything is fine when it's actually not. It's a consistent behavior throughout the whole bash code base.
| rm -rf $batteryfolder | ||
| mkdir -p $batteryfolder | ||
| curl -sSL -o $batteryfolder/repo.zip "https://github.com/actuallymentor/battery/archive/refs/heads/$update_branch.zip" | ||
| unzip -qq $batteryfolder/repo.zip -d $batteryfolder | ||
| cp -r $batteryfolder/$in_zip_folder_name/* $batteryfolder | ||
| rm $batteryfolder/repo.zip |
There was a problem hiding this comment.
The download/unzip/copy steps aren’t checked for failure (no set -e and no explicit exit-status checks). If curl/unzip fails, the script can continue and still print a successful install message, potentially leaving a broken partial installation. Consider enabling strict error handling (e.g. set -euo pipefail) or adding explicit checks with a clear failure message before proceeding to install steps.
There was a problem hiding this comment.
Already answered regarding this kind of issues above. In practice, setup.sh is the most reliable piece of code here. A single pill for all the problems.
| function ensure_owner() { | ||
| local owner="$1" group="$2" path="$3" | ||
| [[ -e $path ]] || { return 1; } | ||
| local cur_owner=$(stat -f '%Su' "$path") | ||
| local cur_group=$(stat -f '%Sg' "$path") | ||
| if [[ $cur_owner != "$owner" || $cur_group != "$group" ]]; then | ||
| sudo chown "${owner}:${group}" "$path" | ||
| fi | ||
| } | ||
|
|
||
| function ensure_owner_mode() { | ||
| local owner="$1" group="$2" mode="$3" path="$4" | ||
| ensure_owner "$owner" "$group" "$path" || return | ||
| local cur_mode=$(stat -f '%Lp' "$path") | ||
| if [[ $cur_mode != "${mode#0}" ]]; then | ||
| sudo chmod "$mode" "$path" | ||
| fi | ||
| } | ||
|
|
||
| # Use the following function to apply any setup related fixes which require root permissions. | ||
| # This function is executed by 'update_silent' action with EUID==0. | ||
| function fixup_installation_owner_mode() { | ||
| local username=$1 | ||
|
|
||
| ensure_owner_mode root wheel 755 "$visudo_folder" | ||
| ensure_owner_mode root wheel 440 "$visudo_file" | ||
|
|
||
| ensure_owner_mode root wheel 755 "$binfolder" | ||
| ensure_owner_mode root wheel 755 "$battery_binary" | ||
| ensure_owner_mode root wheel 755 "$smc_binary" | ||
|
|
||
| ensure_owner_mode $username staff 755 "$(dirname "$daemon_path")" | ||
| ensure_owner_mode $username staff 644 "$daemon_path" | ||
|
|
||
| ensure_owner_mode $username staff 755 "$configfolder" | ||
| ensure_owner_mode $username staff 644 "$pidfile" | ||
| ensure_owner_mode $username staff 644 "$logfile" | ||
| ensure_owner_mode $username staff 644 "$maintain_percentage_tracker_file" | ||
| ensure_owner_mode $username staff 644 "$maintain_voltage_tracker_file" |
There was a problem hiding this comment.
The combination of ensure_owner / ensure_owner_mode and fixup_installation_owner_mode performs chown/chmod as root on paths under $configfolder and $daemon_path, which reside in user-writable directories (e.g. $HOME/.battery, $HOME/Library/LaunchAgents). Because these helpers unconditionally follow symlinks (stat/chown/chmod on $path without checking for symlinks), an unprivileged local user can point these paths (or files like $logfile or $pidfile) at arbitrary system locations (e.g. /etc/sudoers or other root-owned files) and have them re-owned and world-readable during a passwordless battery update_silent, leading to full local privilege escalation. To mitigate, avoid performing privileged ownership/permission fixes on user-controlled paths without first ensuring they are not symlinks and live under a root-owned directory, or move this metadata into a root-owned location that users cannot replace with symlinks.
There was a problem hiding this comment.
these helpers unconditionally follow symlinks
Oh, this is a good one if true. Must be fixed. Looking into it.
There was a problem hiding this comment.
@actuallymentor The attack works. When executed without any options (at least on Sequoia), chown/chmod change the ownership/mode of the link target, not the link itself. This behavior is platform dependent. It's crazy. Now I have to review every chown/chmod usage in the project and pass explicit options to avoid default behavior. It can take some time, I think, should be ready by tomorrow if full retest is needed.
Anyday, Western Europe |
|
I'll do the merge tomorrow and tag you |
|
@actuallymentor Ok, but let me add a small commit addressing the Cpilot's comment #446 (comment) . I will try to allocate some time this evening. |
|
@actuallymentor I added 2 trivial commits. The main change is to use -h option with chown and chmod, preventing them from following symlinks. All tests passed on both Sequoia and Tahoe. I approve the PR for merging. |
|
@base47 merged, encountered a CLI error I tested the local GUI with the updated battery (including my fix) and the update went well |
|
Release published here. Let's keep an eye out for issues. Thanks for the work! |
|
@actuallymentor Sequoia updated GUI to 1.4 and battery.sh to 1.3.4 |
|
@actuallymentor I forgot to update README with the new paths and the |
Fix security issues, simplify app setup/updates, fix calibrate and some minor issues
Primary goal of this PR is to fix the #443 .
All changes in bash scripts and GUI (including updates from current version 1.3.2 using GUI and Terminal) were tested on Sequoia 15.7.3 and Tahoe 26.2 (which is the latest).
During the update GUI users will be asked to reinstall background executables. Terminal users can just run
battery updateas usual. In both cases user password will be required.First of all let me start with assuring you that this PR is not vibe-coded. Every change I made have a reason, I'm aware of each modification and understand what they do. Now, let’s look at the changes from a bird’s-eye perspective
Security improvements
Battery limiter requires root previleges to set the values of SMC keys which control macbook charging. That means that maintainers have to pay a special attention to app security, specifically to privilage escalation attacks. This PR addresses the following attack scenarios:
Setup/Update functionality in battery.sh
Since the battery background executables are not writable by the user, root privileges are required for updates. To keep updates silent on each startup of the GUI app, this PR adds a passwordless 'update_silent' action to the battery.sh script (via visudo config, same way as we already do for smc). This same action is also used by the 'battery update' action, so updates from the Terminal are also passwordless. Normally, with this PR, a user will have to enter a password twice: during installation of the background binaries and during uninstall.
Users no longer need to use the
battery visudoaction, even though I suspect few of them ever did. Theupdate_silentaction executes battery visudo after each update. Even if no updates are available,update_silentstill invokesbattery visudoand ensures that the ownership and permissions of the battery files are intact. Thevisudoaction does not overwrite sudoers config on every invocation. It writes it only if it is missing or outdated, so it is OK to invoke it on each update.In the past some users had an issues related to the ownership of battery config folder or battery daemon plist, because they invoked
battery visudoor other actions with sudo. I did it many times myself. With this PR thevisudoaction uses system allocated cache folder to store its temp file and not touching config folder at all. Additionally, the PR adds some guards to make sure some actions are not executed with sudo by mistake.Setup/Update functionality in app/modules/battery.js
This functionality is located in
initialize_batteryfunction. This PR simplifies the process to a simple condition:The decision about whether to install or update is based on:
If any of the conditions fail, the GUI app triggers reinstallation of background components asking for user password.
Silent update is launched otherwise.
The time app checks for updates (when user sees the "Updating..." message in menubar) is significantly reduced with this PR comparing to current version 1.3.2, even though I'm not sure why.
exec_async() and other shell execution helpers in app/modules/battery.js
Existing exec_async related functionality makes it difficult to maintain the app and introduces some bugs:
With this PR:
Other changes
--helpand--versionactions (cli support --help #433). Because they are trivial to implement, and it’s sometimes annoying to run battery help with its long output and scroll back just to find out which version is installed.adapteraction. Before the fix,battery adapter onwould actually disable the adapter.battery chargekilled the maintenance process, whilebattery dischargedid not (otherwise maintenance process would kill itself with --force-discharge option). With this PR, both "charge" and "discharge" actions kill maintenance process, but only when they are called by user from Terminal. This prevents interference between the two charge-controlling tasks. These actions wouldn't work otherwise. Additionally, since they stop maintenance, both actions now restore it on completion, again only when run directly from the Terminal. This change is implemented using environment variable flag, so affected clients (if any) can easily adapt to the new behavior.calibrateaction. It was severely broken and was trying to run calibration in a background process for no reason. With this PR, it runs in a Terminal window. Similar to the charge/discharge actions, it terminates the maintenance process and restores it upon completion. The opposite is true as well: if the user launches maintenance in the Terminal or by starting the GUI menubar app, the calibration process is terminated. We would need to implement some kind of confirmations one day. Tested it once and now coconutBattery reports that battery health increased by 2%. Maybe it was worth it.