Summary
photos/ is created by whichever uid gets there first, and it is created 0755 with no
re-chmod. Both writers run under both uids: bin/tool-photograph and
bin/tool-describe-scene are invoked as pi from the voice loop and px-mind, and as root
from bin/tool-wander's elevated subtree. If a root-side capture is the first to create the
directory, every subsequent pi-side capture fails permanently with EACCES, because
atomic_write/mkstemp needs write permission on the directory, not just on the file.
This is the third instance of the same shape. state/health/ and the px-brain mailbox both
hit it; health.py already carries the fix.
Sites
bin/tool-photograph:55 — photo_path.parent.mkdir(parents=True, exist_ok=True)
bin/tool-describe-scene:46 — dest.parent.mkdir(parents=True, exist_ok=True) (_frigate_latest_jpg)
Both target PROJECT_ROOT / "photos" (tool-photograph:24, tool-describe-scene:204,
api.py:3080-3082 — all three agree).
Evidence
Latent, not yet firing, in the live tree — but already realised in a worktree:
pi:pi drwxr-xr-x /home/pi/picar-x-hacking/photos <- live tree, fine today
root:root drwxr-xr-x /home/pi/px-vision-wt/photos <- root won the race here
In the root-owned one, pi is locked out:
$ touch photos/.pi_write_probe
touch: cannot touch 'photos/.pi_write_probe': Permission denied
The live tree is only healthy by accident of ordering: pi happened to create it first. The
root path has been able to mkdir since before #202 — only the Claude call downstream of it
was broken, so this was never reached in anger.
Note the failure is silent from the user's side: tool-photograph returns
{"status": "error", "error": "cannot create photos dir: ..."}, and describe-scene falls back
to FALLBACK_DESCRIPTION, so the robot keeps talking and nothing looks broken.
Fix
Mirror health.py::_ensure_health_dir — create, then re-chmod to a world-writable sticky mode
on every write, so whichever uid wins the creation race, both can write:
d.mkdir(parents=True, exist_ok=True)
if (d.stat().st_mode & 0o7777) != _MODE:
os.chmod(d, _MODE)
health.py uses 1777 (sticky, world-writable, like /tmp). Sticky matters here for the same
reason it does there: it lets both uids create files while stopping either from deleting the
other's. Do this in tool-photograph, where the directory is conceptually owned, and have
tool-describe-scene call the same helper rather than open-coding a second mkdir.
A one-off chmod 1777 photos/ on affected trees is the remediation for anything already
root-owned.
Out of scope
Deliberately not folded into #202 — that PR was a tight 5-file story about credentials under
sudo, and pulling tool-photograph in for a latent problem would have widened it.
Separately: photos/photos/
Not a writer bug, and now dated. All 235 files in photos/photos/ share a single ctime,
2026-08-15 15:57:48–49, and their mtimes are preserved (2026-03-08 … 2026-08-14 12:17, the
newest predating the ctime). Identical copies live in
/home/pi/spark-migration-backup/20260815T043824Z/photos. So the nesting is a one-time restore
artifact of the Aug 15 fossil→git migration — a copy into a photos/ that already existed —
and nothing has been written there since. The code has always agreed on PROJECT_ROOT/"photos".
Consequence is read-side only: GET /photos/{filename} (api.py:3073) serves new photos
correctly and those 235 historical ones not at all, and the dashboard thumbnail at
api.py:2965 takes r.path.split('/').pop() — the basename — so a thought whose recorded path
ends photos/photos/X.jpg requests /photos/X.jpg and 404s. It works when tested with a fresh
photo, which is what makes it confusing. api.py:3082's traversal guard correctly refuses to
reach into the nested dir, so the fix is to move the files up, not to loosen the guard.
Concrete actions
Summary
photos/is created by whichever uid gets there first, and it is created0755with nore-chmod. Both writers run under both uids:
bin/tool-photographandbin/tool-describe-sceneare invoked aspifrom the voice loop and px-mind, and asrootfrom
bin/tool-wander's elevated subtree. If a root-side capture is the first to create thedirectory, every subsequent
pi-side capture fails permanently withEACCES, becauseatomic_write/mkstempneeds write permission on the directory, not just on the file.This is the third instance of the same shape.
state/health/and the px-brain mailbox bothhit it;
health.pyalready carries the fix.Sites
bin/tool-photograph:55—photo_path.parent.mkdir(parents=True, exist_ok=True)bin/tool-describe-scene:46—dest.parent.mkdir(parents=True, exist_ok=True)(_frigate_latest_jpg)Both target
PROJECT_ROOT / "photos"(tool-photograph:24,tool-describe-scene:204,api.py:3080-3082— all three agree).Evidence
Latent, not yet firing, in the live tree — but already realised in a worktree:
In the root-owned one,
piis locked out:The live tree is only healthy by accident of ordering:
pihappened to create it first. Theroot path has been able to
mkdirsince before #202 — only the Claude call downstream of itwas broken, so this was never reached in anger.
Note the failure is silent from the user's side:
tool-photographreturns{"status": "error", "error": "cannot create photos dir: ..."}, and describe-scene falls backto
FALLBACK_DESCRIPTION, so the robot keeps talking and nothing looks broken.Fix
Mirror
health.py::_ensure_health_dir— create, then re-chmod to a world-writable sticky modeon every write, so whichever uid wins the creation race, both can write:
health.pyuses1777(sticky, world-writable, like/tmp). Sticky matters here for the samereason it does there: it lets both uids create files while stopping either from deleting the
other's. Do this in
tool-photograph, where the directory is conceptually owned, and havetool-describe-scenecall the same helper rather than open-coding a secondmkdir.A one-off
chmod 1777 photos/on affected trees is the remediation for anything alreadyroot-owned.
Out of scope
Deliberately not folded into #202 — that PR was a tight 5-file story about credentials under
sudo, and pullingtool-photographin for a latent problem would have widened it.Separately:
photos/photos/Not a writer bug, and now dated. All 235 files in
photos/photos/share a single ctime,2026-08-15 15:57:48–49, and their mtimes are preserved (2026-03-08 … 2026-08-14 12:17, thenewest predating the ctime). Identical copies live in
/home/pi/spark-migration-backup/20260815T043824Z/photos. So the nesting is a one-time restoreartifact of the Aug 15 fossil→git migration — a copy into a
photos/that already existed —and nothing has been written there since. The code has always agreed on
PROJECT_ROOT/"photos".Consequence is read-side only:
GET /photos/{filename}(api.py:3073) serves new photoscorrectly and those 235 historical ones not at all, and the dashboard thumbnail at
api.py:2965takesr.path.split('/').pop()— the basename — so a thought whose recorded pathends
photos/photos/X.jpgrequests/photos/X.jpgand 404s. It works when tested with a freshphoto, which is what makes it confusing.
api.py:3082's traversal guard correctly refuses toreach into the nested dir, so the fix is to move the files up, not to loosen the guard.
Concrete actions
tool-photograph: createphotos/with a_ensure_health_dir-style create-then-re-chmodhelper (mode
1777), applied on every write, not just at creation.tool-describe-scene:46: call that helper instead of its own baremkdir.sudo chmod 1777 /home/pi/px-vision-wt/photos(and any other worktree).GET /photos/{filename}and theapi.py:2965dashboard thumbnails can reach them:mv photos/photos/*.jpg photos/ && rmdir photos/photosBackup copies remain at
/home/pi/spark-migration-backup/20260815T043824Z/photos, so thisis reversible. Do not relax the
api.py:3082traversal guard — it is behavingcorrectly by refusing to serve out of a nested directory; the files are in the wrong place,
the guard is not.
mirroring the existing health-dir test.