Skip to content

Inbox: images in agent messages load (fixes #1813) - #1819

Merged
backnotprop merged 2 commits into
mainfrom
fix/inbox-message-images
Oct 10, 2026
Merged

backnotprop merged 2 commits into
mainfrom
fix/inbox-message-images

Conversation

@backnotprop

@backnotprop backnotprop commented Oct 10, 2026 •

Copy link
Copy Markdown
Owner

Fixes #1813.

An agent's send_message body with ![alt](.walkthrough/shots/after.png) rendered <img src="/api/image?path=…">. The Inbox server has no /api/image, so every image in a message was a 404.

What changes

  • Window (packages/inbox/images.ts, Thread.tsx, App.tsx): each message body is drawn with imageBaseDir = inbox-message:<id>, and the window installs an image-src resolver through configurePlannotatorUI. The resolver maps that marker to /api/inbox/messages/<id>/image?path=<src as written>. Markdown images, <img> in raw HTML and images in :::question context all use it. No @plannotator/ui change: this uses the existing imageBaseDir prop and imageSrcResolver seam.
  • Server (packages/server/inbox-message-images.ts, rules in packages/shared/inbox/message-images.ts): the new route, mounted in the Inbox route table behind the existing Host allowlist. It sets no CORS headers.
  • Store (additive): an agent message records base_path, the realpath of its project_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 no base_path and 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:

  1. Referenced by the body. The path must appear in that message's body as an image reference: a markdown ![…](src) target 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 is 403 image_not_referenced, and this check runs before the disk is touched.
  2. Images only. The extension must be png, jpg, jpeg, gif, webp, svg, avif, bmp, ico or apng before anything is read. Then the Content-Type is sniffed from magic bytes (code review's sniffImageContentType) and is never taken from the extension. A non-image is 415, and so is a Git LFS pointer.
  3. Inside the project. A relative path resolves against 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.
  4. One descriptor. The realpath is opened once with O_RDONLY | O_NOFOLLOW | O_NONBLOCK, and every later step reads that descriptor:
    • fstat must report a regular file of at most MAX_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.
    • At most cap + 1 bytes are read, so the size check happens before anything is buffered.
    • After the read, the path must still lead to the same dev/ino, else 409 image_changed. This also catches a parent folder swapped for a symlink in between.
    • Then the MAX_REVIEW_IMAGE_PREVIEW_PIXELS (50 MP) cap applies, else 413.
  5. Headers on every answer, errors included: X-Content-Type-Options: nosniff, Content-Security-Policy: sandbox; default-src 'none'; style-src 'unsafe-inline' (an SVG opened directly never runs) and Cross-Origin-Resource-Policy: same-origin (another site cannot embed it). A successful answer also carries X-Image-Width / X-Image-Height when the image header parses, as code review's image route does. Only GET and HEAD are accepted; any other method is 405.
  6. Not cross-site: a request with Sec-Fetch-Site: cross-site or same-site is refused (403). This includes another localhost port. The window's own <img> sends same-origin.
  7. Unknown or malformed message id: 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 %xx decoded. Each of these attempts passes the same containment checks.

Not changed

  • Remote images stay blocked. The window CSP is img-src 'self' data: blob:, so https:// images in a message still do not load. The resolver passes them through unchanged, and this PR does not loosen the CSP.
  • Images inside an attached markdown file are not served. Only message bodies are covered.
  • packages/shared/inbox/attachments.ts still checks a path with stat and then reads it with readFileSync, 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.
  • iPhone: the phone does not use this route and is not changed. apps/ios draws message bodies natively: MarkdownBlockView uses AttributedString(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 has connect-src 'none'. To show images on the phone, a follow-up would need two things: a door route (messages/<id>/image on 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.ts runs against a real Inbox under a temp PLANNOTATOR_DATA_DIR with a temp git project. It covers:
    • the image serves with the guard headers;
    • a GIF named .png is served as image/gif;
    • an HTML <img> with ?v=2 serves;
    • ../ is 403;
    • a symlink escape is 403;
    • .txt and a fake .png are 415;
    • a project image the body does not reference is 403, and so is an absolute path to an image that is referenced only by a relative path;
    • a missing image is 404, and so is an unknown message;
    • cross-site and same-site requests are 403, and so is a foreign Host;
    • a subfolder project_path resolves relative paths, still bounded by the root;
    • the reference parser;
    • review follow-ups:
      • a FIFO in the project answers 404 at once, and the server keeps answering;
      • an absolute path through a symlinked prefix serves, while an escape through that prefix is 403;
      • the byte cap and the pixel cap are each 413;
      • the %xx-decode fallback serves, with X-Image-Width / X-Image-Height;
      • an SVG is served as image/svg+xml under the sandbox CSP;
      • HEAD returns the headers with no body, and POST is 405;
      • a base_path that is gone at serve time, or outside the project, falls back to the root.
  • packages/inbox/images.test.tsx (DOM, added to the DOM_TESTS=1 step 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.ts runs against the compiled binary. The agent sends through plannotator inbox mcp. Chromium shows that both images use the route and decode (naturalWidth 1), and that an image outside the project does not load.
  • Local results:
    • bun run typecheck: clean.
    • Inbox bun tests (packages/server/inbox*, packages/shared/inbox, packages/inbox, packages/core/inbox*, apps/hook/server/inbox*): 121 pass, 1 DOM-gated skip. With DOM_TESTS=1, the DOM test passes too.
    • Full Inbox Playwright suite: 82 passed, 1 skipped. The skip is the tailnet test, which is run by hand.

Docs: the CLAUDE.md "Plannotator Inbox" section (new "Images in a message" entry and a route row), and the marketing Inbox reference.

A message body's ![alt](path) 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
backnotprop merged commit b591275 into main Oct 10, 2026
44 of 50 checks passed
@backnotprop
backnotprop deleted the fix/inbox-message-images branch October 10, 2026 02:26
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Inbox: markdown images in messages request /api/image, which the Inbox server doesn't serve (404)

1 participant