Skip to content

Plannotator Inbox, step 1: server, store, MCP and the inbox subcommand (no UI yet) - #1745

Merged
backnotprop merged 3 commits into
mainfrom
feat/inbox-step1-server
Oct 7, 2026
Merged

backnotprop merged 3 commits into
mainfrom
feat/inbox-step1-server

Conversation

@backnotprop

Copy link
Copy Markdown
Owner

Step 1 of the owner-approved Plannotator Inbox plan: back end only. It adds the subcommand, the server, the store and the MCP tools. The window (packages/inbox, apps/inbox) waits for an approved design spec. Until then, / serves a page marked as a stub: the list model and a read-only thread.

What works

  • plannotator inbox (run by a person) starts the Inbox in the foreground and opens it in the browser. If an Inbox is already running, it opens that one instead.
  • plannotator inbox --background starts the Inbox detached, prints its URL and exits 0. It never opens a browser. Agents and the shim use this form.
  • --no-open runs the Inbox in the foreground without opening a browser.
  • plannotator inbox mcp is the stdio MCP entry. It is a thin JSON-RPC proxy to the server's /mcp, so the tool list lives once, in the server.
    • It re-reads inbox/inbox.json on every message.
    • If the Inbox is stopped, it starts it with the --background path. No tab opens.
    • When the agent leaves them out, it fills project_path (its working folder) and agent_session (one ses_ id per shim process).
  • Server (packages/server/inbox.ts):
    • Bun-only. It always binds 127.0.0.1 and ignores PLANNOTATOR_REMOTE, PLANNOTATOR_PORT and --tailscale.
    • It takes its last port first, then a random one, never 19432. If the last port is taken, it shows a one-time notice.
    • Routes: health, the list model, threads, picks (saved at once), Send (idempotent), resolve, an SSE event stream with a cursor (replay, then live), stateless /mcp, and a token-guarded stop route.
    • Restart to update: on a health tick, the compiled Inbox runs the binary on disk with --version and reports update when the version differs.
  • Store (packages/shared/inbox/, RESEARCH.md §8): per-project append-only JSONL with one seq counter across the Inbox.
    • Every line is a full snapshot. A torn last line is skipped on read, and a newline goes in before the next append to that file.
    • No caps.
  • Registry: inbox/inbox.json holds { v, pid, port, url, version, token, serverSession, startedAt }.
    • Mode 0600, written atomically. The token rotates on every start. The file stays after exit.
    • Stopped means one of: a dead pid, the port answering with a different serverSession, or a live pid with nothing answering.
    • The Inbox never registers in sessions/, so it never blocks auto-update.
    • Starts are serialized by inbox/start.lock.
  • Wire (packages/core/inbox-*.ts):
    • Workspaces' Question shape, plus message_id, with derived state.
    • Plannotator's QuestionAnswer v1.
    • Stopped: and Holds up: are read the way Workspaces reads them.
    • Keys come from core's findQuestionBlocks.
    • The Send text uses formatQuestionAnswersSection.
  • MCP tools: send_message, read_thread (asked_by: "me"), resolve_message, wait_for_reply.
    • wait_for_reply returns the reply as soon as it lands. Otherwise it returns { status: "waiting", cursor } after 50 s.
    • Success is structuredContent plus one line. A refusal is isError with <snake_code>: <message>.
  • uninstall --purge stops a running Inbox first, through its token route; SIGTERM is the fallback, sent only while the port still answers as that Inbox. --dry-run lists the Inbox. inbox is now in PURGE_OWNED_TOP_LEVEL.
  • Docs:
    • Skill reference: plannotator inbox is documented, and the freshness test passes.
    • AGENTS.md: the Server Runtimes exception sentence, a "Plannotator Inbox" section, an Inbox Server API table, and notes in the Host allowlist section and the PLANNOTATOR_PORT row.

Security layers (every request)

  1. Host allowlist from Validate the Host header on every request (defense in depth) #1742 runs first. A new createRequestHostGuard({ localOnly: true }) option applies the local rule whatever the environment says.
  2. /mcp refuses any request that carries an Origin: 403 origin_not_allowed.
  3. Connection routes (/api/inbox/control/*) need three things: the registry's bearer token (constant-time compare), a loopback Host naming this port, and no Origin. This is the pull-bridge pattern.
  4. State-changing window routes check isSameOriginOrNoOrigin (403 cross_origin), then the serverSession stale-tab guard (409 session_mismatch).
  5. No CORS anywhere. The stub page has a nonce CSP (default-src 'none'), frame-ancestors 'none', and agent text is set only through textContent.
  6. DAST (dast.yml): a seeded Inbox sits behind its own read-only guard on port 19435, with a second ZAP scan and a second evidence validation.

Found while adding DAST: the guard forwards the scanner's Host, plannotator-dast-target:<port>, and since #1742 the annotate target refuses that name with a 403. The workflow's health check expects a 200, so the scheduled scan would have failed. The target now sets PLANNOTATOR_ALLOWED_HOSTS=plannotator-dast-target. I checked this locally by running scripts/dast/target.ts and probing it with that Host: 200 on both targets.

How it was proven (no mocks)

  • packages/server/inbox.test.ts runs a real server on loopback. Requests go through real fetch and raw TCP (so Host and Origin are exactly what the test sends), and the MCP side through the official SDK client over streamable HTTP. It covers:
    • the bind is 127.0.0.1 only, with PLANNOTATOR_REMOTE=1 and PLANNOTATOR_PORT=19432 set: a connect to the external interface is refused, and the port is not 19432;
    • the registry file is 0600;
    • a restart reuses the port and rotates both the token and the serverSession;
    • a stale tab gets a 409 and nothing is written;
    • when the port is taken, the server falls back to a random port and shows the notice;
    • restart to update, by renaming a new script over the "binary";
    • a foreign Host is refused on every route, /mcp included;
    • an Origin on /mcp is refused, even the Inbox's own origin;
    • cross-site POSTs are refused before anything is written;
    • the stop route refuses requests without the token, with a wrong port, or with an Origin;
    • the full question flow: send, list model, pick, revision conflict, bad label, wait_for_reply waking on Send, idempotent Send, read_thread asked_by me/others, resolve, then closed state and a late Send refused;
    • SSE replays from a cursor, then streams live, in order;
    • a real wait_for_reply with nothing arriving returns waiting after 50.16 s.
  • packages/shared/inbox/store.test.ts has fast-check properties over generated sequences of operations: round trip (reopening from disk gives exactly the live view), seq unique and increasing, a torn last line at a random cut point (recovers, and the next append to that same file reads back whole), idempotency keys for agent sends and person Sends, and question keys equal to core's questionKey, with the -2 suffix for a repeated prompt.
  • apps/hook/server/inbox-cli.test.ts runs the subcommand as real processes, each with a temp HOME and data dir:
    • --background with no browser (a fake browser script is never called) and no sessions/ entry;
    • two concurrent starts give one Inbox;
    • kill -9, then a restart: same port, new token;
    • the three stopped readings;
    • the shim, driven by the SDK's StdioClientTransport, starts a stopped Inbox with no tab, fills project_path (the git toplevel) and the session, and carries the person's answer back;
    • a person's plannotator inbox opens the browser;
    • purge stops the Inbox and removes inbox/.
  • Gates:
    • bun run typecheck passes.
    • bun test passes on packages/core, packages/shared, packages/server, apps/hook, tests/, apps/pi-extension, apps/opencode-plugin and scripts: 0 failures.
    • check:release-version passes.
    • actionlint passes on dast.yml.
  • Manual end to end with the compiled binary (bun run --cwd apps/review build && bun run build:hook && bun build … --compile --define __CLI_VERSION__="0.0.0-dev"), with a temp HOME and data dir:
    • inbox --background: exit 0 in 544 ms, prints the URL, lsof shows only 127.0.0.1:<port>, inbox.json mode 600, health OK, and a second start reuses the same pid;
    • the official SDK stdio client against plannotator inbox mcp: 4 tools; send_message with a :::question block lands in project billing, the git root;
    • the list model shows waiting_on_person and stopped;
    • a pick and then a Send through the HTTP API;
    • wait_for_reply returns "Answered 1 question. Go ahead. ## Answers to your questions … Answer: Retry with the same idempotency key (your recommendation)";
    • kill -9, then --background: same port, new pid, token rotated, serverSession rotated, store intact;
    • uninstall --purge --dry-run lists "Stop the running Plannotator Inbox (pid …)" and …/.plannotator/inbox;
    • a 0.0.1-dev binary renamed over the running 0.0.0-dev one: health reports update: { available: true, version: "0.0.1-dev" } on the next tick.

Deviations from PLAN.md

  • @modelcontextprotocol/server is pinned at 2.2.0, not 2.3.1. 2.3.1 was published 2026-10-05, and the repo's bunfig.toml refuses packages younger than 7 days (minimumReleaseAge), so it cannot install yet. 2.2.0 has the same createMcpHandler(factory, { legacy: "stateless" }) API and is MIT; 2.3.x moved to Apache-2.0. Bump to 2.3.1 after 2026-10-12 if wanted. The repo has no NOTICE file yet, so there is no line to add for it.
  • QUESTION_AUTHORING_GUIDE is in send_message's description, not the server instructions. The guide is 2,213 characters, and Claude Code drops instructions after 2,048. The instructions are short (under 2,048, asserted) and point to send_message.
  • The --background child is started under the starter's lock, through an internal env flag, PLANNOTATOR_INBOX_LOCK_HELD, scrubbed at start. This keeps two agents from starting two writers on one store.
  • No delivery field on messages yet. It belongs to the connections (steps 6 and 7). Fields are add-only, so adding it later is safe.

Left for later steps

  • The designed window: packages/inbox, apps/inbox, build:hook wiring, DOM tests, typecheck entries.
  • Attachments and blobs, decisions, guides, browser notifications.
  • The Claude Code mod, Pi and OpenCode connections, and the PLANNOTATOR_INBOX_TOOL knob.
  • A marketing docs page for plannotator inbox.

- `plannotator inbox` (person: start here and open the browser, or open the
  running one), `--background` (detached, prints the URL, never a tab),
  `--no-open`, and `plannotator inbox mcp` (stdio JSON-RPC proxy to /mcp that
  re-reads inbox/inbox.json per message and starts a stopped Inbox).
- packages/server/inbox.ts: Bun-only, always 127.0.0.1, last port first then
  random, never 19432; health, list model, thread, picks, Send, resolve, SSE
  with a cursor, stateless /mcp, token-guarded stop route.
- packages/shared/inbox/: append-only JSONL store (one seq across the Inbox,
  torn-last-line recovery, idempotent sends), registry (0600, token rotated per
  start, kept after exit).
- packages/core/inbox-*.ts: wire shapes (Workspaces' Question + QuestionAnswer
  v1), question parsing with Stopped/Holds up, derived state, Send text.
- MCP tools: send_message, read_thread, resolve_message, wait_for_reply
  (@modelcontextprotocol/server pinned 2.2.0).
- Security: Host allowlist (local rule), no Origin on /mcp, same-origin +
  serverSession on writes, bearer token on connection routes, no CORS.
- uninstall --purge stops a running Inbox first; inbox in the purge list.
- DAST scans a seeded Inbox; the target also allows its own Host name, which
  the Host allowlist (#1742) otherwise refuses.
- Skill reference and AGENTS.md (Server Runtimes exception, Inbox section,
  Inbox Server API table).
- Proofs: live server tests, SDK client over HTTP and stdio, the subcommand as
  real processes, fast-check store properties.
… keys never collide

- sendReply writes the questions' sent lines before the reply line. When the
  reply append fails, the questions are put back in memory; at load, a
  question naming a reply that does not exist reads as picked again. Before,
  the person was stuck on question_already_sent and the agent got nothing.
- A failed append marks the file torn, so the next line starts fresh.
- Two same-named roots whose 6-hex project keys collide get a longer hash
  instead of sharing one folder (the second project.json replaced the first).
- The round-trip property now sends arbitrary text (newlines, quotes, NULs,
  U+2028, lone surrogates) and a crafted JSONL-injection body.
…e probe; edge fixes

- A live pid whose port takes the health request but does not answer (a
  paused or blocked Inbox) read as stopped, so the shim and --background
  started a second Inbox on the same store: proven live with SIGSTOP, both
  processes then wrote duplicate seqs. inboxStatus now reports it as busy;
  callers wait for it (20 s, then an error) and never replace it, and
  uninstall --purge treats it as still running.
- Restart to update ran the compiled binary with --version every 60 s
  (~0.2 s CPU, ~440 MB peak RSS each time). It now stats the binary and runs
  it only when its identity changed.
- The detached Inbox runs in inbox/ (not the starter's project folder, which
  Windows would refuse to delete) with windowsHide.
- An HTTP/1.0 request with no Host (allowed by the Host guard) threw in
  new URL() and answered 500; it is read against this server now.
- wait_for_reply on an already-resolved thread answers resolved at once
  instead of holding the call for 50 s.
- DAST target: binaryPath null (no update probe of the bun binary).
@backnotprop
backnotprop merged commit 56d0a03 into main Oct 7, 2026
28 checks passed
@backnotprop
backnotprop deleted the feat/inbox-step1-server branch October 7, 2026 06:04
backnotprop added a commit that referenced this pull request Oct 7, 2026
The Inbox back end (#1745) ships in the binary, but its window is still a
stub, so 0.28.7 must not advertise it. `plannotator inbox` keeps working
(and keeps `inbox --help`) for developers and the coming connections; it is
only taken out of discovery.

- cli.ts: HIDDEN_SUBCOMMANDS (today `inbox`). Top-level help no longer
  lists the inbox lines.
- unknown-subcommand.ts: hidden subcommands stay known to the dispatcher
  but are never offered as a "Did you mean" suggestion.
- The plannotator knowledge skill drops its Inbox section and table row.
- plannotator-skill-reference.test.ts: every non-hidden subcommand must
  still be documented; a hidden one must be ABSENT from the skill and the
  top-level help, and must still be a real subcommand.
- AGENTS.md: one line on HIDDEN_SUBCOMMANDS and how to un-hide.

Co-authored-by: Claude <noreply@anthropic.com>
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.

1 participant