Matrix mouth: fetch and store chat images (cut A), ChatDelivery v3 #12

Merged
heimdall merged 5 commits from heimdall/chat-images-cut-a into main 2026-11-08 09:30:05 +00:00
Member

Cut A of chat images in the Matrix mouth. Unmerged; nothing deployed.

What it does. For m.image events the Persona is delivered (not its own, not another room, not history), the mouth fetches /_matrix/client/v1/media/download/<server>/<id> as that Persona, from the event's own content.url only, skipping encrypted events. Type by magic bytes (png/jpeg/webp/gif; SVG refused; declared mime ignored), 5 MiB cap by Content-Length and bytes read, 40 MP header pixel cap, no decoding. Stored under IMAGE_STORE_ROOT/<slug>/<sha256 room id>/<sha256 bytes>[.ext], files 0640, dirs 2750 (setgid kept), temp+rename. Deleted when 24 h old and acked, whichever is later; 50 MiB per room, oldest first. Unset IMAGE_STORE_ROOT = today's behaviour exactly.

Wire. Delivery is a 12-tuple; index 11 is nil or [path, mime, size, sha256]; pending kind is gamecult.persona.chat_pending.v2. Golden tests compare against Huginn's persona-chat-v3 fixtures byte for byte (copied into tests/fixtures/persona-chat-v3/). The obsolete v1 pending golden is deleted. docs/bridge.md corrected.

Refusals keep the typed line plus refused=<word> (encrypted, no-url, fetch-failed, too-big, wrong-type, bad-header, too-many-pixels, store-failed) and a nil image.

Systemd wiring (not in this PR, Eureka owns it). The unit needs StateDirectory=gamecult/bifrost/persona-images and IMAGE_STORE_ROOT=/var/lib/gamecult/bifrost/persona-images; per-Persona dirs bifrost:mind-<slug> mode 2750.

Deviations. With no store, no refused= word is added (today's line). tests/persona-matrix-mouth.test.mjs now answers its fake homeserver in-process: Node's HTTP client cannot start under ulimit -v 8000000, so the suite could not run under the memory cap at all. persona-matrix-mouth-send-gate.test.mjs still uses a real socket and was not run under the cap. Three runtime tests (cursor, brake store, N-persona spawn) fail on this host before and after: koffi native module is missing in the CultLib checkout.

Mutants (each guard removed by hand, one test file run under ulimit -v 8000000, reverted, never committed):

Guard Failing test
own token fetch uses this Persona's token...
no redirects same
plain mxc url only only a plain mxc url is fetched
url from event content only image_delivery: the url is the event's own content.url
delivered rooms only image_delivery: only an event the Persona is delivered is fetched
encrypted skipped image_delivery: an encrypted attachment is never fetched
magic-byte allowlist, SVG the type is decided by the magic bytes
declared mime ignored the declared type is never read
sniffed mime stored image_delivery: the file name is ours
Content-Length cap a Content-Length over 5 MiB is refused
streamed bytes cap the bytes actually read are capped at 5 MiB
pixel cap / unparsable header the pixel cap...; a header that will not parse...
room-hash layout the layout is <sha256 room>/<sha256 bytes>
path never absolute/.. an image path is never absolute...
file 0640 / dir 2750 files are 0640 and directories 0750...
atomic write a write that cannot finish leaves no temp file
retention acked / 24 h a stored image stays until it is at least 24 h old and acked
ack releases images image_delivery: a stored image stays until its delivery is acked
50 MiB cap / oldest first a room holds at most 50 MiB
refusal word in line image_delivery: a refused or failed fetch...
store failure contained image_delivery: a store that cannot write...
12-tuple index 11 a delivery is a 12-tuple...
golden bytes golden fixture: gamecult.persona.chat_pending.v2.image

Tests: tests/persona-chat-images.test.mjs (19), tests/persona-matrix-mouth.test.mjs (51), tests/persona-matrix-mouth-runtime.test.mjs (20 pass, 3 pre-existing koffi failures).

🤖 Generated with Claude Code

Cut A of chat images in the Matrix mouth. Unmerged; nothing deployed. **What it does.** For `m.image` events the Persona is delivered (not its own, not another room, not history), the mouth fetches `/_matrix/client/v1/media/download/<server>/<id>` as that Persona, from the event's own `content.url` only, skipping encrypted events. Type by magic bytes (png/jpeg/webp/gif; SVG refused; declared mime ignored), 5 MiB cap by Content-Length and bytes read, 40 MP header pixel cap, no decoding. Stored under `IMAGE_STORE_ROOT/<slug>/<sha256 room id>/<sha256 bytes>[.ext]`, files 0640, dirs 2750 (setgid kept), temp+rename. Deleted when 24 h old and acked, whichever is later; 50 MiB per room, oldest first. Unset `IMAGE_STORE_ROOT` = today's behaviour exactly. **Wire.** Delivery is a 12-tuple; index 11 is nil or `[path, mime, size, sha256]`; pending kind is `gamecult.persona.chat_pending.v2`. Golden tests compare against Huginn's `persona-chat-v3` fixtures byte for byte (copied into `tests/fixtures/persona-chat-v3/`). The obsolete v1 pending golden is deleted. `docs/bridge.md` corrected. **Refusals** keep the typed line plus `refused=<word>` (encrypted, no-url, fetch-failed, too-big, wrong-type, bad-header, too-many-pixels, store-failed) and a nil image. **Systemd wiring (not in this PR, Eureka owns it).** The unit needs `StateDirectory=gamecult/bifrost/persona-images` and `IMAGE_STORE_ROOT=/var/lib/gamecult/bifrost/persona-images`; per-Persona dirs `bifrost:mind-<slug>` mode 2750. **Deviations.** With no store, no `refused=` word is added (today's line). `tests/persona-matrix-mouth.test.mjs` now answers its fake homeserver in-process: Node's HTTP client cannot start under `ulimit -v 8000000`, so the suite could not run under the memory cap at all. `persona-matrix-mouth-send-gate.test.mjs` still uses a real socket and was not run under the cap. Three runtime tests (cursor, brake store, N-persona spawn) fail on this host before and after: `koffi` native module is missing in the CultLib checkout. **Mutants** (each guard removed by hand, one test file run under `ulimit -v 8000000`, reverted, never committed): | Guard | Failing test | |---|---| | own token | fetch uses this Persona's token... | | no redirects | same | | plain mxc url only | only a plain mxc url is fetched | | url from event content only | image_delivery: the url is the event's own content.url | | delivered rooms only | image_delivery: only an event the Persona is delivered is fetched | | encrypted skipped | image_delivery: an encrypted attachment is never fetched | | magic-byte allowlist, SVG | the type is decided by the magic bytes | | declared mime ignored | the declared type is never read | | sniffed mime stored | image_delivery: the file name is ours | | Content-Length cap | a Content-Length over 5 MiB is refused | | streamed bytes cap | the bytes actually read are capped at 5 MiB | | pixel cap / unparsable header | the pixel cap...; a header that will not parse... | | room-hash layout | the layout is `<sha256 room>/<sha256 bytes>` | | path never absolute/`..` | an image path is never absolute... | | file 0640 / dir 2750 | files are 0640 and directories 0750... | | atomic write | a write that cannot finish leaves no temp file | | retention acked / 24 h | a stored image stays until it is at least 24 h old and acked | | ack releases images | image_delivery: a stored image stays until its delivery is acked | | 50 MiB cap / oldest first | a room holds at most 50 MiB | | refusal word in line | image_delivery: a refused or failed fetch... | | store failure contained | image_delivery: a store that cannot write... | | 12-tuple index 11 | a delivery is a 12-tuple... | | golden bytes | golden fixture: gamecult.persona.chat_pending.v2.image | Tests: `tests/persona-chat-images.test.mjs` (19), `tests/persona-matrix-mouth.test.mjs` (51), `tests/persona-matrix-mouth-runtime.test.mjs` (20 pass, 3 pre-existing koffi failures). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
m.image events in delivered messages are fetched as the receiving Persona,
classified by magic bytes, bounded by size and header pixel count, and stored
under IMAGE_STORE_ROOT/<persona>/<room hash>/<bytes hash>[.ext] (0640, dirs
2750). The delivery tuple gains index 11, nil or [path, mime, size, sha256];
the pending kind becomes gamecult.persona.chat_pending.v2 with Huginn's v3
golden bytes. Refusals keep the typed text line plus a fixed-word reason.
The mouth test homeserver is answered in-process so the suite runs under a
capped address space.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
A chmod by bifrost, which is not in the Persona's group, clears the setgid bit, so files
would take bifrost's group and the Persona's reader could not open them. Directories and
files are now created with exact modes (fixed umask around mkdir and open), the room
directory must have inherited setgid from the installer's Persona directory or the image
is refused (store-failed), and the Persona directory is never chmodded. Adds behavioural
redirect-refused and fetch-timeout tests.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
The room directory's gid must equal the Persona directory's, else store-failed (a setgid
directory in the wrong group would still give files the wrong group). A missing Persona
directory is refused rather than created; only the room directory is made. The stat is
injectable so the group case is testable where the sandbox cannot chown.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
GameCult/Bifrost!12
No description provided.