Skip to content

photos/ mkdir race locks pi out when root creates it first #204

Description

@adrianwedd

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:55photo_path.parent.mkdir(parents=True, exist_ok=True)
  • bin/tool-describe-scene:46dest.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

  • tool-photograph: create photos/ with a _ensure_health_dir-style create-then-re-chmod
    helper (mode 1777), applied on every write, not just at creation.
  • tool-describe-scene:46: call that helper instead of its own bare mkdir.
  • Remediate trees where root already won the race:
    sudo chmod 1777 /home/pi/px-vision-wt/photos (and any other worktree).
  • Flatten the migration nesting — move the 235 files up one level so
    GET /photos/{filename} and the api.py:2965 dashboard thumbnails can reach them:
    mv photos/photos/*.jpg photos/ && rmdir photos/photos
    Backup copies remain at /home/pi/spark-migration-backup/20260815T043824Z/photos, so this
    is reversible. Do not relax the api.py:3082 traversal guard — it is behaving
    correctly by refusing to serve out of a nested directory; the files are in the wrong place,
    the guard is not.
  • Regression test: assert the photos dir is group/other-writable after a root-first create,
    mirroring the existing health-dir test.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions