Run bash (.sh) via launchd using a plist in /Library/LaunchAgents

I have a .plist file located in /Library/LaunchAgents which properly references a script called "test.sh" located in ~/Documents/Scripts. I have also tried moving this script to ~/Library/Scripts and /Library/Scripts. The script is chmod +x and runs perfectly well from manual terminal session. I have also chown to root:wheel... nothing helps.


When the script is called by launchd via the plist, I get "Operation not allowed" error. This is written to stderr and in launchd.log


Similar is so easy on Linux. I am finding it impossible on macOS.


In systems settings, terminal has full disk access. Also, the script is auto added to Login Items and Extensions and this is set to enable.


Why is this sooo hard? All I want to do is run a script every hour from a background process. It all works but Mac refuses to run the actual script.


Thoughts?


MacBook Pro (M4 Pro, 2024)

Posted on Oct 24, 2025 9:12 AM

Reply
Question marked as Top-ranking reply

Posted on Oct 24, 2025 9:41 AM

Neither macOS nor its LaunchAgent implementation have anything to do with Linux, and some learning requirement is needed on your behalf on macOS.


That .plist should reside in ~/Library/LaunchAgents. It needs no special permissions on it as 644 (e.g. -rw-r--r--) is fine with your username and group ownership. Then it distills to the syntax that you have used in the .plist.


Below is a LaunchAgent .plist that I wrote some time ago that launches a shell script every 15 minutes to check on battery percentage above a certain value that I pass to the script. That shell script also resides in ~/Library/LaunchAgents for continuity.

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
	<key>Disabled</key>
	<false/>
    <key>RunAtLoad</key>
    <true/>
    <key>KeepAlive</key>
    <false/>
	<key>Label</key>
	<string>com.local.battery_percent.plist</string>
    <!-- 3600 hourly, 1800 every 30 min, 900 every 15 min  -->
    <key>StartInterval</key>
    <integer>900</integer>
	<key>Program</key>
    <string>/Users/viking/Library/LaunchAgents/battery_percent.sh</string>
    <key>ProgramArguments</key>
    <array>
        <string>/Users/viking/Library/LaunchAgents/battery_percent.sh</string>
        <!-- minimum battery percentage to start warnings
             this value will be passed as argument to program -->
        <string>90</string>
    </array>
</dict>
</plist>

With this setup, you can log out of your account and sign back in and the .plist will launch and then again every 15 minutes thereafter.

27 replies
Question marked as Top-ranking reply

Oct 24, 2025 9:41 AM in response to Ashler

Neither macOS nor its LaunchAgent implementation have anything to do with Linux, and some learning requirement is needed on your behalf on macOS.


That .plist should reside in ~/Library/LaunchAgents. It needs no special permissions on it as 644 (e.g. -rw-r--r--) is fine with your username and group ownership. Then it distills to the syntax that you have used in the .plist.


Below is a LaunchAgent .plist that I wrote some time ago that launches a shell script every 15 minutes to check on battery percentage above a certain value that I pass to the script. That shell script also resides in ~/Library/LaunchAgents for continuity.

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
	<key>Disabled</key>
	<false/>
    <key>RunAtLoad</key>
    <true/>
    <key>KeepAlive</key>
    <false/>
	<key>Label</key>
	<string>com.local.battery_percent.plist</string>
    <!-- 3600 hourly, 1800 every 30 min, 900 every 15 min  -->
    <key>StartInterval</key>
    <integer>900</integer>
	<key>Program</key>
    <string>/Users/viking/Library/LaunchAgents/battery_percent.sh</string>
    <key>ProgramArguments</key>
    <array>
        <string>/Users/viking/Library/LaunchAgents/battery_percent.sh</string>
        <!-- minimum battery percentage to start warnings
             this value will be passed as argument to program -->
        <string>90</string>
    </array>
</dict>
</plist>

With this setup, you can log out of your account and sign back in and the .plist will launch and then again every 15 minutes thereafter.

Oct 24, 2025 11:33 AM in response to VikingOSX

Thank you for the guidance. And all I meant about Linux is it is easy to accomplish this task; not that macOS and Linux are in anyway the same in this effort.


It seems there is some built in prohibition on scripts or bash etc. I made a change;


I moved the location of the .plist from /Library/LaunchAgents to ~/Library/LaunchAgents (turns out this does not matter)


I moved the .sh to ~/Library/LaunchAgents from ~/Documents/Scripts (turns out this really matters)


Now everything works. It seems the location of the .sh is the lynchpin.


I also moved the .plist back to /Library/LaunchAgents from ~/Library/LaunchAgents and it still works.


Seems like macOS 26 really wants the .sh in specific location(s) in such a use case (called by launchd).


I looked for a list of "approved" locations but didn't find anything from Apple. That said, I am not sure for what to even search.

Oct 24, 2025 5:26 PM in response to etresoft

All aspects of the referred to locations in the .plist and .sh have full paths. No attempted use of variables or “~” paths inside the files.


I moved the .sh to ~/Library/Scripts/. This works as well except the script is then visible in the Scripts menu. I prepended a period to the .sh file name to hide it. All is good.

Oct 24, 2025 9:55 AM in response to Ashler

Here are a few things that VikingOSX didn't mention.


There are multiple different session types and launchd domains on the Mac. You don't need to know what they are. The only one you really want to use is the "gui" domain. You can get a listing of all tasks running in this domain with:


launchctl print gui/501


(where your user ID is 501)


Launch daemons aren't tied to a particular login. They are owned by root. They run in the system domain as root. The plist config file is stored in /Library/LaunchDaemons and the executable is in some other system location.


Launch agents are owned by root, but they run as the user currently logged in. The plist config file is stored in /Library/LaunchAgents and the executable is in some other system location.


User launch agents are owned by the use and run as the user. The plist config file is stored in ~/Library/LaunchAgents and the executable is somewhere in your home directory (most likely).


You can read the documentation for the plist config file format with:


man launchd.plist


Shell scripts can be tricky. There are multiple ways to do construct them. VikingOSX's example assumes that the sh executable script has the proper shebang line and has 755 (-rwx-r-xr-x) permissions.


However, the launchd environment is significantly different than your Terminal environment. You might want to start with a command that just does "env" so you can see those differences. Don't try to be too fancy at first.


Aside from running the launchctl and man commands, Terminal is not involved at all.

Oct 25, 2025 9:14 PM in response to Ashler

There is nothing magical about processes started by cron, or processes started by launchd, or processes started by an Automator “Run Shell Script”, or invoked at a terminal command line.


They are all run under some account. If the root account, the process will have more access. If your user account it will have your access.


For any invocation method, besides a terminal session, the process will have a very minimum of environment variables and a very limited number of directories in PATH, that will limit what commands are available, without specifying a full path to the command.


Some of the invocation methods process your command, tokenizing the strings, stripping quoted strings, performing $var subscriptions, etc… before passing your command to a shell for processing. This can cause havoc when your quoted strings are protecting spaces, as the next level of shell processing will not have the quotes, and split things at those spaces. For those environments, you need to wrap an extra layer of quotes. It is a pain, but it is price you pay for using some invocation methods.


The simplest approach is to put everything in a shell script, and invoke it without any arguments. Once you start passing arguments, you may find a need to quote something, and get into a mess. So if you need to have the script have arguments, write several wrapper scripts, each calling the worker script with the needed unique arguments.


Again make sure all the commands run without your normal terminal environment. Maybe create a testing account and DO NOT customize the shell’s initialization scripts. That should be close to any of the invocation methods for an environment.


But to be sure, run a script via your chosen invocation method that write stuff in its environment to a file


( printenv
  echo “”
  pwd
  echo “”
  id
  echo “”
  set
  echo “”
  echo “list of call arguments”
  for j in “${@}”
  do
      echo “$j”
  done
) >/tmp/env.out 2>&1


add additional commands you can think of that will help you.


Side note: I have run rsync via cron jobs on Macs, DIGITAL UNIX, Solaris, and AIX. And while I use rsync in scripts as part of my job, it is a royal pain in the 🫏 to get right. I generally only run it in a script that I’ve tested before sticking it in a cron job. I never run it from the command line, as I always get it wrong.

Oct 25, 2025 12:11 PM in response to MrHoffman

MrHoffman wrote:

As for performing the rsync via cron task, maybe this helps: https://rasterweb.net/raster/2025/03/17/scheduling-rsync-in-macos/

This statement doesn't give me much confidence:


I tested things, added the cron job and it didn’t seem to work. I debugged, I tested, I searched… It seems macOS could not run an rsync job via cron. I tried to add cron, rsync, zsh, and iTerm, Terminal.app, and WezTerm to the “Full Disk Permissions” thing in the System Preferences. Eventually I gave up.


However, wrapping it all into an Automator app is a very good idea. That way, you can give just your Automator app Full Disk Access and that would significantly ameliorate the security problems you'd otherwise introduce.


The more Apple-centric means of scheduling and scripting stuff involves using AppleScript to trigger the shell script. Though cron works, so long as the rsync job has access through protections and TCC / full disk access. And AppleScript having its own issues.

It's also important to remember that Apple doesn't even include rsync with the operating system anymore. Apple's rsync is actually openrsync due to GPL 3 restrictions in the original rsync. So there's no guarantee than any rsync examples from other OS versions, or even macOS from last year, are valid anymore.

Oct 25, 2025 7:52 AM in response to Ashler

Another victim of the Apple 5000 character limit. <shakes fist impotently>


You can still use shell scripts if you want. You just need to learn a bit about how scripting works and how macOS security works. You may have given the Terminal app access to those privacy-sensitive folders, but that doesn't mean random script interpreters get access too. You'll have to manually override Full Disk Access settings for that.

Oct 25, 2025 2:41 PM in response to Ashler

Ashler wrote:

I guess rsync is a rabbit hole.

The rabbit hole is the process that led you to it. Start digging and you're bound to find some interesting things - ancient and filthy, but still interesting. But here in the forums, we really want people to have a great overall experience. Sometimes that means giving up what no longer serves.


Perhaps a more specific pointer though? As in what did you have in mind?

The best solution for backup is Time Machine using an external drive. These days SSDs are relatively cheap and much faster than the old mechanical drives.


It's also important to consider material realities. Modern computers with integrated SSDs are much more reliable than older computers from the early days of Time Machine, or especially rsync days. People who demand instant uptime from a catastrophic event are living a fantasy. Such things are exceptionally rare. Backups are still important, but just not to the same degree or for the same end goals. These days, people are more likely to use their backup to rollback from some disruptive new OS update than they are to need a backup due to hardware failure.


Plus, most people use at least one cloud service. These aren't truly backups, but they're close, and even backups aren't quite what they once were.


Modern Time Machine is quite smart and was designed for all these modern realities. It's constantly making local snapshots, so you really don't need an external backup every hour. Just whenever you remember to plugin the backup drive is good enough.


And if you like to tinker with Linux, then you can still to network Time Machine. But if some old, Linux-based NAS doesn't allow you to upgrade or tinker, then that's really not the Mac's fault.


As for Apple Script, I actually have a version of this where an Automator step uses AppleScript to tell terminal to run the .sh.

Well, you didn't bite on my explanation for why Apple's security is the way it is. But that doesn't mean you aren't impacted by it. As MrHoffman's earlier link shows, lots of people are confused by the macOS security model. Shell scripts simply aren't part of it at all, even when running as root. They have no access to protected user data. You can give individual executables Full Disk Access, but that's risky in the case of shell script interpreters like sh, zsh, and bash. It gives the interpreter itself Full Disk Access, regardless of what script it's running. Some scripts aren't safe.


But apps that are launched by the user can interact with the user regarding their intent. You can still give them Full Disk Access ahead of time. But the default is that they have to ask first. Only apps have this capability. Shell scripts silently fail unless they already have Full Disk Access. So if you have an app like Automator, or an app built from Automator, then you don't need Terminal. Automator itself executes your interpreter, which then runs your shell script. Now you can allow private data access to just that one Automator-generated app, not Terminal, not bash, and not random internet hackers.


The the source paths are held in lists in the script. Where the source paths have spaces, they get handled differently when executed from CRON. For example "/Library/Application Support/" gets broken into "/Library/Application" and "/Library/Application Support/"; two paths. And since this is a single source it writes to both paths. LOL.

Paths can generally be quoted with double or single quotes. In rare case where that isn't possible, you can escape the spaces with \.


So cron causes (?) bash to handle " " in a bash list differently than terminal and bash. Honestly this seems like a me problem as perhaps there is a universal way to quote variables for paths with spaces held in a list in a bash script.

** shebang is: #!/bin/bash and "which bash" is /bin/bash

It's also important to note that the default shell on macOS is zsh. The GPL v3 license was constructed specifically so Apple (and only Apple) couldn't use it. Bash is still included, but it's an 18-year old version.


Might just convert everything to python and see if then there is more consistent execution behaviors among the different ways to call a .py

That's not a bad idea at all. However, Python is not included with the operating system. You would need to install it yourself. Perl is still included.

Oct 24, 2025 6:28 PM in response to Ashler

Ashler wrote:

I moved the .sh to ~/Library/Scripts/. This works as well except the script is then visible in the Scripts menu. I prepended a period to the .sh file name to hide it. All is good.

That's a better location. Ideally, you want to put such things in the designated location. Unfortunately for this particular type of file, there is no such designated location. While "Library" is a good place, there are many locations inside "Library" that would be off-limits. Ideally, just create your own folder in ~/Library and put it there.


If you're already familiar with Linux, you can always create a "bin" folder in your home directory. That's what I do.

Oct 25, 2025 7:51 AM in response to Ashler

Ashler wrote:

The test script works fine as all it is doing is echoing to a log as a test.

There's nothing wrong with that. There are a lot of pieces to assemble to get that running. This is an important first step.


The real script works from a scheduling/launchd/plist perspective.. but however it is running, the script's rsync lines cannot access my user Documents and Pictures etc. directories

Ah! The truth comes out. This is one of those cases where an initial "what are you trying to do?" query would have been useful. But we've been well-trained to avoid that question to limit the OP apoplectic rage factor. But it's always been a great question.


The script works perfectly fine if I manually run it. I mean come on, all I want to do is automate this to rsync every hour. Should not be this difficult.

Do you want to know why it's that difficult? It's fascinating really. I'm sure you don't. 😄


Very frustrating. This is my computer, my files, my network, etc.

Your computer? You files? Your network? Wow! And when you run commands, they work perfectly. But when some random, unsigned script running in a background context attempts to siphon off all of your files it doesn't work? Remind me again if this is a good thing or a bad thing.


If Apple is going to prohibit basic scripting, at least produce errors and documentation on what exactly is the prohibition. A perfectly well working script cannot be scheduled by the administrator of the system?

Are you equating "basic scripting" to "scheduled execution of rsync"? I've only been working with Unix-based system for less than 40 years, so still learning. But I avoid rsync. What a nasty piece of work that is.


So secure it is not useable?

As you've repeatedly stated, it works fine and is perfectly usable. You're just unable to schedule it to run unattended.


I was hoping to use the Apple OS ecosystem/standards (in a sense) but nope. I guess I have to explore cron on macOS or something else.

There's a song that has a line like "two steps forward, one step back" 🎶


(Am I the only one who notices the incredible lack of musical notes in Unicode?)


As MrHoffman correctly stated, cron is an excellent choice for many automation tasks. It's much easier than plist config files. But you didn't ask about cron or about scheduling rsync to backup your privacy-sensitive files. You asked about launch agents, so we answered questions about launch agents.


And all this is due to I have significant investment in perfectly good, but older, network appliances that are unfortunately dependent on AFP for TimeMachine backups. So Apple kills AFP, but provides no workaround. In fact, my TimeMachines are literarily useless now. $ --> trash.

Cue the rant incoming...


Yes, it's awful that Apple discontinued AFP with absolutely no networking protocol replacement. Steve Jobs never would have done that. Dear Apple, we didn't buy new devices, and constantly keep them updated, just so you could break compatibility to all of our other devices we purchased and last updated in 2002!


Vendors of older appliances certainly are not going to help either.

Yeah! It's almost like all anybody wants to do anymore is sell new products. Sell, sell, sell! That's all they want!


D**n Capitalists!


So fine, rsync via ssh to the older NAS'.... only Apple is making that very difficult. Again, scripts work. Automation routes seem unsupported.

Totally unsupported. Nobody does automation anymore. They're too busy.


Yeah. So this is really awkward. This is a classic case of someone digging themselves into a rabbit hole, and then posting a question asking for advice regarding more efficient shovels. The answer isn't a new shovel, it's a ladder.


There are far better ways to backup your data. I'm sorry if your old NAS devices are discontinued, unsupported, and haven't been updated in years. I've been around these parts long enough to remember the day when Apple discontinued AFP. Every NAS in the world broke that day. After about 3 minutes of digging, I discovered that Apple had publicly deprecated a key, but obsolete, AFP security protocol 9 years earlier.


So what did everyone do? Did they update their systems to switch to SMB? No! Of course not! They exchanged notes and figured out how to re-enable the deprecated AFP! Whew! Dodged a bullet on that one! And then, next year, Apple actually removed support for the insecure protocol and everything broke again.


Since then, I've seen this pattern repeated multiple times. Apple's been pretty consistent in discontinuing outdated technologies 10 years after they become obsolete or unsafe. And boy do people hate that! They hate it so much they stopped buying Apple devices entirely and threw them all in the trash, just like you. Oh wait...nobody did that, did they? Hmmm....

Oct 25, 2025 12:43 PM in response to MrHoffman

Def don't need GUI so I moved to cron.. it runs the script from a permissions perspective however, very oddly, it handles the quoted paths differently than terminal/bash.


The the source paths are held in lists in the script. Where the source paths have spaces, they get handled differently when executed from CRON. For example "/Library/Application Support/" gets broken into "/Library/Application" and "/Library/Application Support/"; two paths. And since this is a single source it writes to both paths. LOL.


So cron causes (?) bash to handle " " in a bash list differently than terminal and bash. Honestly this seems like a me problem as perhaps there is a universal way to quote variables for paths with spaces held in a list in a bash script.


** shebang is: #!/bin/bash and "which bash" is /bin/bash


Maybe instead of "<full path to .sh>" as the cron line I should call the .sh a different way?

Like perhaps "/bin/bash <full path to .sh>"(?)


Might just convert everything to python and see if then there is more consistent execution behaviors among the different ways to call a .py

This thread has been closed by the system or the community team. You may vote for any posts you find helpful, or search the Community for additional answers.

Run bash (.sh) via launchd using a plist in /Library/LaunchAgents

Welcome to Apple Support Community
A forum where Apple customers help each other with their products. Get started with your Apple Account.