Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<img src>` load through `GET /api/inbox/messages/:id/image?path=<src as written>`. The window draws each body with `imageBaseDir` = `inbox-message:<id>` 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 `<img src>` 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:
Expand Down Expand Up @@ -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=<src as written>`: 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 `<base href>` at its folder's asset route |
| `/api/html-assets/<token>/<path>` | 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) |
Expand Down
1 change: 1 addition & 0 deletions apps/marketing/src/content/docs/reference/inbox.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <agent>" until the agent reads it, then "Delivered to <agent>, <time>".
- **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, `![alt](path)` or `<img src="path">`, 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**.
Expand Down
7 changes: 7 additions & 0 deletions packages/core/inbox-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`). */
Expand Down
3 changes: 3 additions & 0 deletions packages/inbox/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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 () => [],
Expand Down
2 changes: 2 additions & 0 deletions packages/inbox/components/Thread.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = () => {};
Expand Down Expand Up @@ -88,6 +89,7 @@ function MessageBody({
maxWidth={null}
stickyActions={false}
disableCodePathValidation
imageBaseDir={inboxMessageImageBase(message.id)}
answerOnly
readOnly={readOnly}
questionAnswers={answers}
Expand Down
76 changes: 76 additions & 0 deletions packages/inbox/images.test.tsx
Original file line number Diff line number Diff line change
@@ -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/<id>/image with the src as written', async () => {
setImageSrcResolver(inboxImageSrcResolver);
const body = [
'The page after the fix:',
'',
'![after](.walkthrough/shots/after.png)',
'',
'<p><img src="/abs/shots/before.png" alt="before"></p>',
'',
'![remote](https://example.com/x.png)',
].join('\n');
host = document.createElement('div');
document.body.appendChild(host);
await act(async () => {
root = createRoot(host!);
root.render(
<Viewer
blocks={parseMarkdownToBlocks(body, { frontmatter: false })}
markdown={body}
annotations={[]}
onAddAnnotation={() => {}}
onSelectAnnotation={() => {}}
selectedAnnotationId={null}
mode="selection"
taterMode={false}
maxWidth={null}
stickyActions={false}
disableCodePathValidation
imageBaseDir={inboxMessageImageBase(ID)}
answerOnly
readOnly
/>,
);
});
const src = (alt: string) => host!.querySelector<HTMLImageElement>(`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');
});
43 changes: 43 additions & 0 deletions packages/inbox/images.ts
Original file line number Diff line number Diff line change
@@ -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/<id>/image?path=<src>`. 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;
};
20 changes: 19 additions & 1 deletion packages/server/inbox-mcp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)!;
Expand Down
Loading
Loading