About six months into running four AI blogs on fully automated Cowork scheduled tasks, this error appeared in the task logs with no warning:
fatal: could not create work tree dir '/tmp/repos/claudelab.net': Permission denied
The /tmp directory is supposed to be world-writable. Disk space was fine — over 1,600 MB available. So why Permission denied? It turned out that /tmp/repos/ itself was owned by nobody:nogroup, and the current session user had no write access to that subdirectory.
Running independent apps with over 50 million total downloads since 2014, I've learned that automated systems built on the happy path will always fail in unexpected ways. This is one of those situations. Here is how to detect and recover from it automatically.
Why /tmp/repos Ends Up Owned by nobody
In Cowork's VM environment, subdirectories under /tmp/ can persist between sessions carrying the ownership of whoever created them. If a previous session created /tmp/repos/ under nobody:nogroup, the next session running under a different user context cannot write to it.
# Check ownership
ls -la /tmp/repos/
# drwxr-xr-x 3 nobody nogroup 4096 ... ← blocked — this is the problem
# drwxr-xr-x 3 exciting-friendly-einstein ... ← fine/tmp/ itself has the sticky bit (drwxrwxrwt), meaning any user can create files or directories inside it. But /tmp/repos/ as a subdirectory is only writable by its owner. So mkdir -p /tmp/repos/claudelab.net may return no error, yet git clone into it will be rejected. That mismatch makes this tricky to debug.
The situation sometimes resets when a new session is started, but in scheduled task contexts, "manually intervene each time it breaks" is not a real solution. The code itself needs to detect and route around the problem.
The Fix: Automatic Fallback to $HOME/repos
The most reliable solution is automatically switching to $HOME/repos/ when /tmp/repos/ is not writable.
# Check /tmp/repos writability, fall back if needed
if [ -d /tmp/repos ] && [ ! -w /tmp/repos ]; then
echo "⚠️ /tmp/repos not writable (likely nobody-owned) — switching to $HOME/repos"
WORK="$HOME/repos/claudelab.net"
elif [ -w /tmp ] && 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 "📁 Working directory: $WORK"$HOME in Cowork's VM resolves to /sessions/<session-name>/, which the current session always owns. It is also more persistent than /tmp/, making it well-suited for the persistent repository approach — keeping the cloned repository and updating it with git pull --rebase on subsequent runs, instead of re-cloning each time.
Combined Diagnostic: Permission Denied + ENOSPC
Both Permission denied and No space left on device (ENOSPC) will halt a git clone, but they require different responses. The following block checks both at the start of a scheduled task:
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:]')
# --- 1. Check /tmp/repos writability ---
if [ -d /tmp/repos ] && [ ! -w /tmp/repos ]; then
echo "⚠️ /tmp/repos owned by nobody — switching to $HOME/repos"
WORK="$HOME/repos/claudelab.net"
else
WORK="/tmp/repos/claudelab.net"
mkdir -p /tmp/repos 2>/dev/null || WORK="$HOME/repos/claudelab.net"
fi
# --- 2. Check available disk space ---
FREE_MB=$(df "$(dirname "$WORK")" --output=avail -m 2>/dev/null | tail -1 | tr -d ' ')
echo "📊 Available: ${FREE_MB}MB (target: $WORK)"
if [ "${FREE_MB:-0}" -lt 300 ]; then
echo "⚠️ Low disk — cleaning up old repositories"
find "$(dirname "$WORK")" -maxdepth 1 -type d \
-not -name "$(basename $WORK)" \
-exec rm -rf {} + 2>/dev/null
FREE_MB=$(df "$(dirname "$WORK")" --output=avail -m 2>/dev/null | tail -1 | tr -d ' ')
echo "📊 After cleanup: ${FREE_MB}MB"
fi
if [ "${FREE_MB:-0}" -lt 200 ]; then
echo "⛔ Still not enough disk space — aborting task"
exit 1
fi
# --- 3. Clone or pull ---
if [ -d "$WORK/.git" ]; then
cd "$WORK"
git remote set-url origin "https://${GITHUB_TOKEN}@github.com/masakihirokawa/claudelab.net.git"
git pull --rebase origin main || {
rm -rf "$WORK"
git clone --depth 1 "https://${GITHUB_TOKEN}@github.com/masakihirokawa/claudelab.net.git" "$WORK"
cd "$WORK"
}
else
git clone --depth 1 "https://${GITHUB_TOKEN}@github.com/masakihirokawa/claudelab.net.git" "$WORK"
cd "$WORK"
fi
git config user.email "masakihirokawa@gmail.com"
git config user.name "masakihirokawa"
echo "✅ Repository ready"The instinct behind this defensive pattern comes from twelve years of individual app development. The crash reports that suddenly spiked for Beautiful HD Wallpapers before the v2.1.0 release were caught early because the monitoring was set up to expect failure — not just success. The same thinking applies here.
/tmp vs $HOME — When to Use Each in Cowork VMs
The right choice depends on how often the task runs and whether state needs to persist across executions.
When /tmp/repos/ works fine
For one-off scripts or tasks where a fresh clone every time is acceptable, /tmp is simpler to manage. The caveat is the nobody issue: always add the writability check before assuming it is available.
When $HOME/repos/ is the better choice
For scheduled tasks that run multiple times per day, $HOME/repos/ combined with the persistent repository strategy is significantly faster. Re-cloning a ~30 MB repository on every run adds unnecessary latency; pulling just the diff takes seconds instead.
For the four-site setup running Claude Lab, Gemini Lab, Antigravity Lab, and Rork Lab at four articles per day each, $HOME/repos/ with persistent repositories is the only approach that runs reliably. Combined, the four repositories stay at around 120 MB without node_modules — well within what the VM handles comfortably.
Updating Your SKILL.md
If you manage scheduled tasks through a SKILL.md file, add this four-line fallback detection at the top of Step 0:
# Add at the start of Step 0
_WORK_BASE="/tmp/repos"
if [ -d "$_WORK_BASE" ] && [ ! -w "$_WORK_BASE" ]; then
_WORK_BASE="$HOME/repos"
fi
WORK="${_WORK_BASE}/claudelab.net"
mkdir -p "$_WORK_BASE" 2>/dev/null || WORK="$HOME/repos/claudelab.net"One important note: when updating SKILL.md, also update the scheduled task prompt itself. The two are separate documents, and keeping them in sync prevents the task from reverting to old behavior on the next execution.
Quick Error Reference
When git clone fails, the error message usually tells you exactly what happened:
Permission denied — Directory ownership problem. Run ls -la on the parent directory to check who owns it.
No space left on device — Disk exhaustion. Run df -h and remove .next/ build caches or stale repositories.
Could not resolve host — Network access issue. Check whether the VM has internet access enabled.
Repository not found — Expired token or renamed repository. Regenerate the GitHub PAT and update SKILL.md.
index.lock: File exists — Previous git process crashed mid-operation. Remove .git/index.lock manually, or re-clone the repository.
What to Do Right Now
Run ls -la /tmp/repos/ and look at the owner column. If you see nobody nogroup, update your SKILL.md Step 0 with the four-line fallback pattern above. If you are hitting disk errors instead, add the ENOSPC diagnostic block before the clone step.
Both fixes take under five minutes to implement. The failure mode they prevent can silently break automated publishing for hours before anyone notices.