深夜に動くはずのスケジュールタスクが、朝になっても記事を1本も出していない。ログを開くと、原因はこの1行だけでした。
fatal: could not create work tree dir '/tmp/repos/claudelab.net': Permission denied
/tmp は誰でも書き込める一時領域のはずです。ディスク残量も 1,600 MB 以上ありました。それでも弾かれる。調べてみると、/tmp/repos/ というサブディレクトリそのものが nobody:nogroup 所有になっており、そのときのセッションユーザーには書き込み権限がなかったのです。
私はこの症状に何度か出会っていて、そのたびに「前回書いたはずの回避コードが、なぜか効いていない」という同じ落胆を味わってきました。個人開発で自動化を組んでいると、この種の環境依存の失敗はいつも静かに起きます。以下では原因だけでなく、回避コードのほうに潜んでいた欠陥まで含めて書きます。掲載しているシェルの挙動は、症状が再発したときに私自身が Cowork の VM 上で走らせ直して確認したものです。
なぜ /tmp/repos が nobody 所有になるのか
Cowork の VM では、/tmp/ 以下のサブディレクトリが前のセッション由来の所有者のまま残ることがあります。前のセッションが nobody:nogroup として /tmp/repos/ を作っていた場合、次のセッションは別のユーザーコンテキストで動くため、そのディレクトリには書き込めません。
鍵になるのは、スティッキービットが効く範囲です。/tmp/ 直下は drwxrwxrwt で全ユーザーが書き込めますが、その下に作られた /tmp/repos/ は通常の rwxr-xr-x で作成されます。書き込めるのは所有者だけです。
実際に再発したときの VM を覗くと、こうなっていました。
$ ls -ld /tmp /tmp/repos
drwxrwxrwt 31 nobody nogroup 4096 /tmp
drwxr-xr-x 3 nobody nogroup 4096 /tmp/repos
$ id
uid=1296(trusting-keen-edison) gid=1296(trusting-keen-edison)
/tmp は誰でも書ける。/tmp/repos は nobody だけが書ける。そして自分は nobody ではない。3行並べると当たり前の話ですが、エラーメッセージが指しているのは /tmp/repos/claudelab.net のほうなので、視線が一段深いところに引っ張られて気づきにくいのだと思います。
mkdir -p が通ってしまうから、気づくのが遅れる
この問題が厄介なのは、手前の処理が静かに成功してしまう点にあります。
$ mkdir -p /tmp/repos
$ echo $?
0
mkdir -p は対象がすでに存在すれば何もせず終了コード 0 を返します。その既存ディレクトリに書き込めるかどうかは見ていません。 ですから次のような書き方は、安全弁のように見えて実際にはまったく作動しません。
# ❌ 効かない書き方
mkdir -p /tmp/repos 2>/dev/null || WORK="$HOME/repos/claudelab.net"/tmp/repos が nobody 所有で存在している状況では mkdir -p が 0 を返すため、|| の右辺は永久に実行されません。そのまま git clone に進んで、そこで初めて Permission denied を受け取ります。私が「回避コードを入れたはずなのに効いていない」と感じていた原因は、まさにこれでした。
判定は作成の成否ではなく、書き込み可否そのもので行う必要があります。
# ✅ 実際に判定できる書き方
[ -d /tmp/repos ] && [ ! -w /tmp/repos ] && echo "書き込み不可"先ほどの VM でこの2条件を評価すると、期待どおり「書き込み不可」と表示され、フォールバックが発火しました。-w はディレクトリに対しても機能するので、追加のツールは要りません。
この問題が起きやすいのは、経験上つぎの3つの場面です。Cowork の更新やセッション再起動で内部ユーザーコンテキストが切り替わった直後。複数のスケジュールタスクが同じ /tmp/repos/ を共有していて、先に走ったタスクが nobody として作ってしまったとき。そして前回のタスクが git 操作の途中で異常終了し、中途半端なディレクトリを残したとき。共通しているのは「前回の実行状態が今回に影響する」という点です。スケジュールタスクは毎回まっさらな環境で起動しているように見えて、/tmp/ 以下はセッションをまたいで共有されています。
対処法:$HOME/repos へのフォールバック
最も確実なのは、/tmp/repos/ が使えないときに $HOME/repos/ へ自動的に切り替えることです。
# /tmp/repos の「書き込み可否」を見てフォールバックする
if [ -d /tmp/repos ] && [ ! -w /tmp/repos ]; then
echo "⚠️ /tmp/repos が書き込み不可(nobody 所有の可能性)— $HOME/repos に切り替え"
WORK="$HOME/repos/claudelab.net"
elif mkdir -p /tmp/repos 2>/dev/null && [ -w /tmp/repos ]; then
WORK="/tmp/repos/claudelab.net"
else
WORK="$HOME/repos/claudelab.net"
fi
mkdir -p "$(dirname "$WORK")" || { echo "⛔ 作業ディレクトリを作成できません"; exit 1; }
echo "📁 作業ディレクトリ: $WORK"ポイントは elif の側でも mkdir -p の成否だけで判断せず、続けて [ -w /tmp/repos ] を確認していることです。作れたことと書けることは別物だ、という一点さえ押さえておけば、この分岐は素直に読めます。
$HOME は Cowork の VM では /sessions/セッション名/ 以下に展開され、現在のセッションが所有しているため確実に書き込めます。
掃除コードが作業ディレクトリごと消してしまう
ディスク不足に備えて「古いリポジトリを自動削除する」処理を入れている方は多いと思います。私も入れていました。よく見かけるのは、こういう書き方です。
# ❌ 危険:作業ディレクトリごと消える
find "$(dirname "$WORK")" -maxdepth 1 -type d \
-not -name "$(basename $WORK)" \
-exec rm -rf {} + 2>/dev/null意図は「/tmp/repos/ の中から、いま使うリポジトリ以外を消す」です。ところが find は探索の起点そのものも結果に含めます。/tmp/repos の basename は repos であって claudelab.net ではないため、-not -name の除外をすり抜け、親ディレクトリごと rm -rf の対象になります。
実際に検証用のディレクトリを作って走らせました。
# 実行前
/tmp/t1/repos/claudelab.net/KEEPME
/tmp/t1/repos/gemilab.net
/tmp/t1/repos/rorklab.net
# 上記コマンドを実行
# 実行後
ls: cannot access '/tmp/t1/repos': No such file or directory
残すはずだった KEEPME → 消失
守ろうとしたディレクトリごと消えました。しかもこの処理は「残量が閾値を下回ったとき」にしか走らないため、通常のテストではまず再現しません。ディスクが逼迫した日にだけ、静かに作業場所を破壊します。
修正は1語です。-mindepth 1 を足して、起点そのものを結果から外します。
# ✅ 安全:起点を除外する
find "$(dirname "$WORK")" -mindepth 1 -maxdepth 1 -type d \
-not -name "$(basename "$WORK")" \
-exec rm -rf {} + 2>/dev/null同じ検証を通すと、今度は claudelab.net と KEEPME がそのまま残り、他のリポジトリだけが消えました。あわせて $WORK は必ずダブルクォートで囲んでください。パスに空白が入ったときに basename の引数が割れます。
ディスク不足(ENOSPC)を含めた診断ブロック
Permission denied と No space left on device はどちらも git clone を止めますが、対処は別物です。スケジュールタスクの冒頭で両方をまとめて診断する形にしておくと、朝のログを読む時間が短くなります。
もう一つ、残量チェックには落とし穴があります。df に存在しないディレクトリを渡すと出力が空になり、${FREE_MB:-0} が 0 と評価されて「残量ゼロ」と誤判定されます。手元で確認した挙動はこうでした。
$ FREE_MB=$(df /tmp/does-not-exist --output=avail -m 2>/dev/null | tail -1 | tr -d ' ')
$ echo "[${FREE_MB}] -> ${FREE_MB:-0}"
[] -> 0
初回実行時はまだ作業ディレクトリが無いのが普通ですから、df を呼ぶ前にディレクトリを作っておくか、空判定を挟む必要があります。以上を織り込んだブロックが次のものです。
WS="$(ls -d /sessions/*/mnt/Dolice\ Labs 2>/dev/null | head -1)"
GITHUB_TOKEN=$(grep -A1 "Claude Lab" "${WS}/_documents/_github_tokens/github_tokens.txt" \
| tail -1 | tr -d '[:space:]')
REPO_URL="https://${GITHUB_TOKEN}@github.com/masakihirokawa/claudelab.net.git"
# --- 1. 書き込み可否で作業先を決める ---
if [ -d /tmp/repos ] && [ ! -w /tmp/repos ]; then
WORK="$HOME/repos/claudelab.net"
elif mkdir -p /tmp/repos 2>/dev/null && [ -w /tmp/repos ]; then
WORK="/tmp/repos/claudelab.net"
else
WORK="$HOME/repos/claudelab.net"
fi
BASE="$(dirname "$WORK")"
mkdir -p "$BASE" || { echo "⛔ $BASE を作成できません"; exit 1; }
# --- 2. 残量チェック(空文字を「不明」として扱う) ---
FREE_MB=$(df "$BASE" --output=avail -m 2>/dev/null | tail -1 | tr -d ' ')
if [ -z "$FREE_MB" ]; then
echo "⚠️ 残量を取得できませんでした($BASE)— 掃除はスキップします"
else
echo "📊 残量: ${FREE_MB}MB(作業先: $WORK)"
if [ "$FREE_MB" -lt 300 ]; then
echo "⚠️ 残量不足 — 他のリポジトリを削除します"
find "$BASE" -mindepth 1 -maxdepth 1 -type d \
-not -name "$(basename "$WORK")" \
-exec rm -rf {} + 2>/dev/null
FREE_MB=$(df "$BASE" --output=avail -m 2>/dev/null | tail -1 | tr -d ' ')
echo "📊 削除後残量: ${FREE_MB:-不明}MB"
fi
if [ "${FREE_MB:-0}" -lt 200 ]; then
echo "⛔ それでも残量不足 — タスクを中止します"
exit 1
fi
fi
# --- 3. clone または pull ---
if [ -d "$WORK/.git" ]; then
cd "$WORK" || exit 1
git remote set-url origin "$REPO_URL"
git pull --rebase origin main || {
cd "$BASE" || exit 1
rm -rf "$WORK"
git clone --depth 1 "$REPO_URL" "$WORK" || exit 1
cd "$WORK" || exit 1
}
else
git clone --depth 1 "$REPO_URL" "$WORK" || exit 1
cd "$WORK" || exit 1
fi
git config user.email "masakihirokawa@gmail.com"
git config user.name "Masaki Hirokawa"
echo "✅ リポジトリ準備完了"git pull が失敗したときの再 clone で、rm -rf "$WORK" の前に cd "$BASE" を挟んでいるのは、削除済みのディレクトリに居座ったまま git clone を呼ぶと環境によっては失敗するためです。細かい話ですが、リカバリ経路こそ落ち着いて書いておきたいところです。
/tmp と $HOME をどう使い分けるか
議論より実測が早いので、実際に計測しました。対象は Claude Lab のリポジトリ 1 本です。
| 操作 | 所要時間 | 備考 |
|---|---|---|
git clone --depth 1(新規) | 約 3.8 秒 | 作業ツリー込みで 45 MB |
git pull --rebase(差分なし) | 約 0.8 秒 | 既存の shallow クローンに対して |
1 回あたりの差は 3 秒ほどです。単発なら誤差の範囲でしょう。ただ 4 サイト分を 1 日に何度も回すと、この 3 秒はタスクごとに積み上がっていきます。それ以上に効いてくるのは、clone は毎回ネットワークに依存するという点です。差分更新なら、通信が不安定な時間帯でも失敗しにくくなります。
なお --depth 1 の shallow クローンに対して git pull --rebase origin main が使えるかは、迷いやすいところだと思います。手元で確認した限りでは問題なく動作し、git rev-parse --is-shallow-repository は true のまま、ローカルのコミット数も 1 のままでした。履歴を掘る必要がないタスクなら、shallow のまま差分更新を続けて構いません。
判断の目安としては、単発スクリプトや履歴を残したくない処理は /tmp、1 日に複数回動くスケジュールタスクは $HOME/repos/ の永続リポジトリ方式、という分け方に落ち着いています。/tmp を使う場合でも、書き込み可否チェックだけは必ず入れてください。
よくある関連エラーとその見分け方
同じ git clone の失敗でも、メッセージを見れば原因はほぼ確定します。
| エラーメッセージ | 原因 | 最初にやること |
|---|---|---|
Permission denied | 親ディレクトリの所有者問題 | ls -ld で所有者を確認し、$HOME/repos/ へ切り替える |
No space left on device | ディスク不足 | df -h で確認し、他のリポジトリと .next/ を削除する |
Could not resolve host | ネットワーク未接続 | VM のインターネットアクセス設定を確認する |
Repository not found | トークン期限切れ/リポジトリ名変更 | PAT を再生成し、SKILL.md 側も更新する |
index.lock: File exists | 前回の git プロセスが異常終了 | .git/index.lock を削除するか、再 clone する |
Repository not found については一点だけ補足させてください。トークンが無効なときも同じ文言が返ることがあります。リポジトリ名の打ち間違いを疑う前に、まずトークンの有効期限を確認したほうが早く着地します。
SKILL.md への組み込み方
スケジュールタスクを SKILL.md で管理している場合は、Step 0 のリポジトリ準備の冒頭に次を置いておくと、nobody 問題が再発しても自動で迂回できます。
# Step 0 の冒頭(書き込み可否で判定する)
_BASE="/tmp/repos"
if [ -d "$_BASE" ] && [ ! -w "$_BASE" ]; then
_BASE="$HOME/repos"
elif ! mkdir -p "$_BASE" 2>/dev/null || [ ! -w "$_BASE" ]; then
_BASE="$HOME/repos"
fi
mkdir -p "$_BASE" || exit 1
WORK="${_BASE}/claudelab.net"SKILL.md を直したら、スケジュールタスク本体のプロンプトにも同じ変更を反映してください。両者は別の文書として読み込まれるため、片方だけ直しても次回の実行で古い挙動に戻ります。手動実行では気づきにくく、私はこれで一度、直したはずの不具合を翌朝もう一度見ることになりました。
次にとるべきアクション
まず ls -ld /tmp/repos を実行してください。所有者に nobody nogroup が見えたら、Step 0 の判定を mkdir -p の成否から -w に置き換えます。ここが今回いちばん効く一手です。
次に、掃除コードの find を開いて -mindepth 1 があるか確かめてください。無ければ足してください。ディスクが逼迫した日にだけ発火して作業場所を消す処理は、静かなぶん厄介です。
どちらの修正も数分で終わります。自動化は何も起きていないときではなく、何かが起きたときに真価を問われます。同じ症状で朝を無駄にしている方の役に立てば嬉しいです。お読みいただきありがとうございました。