◉CLAUDE LABJP
●2.1.301 — Adds the vim % motion, LaTeX math rendering in the VS Code panel, and a retry-watchdog limit for usage-limit waits (10/15)●2.1.299 — Conversations over 200K tokens left idle about 54 minutes get compacted; MCP allow rules now match only the server they name●SONNET 4.5 — Retires on the Claude API on 11/30, 45 days left. Migrate to Sonnet 5.5●WINDOWS — Reports say hooks break silently only on Japanese-locale Windows; check the character encoding first●FAST MODE — Opus 5.5 fast mode is out of research preview and no longer needs the beta header (10/15)●NEW — I Put a Ten-Line Promise File at the To…●2.1.301 — Adds the vim % motion, LaTeX math rendering in the VS Code panel, and a retry-watchdog limit for usage-limit waits (10/15)●2.1.299 — Conversations over 200K tokens left idle about 54 minutes get compacted; MCP allow rules now match only the server they name●SONNET 4.5 — Retires on the Claude API on 11/30, 45 days left. Migrate to Sonnet 5.5●WINDOWS — Reports say hooks break silently only on Japanese-locale Windows; check the character encoding first●FAST MODE — Opus 5.5 fast mode is out of research preview and no longer needs the beta header (10/15)●NEW — I Put a Ten-Line Promise File at the To…
Articles/Cowork
◈ Cowork/2026-05-15Intermediate

git clone Fails in Cowork Scheduled Tasks — Diagnosing /tmp Permission Errors and Disk Space Issues

When git clone fails with Permission denied or No space left on device inside a Cowork VM, the cause is usually subdirectory ownership under /tmp. Here is how to detect it correctly, plus two real bugs hiding in the usual workaround code.

Cowork47troubleshooting92git18VMdisk-managementscheduled-tasks5

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/null

The 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/null

Re-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:

OperationElapsedNotes
git clone --depth 1 (fresh)~3.8 s45 MB including the working tree
git pull --rebase (no changes)~0.8 sagainst 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:

ErrorCauseFirst step
Permission deniedParent directory ownershipCheck owner with ls -ld, switch to $HOME/repos/
No space left on deviceDisk exhaustionRun df -h, remove other repos and .next/
Could not resolve hostNo network accessCheck the VM's internet access setting
Repository not foundExpired token or renamed repoRegenerate the PAT and update SKILL.md too
index.lock: File existsPrevious git process crashedDelete .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.

Share

Thank You for Reading

Claude Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.

  • ✦Copy-paste ready implementation code
  • ✦New advanced guides published daily
  • ✦$5/mo or $10 for lifetime access
View Membership →

If you found this article helpful, a small tip ($1.50) would mean a lot to us. Your support helps keep this site ad-free and covers server and hosting costs.

Related Articles

◈ Cowork2026-10-16
The Token-in-URL Way of Authenticating git Stopped Working in One Scheduled Task
A git clone stopped with 'could not read Username'. Two ways of putting a token in the URL behaved differently, and this is how I ended up on askpass.
◈ Cowork2026-05-06
Claude in Chrome Not Working? A Practical Troubleshooting Guide
Buttons that do nothing, pages that never load, tasks that report success but change nothing. Symptom-by-symptom fixes, plus the 42-run log where adding waits and verification steps moved completion from 62% to 95%.
◈ Cowork2026-10-15
Visible in the Listing, Zero Hits by Name: Counting Japanese Filenames With Split Dakuten
In a folder I gave Cowork, Japanese filenames were missing from name searches. Counting about 19,000 entries turned up 17 names with decomposed dakuten. Here is how I counted and fixed it.