●DEFAULT — Sonnet 5 is now the default model for Pro, Team Standard, and Enterprise seats, with a native 1M-token context window and adaptive thinking on by default●REWIND — The /rewind command can now restore a conversation after /clear. At a checkpoint you choose whether to roll back the code, the conversation, or both●ORGDEFAULT — Organization default models are supported. When you have not picked a model yourself, /model shows it as Org default or Role default●ATTACH — File attachments in chat are now clickable. Cmd or Ctrl-click reveals the file in Finder or Explorer●BASH — Bash mode gained live file path autocomplete, and auto mode now spells out the reason when it declines an action●EXPIRE — The claim window for the $100 promotional credit closed on August 2. Credits already claimed expire September 17, so any evaluation run needs to sit inside that window●DEFAULT — Sonnet 5 is now the default model for Pro, Team Standard, and Enterprise seats, with a native 1M-token context window and adaptive thinking on by default●REWIND — The /rewind command can now restore a conversation after /clear. At a checkpoint you choose whether to roll back the code, the conversation, or both●ORGDEFAULT — Organization default models are supported. When you have not picked a model yourself, /model shows it as Org default or Role default●ATTACH — File attachments in chat are now clickable. Cmd or Ctrl-click reveals the file in Finder or Explorer●BASH — Bash mode gained live file path autocomplete, and auto mode now spells out the reason when it declines an action●EXPIRE — The claim window for the $100 promotional credit closed on August 2. Credits already claimed expire September 17, so any evaluation run needs to sit inside that window
Existence Checks Pass, Writes Fail — Probing Capabilities Before an Unattended Run
A directory existing and a directory being writable are two different facts. Measured results from five broken-environment cases, and a capability-probe preflight for unattended Claude Code runs.
I opened the log first thing in the morning and found the run had stopped on line one.
It had been running the same way for months. Not a character of the code had changed. What changed was the environment underneath it: the working directory had quietly become owned by a different user, and writes to it were no longer permitted.
What actually bothered me was what came next. There was a check at the top of the script. Does the directory exist? If not, create it. If so, use it. I had written that shape for years without questioning it.
That check passed cleanly that morning. The directory existed.
It just could not be written to.
Existing Is Not the Same as Being Usable
I reproduced the state locally first — a directory whose owner keeps read and execute but loses write.
The zero from mkdir -p is the part that surprised me. It does not mean "created and ready." It means "already there, nothing to do." That is correct by the POSIX definition. But anyone who writes mkdir -p "$DIR" && cd "$DIR" reads that zero as a guarantee about the next step.
[ -d ] has the same shape. It is a question about existence, and I had been leaning on it as a question about permission. You do not get answers to questions you never asked.
That gap is precisely where the run broke.
Measuring Existence Checks Against Capability Probes
So I split verification into two styles and compared them.
The first is the existence check I had always written: os.path.isdir, os.path.exists, the presence of a .git directory. The second is a capability probe — perform the operation you are about to perform, once, at the smallest possible scale. If you plan to write, write. If you plan to delete, delete. If you plan to use git, run git rev-parse through it.
I built five environments that break in different ways: a directory that exists but rejects writes, a path that is a file where a directory was expected, a dangling symlink, a directory containing a .git entry that is not a repository, and a genuinely read-only mount.
Here is what came back.
Case
Existence check
Capability probe
Exists but not writable
OK
NG
File where a directory was expected
NG
NG
Dangling symlink
NG
NG
Has .git but is not a repository
OK
NG
Read-only mount
OK
NG
Three of five — 60% — passed on existence alone. And all three share a trait: the path is entirely correct, only the operation is impossible. You can stare at the spelling of that path forever and find nothing.
I also recorded what happens if you proceed anyway.
Case
Actual failure
Not writable
touch: cannot touch ...: Permission denied
File where a directory was expected
touch: cannot touch ...: Not a directory
Has .git but is not a repository
fatal: not a git repository (exit 128)
Read-only mount
touch: cannot touch ...: Read-only file system
The painful part of an unattended run is when these surface. All the heavy work completes, and only the final write fails. In this repository, the stage that reads and tallies 1,594 article files (about 19.29 million characters) takes 0.093 seconds. The five capability probes together take 2.57 milliseconds. If your first stage takes minutes instead of milliseconds, those minutes are thrown away in full.
As a share of that first stage, the probes cost roughly 2.8%. Across three consecutive runs, the ratio between "finish everything, then fail" and "probe first, then fail" measured 6,470x, 8,315x, and 6,892x. Probes are cheap because they perform the real operation at a trivial scale rather than reasoning about permissions. There is no inference to get wrong.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦Five broken-environment cases measured side by side: three passed the existence check while the operation was impossible
✦A directory where os.access returns True and create, append, and rename all succeed — but unlink fails with EPERM
✦How a probe that read a credential differently from production rejected a perfectly valid token, and the rule that prevents it
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
Everything so far was still within what I expected. This next result was not.
I probed a directory on a mount I use for build artifacts. Permissions read drwx------, owned by me. os.access(d, os.W_OK) returns True.
Then I measured each operation separately.
Operation
Result
os.access(W_OK)
True
Create (open(path, "w"))
OK
Append (open(path, "a"))
OK
Rename (os.rename)
OK
Delete (os.remove)
FAIL: Operation not permitted
Create, append, and rename all succeed. Delete is refused with EPERM.
I sat with that for a moment. I had assumed one write probe was sufficient, and here was a real filesystem where writability and deletability are governed separately.
The operational consequence is not small. Write a temp file, do the work, clean up afterward — a very ordinary shape. On this mount, the cleanup always fails. And it fails at the very end, after the artifact has already been produced, which yields the confusing state of "everything worked, exit code 1." Re-run it and last night's debris is still sitting there when the new run starts.
If the temp file had a fixed name, the next run would pick up that debris. I have been bitten by fixed-name temp files before, and since then I give every probe file a random suffix.
One principle came out of this: a probe should not ask "can I write here?" but "does the exact sequence of operations I am about to perform succeed?"
Implementing the Preflight
With that settled, here is the module I run immediately before the real work. Checks are declared as data; each one performs its real operation at minimal scale and carries the remedy for its own failure.
#!/usr/bin/env python3"""Preflight capability probes for unattended runs."""from __future__ import annotationsimport osimport subprocessimport timeimport uuidfrom dataclasses import dataclass, fieldfrom typing import CallableCREATE, REPLACE, DELETE, GIT_REPO, SECRET = "create", "replace", "delete", "git", "secret"@dataclassclass Check: name: str kind: str target: str remedy: str # what a human should do when this fails ok: bool = False detail: str = "" ms: float = field(default=0.0)def _probe_path(kind: str, path: str) -> tuple[bool, str]: # Unique per run. A fixed name eventually grabs the debris left by a # previous run that could not delete its own probe file. probe = os.path.join(path, f".preflight-{uuid.uuid4().hex[:8]}") try: with open(probe, "w") as fp: fp.write("probe") except OSError as exc: return False, f"create: {exc.strerror}" try: if kind in (REPLACE, DELETE): os.replace(probe, probe + ".tmp") probe = probe + ".tmp" if kind == DELETE: os.remove(probe) return True, "create/replace/delete all permitted" except OSError as exc: try: # leave behind nothing we can still remove os.remove(probe) except OSError: pass return False, f"{kind}: {exc.strerror}" try: os.remove(probe) except OSError as exc: # If this check never asked for delete, an unlink failure is not fatal return kind != DELETE, f"created, but unlink failed: {exc.strerror}" return True, "ok"def _probe_git(path: str) -> tuple[bool, str]: try: res = subprocess.run( ["git", "-C", path, "rev-parse", "--git-dir"], capture_output=True, text=True, timeout=15, ) except (OSError, subprocess.TimeoutExpired) as exc: return False, f"git unavailable: {exc}" if res.returncode != 0: return False, res.stderr.strip().splitlines()[0] if res.stderr else "not a repository" return True, res.stdout.strip()def _probe_secret(spec: str) -> tuple[bool, str]: # spec = "<file>::<label>". Read it the way production reads it, then # assert the shape. An empty string here resurfaces much later as a 403. path, _, label = spec.partition("::") try: with open(path, encoding="utf-8") as fp: lines = [ln.rstrip("\n") for ln in fp] except OSError as exc: return False, f"{exc.strerror}: {path}" for i, line in enumerate(lines): if line.startswith(label) and i + 1 < len(lines): value = lines[i + 1].strip() if len(value) < 20: return False, f"value for {label!r} is {len(value)} chars — too short" return True, f"{label}: {len(value)} chars" return False, f"label {label!r} not found in {os.path.basename(path)}"PROBES: dict[str, Callable[[str], tuple[bool, str]]] = { CREATE: lambda p: _probe_path(CREATE, p), REPLACE: lambda p: _probe_path(REPLACE, p), DELETE: lambda p: _probe_path(DELETE, p), GIT_REPO: _probe_git, SECRET: _probe_secret,}def run(checks: list[Check]) -> list[Check]: for check in checks: start = time.perf_counter() try: check.ok, check.detail = PROBES[check.kind](check.target) except KeyError: check.ok, check.detail = False, f"unknown probe kind: {check.kind}" check.ms = (time.perf_counter() - start) * 1000 return checksdef report(checks: list[Check]) -> int: failed = [c for c in checks if not c.ok] total = sum(c.ms for c in checks) for c in checks: mark = "PASS" if c.ok else "FAIL" print(f"[{mark}] {c.name:<26} {c.ms:6.2f}ms {c.detail}") print(f"\n{len(checks) - len(failed)}/{len(checks)} passed in {total:.2f}ms") for c in failed: print(f" -> {c.name}: {c.remedy}") return 1 if failed else 0
The caller lists only the places today's run will touch.
checks = [ Check("workspace/clone-root", DELETE, f"{HOME}/repos", "move to another writable location"), Check("artifact-dir", DELETE, outputs_dir, "do not use undeletable dirs for re-runnable output"), Check("repo/claudelab.net", GIT_REPO, f"{HOME}/repos/claudelab.net", "re-clone"), Check("credential", SECRET, f"{tokens}::Claude Lab", "check the label and the value"),]sys.exit(report(run(checks)))
Real output:
[PASS] workspace/clone-root 0.14ms create/replace/delete all permitted
[FAIL] artifact-dir 3.50ms delete: Operation not permitted
[PASS] repo/claudelab.net 2.15ms .git
[FAIL] locked-dir 0.04ms create: Permission denied
[PASS] credential 1.71ms Claude Lab: 40 chars
[FAIL] credential(typo) 1.14ms label 'ClaudeLab' not found in github_tokens.txt
3/6 passed in 8.68ms
-> artifact-dir: do not use undeletable dirs for re-runnable output
-> locked-dir: check ownership and pick another path
-> credential(typo): check the label and the value
Six checks in 8.68 milliseconds. Against a first stage that alone costs 93 milliseconds, that is 9.3% overhead — noise, for an indie developer running this unattended every night.
I made remedy a required field deliberately. The person reading an unattended log is almost always me, several hours later. Reconstructing the original reasoning from a bare Permission denied is real work. The moment you write the check is the moment you understand the fix best, so write it down right there.
A Probe That Reads Differently From Production Will Lie
The first version of this module rejected a perfectly valid credential.
_probe_secret originally compared with if line.strip() == label, assuming the label line read Claude Lab. The actual line is Claude Lab (claudelab.net) — the domain is appended. Production read it with grep -A1 "^Claude Lab", a prefix match, so production was fine. Only the probe, matching exactly, threw it out.
[FAIL] credential 1.65ms label 'Claude Lab' not found in github_tokens.txt
That one stung. The mechanism I wrote to catch broken assumptions had lied to me because of an assumption of its own.
And the lie has two directions. A probe stricter than production halts a healthy environment. A probe looser than production waves a broken one through. You notice the first kind immediately. You never notice the second — and if you have thinned out your production-side checks because you trust the preflight, the loose direction is the dangerous one.
The fix is unglamorous: share the reading code between probe and production. Same function, same regular expression, same defaults. The instant they are written separately, they are free to diverge.
Put differently, a preflight should be a miniature of the real work, never a summary of it. I understood that distinction intellectually before. I understand it differently now.
In the Shell, Assignment Swallows Failure
Hardening the Python side leaves the shell entry point untouched. Consider the line that extracts the credential.
When the label is missing, this command still succeeds. grep returns 1, tail returns 0, and a pipeline's exit status is the status of its last command. GITHUB_TOKEN moves on as an empty string.
Clone with an empty token and you get this:
fatal: could not read Username for 'https://github.com': terminal prompts disabled
Nothing there says the token was empty. In an unattended run there is no terminal to prompt on, so "no credential" is rendered as "cannot prompt." The loudest message appears at the point furthest from the cause.
I assumed set -e would stop it. Measured on bash 5.1.16, the behavior splits:
Form
Does set -e stop it?
V=$(false)
Yes
export V=$(false)
No
local V=$(false) (in a function)
No
V=$(false | cat)
No
V=$(false | cat) with set -o pipefail
Yes
export and local do not stop because the line's exit status becomes that of the export or local builtin itself, which is always 0. A failure that would have propagated from a bare assignment disappears when you prepend a single word.
Three practical rules follow:
Start with set -euo pipefail. Without pipefail, every assignment containing a pipe passes its failures through silently.
Never write export VAR=$(...) or local VAR=$(...). Split the assignment and the export onto two lines.
Inspect the value immediately after obtaining it — its length and shape, not merely its existence.
set -euo pipefailtoken=$(grep -A1 "^Claude Lab" "$TOKENS" | tail -1 | tr -d '[:space:]') || trueif [ "${#token}" -lt 20 ]; then echo "preflight: credential for 'Claude Lab' is empty or too short (${#token} chars)" >&2 exit 78 # EX_CONFIG — signal a configuration fault in the exit codefiexport GITHUB_TOKEN="$token"
The distinct exit code exists so retry logic can branch on it. A configuration fault will not heal on the third attempt, and it deserves different handling than a transient network blip.
Where It Belongs, and What I Deliberately Skip
It belongs immediately before the run. A check you performed last night describes last night's environment. When unattended work happens in a disposable sandbox, measuring again every time is not optional.
I probe only the things that fail silently and let the run continue. These I leave out:
Not probed
Why
Network reachability
Cannot distinguish a blip from an outage. Handle with retries and timeouts
Free disk space
No defensible threshold. Let the real ENOSPC decide
External API health
The probe itself costs money and causes side effects
On disk space, one clarification. I used to check df /tmp and feel reassured. If the path you actually write to lives on a different filesystem, that number guarantees nothing — and on this machine /tmp and the working directory are indeed separate devices. Measure the path you will write to. It is the same lesson as existence versus capability, wearing different clothes.
Running Claude Code headless makes these assumptions even more mobile. As releases tighten controls around Bash execution and subagent spawning, an operation that worked yesterday may not work today. Rather than tracking every specification change, I would rather measure the capabilities my run depends on, in place, every time. It has turned out to be less work overall.
You cannot prevent the ground from shifting. You can prevent a run from continuing for twenty minutes after it has.
Closing
An existence check answers only "is it there?" Unattended runs usually break on "it is there, but you cannot use it." In my five cases, three of those states walked straight through the existence check.
If you want one concrete next step: pick a single output directory in an automation you already run, and add three lines before the real work that create a small file and delete it. That alone surfaces two of the failure classes described here, probably today. Declaring the full set of checks can come later.
Since adding this, my mornings have gotten shorter. When a run fails, it fails in a tenth of a second, with the reason and the fix on one line. That turns out to matter more than I expected.
I appreciate you staying with a topic this unglamorous.
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.