Skip to content

fix(vision): distinct clipboard stage errors, oversized-paste downscale, furl/PDF cascade - #353

Merged
justrach merged 4 commits into
mainfrom
fix/349-350-clipboard-images
Aug 2, 2026
Merged

fix(vision): distinct clipboard stage errors, oversized-paste downscale, furl/PDF cascade#353
justrach merged 4 commits into
mainfrom
fix/349-350-clipboard-images

Conversation

@justrach

@justrach justrach commented Aug 2, 2026

Copy link
Copy Markdown
Owner

Fixes #349. Fixes #350.

What changed

  • StageResult is now a tagged union (ok{bytes,media_type} / no_vision / too_large{bytes,limit} / not_found / read_error) instead of collapsing every failure into .read_fail; both paste call sites (Ctrl-V in readline.zig, /paste+/image in commands_model.zig) print actionable messages with real sizes (e.g. "clipboard image is 7.6 MB — over the 3.5 MB limit").
  • Staging now stats before reading, and oversized images are downscaled instead of dropped: sips -s format png -Z steps through 2048 → 1568 → 1024 until the file fits the budget, with a PNG-magic check on every step so a non-PNG can never ship mislabeled.
  • grabClipboardImage is now a flavor cascade: «class PNGf»«class furl» (validated: absolute, regular, non-empty, image-gated via extension or sips probe — the plain-text /hello coercion trap is guarded) → «class PDF » via sips conversion. File-URL clipboards (the Telegram case) and PDF clipboards now attach.
  • The shared /tmp/.harness-clip.png is replaced by a unique per-paste path (/tmp/graff-clip-<pid>-<16hex>.<ext>) built once and interpolated into the AppleScript argv, deleted after staging on every path.
  • A clipboard_paste trace event (result, flavor, bytes, mime — never payload) is emitted for every paste outcome, so dropped attachments are finally visible in .graff/traces.
  • Dead .no_vision prong removed from the Ctrl-V handler; cascade/budget/temp-path logic extracted to new src/vision_clipboard.zig with 10 headless regression tests (683/683 pass). All files stay ≤600 lines.

Why

  • Problem/failure mode: a >5 MiB moodboard paste printed a generic "couldn't read the clipboard image" and the prompt silently went out text-only (Clipboard moodboards over 5 MB are dropped before the trajectory with a generic read error #349, reproduced live on 0.0.231); Telegram-style file-URL clipboards were rejected as "no image on the clipboard" (Telegram-copied images are not detected by clipboard paste on macOS #350, reproduced via a 12-case synthetic clipboard matrix). Neither failure left any trace event.
  • Reason for this approach: investigation showed macOS lazily synthesizes PNGf from any raster flavor, so a TIFF fallback (the issue's guess) would change nothing — only pixel-less clipboards (file-url/PDF) fail, hence the cascade. And simply raising the 5 MiB read cap would make things worse: 5 MiB raw base64-expands to 6.67 MiB, over the ~5 MB provider cap. Downscaling is the only fix that actually delivers the image.
  • Constraints/trade-offs: the budget constant is 3,700,000 bytes — the largest round number whose base64 expansion clears both a 5 MB and a 5 MiB reading of the provider cap (a literal 3.75 MiB would base64 to 5,242,880 and fail a decimal cap). On non-macOS there is no sips, so oversized images fail with the honest too_large message rather than downscaling.
  • Rejected alternatives: raising the cap (wire failure, above); NSPasteboard via an ObjC shim (heavier than three osascript flavors for the same coverage); keeping the fixed temp path (reproduced a real cross-session race and it's a symlink-planting target).

Verification

Live PTY smoke against the rebuilt binary: 7.6 MB PNG now downscales at 2048 and attaches (previously dropped); 1.4 MB control unchanged; file-URL clipboard attaches via cascade (PNGf confirmed to fail on it first); PDF clipboard attaches; plain-text clipboard still correctly refused; oversized non-image reports its real size. Temp-file hygiene confirmed (/tmp/graff-clip-* empty after runs). Adversarially reviewed; the one blocking finding (sips -Z preserves source encoding → JPEG bytes in a .png) was fixed in the follow-up commit with byte-level file/magic verification.

🤖 Generated with Claude Code

https://claude.ai/code/session_01QbsV84fdmf39Bh2RF8LdPs

justrach and others added 4 commits August 3, 2026 00:44
…le, furl/PDF cascade (#349, #350)

A Ctrl-V paste could fail four different ways and say the same thing about
all of them — or say nothing at all — and a clipboard that held a *file* or
a PDF instead of raster pixels was dropped silently.

StageResult is now a tagged union (ok / no_vision / too_large / not_found /
read_error) instead of `enum { ok, no_vision, read_fail }`. `too_large`
carries the real byte count and the limit, so the line the user reads is
"the clipboard image is 7.3 MB — over the 3.5 MB limit; downscale it or use
a smaller crop" rather than the old guess "missing, or larger than 5MB".
The staging path stats before it reads, so that size is a number we looked
up, never something inferred from error.StreamTooLong. Both call sites
(readline.zig Ctrl-V and commands_model.zig /image + /paste) print the new
lines; so does the drag-and-drop stager.

The 5 MiB read cap was wrong in both directions: 5 MiB of raw bytes
base64-expands to 6.99 MB, which is already over the ~5 MB per-image
provider cap, while refusing perfectly sendable 4 MB screenshots. Raising it
would make the wire failure worse. Instead there is now ONE named budget —
vision_clipboard.max_staged_image_bytes = 3,700,000, documented with the 4/3
base64 math (it encodes to 4,933,336 bytes) — and an over-budget image is
re-encoded with `sips -Z` stepping 2048 → 1568 → 1024 until it fits.
`too_large` is only surfaced when even the smallest step is still too big.
A 7,682,253-byte PNG that used to be dropped now attaches at 2,963,762 bytes.

grabClipboardImage asks for one pasteboard flavor per osascript invocation
and stops at the first that yields a real file: «class PNGf» exactly as
before (macOS synthesizes it from TIFF/JPEG/HEIC and from live data-provider
promises, so it still covers almost everything), then «class furl», then
«class PDF ». The furl arm cannot trust the coercion: on a plain-text
clipboard `the clipboard as «class furl»` SUCCEEDS and hands back /hello for
the text "hello". So every furl path is validated — it must exist, be a
regular non-empty file, and either carry a stageable extension or survive a
`sips -g pixelWidth` probe (sips exits 13 on a non-image). A file the user
owns is staged in place and never deleted; anything else is normalized to
PNG into our own temp file. The osascript export is statted afterwards,
because exit 0 is not proof the bytes landed.

/tmp/.harness-clip.png is gone. It was hardcoded twice — a Zig constant and,
separately, a string inside the AppleScript source — with no mechanism
keeping them in sync, and being fixed and world-writable it was both a
symlink-planting target and a live race: a concurrent process deleting the
file between osascript writing it and the stager reading it produced a
spurious read_fail (reproduced during the investigation). Paths are now
built once per invocation with std.fmt.allocPrint from the pid plus 64 bits
of io.random, interpolated into the script's argv so the two cannot drift,
and restricted to characters needing no shell or AppleScript quoting. Grab
owns its temp file and releases it from a defer at both call sites, success
or failure; the intermediate .pdf never outlives the call.

Every paste attempt now emits a `clipboard_paste` trace record (result,
cascade flavor, byte count, mime — never the pixels). Before this a dropped
paste left ZERO evidence: failing and passing runs produced byte-identical
.graff/traces JSONL, which is why #350 was invisible.

Removed the dead `.no_vision` prong at readline.zig:272. It was unreachable:
clipboardPasteSource checks vision capability BEFORE it touches the
clipboard (#258), so a text-only model returns .no_vision without ever
reaching the grabber, and stageImagePath could never answer .no_vision from
inside that arm.

The MCP image ceiling points at the same constant — the base64 math is
identical whether the pixels came from a tool or from disk, so an oversized
screenshot is refused at our boundary with a note the model reads instead of
becoming a provider 400 one call later.

The pasteboard cascade, temp paths, sips gate/downscaler and the budget live
in a new src/vision_clipboard.zig (600-line-per-file convention); vision.zig
re-exports what readline/commands_model need. Tests cover the budget's
base64 math, temp-path uniqueness and charset, the /hello furl trap plus
non-image and non-regular-file rejection, planStage's not_found vs
too_large-with-the-real-byte-count split, and the downscale ladder including
cleanup of rejected steps. Nothing requiring a live pasteboard is in the
default suite.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QbsV84fdmf39Bh2RF8LdPs
….png

`sips -Z <dim> in --out out.png` preserves the SOURCE encoding regardless of
the output filename: verified on macOS 15, `sips -Z 2048 photo.jpg --out
out.png` exits 0 and `file out.png` reports `JPEG image data ... 2048x1152`.

Every downscale temp is named `.png` and `stageImagePath` derives the provider
`media_type` from that extension, so `/image photo.jpg` on a 4 MB phone photo
(or the #350 furl paste of the same file) shipped JPEG bytes declared as
`image/png`. Anthropic validates the magic bytes against `source.media_type`
and 400s with "image does not match the provided media type"; the
OpenAI/responses arms embed the same wrong mime in the `data:` URL. That is a
regression from the #349 budget fix — under the old 5 MiB raw cap the same
photo went out untouched as `image/jpeg` and worked.

Two changes, belt and braces:

- `sipsResize` now passes `-s format png`, which makes the output a real PNG
  (verified: 2048px re-encode of the same photo is a genuine PNG).
- `fitToBudget` accepts a step only if the file starts with the PNG signature,
  so the invariant holds for any resizer, not just the one we ship.

The PNG re-encode is much larger than its JPEG source — 2,795,024 bytes vs
468,171 for that photo at 2048px — but still under the 3,700,000 budget, and
the 1568/1024 rungs cover anything noisier.

Also corrects the comment in vision.zig that asserted "a downscale step is
always PNG", which was exactly the false premise behind the bug.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QbsV84fdmf39Bh2RF8LdPs
… the 600-line cap

Two things kept this branch red.

1. CI pins codegraff-zig-0.17.0-dev.813+2153f8143, which rejects the spaced
   repeat operator outright:

     error: binary operator '*' has whitespace on one side, but not the other

   `"0123456789" ** 4` in the planStage test is that form, so the whole tree
   failed to compile on CI while building fine on 0.16. Replaced with an
   explicit `++` concat, which both compilers accept — same convention already
   documented at src/shapes.zig:174 (where the array case uses `@splat`).

2. Running canonical `zig fmt` over the file splits the paired
   `"-e", "script"` osascript argv literals onto separate lines, which pushed
   vision_clipboard.zig to 618 lines and tripped the hard
   scripts/check-zig-lines.sh gate (600 max). Rather than fight fmt or
   restructure the argv builders, the test block moves to
   src/vision_clipboard_tests.zig, mirroring how imagegen.zig keeps its tests
   in imagegen_tests.zig. vision_clipboard.zig references it from a `test {}`
   block, because an unreferenced module's tests never run.

   407 lines of implementation + 238 lines of tests, both well under the cap.
   Every one of the 9 tests moved verbatim; no coverage was dropped.

Verified against both compilers with GRAFF_TRAJECTORY_ENDPOINT set:
  0.17.0-dev.813+2153f8143 (CI): zig build, zig fmt --check src build.zig,
    scripts/check-zig-lines.sh, zig build test (684/684), and
    tests/learn_e2e.py — all pass.
  0.16.0 (local toolchain): zig build and zig build test (684/684) pass.

Test count went 683 -> 684: the delta is the new anonymous `test {}` import
block itself, not a changed assertion.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QbsV84fdmf39Bh2RF8LdPs
The Windows CI job fails two of these tests, both because the clipboard
staging path is POSIX-shaped by design, not because the logic is wrong.

- "furlLooksStageable: the plain-text /hello coercion trap": the input to
  this function is always AppleScript's `POSIX path of …` output, so it
  validates with a leading-`/` test. On Windows a tmpDir realPath is `C:\...`
  (fails the check that should pass) and "/hello" is not an absolute path at
  all (passes a check for the wrong reason), so every case is decided by the
  wrong branch.

- "fitToBudget: downscales instead of dropping": `fitToBudget` writes each
  downscale step to `tempPath`, which hardcodes /tmp deliberately — the path
  is interpolated into AppleScript and must need no quoting. With no /tmp on
  Windows every step fails to write and the ladder reports "cannot shrink"
  for a reason unrelated to the test.

Both are now `error.SkipZigTest` on Windows only, so Linux and macOS keep
running them. "fitToBudget: a step that is not really a PNG" is skipped on the
same grounds even though it was green: it only passed because every step
failed to write, which would keep it green if the magic-byte check were
deleted — a false signal is worse than an honest skip.

No production behaviour changes. `furlLooksStageable` does NOT need a comptime
OS gate — its only caller is `grabFurl`, reachable solely through
`grabClipboardImage`, which already returns null off macOS; that reasoning is
now recorded in its doc comment so the next reader does not re-derive it.

Verified with GRAFF_TRAJECTORY_ENDPOINT set:
  0.17.0-dev.813 (CI pin): zig build, zig fmt --check src build.zig,
    scripts/check-zig-lines.sh, zig build test (684/684), tests/learn_e2e.py.
  0.16.0: zig build and zig build test (684/684).
  x86_64-windows-gnu: the test binary cross-compiles clean (it can only be
    run on a Windows host, but the gated code type-checks for that target).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QbsV84fdmf39Bh2RF8LdPs
@justrach
justrach merged commit 95bba4d into main Aug 2, 2026
6 checks passed
@justrach
justrach deleted the fix/349-350-clipboard-images branch August 4, 2026 08:39
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant