Skip to content

Set and remove a contact's photo with hey contact photo - #566

Open
robzolkos wants to merge 6 commits into
mainfrom
contact-avatar
Open

robzolkos wants to merge 6 commits into
mainfrom
contact-avatar

Conversation

@robzolkos

@robzolkos robzolkos commented Oct 12, 2026 •

Copy link
Copy Markdown
Collaborator

What

  • hey contact photo set <contact-id> <file> uploads a local image as a contact's photo. It calls Attachments.Upload, then Contacts.UpdateAvatar with the blob's signed ID, and answers the contact with its new avatar_url.
  • hey contact photo remove <contact-id> calls Contacts.DeleteAvatar, which puts back HEY's default avatar. Removing from a contact with no photo still succeeds.
  • avatar is an alias for photo.

This is the CLI half of basecamp/haystack#8895 (the JSON contract) and basecamp/hey-sdk#229 (the operations, shipped in go/v0.32.0; we already pin v0.32.1).

Edge cases, from reading haystack

HEY's avatar endpoint (Contacts::Avatars::UploadsController#update) attaches whatever blob it's handed. Nothing goes wrong until the avatar is drawn: Avatar#variant_for resizes it with blob.variant(resize_to_fill: [300, 300], …). HEIC and AVIF are removed from variable_content_types, so a photo straight off an iPhone, or a file that isn't an image, gets stored and is never shown. AvatarsController#show falls back to the contact's initials, so the upload silently does nothing. The CLI therefore checks the file before sending anything:

Check Why
PNG or JPEG, sniffed from the bytes, not the extension The web picker takes only these: accept: "image/png, image/jpeg" (contacts/avatars/_edit.html.erb)
Header decodes with non-zero dimensions (image.DecodeConfig) Catches a truncated file, or a PNG signature on top of junk
At most 40 MiB The web form posts through kamal-proxy's max_request_body: 41943040 (config/deploy.yml), so nothing bigger reaches a contact from the web. The direct upload to storage has no cap of its own
At most 58 MP and 16000 px a side A modern phone's full-resolution photo fits (iPhone 48 MP, 8064×6048; 50 MP Android). HEY shows avatars at 300×300 at most, so this is a sanity cap, kept under what HEY can resize. See "Size limits, measured" below
A regular, non-empty file

Size limits, measured

policy.xml's 128 MP area ceiling isn't the one that matters. The cell that resizes the photo runs under its own, tighter limits (hotcell/Dockerfile): MAGICK_MEMORY_LIMIT=896MiB, and a 256 MB file_size (hotcell/config.rb) that kills a worker whose cache spills to disk. haystack notes that this verdict is recorded as permanent.

I ran HEY's variant command (-auto-orient -resize 300x300^ … -extent 300x300 -quality 40 -strip) in the hotcell image (ImageMagick 6.9.12 Q16), under HEY's policy.xml and the cell's env and rlimits, on 28 generated photos:

Photo Cost Works Fails
Upright PNG or JPEG (RGB, RGBA, gray) 8 B/px → ~117.4 MP 10800² 11000²
JPEG with EXIF orientation 2–8 (flip, 180 or 90 alike): auto-orient holds a second copy 16 B/px → ~58.7 MP 7600² 7700² (worker killed, exit 25)
CMYK JPEG 10 B/px → ~94 MP 9600² 9800×9600
Rotated CMYK JPEG 20 B/px → ~47 MP 6000² 7000²
Any side over 16,000 px 16000×7000 16001×100

A PNG's orientation isn't applied (a 9000² PNG with orientation 6 works), so PNGs never pay double.

The CLI doesn't model any of that. HEY does the resizing, and shows a contact photo at 300×300 at most, so the CLI caps at 58 MP. That admits a modern phone's full-resolution photo and stays under the tightest measured ceiling (58.7 MP, rotated JPEG), so a photo the CLI accepts always resizes. The only exception is a rotated CMYK JPEG over 47 MP, which no camera produces. The 16000 px side limit from policy.xml stays.

The 40 MiB byte cap is the one limit that isn't about something failing. The cell reads its input through a file descriptor, and direct uploads bypass the proxy, so HEY would take bigger files. It matches what the web form can send.

The filename doesn't matter. Rails picks the variant format from the extension only when it agrees with the content type (Representable#format, at haystack's pinned revision), and the CLI declares the type it sniffed. So a PNG named .jpg, or a JPEG with no extension, renders fine. The help also repeats HEY's advice from the profile photo page (contacts/users/edit.html.erb): a square image without transparency works best.

Other behaviour:

  • Read before upload: the contact is fetched first, so a mistyped ID costs a GET rather than an upload. Direct uploads are rate-limited to 1,000 per hour per person.
  • Not-editable contacts: HEY answers 404, not 403, for a contact you can't edit (ensure_contact_editable): hidden, alias, tombstone, system contact, or another HEY user. A not_found from either write therefore carries a hint saying so. Your own user contact is allowed.
  • Caching: the success line repeats HEY's own warning that the old photo can still show for up to 30 minutes.

Not done, on purpose

  • No full decode: a PNG with a valid header and corrupt pixel data still gets through. Decoding the whole image would cost memory in proportion to its pixel count, up to 40 MiB of compressed input. It's a rare accident, and it's recoverable with photo remove.
  • No GIF or WebP: Active Storage can resize them, but HEY's picker doesn't offer them. Following the web app avoids a format that might draw differently there.
  • No TUI binding.

Tests

  • internal/cmd/contact_photo_test.go:
    • the request order (GET contact → direct upload → storage PUT → avatar PUT with the signed ID)
    • the declared content type comes from the bytes (a PNG named .jpg, a JPEG with no extension)
    • twelve bad files rejected before any request: missing, dangling symlink, directory, empty, HEIC, GIF, PDF named .png, text named .jpg, broken PNG header, PNG cut off at its signature, JPEG cut off before its frame header, over the byte limit
    • a FIFO is refused without being opened (contact_photo_unix_test.go, with a 5s deadline; it hangs without the fix)
    • a photo of exactly the byte limit is accepted
  • the size limits: one pixel row over 58 MP, 64/108/200 MP phone photos, and a side over 16000 px are rejected before any request. Exactly 58 MP, iPhone 48 MP (landscape and portrait), 50 MP Android, and the widest and tallest allowed sides are accepted. These use hand-built headers, so no pixels are allocated
    • bad arguments send nothing
    • a missing contact never uploads (404 and 204)
    • a failed reservation or storage PUT never sets the avatar
    • the not_found hint on set and remove
    • remove, styled output for both commands (via the alias too), and name sanitizing
  • tests/smoke/contacts_test.go: TestContactPhoto creates a contact, sets a generated PNG, checks that avatar_url changed and that fetching it returns a raster image rather than the SVG initials fallback, removes it, checks that avatar_url is back to the default, then removes again. Validation cases were added too. I haven't run it: the dev server wasn't up.
  • make test passes and golangci-lint v2.14.0 reports 0 issues. .surface, docs/cli.md and the skill are updated.

`hey contact photo set <contact-id> <file>` uploads a local image as the
contact's avatar (Attachments.Upload, then Contacts.UpdateAvatar with the
blob's signed ID); `hey contact photo remove <contact-id>` takes it off
(Contacts.DeleteAvatar). `avatar` is an alias for `photo`.

The file is checked before anything is sent, because HEY stores any blob it
is handed and only fails later, when it resizes the photo to show it:

- PNG or JPEG only, the formats HEY's photo picker accepts, judged by the
  bytes rather than the extension. A HEIC from a phone would otherwise
  leave the contact with a broken avatar.
- The header must decode with real dimensions, which catches a truncated or
  mislabelled file.
- At most 40 MiB, the most HEY's web form can post through its proxy. The
  direct upload has no cap of its own.
- A regular, non-empty file.

The contact is read before the upload, so a mistyped ID costs a read rather
than an upload. HEY answers 404 for a contact you cannot edit (hidden, an
alias, another HEY user), so a not_found from either write says so in its
hint.
@robzolkos
robzolkos requested a review from a team as a code owner October 12, 2026 19:26
Copilot AI balanced review requested due to automatic review settings October 12, 2026 19:26

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 7 files

You’re at about 94% of the monthly reviewed-line limit. You may want to disable incremental reviews to conserve quota. Reviews will continue until that limit is exceeded. If you need help avoiding interruptions, please contact contact@cubic.dev.

Reply with feedback, questions, or to request a fix.

View guided diff | Turn on auto-fix | Re-trigger cubic

Comment thread docs/cli.md Outdated
Comment thread internal/cmd/contact_photo.go Outdated
Comment thread internal/cmd/contact_photo.go
Comment thread tests/smoke/contacts_test.go Outdated
Comment thread internal/cmd/contact_photo_test.go
Comment thread internal/cmd/contact_photo_test.go

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The new API operations and upload caller must be recorded in API-COVERAGE.md.

1 open finding
What changed in this PR

Adds CLI support for setting and removing contact photos with validation and SDK-backed uploads.

Changes:

  • Adds hey contact photo set/remove and avatar alias.
  • Validates PNG/JPEG files and provides comprehensive tests.
  • Updates CLI documentation, skill guidance, smoke coverage, and command surface.

[!TIP]
If you aren't ready for review, convert to a draft PR.
Click "Convert to draft" or run gh pr ready --undo.
Click "Ready for review" or run gh pr ready to reengage.

File Description
internal/​cmd/​contact_photo.go Implements photo validation, upload, set, and removal.
internal/​cmd/​contact_photo_test.go Tests validation, requests, errors, and output.
internal/​cmd/​contacts.go Registers the photo subcommand.
tests/​smoke/​contacts_test.go Adds end-to-end photo lifecycle coverage.
docs/​cli.md Documents contact photo behavior.
skills/​hey/​SKILL.md Adds agent guidance and examples.
.surface Records the new command surface.

🧠 Review effort: Balanced


💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread internal/cmd/contact_photo.go
HEY resizes a contact photo with ImageMagick under hotcell/policy.xml,
which refuses an image wider or taller than 16KP and spills one over 128MP
to a disk cache that its disk ceiling refuses (haystack's
imagemagick_policy_test proves 15000x15000). HEY would store such a photo
and then fail to draw it, as with a HEIC, so the CLI refuses it from the
dimensions it already decodes. A 200MP phone photo is past both limits.

The help also repeats HEY's own advice from the profile photo page: a
square image without transparency works best.
The 128MP cap came from policy.xml, but the cell that resizes a photo runs
under tighter limits of its own (hotcell/Dockerfile: MAGICK_MEMORY_LIMIT
896MiB, and a 256MB file_size that kills a worker whose cache spills to
disk). Running HEY's variant command under that policy and those limits in
the hotcell image (ImageMagick 6.9.12 Q16) puts the line at 896MiB of pixel
cache:

- 8 bytes a pixel, so an upright photo tops out near 117MP
  (10800x10800 resizes, 11000x11000 does not, grayscale too);
- 10 bytes a pixel for a CMYK JPEG, near 94MP (9600x9600 / 9800x9600);
- twice that for a JPEG whose EXIF orientation is not upright, because
  auto-orienting it holds a second copy: near 58.7MP (7600x7600 /
  7700x7700, for a flip, a 180 and a 90 alike) and 47MP for CMYK. Past it
  the worker is killed, a verdict HEY records as permanent.

A PNG's orientation is not applied, so a PNG never pays twice. A 108MP
phone photo taken in portrait is past the budget; 48MP ones are not.

The CLI reads the JPEG's EXIF orientation itself (jpegOrientation, which
reads only the markers before the scan) and the colour model from the
header it already decodes. All 28 measured files get the verdict HEY gave
them.

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 4 files (changes from recent commits).

You’re at about 96% of the monthly reviewed-line limit. You may want to disable incremental reviews to conserve quota. Reviews will continue until that limit is exceeded. If you need help avoiding interruptions, please contact contact@cubic.dev.

Reply with feedback, questions, or to request a fix.

View guided diff | Turn on auto-fix | Re-trigger cubic

Comment thread internal/cmd/contact_photo.go Outdated
Comment thread skills/hey/SKILL.md Outdated
HEY shows a contact photo at 300×300 at most and does the resizing itself,
so the CLI only needs a sane ceiling, not a model of ImageMagick's memory.
58MP admits a full-resolution photo from a modern phone (an iPhone's 48MP,
8064×6048; 50MP Android cameras) and sits under the tightest ceiling
measured against HEY's own variant command: about 58.7MP for a JPEG whose
EXIF orientation has to be applied (7600² resizes, 7700² kills the
worker). The 16000-pixel side limit from policy.xml stays.

That retires the per-colour-model budget and the EXIF orientation reader.
The one case left uncovered is a rotated CMYK JPEG over 47MP, which no
camera produces.

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 4 files (changes from recent commits).

You’re at about 97% of the monthly reviewed-line limit. You may want to disable incremental reviews to conserve quota. Reviews will continue until that limit is exceeded. If you need help avoiding interruptions, please contact contact@cubic.dev.

Reply with feedback, questions, or to request a fix.

View guided diff | Turn on auto-fix | Re-trigger cubic

Comment thread docs/cli.md Outdated
…e photo

- openContactPhoto stats the path before opening it, as prepareAttachments
  does: opening a named pipe blocks until something writes to it, so a FIFO
  hung instead of being refused. A Unix test proves it returns.
- The smoke test fetches the avatar after setting it and requires a raster
  image. avatar_url changes with the blob whether or not HEY can resize it,
  and AvatarsController answers a photo it cannot resize with the contact's
  initials as SVG, so the URL alone proved nothing. Its cleanup also removes
  the photo before hiding the contact.
- The upload-failure test checks that the failing request was reached.
- A photo HEY cannot resize is never shown, and the contact keeps their
  initials; it is not a broken avatar. The comments and docs say so, the
  docs name the one case past the 58MP cap's cover (a rotated CMYK JPEG
  over 47MP), and the format hint says PNG or JPEG is this command's rule
  rather than claiming HEY can show nothing else.
- photo remove mentions the 30-minute avatar cache, as set does.
- API-COVERAGE.md lists the avatar endpoints and the new callers.

@cubic-dev-ai cubic-dev-ai Bot left a comment •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All reported issues were addressed across 6 files (changes from recent commits).

You’re at about 98% of the monthly reviewed-line limit. You may want to disable incremental reviews to conserve quota. Reviews will continue until that limit is exceeded. If you need help avoiding interruptions, please contact contact@cubic.dev.

Reply with feedback, questions, or to request a fix.

View guided diff | Turn on auto-fix | Re-trigger cubic

Comment thread tests/smoke/contacts_test.go Outdated
Comment thread docs/cli.md Outdated
…e docs

The avatar request used http.Get, so a stalled response held the smoke
suite until its global timeout; it now uses a 10s client like the other
direct requests. docs/cli.md wrote `hey contact photo remove` without
the contact ID it requires.
@robzolkos

Copy link
Copy Markdown
Collaborator Author

Review round summary (head 1cd46c4)

CI is green on 1cd46c4 (19 pass, 4 skipped as usual) and the branch merges cleanly. All 12 review threads are resolved.

Fixed:

  • da5aa9b: a FIFO no longer hangs photo set (stat before open, with a Unix test that fails without the fix).
  • da5aa9b: the smoke test fetches the avatar and requires a raster image, not the SVG initials fallback, and its cleanup removes the photo.
  • da5aa9b: the upload-failure test checks that the failing request was actually reached.
  • da5aa9b: API-COVERAGE.md rows for the avatar endpoints and the new callers.
  • da5aa9b: the docs name the rotated CMYK JPEG > 47 MP gap, and describe a photo HEY can't resize correctly (it's never shown, and the contact keeps their initials).
  • da5aa9b: photo remove mentions the 30-minute avatar cache.
  • da5aa9b: the format hint says PNG/JPEG is this command's rule, not HEY's limit.
  • 1cd46c4: a bounded timeout on the smoke test's avatar fetch, and <contact-id> in the docs' photo remove mention.

Declined:

Outside review: a read-only review with openai-codex/gpt-6.1-sol (thinking: high, via pi) on 1e450be raised three findings, all fixed in da5aa9b: the FIFO hang, the smoke test's avatar_url check proving nothing about whether the photo renders, and the format hint overstating HEY's limits.

Not yet reported: cubic hasn't posted a review of 1cd46c4, which changes only the smoke test's HTTP client and one docs phrase. It reported 98% of its monthly reviewed-line quota on the previous round.

Not run: make test-smoke, which needs the dev server.

@robzolkos

Copy link
Copy Markdown
Collaborator Author

Smoke tests now run against a local dev server (haystack 7d0ed4d531), on head 1cd46c4:

  • TestContactPhoto and TestContactCommandsValidateInput pass with HEY_SMOKE_STRICT=1. The server log shows HEY's image worker resizing the uploaded PNG (139 bytes in, 1,172 bytes out) and serving that variant, not the initials.
  • The full make test-smoke passes: 178 tests including subtests, none failed or skipped.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants