朝のスケジュールタスクのログを開くと、fatal: could not create work tree dir '/tmp/repos/claudelab.net': Permission denied の1行で止まっていることがあります。実行ユーザーは確かに動いている、Git トークンも有効、ディスク容量も残っている。けれども /tmp/repos のサブディレクトリが前回セッションの後始末で nobody:nogroup 所有のまま残っていて、新しいセッションのユーザーでは書き込みも削除もできない、という状態でした。
私は Dolice Labs として Claude Lab・Gemini Lab・Antigravity Lab・Rork Lab の4サイトを Claude Code のスケジュールタスクで自動更新しています。各サイト毎日4本の記事を別ユーザー権限のサンドボックスで生成しているため、この /tmp まわりの権限事故は半年で十数回ぶつかってきました。今回は再現の原因と、無人運用でも止まらない $HOME/repos フォールバックの最小実装をまとめます。
症状とログ
代表的なログはこの形です。
$ git clone --depth 1 https://...@github.com/masakihirokawa/claudelab.net.git /tmp/repos/claudelab.net
Cloning into '/tmp/repos/claudelab.net'...
fatal: could not create work tree dir '/tmp/repos/claudelab.net': Permission denied
加えて、ls -la /tmp/repos/ でディレクトリ所有者が nobody:nogroup になっているのが特徴です。
$ ls -la /tmp/repos/
drwxr-xr-x 3 nobody nogroup 4096 May 25 07:34 .
drwxrwxrwt 16 nobody nogroup 4096 May 27 01:34 ..
drwxr-xr-x 7 nobody nogroup 4096 May 25 07:34 antigravitylab.net
このときの私の実行ユーザーは nobody ではないので、既存ディレクトリの中に新しいワークツリーを作れません。rm -rf も同じ理由で弾かれます。
なぜ起きるのか
サンドボックス VM では、セッションごとに実行ユーザーが切り替わる構成があります。前回セッションが /tmp/repos/{site} を作って nobody:nogroup で残したまま終了し、次のセッションが別ユーザーで起動すると、その所有権を引き継げません。/tmp 自体は sticky bit 付き (drwxrwxrwt) なので新規ディレクトリは作れますが、既に存在する他人所有のサブツリーには手が出せない、というのが本質です。
ENOSPC(ディスク不足)と紛らわしい挙動ですが、エラーメッセージが No space left on device か Permission denied かで切り分けられます。前者はキャッシュ削除、後者は書き込み先の付け替えで解決します。
最小フォールバック実装
私が4サイトの SKILL.md に組み込んでいるのは、/tmp/repos が書き込めなければ $HOME/repos に退避する3行です。
WORK="/tmp/repos/${SITE}"
# /tmp/repos が書けるか実地で検証(権限と容量の両方)
if ! mkdir -p "$WORK" 2>/dev/null; then
WORK="$HOME/repos/${SITE}"
mkdir -p "$WORK"
fi
echo "WORK=$WORK"mkdir -p を実地に叩いて成否で判定するのが要点です。test -w /tmp/repos だけだと「ディレクトリ自体は書ける、けれど中の claudelab.net/ には書けない」というケースを取りこぼします。私は半年前にこの判定漏れでスケジュールタスクが2日連続で空振りしました。
clone と pull の分岐
フォールバック先を決めた後は、既存リポジトリがあるかどうかで clone と pull --rebase を分けます。
WS="$(ls -d /sessions/*/mnt/Dolice\ Labs 2>/dev/null | head -1)"
TOKEN=$(grep -A1 "^${SITE_LABEL}" "${WS}/_documents/_github_tokens/github_tokens.txt" \
| tail -1 | tr -d '[:space:]')
if [ -d "$WORK/.git" ]; then
cd "$WORK" && git pull --rebase origin main 2>&1 | tail -5
else
git clone --depth 1 \
"https://${TOKEN}@github.com/masakihirokawa/${SITE}.git" "$WORK" \
2>&1 | tail -5
fi--depth 1 は記事追加用途であれば充分です。Cloudflare の CI 側で prebuild フックが generate-content.mjs を走らせる設計なので、ローカルで npm install や Next.js のビルドを試す必要はありません。記事を1本書いて push するだけなら、リポジトリの実体は数十 MB に収まります。
ディスク不足との合わせ技
$HOME/repos に逃がしても、ホームディレクトリ側が満杯であれば同じことです。容量チェックを前段に置き、500MB を切ったら他サイトの作業用リポジトリを掃除しておくのが安全策でした。
FREE_MB=$(df "$HOME" --output=avail -m 2>/dev/null | tail -1 | tr -d ' ')
if [ "${FREE_MB:-0}" -lt 500 ]; then
for OTHER in "$HOME/repos"/*; do
[ "$OTHER" != "$WORK" ] && rm -rf "$OTHER" 2>/dev/null
done
fi4サイトを並行で動かしていると、午前2時のプレミアム枠と午後3時の通常記事枠で同じ VM が再利用されるケースがあります。前のタスクが残した node_modules で 800MB 食っていたこともあり、容量解放のロジックを最初に通すだけで全体の安定性がだいぶ変わりました。
予防 — SKILL.md にフォールバックを直接書いておく
スケジュールタスクの本体プロンプトと、各サイトの _skills/{site}/SKILL.md の両方に、フォールバック処理を明示しておくのが現実的でした。CLAUDE.md だけに書いておいても、スケジュール実行時のコンテキストには SKILL.md と本体プロンプトしか届かない場面があるためです。
# _skills/claudelab/SKILL.md の Step 0 に貼ってある実装
WORK="/tmp/repos/claudelab.net"
mkdir -p "$WORK" 2>/dev/null || WORK="$HOME/repos/claudelab.net"
mkdir -p "$WORK"|| で短く書く版は、シェルスクリプトに直接書く時には読みやすいです。CLAUDE.md には事象と背景、SKILL.md には実装、本体プロンプトには「失敗時は自動リカバリ」と書き分けると、後から読み返した時にも経緯が追えます。
切り分けチェックリスト
事故ったときに最初に確かめている順番です。
ls -la /tmp/repos/で対象サブディレクトリの所有者を確認するwhoamiとidで現在の実行ユーザーを確認するdf -h /tmpとdf -h $HOMEで両方の空き容量を確認するmkdir -p "$WORK" && touch "$WORK/.write_test" && rm "$WORK/.write_test"で実書き込みテストを通す- それでも駄目なら
WORK=$HOME/repos/${SITE}に切り替えて同じテストを通す
このうち4のステップは、test -w よりも実地検証のほうが副作用込みで判定できるので確実でした。私はここで1度はまって、ストレージドライバが書き込み許可だけ返しておいて実際の書き込みで EPERM を返す事象に当たったことがあります。
なぜ私は4年前からこの仕組みを必要としているか
私が個人開発を始めたのは2014年で、当時から Beautiful HD Wallpapers などの壁紙アプリと癒し系アプリを App Store と Google Play で出し続けてきました。累計で5,000万ダウンロードまで来ているのですが、AdMob 月収を安定させながら現代美術家としての活動も続けるとなると、ブログ運営の手は最大限自動化する以外に道がありませんでした。
2014年〜2019年ごろは手作業で記事を書いていましたが、Claude Code のスケジュールタスクと組み合わせるようになってから、4サイト × 1日4本という分量を無人で回せるようになりました。とはいえサンドボックスの権限事故で1日4本が0本になる失敗は、月初の Stripe メンバーシップ収益にも直結します。Permission denied 1行で2日空いた朝、ログイン中だった iPhone から Apple Watch 越しに通知が飛んできて、吉祥寺のカフェで対応した記憶があります。
それ以来、/tmp を当てにしないことと、フォールバック先での書き込み実地検証は、自動運用の最低保証ラインとして外せなくなりました。
4サイト横展開の小ネタ
4つの SKILL.md を同じパターンで書き換えるとき、サイト名だけを差し替える前提でテンプレートを共通化しています。
# 共通スニペット(4サイト全てに同じ形で組み込み)
SITE_REPO="$1" # 例: claudelab.net
WORK="/tmp/repos/${SITE_REPO}"
mkdir -p "$WORK" 2>/dev/null || WORK="$HOME/repos/${SITE_REPO}"
mkdir -p "$WORK"
touch "$WORK/.write_test" 2>/dev/null || {
echo "write test failed even on fallback: $WORK" >&2
exit 1
}
rm "$WORK/.write_test"
echo "WORK=$WORK".write_test のステップを最後に置いておくと、ストレージドライバが嘘の許可を返す事例も拾えます。私はこれを Antigravity Lab で1度経験してから、必ず touch + rm のセットで検証するようにしました。
自動運用へ落とし込む
4サイト分のスケジュールタスクを Cowork 側から走らせている都合上、エラー時に止めるのではなく自動で次の手を打たせる方針にしました。フォールバック成功時にもログには WORK=$HOME/repos/... と書き出しておくと、後で task_error.log を眺めたときに「あの日のあの記事はホーム側で生成された」と追跡できます。
無人運用で記事を更新し続けるなら、/tmp を当てにしないという前提に切り替えるのが結局のところ一番強い対策でした。$HOME/repos は同じセッション内では確実に書ける場所で、シャットダウン時に消えてもまた clone すればよいだけです。
似たような無人パイプラインを組んでいる方の参考になれば幸いです。お読みいただきありがとうございました。