Repository navigation
Inbox: images in agent messages load (fixes #1813) - #1819
Merged
Merged
Conversation
A message body's  and <img src> now load through a per-message route, GET /api/inbox/messages/:id/image?path=<src as written>, instead of the /api/image the Inbox never served. The path must be an image the message's own body references, inside the message's project (realpath, so ../ and symlink escapes are refused), an image by extension and by magic bytes, at most 10 MB. Answers carry nosniff, a sandbox CSP and a same-origin CORP; cross-site requests are refused.
… review fixes - Open the realpath once with O_RDONLY|O_NOFOLLOW|O_NONBLOCK, fstat the fd (regular file, size <= cap), read at most cap + 1 bytes, then check the path still leads to the same dev/ino. A FIFO, device or a swap to a symlink is refused without blocking or buffering. - Containment is judged by the realpath, so an absolute path through a symlinked prefix (/tmp vs /private/tmp) is no longer a false 403. - X-Image-Width / X-Image-Height on the answer, as code review's route. - Docs: the route serves any message's body, not only an agent's. - Tests: a FIFO in the project, a symlinked prefix, the byte and pixel caps, the %xx fallback, a stale or outside base_path, an SVG under the sandbox CSP, HEAD and 405. - CI: images.test.tsx joins the DOM_TESTS=1 step; drop the unused React import that failed typecheck.
backnotprop
added a commit
that referenced
this pull request
Oct 10, 2026
Conflict: tests/e2e/playwright.config.ts (both sides added a spec to testMatch and the header comment); kept both, inbox-message-images.spec.ts and inbox-tailscale.spec.ts. Auto-merged: packages/server/inbox.ts (the image route sits in the shared handler, so the tailnet listener reaches it behind its Host, owner-login and Sec-Fetch-Site checks), AGENTS.md, the marketing Inbox page, packages/inbox/App.tsx, packages/shared/package.json. The image route through the tailnet: a test shows the owner's same-origin read, a wrong or missing login refused, another site refused first by the listener, and the route's own cross_origin refusal intact on the window's port. Docs say so.
1 task
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #1813.
An agent's
send_messagebody withrendered<img src="/api/image?path=…">. The Inbox server has no/api/image, so every image in a message was a 404.What changes
packages/inbox/images.ts,Thread.tsx,App.tsx): each message body is drawn withimageBaseDir = inbox-message:<id>, and the window installs an image-src resolver throughconfigurePlannotatorUI. The resolver maps that marker to/api/inbox/messages/<id>/image?path=<src as written>. Markdown images,<img>in raw HTML and images in:::questioncontext all use it. No@plannotator/uichange: this uses the existingimageBaseDirprop andimageSrcResolverseam.packages/server/inbox-message-images.ts, rules inpackages/shared/inbox/message-images.ts): the new route, mounted in the Inbox route table behind the existing Host allowlist. It sets no CORS headers.base_path, the realpath of itsproject_path, when that path is a subfolder of the project. Relative paths then resolve the way attachments already do ("relative to project_path"). Older records have nobase_pathand resolve from the project root.Security rules
The route is not a file read for arbitrary paths. It reads one image that one message shows. That message can be an agent's or the person's own reply; both bodies are rendered the same way:
pathmust appear in that message's body as an image reference: a markdowntarget as written, or an<img src>value with its entities decoded. The comparison is exact. A caller therefore cannot use the route to probe other project files, even though any agent can send a message. A non-referenced path is403 image_not_referenced, and this check runs before the disk is touched.Content-Typeis sniffed from magic bytes (code review'ssniffImageContentType) and is never taken from the extension. A non-image is415, and so is a Git LFS pointer.base_path, else the project root. An absolute path is used as written. The decision is made on the realpath, which must be inside the project root (itself a realpath). This refuses../and a symlink that leads out of the project (403 outside_project). An absolute path spelled through a symlinked prefix, such as macOS/tmp→/private/tmp, is judged by where it really leads, so it is not a false 403. A path that does not exist and lexically leaves the project is also refused.O_RDONLY | O_NOFOLLOW | O_NONBLOCK, and every later step reads that descriptor:fstatmust report a regular file of at mostMAX_REVIEW_IMAGE_PREVIEW_BYTES(10 MB). A FIFO or a device is refused without blocking. Without this, a FIFO would hang the Inbox's event loop.409 image_changed. This also catches a parent folder swapped for a symlink in between.MAX_REVIEW_IMAGE_PREVIEW_PIXELS(50 MP) cap applies, else413.X-Content-Type-Options: nosniff,Content-Security-Policy: sandbox; default-src 'none'; style-src 'unsafe-inline'(an SVG opened directly never runs) andCross-Origin-Resource-Policy: same-origin(another site cannot embed it). A successful answer also carriesX-Image-Width/X-Image-Heightwhen the image header parses, as code review's image route does. Only GET and HEAD are accepted; any other method is405.Sec-Fetch-Site: cross-siteorsame-siteis refused (403). This includes another localhost port. The window's own<img>sendssame-origin.404 message_not_found. A path with control characters or over 2048 characters:400. A URL scheme (file:,javascript:):400.If the path as written does not exist, the route tries it without its
?query/#fragment, then with%xxdecoded. Each of these attempts passes the same containment checks.Not changed
img-src 'self' data: blob:, sohttps://images in a message still do not load. The resolver passes them through unchanged, and this PR does not loosen the CSP.packages/shared/inbox/attachments.tsstill checks a path withstatand then reads it withreadFileSync, the same pattern this route replaced with a single descriptor. I left it as is in this PR, per review; it should get the same descriptor-based read in a follow-up.apps/iosdraws message bodies natively:MarkdownBlockViewusesAttributedString(markdown:, .inlineOnlyPreservingWhitespace), which does not render images, so it never requests/api/image. The web surface (surface.html) renders attachments and guides, not message bodies, and its CSP hasconnect-src 'none'. To show images on the phone, a follow-up would need two things: a door route (messages/<id>/imageon the device allowlist) and a native image view that fetches with the bearer token. This PR adds neither.Tests
packages/server/inbox-message-images.test.tsruns against a real Inbox under a tempPLANNOTATOR_DATA_DIRwith a temp git project. It covers:.pngis served asimage/gif;<img>with?v=2serves;../is 403;.txtand a fake.pngare 415;project_pathresolves relative paths, still bounded by the root;%xx-decode fallback serves, withX-Image-Width/X-Image-Height;image/svg+xmlunder the sandbox CSP;base_paththat is gone at serve time, or outside the project, falls back to the root.packages/inbox/images.test.tsx(DOM, added to theDOM_TESTS=1step in.github/workflows/test.yml): ui's real Viewer, drawn as the thread draws it, gives markdown and HTML<img>elements the per-message route. Remote images are unchanged.tests/e2e/inbox-message-images.spec.tsruns against the compiled binary. The agent sends throughplannotator inbox mcp. Chromium shows that both images use the route and decode (naturalWidth1), and that an image outside the project does not load.bun run typecheck: clean.packages/server/inbox*,packages/shared/inbox,packages/inbox,packages/core/inbox*,apps/hook/server/inbox*): 121 pass, 1 DOM-gated skip. WithDOM_TESTS=1, the DOM test passes too.Docs: the CLAUDE.md "Plannotator Inbox" section (new "Images in a message" entry and a route row), and the marketing Inbox reference.