The morning log opens with a single line: fatal: could not create work tree dir '/tmp/repos/claudelab.net': Permission denied. The runner is healthy, the GitHub token is valid, the disk has room. What is actually happening is that a leftover subdirectory under /tmp/repos from a previous session is still owned by nobody:nogroup, and the new session's user can neither write into it nor remove it.
As Dolice Labs I run four sites — Claude Lab, Gemini Lab, Antigravity Lab, and Rork Lab — on autonomous Claude Code schedules. Each site publishes four articles a day from a sandboxed VM where the run-as user can shift between sessions. Over half a year of operation I have hit this /tmp ownership edge case more than a dozen times. Below is what I have settled on: a minimum $HOME/repos fallback that keeps unattended runs alive without any human intervention.
What the failure looks like
The clone fails with Permission denied, not No space left on device:
$ 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
And the directory listing reveals foreign ownership:
$ 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
The current user is no longer nobody, so writing into the existing tree is rejected. rm -rf fails for the same reason.
Why it happens
In the sandboxed VM, the executing user can rotate between sessions. A previous session created /tmp/repos/{site} while running as nobody:nogroup and then shut down without cleaning up. When a fresh session boots under a different user, ownership of those leftover paths cannot be inherited. /tmp itself is sticky-bit world-writable (drwxrwxrwt), so new top-level directories are fine; only the orphaned subtree from the prior run is locked.
This is easy to confuse with ENOSPC. The distinguishing signal is the exact error string: No space left on device versus Permission denied. The first one calls for cache cleanup; the second calls for switching where you write to.
The minimum fallback
The three lines I have baked into every site's SKILL.md are these:
WORK="/tmp/repos/${SITE}"
# Verify writability for real (handles both permissions and capacity)
if ! mkdir -p "$WORK" 2>/dev/null; then
WORK="$HOME/repos/${SITE}"
mkdir -p "$WORK"
fi
echo "WORK=$WORK"The key detail is exercising mkdir -p and checking its exit code instead of relying on test -w /tmp/repos. The simple test passes when the parent directory is writable, even if the child path you actually need is owned by someone else. I spent two consecutive mornings of empty publishing logs before I learned to verify by doing rather than by asking.
Clone vs. pull
Once the working path is settled, branch on whether a checkout already exists:
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 is plenty for an article-only workflow. Cloudflare's CI runs generate-content.mjs from a prebuild hook, so there is no need to npm install or boot Next.js locally just to push one MDX file. The on-disk footprint of a working repo can stay under a few tens of MB.
Disk pressure on the fallback path
Falling back to $HOME/repos only helps if home has room. I keep a sanity check up front and drop sibling site repos before they start trampling on each other:
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
fiBecause the same VM is sometimes reused between my 02:00 premium slot and my 15:00 daily slot, a stray node_modules from an earlier task could swallow 800MB on its own. Running the cleanup logic first made the rest of the pipeline noticeably more stable.
Why I have needed this since 2014
I have been shipping indie iPhone and Android apps since 2014 — Beautiful HD Wallpapers and a healing-focused catalog among them — and the lifetime download count crossed 50 million somewhere along the way. Sustaining AdMob revenue while also maintaining a contemporary-art practice means automating the blog pipeline as far as it will go. Trading hand-written posts for a Claude Code schedule was what made 4 sites × 4 posts a day feasible.
That said, an unattended pipeline that goes dark for a day directly affects the monthly Stripe membership numbers. The first time a single Permission denied killed two mornings in a row, I ended up patching it from my iPhone at a Kichijoji café. Since then, not trusting /tmp and verifying writability on the fallback path have been the two non-negotiables of the runbook.
A reusable snippet for multi-site setups
Across the four sites I keep the snippet identical, with the repo name as the only parameter:
# Shared snippet — same shape across all four sites
SITE_REPO="$1" # e.g. 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"The trailing .write_test step is what catches the storage-driver class of bugs — the rare case where the kernel reports permission granted but the actual write returns EPERM. I hit one such case on Antigravity Lab and have kept the touch-and-remove pair in place ever since.
Triage checklist
When the schedule does go red, this is the order I work through:
ls -la /tmp/repos/to see who owns the subtree.whoamiandidto confirm the current execution user.df -h /tmpanddf -h $HOMEto compare available capacity on both candidates.mkdir -p "$WORK" && touch "$WORK/.write_test" && rm "$WORK/.write_test"for a real write test.- If that still fails, switch
WORK=$HOME/repos/${SITE}and run the same test again.
Step 4 is the one that earns its keep. A passive test -w would have told me the path was writable in the very session that later failed mid-clone.
Treating $HOME/repos as the source of truth
Because four sites are wired into Cowork-side schedules, the unwritten contract is "do not stop on error, take the next action automatically." Even when the fallback succeeds, I write the chosen path to the log so that task_error.log makes it obvious afterwards which morning's article was generated from home rather than from /tmp.
If you are running a similar unattended pipeline, treating /tmp as a hint rather than a contract is the single change that buys you the most reliability. $HOME/repos lives only as long as the session, but a one-line fallback and a shallow clone are enough to keep yesterday's failure from becoming today's missing post.
Thanks for reading — I hope this is useful for anyone running a parallel indie pipeline of their own.