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) `` 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 , ".
- **New message.** Next to Reply, **New message** writes to an agent session that is running now in the thread's project (Claude Code, Pi or OpenCode 2 with the inbox tool; Pi and OpenCode 2 sessions are listed once they have written in that project). With one session running, the message goes to it; with several, you pick one. The session takes it as its next turn. The message belongs only to the session you picked: another agent session reading the thread does not take it. When no session is running, the button says so and offers Reply instead.
- **Files.** Files an agent attached open beside the thread: markdown, text, HTML, Mermaid and Graphviz. Annotate them as in Plannotator, and the annotations ride your next Send. If the agent changes a file after it sent it, the Inbox says so and can still show the version it sent.
+- **Images.** An image in a message, `` or ` `, shows when the file is in the message's project: a path relative to the folder the agent sent from (the project root for your own replies), or an absolute one inside the project. PNG, JPEG, GIF, WebP, SVG, AVIF, BMP and ICO up to 10 MB. Images from the web (`https://…`) do not load.
- **Decisions.** A question can record your answer as a project decision. The switch on the card is on by default when the agent wrote `Decision: when answered`. The **Decisions** page lists what is waiting, what is settled, and what was replaced or retired.
- **Guided reviews.** An agent can send a guided review of a code change. It opens as sections, files and the diff, and you can tick sections as reviewed.
- **Notifications.** The open page can raise a browser notification when a new question arrives while the tab is in the background. The Inbox asks once, the first time something arrives. You can change this later in **Settings**.
diff --git a/packages/core/inbox-types.ts b/packages/core/inbox-types.ts
index 2c5c4cb789..66829aa04e 100644
--- a/packages/core/inbox-types.ts
+++ b/packages/core/inbox-types.ts
@@ -227,6 +227,13 @@ export interface InboxMessage {
* message and on records older than step 8.
*/
to?: InboxAddressee | null;
+ /**
+ * On an agent's message: the realpath of the `project_path` it was sent
+ * from, when that is a folder inside the project other than its root.
+ * Relative image paths in the body start there (#1813); absent, they start
+ * at the project root. Added after step 8: older records lack it.
+ */
+ base_path?: string | null;
}
/** The agent session a person's New message is addressed to (`InboxMessage.to`). */
diff --git a/packages/inbox/App.tsx b/packages/inbox/App.tsx
index e825e55b6d..f80e2c8b81 100644
--- a/packages/inbox/App.tsx
+++ b/packages/inbox/App.tsx
@@ -27,6 +27,7 @@ import {
} from './api';
import { filterSections, heldNotice, refreshHeldRows, waitingCount } from './held';
import { tildePath } from './format';
+import { inboxImageSrcResolver } from './images';
import type { ConnectContext } from './harnesses';
import { Sidebar } from './components/Sidebar';
import { SidebarShell } from './shell/SidebarShell';
@@ -46,6 +47,8 @@ import { GuidePane } from './components/GuidePane';
// posting nowhere.
configurePlannotatorUI({
serverSync: () => {},
+ // Images in a message load from the Inbox's per-message route (#1813).
+ imageSrcResolver: inboxImageSrcResolver,
webmcp: { enabled: false, namePrefix: 'plannotator.' },
// Skill references in comments read the host's skills; the Inbox has none to offer.
skillCatalogTransport: async () => [],
diff --git a/packages/inbox/components/Thread.tsx b/packages/inbox/components/Thread.tsx
index 54248b6736..59942393c1 100644
--- a/packages/inbox/components/Thread.tsx
+++ b/packages/inbox/components/Thread.tsx
@@ -14,6 +14,7 @@ import { agentName, clockTime, plural } from '../format';
import { AuthorMark, DecisionDiamond, Icon } from '../icons';
import { DecisionCard } from './DecisionCard';
import { GuideCard } from './GuideCard';
+import { inboxMessageImageBase } from '../images';
import { NewMessageButton, NewMessageComposer, NewMessagePopover, hostName, useLiveSessions, type NewMessageMode } from './NewMessage';
const noop = () => {};
@@ -88,6 +89,7 @@ function MessageBody({
maxWidth={null}
stickyActions={false}
disableCodePathValidation
+ imageBaseDir={inboxMessageImageBase(message.id)}
answerOnly
readOnly={readOnly}
questionAnswers={answers}
diff --git a/packages/inbox/images.test.tsx b/packages/inbox/images.test.tsx
new file mode 100644
index 0000000000..a8a4e46507
--- /dev/null
+++ b/packages/inbox/images.test.tsx
@@ -0,0 +1,76 @@
+/**
+ * Images in a message (#1813): a message body drawn by ui's Viewer
+ * the way the thread draws it (images based on the message) asks the Inbox's
+ * per-message route for a relative or absolute image, markdown and HTML
+ * alike, and never the bare `/api/image` the Inbox does not serve.
+ */
+import { afterEach, expect, test } from 'bun:test';
+import { act } from 'react';
+import { createRoot, type Root } from 'react-dom/client';
+import { resetImageSrcResolver, setImageSrcResolver } from '@plannotator/ui/components/ImageThumbnail';
+import { parseMarkdownToBlocks } from '@plannotator/ui/utils/parser';
+import { inboxImageSrcResolver, inboxMessageImageBase } from './images';
+
+const hasDom = typeof document !== 'undefined';
+// Viewer pulls in web-highlighter, which reads `window` at module-eval time.
+const Viewer = (hasDom ? (await import('@plannotator/ui/components/Viewer')).Viewer : null) as typeof import('@plannotator/ui/components/Viewer')['Viewer'];
+
+let root: Root | null = null;
+let host: HTMLElement | null = null;
+
+afterEach(() => {
+ act(() => root?.unmount());
+ host?.remove();
+ root = null;
+ host = null;
+ resetImageSrcResolver();
+});
+
+const ID = 'msg_01K7ZZZZZZZZZZZZZZZZZZZZZZ';
+
+test.skipIf(!hasDom)('a message image loads from /api/inbox/messages//image with the src as written', async () => {
+ setImageSrcResolver(inboxImageSrcResolver);
+ const body = [
+ 'The page after the fix:',
+ '',
+ '',
+ '',
+ '
',
+ '',
+ '',
+ ].join('\n');
+ host = document.createElement('div');
+ document.body.appendChild(host);
+ await act(async () => {
+ root = createRoot(host!);
+ root.render(
+ {}}
+ onSelectAnnotation={() => {}}
+ selectedAnnotationId={null}
+ mode="selection"
+ taterMode={false}
+ maxWidth={null}
+ stickyActions={false}
+ disableCodePathValidation
+ imageBaseDir={inboxMessageImageBase(ID)}
+ answerOnly
+ readOnly
+ />,
+ );
+ });
+ const src = (alt: string) => host!.querySelector(`img[alt="${alt}"]`)?.getAttribute('src');
+ expect(src('after')).toBe(`/api/inbox/messages/${ID}/image?path=${encodeURIComponent('.walkthrough/shots/after.png')}`);
+ expect(src('before')).toBe(`/api/inbox/messages/${ID}/image?path=${encodeURIComponent('/abs/shots/before.png')}`);
+ // Remote images keep their URL (the window's CSP does not load them).
+ expect(src('remote')).toBe('https://example.com/x.png');
+});
+
+test('the resolver routes only a message marker; anything else keeps the default URL', () => {
+ expect(inboxImageSrcResolver('a.png', inboxMessageImageBase(ID))).toBe(`/api/inbox/messages/${ID}/image?path=a.png`);
+ expect(inboxImageSrcResolver('a.png', 'inbox-message:../../evil')).toBe('/api/image?path=a.png&base=inbox-message%3A..%2F..%2Fevil');
+ expect(inboxImageSrcResolver('a.png')).toBe('/api/image?path=a.png');
+});
diff --git a/packages/inbox/images.ts b/packages/inbox/images.ts
new file mode 100644
index 0000000000..496e47c9bb
--- /dev/null
+++ b/packages/inbox/images.ts
@@ -0,0 +1,43 @@
+/**
+ * Images in a message (#1813). A message body is drawn by ui's
+ * Viewer with `imageBaseDir` set to `inboxMessageImageBase(message.id)`, and
+ * the window installs `inboxImageSrcResolver` (configurePlannotatorUI), which
+ * turns that marker plus the src as written into the Inbox's own route:
+ * `/api/inbox/messages//image?path=`. The server reads the image
+ * from the message's project only when the message's body shows it
+ * (packages/shared/inbox/message-images.ts). There is no route that takes a
+ * bare path.
+ *
+ * Remote `http(s)` images are left as written: the window's CSP (`img-src
+ * 'self' data: blob:`) does not load them.
+ */
+import type { ImageSrcResolver } from '@plannotator/ui/configure';
+
+const MESSAGE_BASE_PREFIX = 'inbox-message:';
+const MESSAGE_ID_RE = /^msg_[0-9A-Za-z]+$/;
+
+/** The `imageBaseDir` a message body is drawn with. */
+export function inboxMessageImageBase(messageId: string): string {
+ return `${MESSAGE_BASE_PREFIX}${messageId}`;
+}
+
+/** The URL an image in a message loads from. */
+export function inboxMessageImageUrl(messageId: string, src: string): string {
+ return `/api/inbox/messages/${encodeURIComponent(messageId)}/image?path=${encodeURIComponent(src)}`;
+}
+
+/**
+ * The window's image resolver. Outside a message body there is no image
+ * route in the Inbox, so any other local path keeps ui's default URL, which
+ * the Inbox answers with a 404 as it always has.
+ */
+export const inboxImageSrcResolver: ImageSrcResolver = (path, base) => {
+ if (/^https?:\/\//i.test(path)) return path;
+ if (base?.startsWith(MESSAGE_BASE_PREFIX)) {
+ const id = base.slice(MESSAGE_BASE_PREFIX.length);
+ if (MESSAGE_ID_RE.test(id)) return inboxMessageImageUrl(id, path);
+ }
+ let url = `/api/image?path=${encodeURIComponent(path)}`;
+ if (base && !path.startsWith('/')) url += `&base=${encodeURIComponent(base)}`;
+ return url;
+};
diff --git a/packages/server/inbox-mcp.ts b/packages/server/inbox-mcp.ts
index e0887ff404..2106b5eb1c 100644
--- a/packages/server/inbox-mcp.ts
+++ b/packages/server/inbox-mcp.ts
@@ -10,7 +10,8 @@
* question, approves or sends on the person's behalf.
*/
-import { isAbsolute } from "node:path";
+import { realpathSync } from "node:fs";
+import { isAbsolute, sep } from "node:path";
import { McpServer } from "@modelcontextprotocol/server";
import * as z from "zod";
import { QUESTION_AUTHORING_GUIDE } from "@plannotator/core/question-block";
@@ -227,6 +228,22 @@ export async function sendProject(context: InboxMcpContext, input: { reply_to?:
return context.resolveProject(input.project_path);
}
+/**
+ * The folder inside `project` an agent sent from, when it is not the root
+ * (`InboxMessage.base_path`): relative image paths in its body start there.
+ */
+function sendBasePath(projectPath: string | undefined, project: InboxProject): string | null {
+ if (!projectPath || !isAbsolute(projectPath)) return null;
+ let real: string;
+ try {
+ real = realpathSync(projectPath);
+ } catch {
+ return null;
+ }
+ const root = project.root.endsWith(sep) ? project.root : `${project.root}${sep}`;
+ return real !== project.root && real.startsWith(root) ? real : null;
+}
+
/** An agent's message into `project`, routed by the store (send_message, submit_guide). */
export function sendAgentMessage(
context: InboxMcpContext,
@@ -256,6 +273,7 @@ export function sendAgentMessage(
thread: input.thread ?? null,
attachments,
guide: guide ?? null,
+ base_path: sendBasePath(input.project_path, project),
});
const message = result.message;
const landed = store.project(message.project_id)!;
diff --git a/packages/server/inbox-message-images.test.ts b/packages/server/inbox-message-images.test.ts
new file mode 100644
index 0000000000..a6103f3230
--- /dev/null
+++ b/packages/server/inbox-message-images.test.ts
@@ -0,0 +1,288 @@
+/**
+ * Images in a message (#1813), proved against a real Inbox on
+ * loopback under a temp data dir, a temp git project, the MCP SDK client for
+ * the agent and real `fetch` for the window.
+ */
+import { afterEach, beforeEach, expect, test } from "bun:test";
+import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync, truncateSync, writeFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { Client, StreamableHTTPClientTransport } from "@modelcontextprotocol/client";
+import { inboxMessageImageRefs, readInboxMessageImage } from "@plannotator/shared/inbox/message-images";
+import { MAX_REVIEW_IMAGE_PREVIEW_BYTES } from "@plannotator/shared/diff-paths";
+import { startInboxServer, type InboxServer } from "./inbox";
+
+const roots: string[] = [];
+const servers: InboxServer[] = [];
+const clients: Client[] = [];
+let previousDataDir: string | undefined;
+
+beforeEach(() => {
+ previousDataDir = process.env.PLANNOTATOR_DATA_DIR;
+});
+
+afterEach(async () => {
+ for (const client of clients.splice(0)) await client.close().catch(() => {});
+ for (const server of servers.splice(0)) server.stop();
+ for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
+ if (previousDataDir === undefined) delete process.env.PLANNOTATOR_DATA_DIR;
+ else process.env.PLANNOTATOR_DATA_DIR = previousDataDir;
+});
+
+/** A PNG's signature and IHDR (2 x 3 px): enough for the sniffer and the dimension read. */
+function png(): Uint8Array {
+ const bytes = new Uint8Array(33);
+ bytes.set([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 13, 0x49, 0x48, 0x44, 0x52, 0, 0, 0, 2, 0, 0, 0, 3, 8, 6, 0, 0, 0]);
+ return bytes;
+}
+
+function gif(): Uint8Array {
+ return new Uint8Array([0x47, 0x49, 0x46, 0x38, 0x39, 0x61, 1, 0, 1, 0, 0, 0, 0]);
+}
+
+function world() {
+ const root = realpathSync(mkdtempSync(join(tmpdir(), "plannotator-inbox-img-")));
+ roots.push(root);
+ const project = join(root, "api");
+ mkdirSync(join(project, ".walkthrough", "shots"), { recursive: true });
+ mkdirSync(join(project, "packages", "web", "shots"), { recursive: true });
+ Bun.spawnSync(["git", "init", "-q"], { cwd: project, env: { ...process.env, GIT_CONFIG_GLOBAL: "/dev/null", GIT_CONFIG_NOSYSTEM: "1" } });
+ writeFileSync(join(project, ".walkthrough", "shots", "after.png"), png());
+ // A GIF named .png: the type comes from the bytes.
+ writeFileSync(join(project, ".walkthrough", "shots", "really-a-gif.png"), gif());
+ writeFileSync(join(project, ".walkthrough", "shots", "fake.png"), "not an image at all");
+ writeFileSync(join(project, ".walkthrough", "notes.txt"), "plain text");
+ writeFileSync(join(project, "packages", "web", "shots", "home.png"), png());
+ // Outside the project.
+ writeFileSync(join(root, "secret.png"), png());
+ symlinkSync(join(root, "secret.png"), join(project, ".walkthrough", "shots", "escape.png"));
+ const dataDir = join(root, "data");
+ process.env.PLANNOTATOR_DATA_DIR = dataDir;
+ return { root, project, dataDir };
+}
+
+async function start(dataDir: string): Promise {
+ const server = await startInboxServer({ dataDir, phones: false });
+ servers.push(server);
+ return server;
+}
+
+async function agent(server: InboxServer): Promise {
+ const client = new Client({ name: "inbox-message-images-test", version: "1.0.0" });
+ await client.connect(new StreamableHTTPClientTransport(new URL(`http://127.0.0.1:${server.port}/mcp`)));
+ clients.push(client);
+ return client;
+}
+
+async function send(client: Client, args: Record): Promise> {
+ const result = (await client.callTool({ name: "send_message", arguments: args })) as { isError?: boolean; content: { text: string }[]; structuredContent: Record };
+ if (result.isError) throw new Error(result.content[0]!.text);
+ return result.structuredContent;
+}
+
+function image(server: InboxServer, messageId: string, path: string, headers: Record = {}): Promise {
+ return fetch(`http://127.0.0.1:${server.port}/api/inbox/messages/${messageId}/image?path=${encodeURIComponent(path)}`, { headers });
+}
+
+function expectGuardHeaders(response: Response): void {
+ expect(response.headers.get("x-content-type-options")).toBe("nosniff");
+ expect(response.headers.get("content-security-policy")).toBe("sandbox; default-src 'none'; style-src 'unsafe-inline'");
+ expect(response.headers.get("cross-origin-resource-policy")).toBe("same-origin");
+ expect(response.headers.get("access-control-allow-origin")).toBeNull();
+}
+
+const BODY = [
+ "The page after the fix:",
+ "",
+ "",
+ "",
+ "  ",
+ "",
+ "  ",
+ "",
+ ' ',
+].join("\n");
+
+test("an image the message shows serves from its project, typed by its bytes, with the guard headers", async () => {
+ const { project, dataDir } = world();
+ const server = await start(dataDir);
+ const sent = await send(await agent(server), { project_path: project, body: BODY });
+
+ const ok = await image(server, sent.message_id, ".walkthrough/shots/after.png", { "Sec-Fetch-Site": "same-origin" });
+ expect(ok.status).toBe(200);
+ expect(ok.headers.get("content-type")).toBe("image/png");
+ expectGuardHeaders(ok);
+ expect(new Uint8Array(await ok.arrayBuffer())).toEqual(png());
+
+ // The content type is sniffed, never taken from the name.
+ const sniffed = await image(server, sent.message_id, ".walkthrough/shots/really-a-gif.png");
+ expect(sniffed.status).toBe(200);
+ expect(sniffed.headers.get("content-type")).toBe("image/gif");
+
+ // An in raw HTML, with a query string, as the browser reads its src.
+ const html = await image(server, sent.message_id, "./.walkthrough/shots/after.png?v=2");
+ expect(html.status).toBe(200);
+ expect(html.headers.get("content-type")).toBe("image/png");
+});
+
+test("escapes, non-images, unreferenced paths, unknown messages and other sites are refused", async () => {
+ const { project, dataDir } = world();
+ const server = await start(dataDir);
+ const sent = await send(await agent(server), { project_path: project, body: BODY });
+ const id = sent.message_id as string;
+
+ const up = await image(server, id, "../secret.png");
+ expect(up.status).toBe(403);
+ expect(((await up.json()) as { code: string }).code).toBe("outside_project");
+ expectGuardHeaders(up);
+
+ const link = await image(server, id, ".walkthrough/shots/escape.png");
+ expect(link.status).toBe(403);
+ expect(((await link.json()) as { code: string }).code).toBe("outside_project");
+
+ // A non-image: by its name before anything is read, and by its bytes.
+ expect((await image(server, id, ".walkthrough/notes.txt")).status).toBe(415);
+ expect((await image(server, id, ".walkthrough/shots/fake.png")).status).toBe(415);
+
+ // A file in the project the body does not show cannot be probed.
+ const unreferenced = await image(server, id, "packages/web/shots/home.png");
+ expect(unreferenced.status).toBe(403);
+ expect(((await unreferenced.json()) as { code: string }).code).toBe("image_not_referenced");
+ expect((await image(server, id, join(project, ".walkthrough/shots/after.png"))).status).toBe(403);
+
+ expect((await image(server, id, ".walkthrough/shots/missing.png")).status).toBe(404);
+ const unknown = await image(server, "msg_00000000000000000000000000", ".walkthrough/shots/after.png");
+ expect(unknown.status).toBe(404);
+ expect(((await unknown.json()) as { code: string }).code).toBe("message_not_found");
+
+ // Another site, or another port on this machine, gets nothing.
+ expect((await image(server, id, ".walkthrough/shots/after.png", { "Sec-Fetch-Site": "cross-site" })).status).toBe(403);
+ expect((await image(server, id, ".walkthrough/shots/after.png", { "Sec-Fetch-Site": "same-site" })).status).toBe(403);
+ // A foreign Host never reaches the route (the Inbox's allowlist).
+ const rebound = await fetch(`http://127.0.0.1:${server.port}/api/inbox/messages/${id}/image?path=${encodeURIComponent(".walkthrough/shots/after.png")}`, {
+ headers: { Host: "evil.example" },
+ });
+ expect(rebound.status).toBe(403);
+});
+
+test("relative paths start at the folder the agent sent from, still bounded by the project", async () => {
+ const { project, dataDir } = world();
+ const server = await start(dataDir);
+ const client = await agent(server);
+ const sent = await send(client, { project_path: join(project, "packages", "web"), body: "\n\n\n\n" });
+ expect(sent.project.root).toBe(project);
+ expect((await image(server, sent.message_id, "shots/home.png")).status).toBe(200);
+ expect((await image(server, sent.message_id, "../../.walkthrough/shots/after.png")).status).toBe(200);
+ expect((await image(server, sent.message_id, "../../../secret.png")).status).toBe(403);
+ // An absolute path inside the project works when the body names it.
+ const absolute = join(project, ".walkthrough", "shots", "after.png");
+ const second = await send(client, { project_path: project, body: `` });
+ expect((await image(server, second.message_id, absolute)).status).toBe(200);
+});
+
+test("the body's image references are read as the window renders them", () => {
+ const refs = inboxMessageImageRefs(
+ [
+ "  ",
+ " ",
+ "Not an image: [link](five.png).",
+ ].join("\n"),
+ );
+ expect([...refs].sort()).toEqual(["four.png", "one.png", "two&three.png"]);
+});
+
+/** A PNG header claiming `width` x `height` pixels. */
+function pngSized(width: number, height: number): Uint8Array {
+ const bytes = png();
+ const view = new DataView(bytes.buffer);
+ view.setUint32(16, width);
+ view.setUint32(20, height);
+ return bytes;
+}
+
+test("a FIFO in the project is refused at once, never read (the Inbox does not hang)", async () => {
+ const { project, dataDir } = world();
+ const fifo = join(project, ".walkthrough", "shots", "pipe.png");
+ expect(Bun.spawnSync(["mkfifo", fifo]).exitCode).toBe(0);
+ const server = await start(dataDir);
+ const sent = await send(await agent(server), { project_path: project, body: "" });
+ const started = Date.now();
+ const answer = await image(server, sent.message_id, ".walkthrough/shots/pipe.png", { "Sec-Fetch-Site": "same-origin" });
+ expect(answer.status).toBe(404);
+ expect(((await answer.json()) as { code: string }).code).toBe("image_missing");
+ expect(Date.now() - started).toBeLessThan(5000);
+ // The server still answers afterwards.
+ expect((await fetch(`http://127.0.0.1:${server.port}/api/inbox/health`)).status).toBe(200);
+});
+
+test("an absolute path through a symlinked prefix is judged by where it leads", async () => {
+ const { root, project, dataDir } = world();
+ // As macOS /tmp is really /private/tmp: the project reached under another spelling.
+ const alias = join(root, "alias");
+ symlinkSync(project, alias);
+ const spelled = join(alias, ".walkthrough", "shots", "after.png");
+ const escape = join(alias, ".walkthrough", "shots", "escape.png");
+ const outside = join(root, "secret.png");
+ const server = await start(dataDir);
+ const sent = await send(await agent(server), { project_path: project, body: `  ` });
+ expect((await image(server, sent.message_id, spelled)).status).toBe(200);
+ expect((await image(server, sent.message_id, escape)).status).toBe(403);
+ expect((await image(server, sent.message_id, outside)).status).toBe(403);
+});
+
+test("the byte cap and the pixel cap are 413", async () => {
+ const { project, dataDir } = world();
+ const big = join(project, ".walkthrough", "shots", "big.png");
+ writeFileSync(big, png());
+ truncateSync(big, MAX_REVIEW_IMAGE_PREVIEW_BYTES + 1);
+ writeFileSync(join(project, ".walkthrough", "shots", "wide.png"), pngSized(10_000, 10_000));
+ const server = await start(dataDir);
+ const sent = await send(await agent(server), { project_path: project, body: " " });
+ const tooBig = await image(server, sent.message_id, ".walkthrough/shots/big.png");
+ expect(tooBig.status).toBe(413);
+ expectGuardHeaders(tooBig);
+ expect((await image(server, sent.message_id, ".walkthrough/shots/wide.png")).status).toBe(413);
+});
+
+test("a percent-encoded src falls back to the decoded name; an SVG is image/svg+xml under the sandbox CSP; HEAD and 405", async () => {
+ const { project, dataDir } = world();
+ writeFileSync(join(project, ".walkthrough", "shots", "my shot.png"), png());
+ writeFileSync(join(project, ".walkthrough", "shots", "flow.svg"), ' ');
+ const server = await start(dataDir);
+ const sent = await send(await agent(server), { project_path: project, body: "  " });
+ const id = sent.message_id as string;
+
+ const decoded = await image(server, id, ".walkthrough/shots/my%20shot.png");
+ expect(decoded.status).toBe(200);
+ expect(decoded.headers.get("content-type")).toBe("image/png");
+ expect(decoded.headers.get("x-image-width")).toBe("2");
+ expect(decoded.headers.get("x-image-height")).toBe("3");
+
+ const svg = await image(server, id, ".walkthrough/shots/flow.svg");
+ expect(svg.status).toBe(200);
+ expect(svg.headers.get("content-type")).toBe("image/svg+xml");
+ expectGuardHeaders(svg);
+
+ const url = `http://127.0.0.1:${server.port}/api/inbox/messages/${id}/image?path=${encodeURIComponent(".walkthrough/shots/after.png")}`;
+ const head = await fetch(url, { method: "HEAD" });
+ expect(head.status).toBe(200);
+ expect(head.headers.get("content-type")).toBe("image/png");
+ expect((await head.arrayBuffer()).byteLength).toBe(0);
+ const posted = await fetch(url, { method: "POST" });
+ expect(posted.status).toBe(405);
+ expectGuardHeaders(posted);
+});
+
+test("a base_path gone by serve time, or outside the project, falls back to the project root", async () => {
+ const { project, dataDir } = world();
+ const sub = join(project, "packages", "gone");
+ mkdirSync(sub, { recursive: true });
+ const server = await start(dataDir);
+ const sent = await send(await agent(server), { project_path: sub, body: "" });
+ expect(server.store.message(sent.message_id)?.base_path).toBe(sub);
+ rmSync(sub, { recursive: true, force: true });
+ expect((await image(server, sent.message_id, ".walkthrough/shots/after.png")).status).toBe(200);
+ // A base_path outside the project (a record no send writes) is never used.
+ const result = readInboxMessageImage({ body: "", root: project, base: join(project, ".."), path: ".walkthrough/shots/after.png" });
+ expect(result.ok).toBe(true);
+});
diff --git a/packages/server/inbox-message-images.ts b/packages/server/inbox-message-images.ts
new file mode 100644
index 0000000000..cb40850a20
--- /dev/null
+++ b/packages/server/inbox-message-images.ts
@@ -0,0 +1,70 @@
+/**
+ * Plannotator Inbox: `GET /api/inbox/messages//image?path=`, the
+ * images a message shows (#1813). Mounted in the Inbox server's
+ * route table (packages/server/inbox.ts), behind its Host allowlist; it sets
+ * no CORS headers.
+ *
+ * Every decision is in packages/shared/inbox/message-images.ts: 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 its magic bytes, at most 10 MB. This file adds the
+ * transport: a cross-site request is refused before anything is read, and
+ * every answer carries nosniff, a sandbox CSP and a same-origin CORP, so
+ * another site can neither embed nor read an image, and an SVG opened
+ * directly never runs.
+ */
+
+import { isInboxId } from "@plannotator/core/inbox-types";
+import { INBOX_MESSAGE_IMAGE_CSP, readInboxMessageImage } from "@plannotator/shared/inbox/message-images";
+import type { InboxStore } from "@plannotator/shared/inbox/store";
+
+export const INBOX_MESSAGE_IMAGE_ROUTE = /^\/api\/inbox\/messages\/([A-Za-z0-9_]+)\/image$/;
+
+const GUARD_HEADERS = {
+ "X-Content-Type-Options": "nosniff",
+ "Content-Security-Policy": INBOX_MESSAGE_IMAGE_CSP,
+ "Cross-Origin-Resource-Policy": "same-origin",
+};
+
+function refuse(status: number, code: string, error: string): Response {
+ return new Response(JSON.stringify({ error, code }), {
+ status,
+ headers: { ...GUARD_HEADERS, "Content-Type": "application/json; charset=utf-8", "Cache-Control": "no-store" },
+ });
+}
+
+/** Answers the image route, or null when the path is not it. */
+export function inboxMessageImageRoute(req: Request, url: URL, store: InboxStore): Response | null {
+ const match = INBOX_MESSAGE_IMAGE_ROUTE.exec(url.pathname);
+ if (!match) return null;
+ if (req.method !== "GET" && req.method !== "HEAD") return refuse(405, "method_not_allowed", "Use GET.");
+ // The window's own is same-origin; a typed URL is "none". A page on
+ // another site (or another localhost port) never gets an answer.
+ const site = req.headers.get("sec-fetch-site");
+ if (site === "cross-site" || site === "same-site") return refuse(403, "cross_origin", "Cross-origin requests are not accepted.");
+ const id = match[1]!;
+ const message = isInboxId("msg", id) ? store.message(id) : null;
+ if (!message) return refuse(404, "message_not_found", `No message ${id}.`);
+ const project = store.project(message.project_id);
+ if (!project) return refuse(404, "project_not_found", "The message's project is gone.");
+
+ const result = readInboxMessageImage({
+ body: message.body,
+ root: project.root,
+ base: message.base_path ?? null,
+ path: url.searchParams.get("path"),
+ });
+ if (!result.ok) return refuse(result.status, result.code, result.error);
+ return new Response(req.method === "HEAD" ? null : new Uint8Array(result.bytes), {
+ headers: {
+ ...GUARD_HEADERS,
+ "Content-Type": result.contentType,
+ "Content-Length": String(result.bytes.byteLength),
+ "Cache-Control": "private, no-cache",
+ // As code review's image route: the header's size, when it parses.
+ ...(result.width !== undefined && result.height !== undefined
+ ? { "X-Image-Width": String(result.width), "X-Image-Height": String(result.height) }
+ : {}),
+ },
+ });
+}
diff --git a/packages/server/inbox.ts b/packages/server/inbox.ts
index d8a19886e3..d142a0fac1 100644
--- a/packages/server/inbox.ts
+++ b/packages/server/inbox.ts
@@ -90,6 +90,7 @@ import {
import { inboxGuideRoute } from "./inbox-guides";
import { handleFavicon } from "./shared-handlers";
import { createInboxAttachmentRoutes } from "./inbox-attachments";
+import { inboxMessageImageRoute } from "./inbox-message-images";
import { recordInboxAttachments } from "@plannotator/shared/inbox/attachments";
import { createInboxLiveSessions } from "./inbox-sessions";
import { createInboxDevices, DOOR_PREFIX } from "./inbox-devices";
@@ -861,6 +862,11 @@ export async function startInboxServer(options: InboxServerOptions = {}): Promis
const newMessage = await live.route(req, path);
if (newMessage) return newMessage;
+ // #1813: an image a message's body shows, read from its project
+ // (packages/server/inbox-message-images.ts).
+ const image = inboxMessageImageRoute(req, url, store);
+ if (image) return image;
+
// Step 2: attachments by id, annotations, the HTML asset route, delete
// thread and delete project (packages/server/inbox-attachments.ts).
const attached = await attachmentRoutes(req, url);
diff --git a/packages/shared/inbox/message-images.ts b/packages/shared/inbox/message-images.ts
new file mode 100644
index 0000000000..3fa103c653
--- /dev/null
+++ b/packages/shared/inbox/message-images.ts
@@ -0,0 +1,228 @@
+/**
+ * Plannotator Inbox: images a message shows (#1813).
+ *
+ * A message body (an agent's, or the person's own) can carry
+ * `` or ` ` naming an image in the
+ * message's project. The window asks for it through
+ * `GET /api/inbox/messages//image?path=`
+ * (packages/server/inbox-message-images.ts), and this module decides every
+ * answer. The rules, in order:
+ *
+ * 1. The path must appear in THAT message's body as an image reference
+ * (`inboxMessageImageRefs`), compared as written. So the route reads
+ * only what the message shows, never a file a caller names.
+ * 2. It must name an image by extension (png jpg jpeg gif webp svg avif bmp
+ * ico apng, `isReviewImagePath`), before anything is read.
+ * 3. A relative path resolves against the folder the agent sent from
+ * (`base_path`, kept on the message when it is a subfolder of the
+ * project), else the project root; an absolute one is taken as it is.
+ * Its realpath must sit inside the project root (a realpath itself), so
+ * `../` and a symlink that leaves the project are refused alike, while
+ * an absolute path spelled through a symlinked prefix (macOS `/tmp`,
+ * really `/private/tmp`) is judged by where it really leads. A path
+ * that is not on disk and lexically leaves the project is refused too.
+ * 4. The realpath is opened once, `O_RDONLY | O_NOFOLLOW | O_NONBLOCK`, and
+ * everything after that reads the descriptor: `fstat` must say a regular
+ * file of at most MAX_REVIEW_IMAGE_PREVIEW_BYTES, at most cap + 1 bytes
+ * are read, and the path must still lead to that same file afterwards.
+ * A swap to a symlink, a FIFO (a blocking open or read would hang the
+ * Inbox's event loop on it) or a device is refused, never read. Then
+ * MAX_REVIEW_IMAGE_PREVIEW_PIXELS, and the content type is sniffed from
+ * the magic bytes (code review's sniffer), never taken from the name.
+ */
+
+import { closeSync, constants, fstatSync, openSync, readSync, realpathSync, statSync } from "node:fs";
+import { isAbsolute, resolve, sep } from "node:path";
+import { MAX_REVIEW_IMAGE_PREVIEW_BYTES, MAX_REVIEW_IMAGE_PREVIEW_PIXELS, isReviewImagePath } from "../diff-paths";
+import { isLfsPointer, readImageDimensions, sniffImageContentType } from "../review-image";
+
+/** The longest `path` the route takes. */
+export const INBOX_MESSAGE_IMAGE_PATH_MAX = 2048;
+
+/** The image answer's policy: an SVG opened directly never runs a script. */
+export const INBOX_MESSAGE_IMAGE_CSP = "sandbox; default-src 'none'; style-src 'unsafe-inline'";
+
+/** The markdown image grammar the window renders (InlineMarkdown): the src is everything up to the first `)`. */
+const MARKDOWN_IMAGE_RE = /!\[[^\]]*\]\(([^)]+)\)/g;
+/** An ` ` tag's src attribute, quoted or not. */
+const HTML_IMG_SRC_RE = / ]*?\ssrc\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+))/gi;
+
+const NAMED_ENTITIES: Record = { amp: "&", quot: '"', apos: "'", lt: "<", gt: ">", nbsp: " " };
+
+/** The attribute value as the browser reads it (getAttribute decodes character references). */
+function decodeAttribute(value: string): string {
+ return value.replace(/&(#x[0-9a-f]+|#[0-9]+|[a-z]+);/gi, (whole, ref: string) => {
+ if (ref[0] === "#") {
+ const code = ref[1] === "x" || ref[1] === "X" ? parseInt(ref.slice(2), 16) : parseInt(ref.slice(1), 10);
+ return Number.isFinite(code) && code > 0 && code <= 0x10ffff ? String.fromCodePoint(code) : whole;
+ }
+ return NAMED_ENTITIES[ref.toLowerCase()] ?? whole;
+ });
+}
+
+/**
+ * Every image source the body names, as the window sends it: the markdown
+ * `` target as written and each ` ` value decoded. Remote
+ * (`http(s):`), `data:` and `blob:` sources are left out: the window never
+ * asks the server for them.
+ */
+export function inboxMessageImageRefs(body: string): Set {
+ const refs = new Set();
+ const add = (src: string | undefined) => {
+ if (!src) return;
+ if (/^(https?:|data:|blob:)/i.test(src)) return;
+ refs.add(src);
+ };
+ for (const match of body.matchAll(MARKDOWN_IMAGE_RE)) add(match[1]);
+ for (const match of body.matchAll(HTML_IMG_SRC_RE)) add(decodeAttribute(match[1] ?? match[2] ?? match[3] ?? ""));
+ return refs;
+}
+
+export type InboxMessageImageResult =
+ | { ok: true; bytes: Buffer; contentType: string; width?: number; height?: number }
+ | { ok: false; status: number; code: string; error: string };
+
+function refuse(status: number, code: string, error: string): InboxMessageImageResult {
+ return { ok: false, status, code, error };
+}
+
+function isInside(path: string, root: string): boolean {
+ return path === root || path.startsWith(root.endsWith(sep) ? root : `${root}${sep}`);
+}
+
+/**
+ * The filesystem paths a src may mean, in order: as written, without a
+ * `?query` / `#fragment`, and percent-decoded (`my%20shot.png`).
+ */
+function pathCandidates(src: string): string[] {
+ const out = [src];
+ const stripped = src.replace(/[?#].*$/s, "");
+ if (stripped && stripped !== src) out.push(stripped);
+ if (stripped.includes("%")) {
+ try {
+ const decoded = decodeURIComponent(stripped);
+ if (decoded !== stripped) out.push(decoded);
+ } catch {
+ // Not percent-encoded after all.
+ }
+ }
+ return out;
+}
+
+/** A URL scheme the route never reads (`file:`, `javascript:`); a Windows drive letter is a path. */
+function hasScheme(src: string): boolean {
+ return /^[a-z][a-z0-9+.-]*:/i.test(src) && !/^[a-z]:[\\/]/i.test(src);
+}
+
+/**
+ * Read one image a message shows. `root` is the project's root (a realpath);
+ * `base` the folder relative paths start from (the message's `base_path`, or
+ * the root). Never throws.
+ */
+export function readInboxMessageImage(input: { body: string; root: string; base?: string | null; path: string | null }): InboxMessageImageResult {
+ const src = input.path;
+ if (typeof src !== "string" || src === "") return refuse(400, "validation_error", "path: required.");
+ if (src.length > INBOX_MESSAGE_IMAGE_PATH_MAX || /[\u0000-\u001f\u007f]/.test(src)) {
+ return refuse(400, "validation_error", "path: not a usable image path.");
+ }
+ if (!inboxMessageImageRefs(input.body).has(src)) {
+ return refuse(403, "image_not_referenced", "This message does not show that image.");
+ }
+ if (hasScheme(src)) return refuse(400, "validation_error", "path: not a file path.");
+
+ let root: string;
+ try {
+ root = realpathSync(input.root);
+ } catch {
+ return refuse(404, "image_missing", "The message's project is no longer on disk.");
+ }
+ let base = root;
+ if (input.base) {
+ try {
+ const real = realpathSync(input.base);
+ if (isInside(real, root)) base = real;
+ } catch {
+ // The folder the agent sent from is gone: paths start at the root.
+ }
+ }
+
+ const candidates = pathCandidates(src).filter((candidate) => isReviewImagePath(candidate));
+ if (candidates.length === 0) return refuse(415, "not_an_image", "Only png, jpg, gif, webp, svg, avif, bmp and ico images are shown.");
+
+ for (const candidate of candidates) {
+ const named = isAbsolute(candidate) ? resolve(candidate) : resolve(base, candidate);
+ let real: string;
+ try {
+ real = realpathSync(named);
+ } catch {
+ // Not on disk under this spelling: one that leaves the project is
+ // refused as such; otherwise the next spelling is tried.
+ if (!isInside(named, root)) return refuse(403, "outside_project", "That image is outside the message's project.");
+ continue;
+ }
+ // Judged by where the path really leads, never by its spelling.
+ if (!isInside(real, root)) return refuse(403, "outside_project", "That image leads outside the message's project.");
+ const read = readImageFile(real);
+ if (!read.ok) return read;
+ const bytes = read.bytes;
+ if (isLfsPointer(bytes)) return refuse(415, "not_an_image", "That image is stored in Git LFS.");
+ const contentType = sniffImageContentType(bytes);
+ if (!contentType) return refuse(415, "not_an_image", "That file is not a recognized image.");
+ const dims = readImageDimensions(bytes, contentType);
+ if (dims && dims.width * dims.height > MAX_REVIEW_IMAGE_PREVIEW_PIXELS) {
+ return refuse(413, "image_too_large", "That image has too many pixels to show.");
+ }
+ return { ok: true, bytes, contentType, ...(dims ? { width: dims.width, height: dims.height } : {}) };
+ }
+ return refuse(404, "image_missing", "That image is not on disk.");
+}
+
+const OPEN_FLAGS = constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0) | (constants.O_NONBLOCK ?? 0);
+const TOO_LARGE = "That image is larger than 10 MB.";
+const CHANGED = "That image changed while it was read.";
+
+/**
+ * Read a realpath through one descriptor: never follows a final symlink,
+ * never blocks on a FIFO, never buffers more than the cap + 1 bytes, and
+ * refuses when the path no longer leads to the file that was read.
+ */
+function readImageFile(real: string): { ok: true; bytes: Buffer } | Extract {
+ const fail = (status: number, code: string, error: string) => ({ ok: false as const, status, code, error });
+ let fd: number;
+ try {
+ fd = openSync(real, OPEN_FLAGS);
+ } catch (error) {
+ // ELOOP: the last component became a symlink after it was resolved.
+ if ((error as NodeJS.ErrnoException).code === "ELOOP") return fail(409, "image_changed", CHANGED);
+ return fail(404, "image_missing", "That image could not be opened.");
+ }
+ try {
+ const stat = fstatSync(fd);
+ if (!stat.isFile()) return fail(404, "image_missing", "That image is not a file.");
+ if (stat.size > MAX_REVIEW_IMAGE_PREVIEW_BYTES) return fail(413, "image_too_large", TOO_LARGE);
+ // One byte past what fstat said, so a file that grew meanwhile is seen.
+ const buffer = Buffer.alloc(Math.min(stat.size, MAX_REVIEW_IMAGE_PREVIEW_BYTES) + 1);
+ let total = 0;
+ while (total < buffer.length) {
+ const n = readSync(fd, buffer, total, buffer.length - total, null);
+ if (n === 0) break;
+ total += n;
+ }
+ if (total > MAX_REVIEW_IMAGE_PREVIEW_BYTES) return fail(413, "image_too_large", TOO_LARGE);
+ if (total === buffer.length) return fail(409, "image_changed", CHANGED);
+ // The path must still lead to the file this descriptor read, so a parent
+ // folder swapped for a symlink in between is caught.
+ let again: ReturnType | null = null;
+ try {
+ if (realpathSync(real) === real) again = statSync(real);
+ } catch {
+ again = null;
+ }
+ if (!again || again.dev !== stat.dev || again.ino !== stat.ino) return fail(409, "image_changed", CHANGED);
+ return { ok: true, bytes: buffer.subarray(0, total) };
+ } catch {
+ return fail(404, "image_missing", "That image could not be read.");
+ } finally {
+ closeSync(fd);
+ }
+}
diff --git a/packages/shared/inbox/store.ts b/packages/shared/inbox/store.ts
index 819c2b5f91..2257c1ec23 100644
--- a/packages/shared/inbox/store.ts
+++ b/packages/shared/inbox/store.ts
@@ -103,6 +103,8 @@ export interface InboxSendInput {
attachments?: InboxAttachment[];
/** A guided review the message carries (submit_guide); its blob is written once the message lands. */
guide?: InboxGuideRef | null;
+ /** The folder inside the project the agent sent from, when not its root (`InboxMessage.base_path`). */
+ base_path?: string | null;
}
export interface InboxSendResult {
@@ -808,6 +810,7 @@ export class InboxStore {
? { attachments: input.attachments.map((attachment) => ({ ...attachment, sent_at: at })) }
: {}),
...(input.guide ? { guide: input.guide } : {}),
+ ...(input.base_path ? { base_path: input.base_path } : {}),
};
this.appendMessage(message, at);
for (const question of parsed) {
diff --git a/packages/shared/package.json b/packages/shared/package.json
index 3c5c53ff1d..3dc7021eb8 100644
--- a/packages/shared/package.json
+++ b/packages/shared/package.json
@@ -113,6 +113,7 @@
"./inbox/registry": "./inbox/registry.ts",
"./inbox/devices": "./inbox/devices.ts",
"./inbox/attachments": "./inbox/attachments.ts",
+ "./inbox/message-images": "./inbox/message-images.ts",
"./inbox/connection": "./inbox/connection.ts",
"./inbox/agent-link": "./inbox/agent-link.ts"
},
diff --git a/tests/e2e/inbox-message-images.spec.ts b/tests/e2e/inbox-message-images.spec.ts
new file mode 100644
index 0000000000..563a33faae
--- /dev/null
+++ b/tests/e2e/inbox-message-images.spec.ts
@@ -0,0 +1,80 @@
+/**
+ * Images in a message (#1813), proved in a real browser against the
+ * compiled binary. Nothing is mocked: `plannotator inbox --background` runs
+ * under a temp PLANNOTATOR_DATA_DIR and HOME, an agent writes through
+ * `plannotator inbox mcp` (scripts/inbox-sim.ts) with a markdown image and an
+ * HTML relative to its project, and Chromium opens the thread: each
+ * loads from the message's own route and decodes; a reference outside
+ * the project does not load.
+ *
+ * Build the binary first (see inbox.spec.ts), then `bun run test:e2e:inbox`.
+ */
+
+import { expect, test } from '@playwright/test';
+import { spawnSync } from 'node:child_process';
+import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join, resolve } from 'node:path';
+import { SimAgent, scratchProject } from '../../scripts/inbox-sim';
+
+const repo = resolve(__dirname, '../..');
+const binary = resolve(process.env.PLANNOTATOR_E2E_BINARY ?? join(repo, '.local/plannotator'));
+
+/** A 1 x 1 PNG that decodes. */
+const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==', 'base64');
+
+test('a relative image in a message loads from the message route; one outside the project does not', async ({ browser }) => {
+ expect(existsSync(binary), `build the binary first: ${binary}`).toBe(true);
+ const root = realpathSync(mkdtempSync(join(tmpdir(), 'plannotator-inbox-images-e2e-')));
+ const dataDir = join(root, 'data');
+ const env = { PATH: process.env.PATH ?? '', HOME: root, PLANNOTATOR_DATA_DIR: dataDir, PLANNOTATOR_BROWSER: 'none' };
+ const started = spawnSync(binary, ['inbox', '--background'], { env, encoding: 'utf8', timeout: 60_000 });
+ expect(started.status, started.stderr).toBe(0);
+ const url = started.stdout.trim();
+ const project = scratchProject(join(root, 'src'), 'shop');
+ mkdirSync(join(project, '.walkthrough', 'shots'), { recursive: true });
+ writeFileSync(join(project, '.walkthrough', 'shots', 'after.png'), PNG);
+ writeFileSync(join(root, 'src', 'outside.png'), PNG);
+ const agent = await SimAgent.connect({ binary, env, name: 'Claude', host: 'claude-code' });
+ const context = await browser.newContext();
+ try {
+ const sent = await agent.send({
+ project_path: project,
+ subject: 'The checkout page after the fix',
+ body: [
+ 'Here is the page after the fix:',
+ '',
+ '',
+ '',
+ '
',
+ '',
+ '',
+ ].join('\n'),
+ });
+ const id = sent.message_id as string;
+ const page = await context.newPage();
+ await page.goto(`${url}#thread=${sent.thread_id}`);
+ const body = page.locator(`[data-message-id="${id}"]`);
+
+ const route = `/api/inbox/messages/${id}/image?path=${encodeURIComponent('.walkthrough/shots/after.png')}`;
+ for (const alt of ['after the fix', 'html image']) {
+ const img = body.getByRole('img', { name: alt });
+ await expect(img).toHaveAttribute('src', route);
+ await expect.poll(() => img.evaluate((el: HTMLImageElement) => el.complete && el.naturalWidth)).toBe(1);
+ }
+
+ const outside = body.getByRole('img', { name: 'outside' });
+ await expect(outside).toHaveAttribute('src', `/api/inbox/messages/${id}/image?path=${encodeURIComponent('../outside.png')}`);
+ await expect.poll(() => outside.evaluate((el: HTMLImageElement) => el.complete)).toBe(true);
+ expect(await outside.evaluate((el: HTMLImageElement) => el.naturalWidth)).toBe(0);
+ } finally {
+ await agent.close().catch(() => {});
+ await context.close();
+ try {
+ process.kill(JSON.parse(readFileSync(join(dataDir, 'inbox', 'inbox.json'), 'utf8')).pid, 'SIGTERM');
+ } catch {
+ // Already gone.
+ }
+ rmSync(root, { recursive: true, force: true });
+ }
+});
diff --git a/tests/e2e/playwright.config.ts b/tests/e2e/playwright.config.ts
index cb9a13ff22..bfa6f4438c 100644
--- a/tests/e2e/playwright.config.ts
+++ b/tests/e2e/playwright.config.ts
@@ -3,7 +3,7 @@
* binary (`plannotator inbox`), under a temp PLANNOTATOR_DATA_DIR: the window
* (inbox.spec.ts), notifications, decisions (inbox-decisions.spec.ts) and
* guided reviews (inbox-guides.spec.ts), New message
- * (inbox-new-message.spec.ts), the order inside a section (inbox-order.spec.ts), phones (inbox-phones.spec.ts), the sidebar (inbox-sidebar.spec.ts), and the
+ * (inbox-new-message.spec.ts), the order inside a section (inbox-order.spec.ts), phones (inbox-phones.spec.ts), the sidebar (inbox-sidebar.spec.ts), images in messages (inbox-message-images.spec.ts), and the
* surface a phone hosts, in WebKit (surface.spec.ts). Build the binary
* first (see inbox.spec.ts), then `bun run test:e2e:inbox`.
*/
@@ -11,7 +11,7 @@ import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: '.',
- testMatch: ['inbox.spec.ts', 'notifications.spec.ts', 'inbox-decisions.spec.ts', 'inbox-attachments.spec.ts', 'inbox-guides.spec.ts', 'inbox-new-message.spec.ts', 'inbox-order.spec.ts', 'inbox-phones.spec.ts', 'inbox-sidebar.spec.ts', 'surface.spec.ts'],
+ testMatch: ['inbox.spec.ts', 'notifications.spec.ts', 'inbox-decisions.spec.ts', 'inbox-attachments.spec.ts', 'inbox-guides.spec.ts', 'inbox-new-message.spec.ts', 'inbox-order.spec.ts', 'inbox-phones.spec.ts', 'inbox-sidebar.spec.ts', 'inbox-message-images.spec.ts', 'surface.spec.ts'],
outputDir: '../../.local/test-results',
fullyParallel: false,
workers: 1,