launchdの定期実行が手動では動くのに定期では動かないとき、まず疑うべきなのは、macOSのプライバシー保護(TCC:Transparency, Consent, and Control)です。デスクトップ・書類・ダウンロードのフォルダは保護の対象で、どのプログラムが読み書きしてよいかが個別に管理されています。ターミナルから実行すると、ターミナルに与えた許可で動くため成功すると考えられます。launchdから起動すると同じ許可が効かず、Operation not permitted で止まることがあります。
この記事は、当社(株式会社etika)がマーケティングの自動化を自社のMacで定期実行しようとして、この問題に当たった記録です。技術の話ですが、AIやスクリプトでマーケ運用を回している小さなチームなら、同じところでつまずく可能性が高いと考えて書きました。
何を定期実行したかったのか
当社は2026年9月、自社のセミナーの集客にMetaのリード獲得広告を使いました。リード獲得広告とは、Facebook・Instagramの広告の上で申込フォームまで完結する広告です。申込はMeta側にたまるため、誰かが取り出して、CRM(Zoho CRM)に登録し、ウェビナーの参加登録をして、案内のメールを届ける必要があります。
最初は、この処理をスクリプトで用意しておき、人が手で実行する運用でした。ところが広告の配信が予定より1週間早く始まっていて、9月8日から14日まで、スクリプトは一度も実行されませんでした。気づいたときには、申込3件が未処理のまま残っていました。申込から返事が来るまでの時間が延びるほど、関心は冷めていきます。この考え方は「Speed to Leadとは」で詳しく書いています。
そこで、15分ごとに自動で実行する設定にしました。使ったのはmacOS標準のlaunchdです。全体の構成は「リード獲得広告のリードを取りこぼさない」にまとめています。ここでは、その定期実行が動かなかった原因と対処に絞ります。
当社で起きたこと:シェルのラッパーが一度も動かなかった
当社のスクリプトとその設定ファイルは、~/Desktop の下にあるリポジトリに置いています。最初は、よくある形で設定しました。launchdから /bin/zsh を起動し、シェルスクリプトの中で環境を整えてから、Node.jsの同期スクリプトを呼ぶ形です。
ターミナルから同じシェルスクリプトを実行すると、問題なく動きます。ところがlaunchdから起動すると、エラーのログに次の1行が出て終わりました。
/bin/zsh: can't open input file: /Users/you/Desktop/.../auto-sync/run-sync.sh
ファイルは確かにその場所にあります。パスに日本語の全角文字を含んでいたため、最初は文字コードを疑いました。
原因の切り分け:launchdから何が読めて、何が読めないか
推測で直すのをやめて、launchdから起動する診断用の小さなジョブを作り、何が読めて何が読めないかを1つずつ確かめました。2026年9月14日の結果は次のとおりです。
| launchdから試したこと | 結果 |
|---|---|
ls で ~/Desktop の一覧を出す | 拒否(Operation not permitted) |
head で日本語パスのシェルスクリプトを読む | 拒否(Operation not permitted) |
head でリポジトリ直下の設定ファイル(.env)を読む | 拒否(Operation not permitted) |
/usr/local/bin/node でファイルを読む | 成功 |
日本語を含まない .env も読めなかったので、文字コードの問題ではありません。~/Desktop の一覧すら出せないことから、Desktop配下へのアクセスそのものが止められていると分かりました。一方で、/usr/local/bin/node からは読めました。続けて書き込みも試し、こちらも成功しました。
nodeだけが許可されている理由は、当社では特定できていません。過去に何かのきっかけで、このnodeにアクセスの許可が与えられていた可能性があります。許可の状況は、システム設定の「プライバシーとセキュリティ」の「ファイルとフォルダ」で確認できます(Appleの「Macでファイルやフォルダへのアクセスを制御する」)。
対処:nodeを直接起動し、ラッパーもNodeで書く
当社の対処は、launchdの設定で最初に起動するプログラムを /usr/local/bin/node にし、シェルを一切挟まないことでした。シェルスクリプトでしていた処理(二重起動の防止、ログの書き出し、期限の判定、失敗時の通知)は、すべてNodeのスクリプトに書き直しました。設定ファイル(plist)は次の形です。
<?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>Label</key>
<string>com.example.lead-sync</string>
<key>ProgramArguments</key>
<array>
<string>/usr/local/bin/node</string>
<string>/Users/you/Desktop/marketing/auto-sync/run-sync.mjs</string>
<string>--dry-run</string>
</array>
<key>StartInterval</key>
<integer>900</integer>
<key>RunAtLoad</key>
<false/>
<key>StandardOutPath</key>
<string>/Users/you/Library/Logs/lead-sync/launchd.out.log</string>
<key>StandardErrorPath</key>
<string>/Users/you/Library/Logs/lead-sync/launchd.err.log</string>
</dict>
</plist>
StartInterval の900は秒数で、15分ごとに実行する指定です。ログは、会社名などの申込情報を含むため、リポジトリの外(~/Library/Logs)に置いています。
nodeの場所は、必ず絶対パスで書きます。当社のMacでターミナルから which -a node を打つと、2つのnodeが表示されました。ターミナルで普段使われるのは、PATHの先頭にある別のnodeです。launchdの設定に node とだけ書いたり、別のnodeに変えたりすると、そのnodeに許可があるとは限りません。nodeを入れ替えるときは、診断用のジョブで読み書きを確かめ直します。
動作確認はlaunchd経由でしか意味がない
今回の一番の学びは、ターミナルでの手動実行の成功は、定期実行が動く根拠にならないということです。確認は、launchdに登録したうえで、launchd経由で1回走らせて行います。
LBL=com.example.lead-sync
PL="$HOME/Library/LaunchAgents/$LBL.plist"
# 登録して、すぐに1回走らせる
launchctl bootstrap gui/$(id -u) "$PL"
launchctl kickstart gui/$(id -u)/$LBL
# 状態と直近の終了コードを見る
launchctl print gui/$(id -u)/$LBL | grep -E "state|last exit code|run interval"
# 止めて登録を外す
launchctl bootout gui/$(id -u)/$LBL
当社は、本番の処理に切り替える前に、確認だけを行うモードで数回走らせました。このモードでは、書き込み先のフォルダに一時ファイルを作って消す処理も入れ、読むだけでなく書けることも確かめています。お客様に案内メールが届く処理なので、本番モードへの切り替えはそのあとです。9月14日の朝に本番へ切り替え、未処理だった申込もその場で処理されました。
定期実行を止めずに回すための注意点
その後、セミナー当日までの約12日間(9月14日〜25日)、同期は動き続け、ログに残った実行は479回でした(うち4回は確認用の手動のドライラン)。15分ごとなら1日最大96回ですが、実際は1日1〜75回でした。launchd は、Macがスリープ中や電源が切れている間の実行を、起きたあとの1回にまとめるためです。連休の3日間(9月19〜21日)は、1日1〜8回にとどまっていました。運用してみて分かった注意点を3つ挙げます。
- スリープ明けの実行は失敗しやすい:連休中に広告を止めて再開する処理も、launchdで日時を指定して予約しました。停止の回は予定の00:05ではなく、Macがスリープから戻った00:20に実行され、通信のエラーで失敗していました。launchdは、スリープ中に過ぎた予定を起床時に実行しますが、その直後はネットワークがまだつながっていないことがあります。電源が切れている間に過ぎた予定は実行されません。日付を指定した処理は、当日の朝に結果を確かめます
- 終わりの条件をスクリプトに持たせる:同期のスクリプトには「セミナー当日の13時を過ぎたら何もしない」という期限を書いていました。実際には、セミナー後もしばらく登録を外し忘れていましたが、期限の判定があったので余計な処理は走りませんでした。登録を外すのは人の作業なので、外し忘れても害がない作りにしておきます
- 失敗は人に知らせる:定期実行は、失敗しても誰も見ていません。当社は、同期やウェビナー登録に失敗したときにMacの通知を出し、ログの場所を示すようにしています
10月の次のセミナーでも、同じ設定を複製して15分ごとの同期を回しています。
Desktop配下の定期実行を作るときのチェックリスト
- 定期実行するスクリプトや、読み書きするファイルがデスクトップ・書類・ダウンロードの下にあるか。あるなら、この問題が起きる前提で作る
- 対象外の場所に移せるなら、移すのが最も簡単
- 移せない場合、plistの先頭は許可のあるプログラム(当社は
/usr/local/bin/node)を絶対パスで書き、シェルを挟まない - 診断用のジョブで、一覧・読み取り・書き込みの3つを確かめたか
- 動作確認を
launchctl kickstartでlaunchd経由で行ったか - ログをリポジトリの外に出し、失敗時に人へ知らせる仕組みがあるか
- スクリプト側に期限や終了条件を持たせたか
シェルにフルディスクアクセスを与える方法もありますが、当社は試していません。与えると、そのシェルから動くあらゆるスクリプトが保護の外に出る点に注意してください。launchdの基本は、Appleの「launchdを使ったスクリプト管理(ターミナルユーザガイド)」にまとまっています。
広告・CRM・メールなどのマーケ運用を、AIとスクリプトで回す全体像は「AIマーケティングハーネスとは」で紹介しています。Claude Code・Codexとスクリプトで自社の運用を回す仕組みづくりは、「マーケ担当AIエージェント(直接運用型)」で支援の内容を紹介しています。
よくある質問
launchdとは何ですか?
macOSで、プログラムの起動や定期実行を管理する仕組みです。設定はplistという形式のファイルに書き、自分のユーザーだけで動かすものは ~/Library/LaunchAgents に置きます。Appleは、スクリプトを常駐・定期実行させるならlaunchdから起動するよう案内しています。
LaunchAgentsとは何ですか?
ログインしているユーザーの権限で動く、launchdの設定ファイルを置く場所、またはそこに置いた定期実行の設定のことです。~/Library/LaunchAgents に置いたものはそのユーザーだけに効きます。管理者権限で全体に動かすものはLaunchDaemonsと呼びます。
Operation not permittedはsudoで解決しますか?
解決しません。これはファイルの所有者や読み書きの権限ではなく、macOSのプライバシー保護による制限です。どのプログラムに、どのフォルダへのアクセスを許可するかで決まります。
cronに変えれば動きますか?
当社は試していません。cronから起動したプログラムも同じプライバシー保護の対象になるため、Desktop配下を読む場合は同じ問題が起こりうると考えています。Appleはlaunchdの利用を案内しているので、当社はlaunchdで運用しています。
スクリプトをDesktopの外に移せば解決しますか?
多くの場合は解決します。保護の対象はデスクトップ・書類・ダウンロードなどの特定のフォルダなので、定期実行するスクリプトと、それが読み書きするファイルを対象外の場所に置けば、この問題は起きません。当社はリポジトリの場所を変えたくなかったため、Nodeを直接起動する方法を選びました。

