A scheduled task that should have published overnight produced nothing by morning. The log contained exactly one useful line:
fatal: could not create work tree dir '/tmp/repos/claudelab.net': Permission denied
/tmp is supposed to be world-writable. Disk space was fine — over 1,600 MB free. And yet the clone was rejected. The cause turned out to be the subdirectory: /tmp/repos/ itself was owned by nobody:nogroup, and the session user had no write access to it.
I have hit this several times, and each time I felt the same specific disappointment: the workaround I had already written was somehow not firing. Building automation as an indie developer, this kind of environment-dependent failure always arrives quietly. So what follows covers more than the root cause. It also covers the defects hiding inside the workaround itself. Every shell behavior shown here was re-run on a Cowork VM when the symptom came back.
Why /tmp/repos Ends Up Owned by nobody
Inside a Cowork VM, subdirectories under /tmp/ can survive between sessions carrying the ownership of whoever created them. If an earlier session created /tmp/repos/ as nobody:nogroup, the next session runs under a different user context and cannot write into it.
The detail that matters is how far the sticky bit reaches. /tmp/ itself is drwxrwxrwt, so anyone can create entries inside it. But /tmp/repos/, created as an ordinary directory, is rwxr-xr-x — writable by its owner only.
Here is what the VM actually looked like when the failure recurred:
$ 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)
Anyone can write to /tmp. Only nobody can write to /tmp/repos. And you are not nobody. Laid out in three lines it is obvious, but the error message points at /tmp/repos/claudelab.net — one level deeper than the actual problem — which is why it takes a moment to see.
mkdir -p Succeeds, So You Find Out Too Late
What makes this failure mode slippery is that the step before the clone quietly succeeds.
$ mkdir -p /tmp/repos
$ echo $?
0
mkdir -p returns 0 when the target already exists. It never checks whether you can write to it. Which means this very common line looks like a safety net but never actually fires:
# ❌ Does nothing
mkdir -p /tmp/repos 2>/dev/null || WORK="$HOME/repos/claudelab.net"When /tmp/repos exists and is owned by nobody, mkdir -p exits 0, the right side of || is skipped entirely, and execution proceeds to git clone — which is where Permission denied finally surfaces. That is precisely why my "existing workaround" appeared to be doing nothing. It was doing nothing.
The test has to be write access itself, not creation success:
# ✅ Actually detects the condition
[ -d /tmp/repos ] && [ ! -w /tmp/repos ] && echo "not writable"Evaluated on the VM above, this pair of tests reported "not writable" and the fallback fired as intended. -w works on directories, so no extra tooling is needed.
In my experience three situations produce this state. Right after a Cowork update or session restart swaps the internal user context. When several scheduled tasks share the same /tmp/repos/ and whichever runs first creates it as nobody. And after a task crashes partway through a git operation, leaving a half-written directory behind. The common thread is that the previous run leaks into the current one. Scheduled tasks look like they start from a clean slate, but /tmp/ is shared across sessions.
The Fix: Falling Back to $HOME/repos
The reliable answer is to switch to $HOME/repos/ automatically whenever /tmp/repos/ is unusable.
# Decide the work directory by write access, not by mkdir success
if [ -d /tmp/repos ] && [ ! -w /tmp/repos ]; then
echo "⚠️ /tmp/repos not writable (likely nobody-owned) — using $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 "⛔ cannot create work directory"; exit 1; }
echo "📁 Working directory: $WORK"Note that even the elif branch does not trust mkdir -p alone — it follows up with [ -w /tmp/repos ]. Hold on to the single idea that creating and writing are different questions, and the whole branch reads naturally.
In a Cowork VM, $HOME resolves to /sessions/<session-name>/, which the current session owns, so writes there always succeed.
The Cleanup Code That Deletes Your Working Directory
Many of us add a "remove old repositories when disk runs low" step. I did too. The usual shape looks like this:
# ❌ Dangerous: takes the working directory with it
find "$(dirname "$WORK")" -maxdepth 1 -type d \
-not -name "$(basename $WORK)" \
-exec rm -rf {} + 2>/dev/nullThe intent is "delete everything inside /tmp/repos/ except the repo we are about to use." But find includes the starting point in its results. The basename of /tmp/repos is repos, not claudelab.net, so it slips past the -not -name filter and the parent directory itself becomes an argument to rm -rf.
I built a scratch tree and ran it:
# Before
/tmp/t1/repos/claudelab.net/KEEPME
/tmp/t1/repos/gemilab.net
/tmp/t1/repos/rorklab.net
# Run the command above
# After
ls: cannot access '/tmp/t1/repos': No such file or directory
KEEPME — gone
The directory it was supposed to protect went with everything else. Worse, this branch only executes when free space drops below the threshold, so ordinary test runs never reach it. It waits for the one day the disk is tight, then quietly destroys the workspace.
The fix is a single flag. -mindepth 1 excludes the starting point:
# ✅ Safe: skip the starting point
find "$(dirname "$WORK")" -mindepth 1 -maxdepth 1 -type d \
-not -name "$(basename "$WORK")" \
-exec rm -rf {} + 2>/dev/nullRe-running the same scenario left claudelab.net and KEEPME intact while removing the other repositories. Quote "$WORK" as well — an unquoted path containing a space will split the argument to basename.
A Combined Diagnostic Including Disk Exhaustion
Permission denied and No space left on device both stop a clone, but they call for different responses. Diagnosing both at the top of a scheduled task makes the morning log much faster to read.
There is one more trap in the disk check. Passing df a directory that does not exist yields empty output, and ${FREE_MB:-0} then evaluates to 0 — a false "disk full." The measured behavior:
$ FREE_MB=$(df /tmp/does-not-exist --output=avail -m 2>/dev/null | tail -1 | tr -d ' ')
$ echo "[${FREE_MB}] -> ${FREE_MB:-0}"
[] -> 0
On a first run the work directory normally does not exist yet, so either create it before calling df or handle the empty string explicitly. The block below does both:
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. Choose the work directory by write access ---
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 "⛔ cannot create $BASE"; exit 1; }
# --- 2. Disk check, treating empty output as unknown ---
FREE_MB=$(df "$BASE" --output=avail -m 2>/dev/null | tail -1 | tr -d ' ')
if [ -z "$FREE_MB" ]; then
echo "⚠️ could not read free space for $BASE — skipping cleanup"
else
echo "📊 Available: ${FREE_MB}MB (target: $WORK)"
if [ "$FREE_MB" -lt 300 ]; then
echo "⚠️ Low disk — removing other repositories"
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 "📊 After cleanup: ${FREE_MB:-unknown}MB"
fi
if [ "${FREE_MB:-0}" -lt 200 ]; then
echo "⛔ Still not enough space — aborting"
exit 1
fi
fi
# --- 3. Clone or 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 "✅ Repository ready"The cd "$BASE" before rm -rf "$WORK" in the recovery path matters: calling git clone while your shell still sits inside a deleted directory fails on some systems. It is a small thing, but recovery paths are exactly where small things bite.
Choosing Between /tmp and $HOME
Rather than argue about it, I measured. One repository, Claude Lab:
| Operation | Elapsed | Notes |
|---|---|---|
git clone --depth 1 (fresh) | ~3.8 s | 45 MB including the working tree |
git pull --rebase (no changes) | ~0.8 s | against the existing shallow clone |
Three seconds per run. For a one-off script that is noise. Across four sites running several times a day it accumulates — but the more important difference is that a clone depends on the network every single time. Incremental updates are far less likely to fail during a flaky window.
Whether git pull --rebase origin main is safe against a --depth 1 clone is a fair question, and one I wanted to confirm rather than assume. It worked without complaint: git rev-parse --is-shallow-repository still reported true afterward, and the local commit count stayed at 1. If your task never needs history, staying shallow and pulling diffs is fine.
As a rule of thumb, I use /tmp for one-off scripts and anything that should leave no trace, and $HOME/repos/ with a persistent repository for scheduled tasks that run several times a day. Even with /tmp, keep the writability check.
Quick Error Reference
The message almost always identifies the cause:
| Error | Cause | First step |
|---|---|---|
Permission denied | Parent directory ownership | Check owner with ls -ld, switch to $HOME/repos/ |
No space left on device | Disk exhaustion | Run df -h, remove other repos and .next/ |
Could not resolve host | No network access | Check the VM's internet access setting |
Repository not found | Expired token or renamed repo | Regenerate the PAT and update SKILL.md too |
index.lock: File exists | Previous git process crashed | Delete .git/index.lock or re-clone |
One note on Repository not found: an invalid token can return the same wording. Check the token's expiry before you start hunting for a typo in the repository name — it gets you there faster.
Updating Your SKILL.md
If your scheduled tasks are driven by a SKILL.md, put this at the very top of the Step 0 repository setup so a recurrence routes around itself:
# Top of Step 0 — decide by write access
_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"After editing SKILL.md, apply the same change to the scheduled task prompt. They are loaded as separate documents, so fixing only one means the next run reverts to the old behavior. It is invisible during manual testing — I once spent a second morning looking at a bug I had already fixed.
What to Do Right Now
Run ls -ld /tmp/repos. If the owner column reads nobody nogroup, replace the mkdir -p success check in Step 0 with a -w test. That single change does the most work here.
Then open your cleanup code and look for -mindepth 1 in the find call. If it is missing, add it. A step that only fires on a tight-disk day and removes your workspace is the kind of bug that stays hidden for months.
Both edits take a few minutes. Automation proves itself not when nothing is happening, but when something is.