diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8b3c110ff2..a14305135e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -122,6 +122,7 @@ jobs: packages/ui/components/DiagramBlock.askAI.test.tsx packages/ui/components/Viewer.askAISelectionLine.test.tsx packages/ui/components/ImageLightbox.test.tsx + packages/inbox/images.test.tsx packages/ui/components/blocks/QuestionBlock.test.tsx packages/ui/components/blocks/QuestionBlock.host.test.tsx packages/ui/components/blocks/QuestionBlock.decisionToggle.test.tsx diff --git a/AGENTS.md b/AGENTS.md index 65faf674c4..8a9153a049 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1975,6 +1975,12 @@ Sorting and the rest: - Comments take no images. - Delete thread rewrites the project's files without the thread's lines. Delete project removes its folder. Both then remove every blob that no remaining record uses. +**Images in a message** (#1813; `packages/shared/inbox/message-images.ts`, `packages/server/inbox-message-images.ts`, `packages/inbox/images.ts`). +- A message body's (an agent's or the person's own) `![alt](path)` and `` load through `GET /api/inbox/messages/:id/image?path=`. The window draws each body with `imageBaseDir` = `inbox-message:` and installs `inboxImageSrcResolver`, which maps that marker to the route. There is no route that takes a bare path. +- The rules, in order: the path must appear in THAT message's body as an image reference (`inboxMessageImageRefs`: the markdown target as written, the `` value decoded), so the route cannot probe other project files; an image extension (png jpg jpeg gif webp svg avif bmp ico apng) before anything is read, else `415`; a relative path resolves against the message's `base_path` (the realpath of the agent's `project_path` when it is a subfolder of the project, recorded at send time, additive) else the project root; its realpath must sit inside the project root (judged by where it leads, so `/tmp` vs `/private/tmp` spellings work), so `../` and symlink escapes are `403`; the realpath is opened once with `O_RDONLY|O_NOFOLLOW|O_NONBLOCK` and everything after reads that descriptor (`fstat`: a regular file, so a FIFO or device is refused without blocking; at most cap + 1 bytes read; the path re-checked to lead to the same dev/ino, else `409 image_changed`); at most 10 MB and 50 megapixels (`413`), its type sniffed from magic bytes (`sniffImageContentType`, `415` when not an image). `?query` / `#fragment` are dropped and `%xx` decoded as fallbacks. +- Every answer carries `X-Content-Type-Options: nosniff`, `Content-Security-Policy: sandbox; default-src 'none'; style-src 'unsafe-inline'` and `Cross-Origin-Resource-Policy: same-origin`, no CORS, plus `X-Image-Width` / `X-Image-Height` when the header parses; GET and HEAD only (`405`); `Sec-Fetch-Site: cross-site` / `same-site` is refused (`403`). +- Remote `http(s)` images stay as written and do not load: the window's CSP is `img-src 'self' data: blob:`. Images inside an attached markdown file are not served (only message bodies). The iPhone draws message bodies natively and shows no images; the device door has no image route. + **Decisions** (`packages/server/inbox-decisions.ts`; records in `decisions.jsonl`, `InboxDecision` in `inbox-types.ts`). - Fields: `text`, `reason`, `source` `{ kind: answer | agent | person, … }`, `state` `current | replaced | retired`, `version` (a stale one is `409 decision_version_conflict`), `replaces_id`, `replacement_id`, and the dates. - The switch: @@ -2320,6 +2326,7 @@ Not covered, by design: the live-app proxy (`live-proxy-core.ts` has its own Hos | `/mcp` | POST | MCP, stateless; any Origin refused | | `/api/inbox/threads/:id/attachments` | GET | `{ serverSession, attachments: [state], annotations }`: each attachment record with `message_id`, `current: { sha256, size, mtime } \| null`, `changed_since_sent`, `unavailable: { code, message } \| null`, and the thread's annotations waiting for a Send | | `/api/inbox/attachments/:id` | GET | The current file's bytes by id (`?version=sent`: the sent blob), always `text/plain`, CSP `sandbox`. `404 attachment_not_found` / `attachment_missing`, `409 attachment_changed_type`. No route takes a path | +| `/api/inbox/messages/:id/image` | GET | `?path=`: an image the message's body shows, read from its project (see "Images in a message"); sniffed `Content-Type`, nosniff, sandbox CSP, same-origin CORP, `X-Image-Width`/`-Height`. `403 image_not_referenced` / `outside_project` / `cross_origin`, `404 message_not_found` / `image_missing`, `405`, `409 image_changed`, `413 image_too_large`, `415 not_an_image` | | `/api/inbox/attachments/:id/view` | GET | `{ attachment, version, text, html }`: one version's text (`version` is `current` or the sent sha256, the annotations' key); for HTML, `html` is the page with its `` at its folder's asset route | | `/api/html-assets//` | GET | Annotate's asset route (`resolveHtmlAssetRoute`) for the folder of an attached HTML page | | `/api/inbox/annotations` | POST | `{ serverSession?, attachment_id, version, annotation }`: save a new annotation or edit one still waiting (`409 annotation_closed` once sent or removed) | diff --git a/apps/marketing/src/content/docs/reference/inbox.md b/apps/marketing/src/content/docs/reference/inbox.md index 6799f0a2bd..a76a292ae2 100644 --- a/apps/marketing/src/content/docs/reference/inbox.md +++ b/apps/marketing/src/content/docs/reference/inbox.md @@ -38,6 +38,7 @@ You never need to keep the Inbox running yourself. When an agent calls it and it - **A thread.** It reads like email. Questions render as cards, and a click saves your pick at once. **Send** carries your picks, your words and your annotations as one reply. **Mod+Enter** sends. After a Send the reply reads "Saved for " until the agent reads it, then "Delivered to ,