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
18 changes: 17 additions & 1 deletion AGENTS.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,14 @@ Or via `docker run`:
docker run -e PLANNOTATOR_REMOTE=1 -e PLANNOTATOR_PORT=9999 -p 9999:9999 your-image
```

## Proxies and forwarded hostnames

Plannotator answers only the hostnames it expects in the `Host` header: `localhost` and other loopback names everywhere, plus, in remote mode, IP addresses, this machine's hostname (and its `.local` name) and `PLANNOTATOR_URL_HOST`. Port forwarding (SSH, VS Code, `docker -p`) arrives as `localhost` or an IP address and needs nothing; so do GitHub Codespaces and VS Code dev tunnels, whose forwarders present `localhost` to the server. A code-server or Coder port proxy is recognized from the `VSCODE_PROXY_URI` it sets. If another proxy or tunnel forwards its own hostname (a reverse proxy, a Docker service name), Plannotator answers `403` and names the setting to change:

```bash
export PLANNOTATOR_ALLOWED_HOSTS=my-proxy.example.com
```

## Custom browser

The `PLANNOTATOR_BROWSER` environment variable lets you specify a custom browser or script for opening the UI.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ All Plannotator environment variables and their defaults.
| `PLANNOTATOR_REMOTE` | auto-detect | Set to `1` or `true` to force remote mode, `0` or `false` to force local mode, or leave unset to auto-detect via `SSH_TTY` / `SSH_CONNECTION`. Uses a fixed port in remote mode; browser-opening behavior depends on the environment. Remote mode also turns off [live local app annotation](/docs/commands/annotate/#local-apps): `plannotator annotate http://localhost:5173` exits with a startup failure suggesting `--static` rather than opening a live session. |
| `PLANNOTATOR_PORT` | random (local) / `19432` (remote) | Fixed server port or inclusive range such as `19432-19463`. A range uses the first available port. When not set, local sessions use a random port; remote sessions default to `19432`. |
| `PLANNOTATOR_URL_HOST` | unset (`localhost`) | Display-only hostname for advertised session URLs, e.g. a Tailscale MagicDNS name or tailnet IP, so remote-mode links are reachable from another device instead of `http://localhost:<port>`. Host only: bare hostname, IPv4, or bracketed IPv6 such as `[fd7a::1]` — the runtime-chosen port is always appended, and a value carrying a scheme, port, path, or whitespace warns on stderr and falls back to `localhost`. Strictly display-only and remote-only: it never changes which interface the server binds (that stays governed by `PLANNOTATOR_REMOTE`), and a local session ignores the override — the advertised URL stays `localhost`, with a stderr warning to set `PLANNOTATOR_REMOTE=1`. Can also be set via `~/.plannotator/config.json` (`{ "urlHost": "host" }`); the env var takes precedence, and setting it to an empty value suppresses a config-file `urlHost`. |
| `PLANNOTATOR_ALLOWED_HOSTS` | unset | Extra hostnames the session answers on. Every Plannotator server checks the `Host` header of each request: loopback names (`localhost`, `127.x.x.x`, `[::1]`, `*.localhost`) are always accepted; remote mode also accepts IP addresses, this machine's hostname (and its `.local` name) and the configured `PLANNOTATOR_URL_HOST`; a `--tailscale` session accepts its tailnet name; a code-server or Coder port proxy is recognized from its `VSCODE_PROXY_URI`. Codespaces and VS Code dev tunnels present `localhost` and need nothing. Any other hostname gets `403`. If you reach Plannotator through a proxy or tunnel that keeps its own hostname (a reverse proxy, a Docker service name), list it here, comma-separated; a leading dot (`.example.com`) also matches subdomains, and `*` turns the check off. The port is never compared. |
| `PLANNOTATOR_BROWSER` | system default | Custom browser to open the UI in. macOS: app name or path. Linux/Windows: executable path. Can also be a script. Takes priority over `BROWSER`. Also settable per-invocation with `--browser`. |
| `BROWSER` | (none) | Standard env var for specifying a browser. VS Code sets this automatically in devcontainers. Used as fallback when `PLANNOTATOR_BROWSER` is not set. |
| `PLANNOTATOR_ORIGIN` | auto-detect | Explicit agent-origin override. Valid values: `claude-code`, `amp`, `droid`, `opencode`, `codex`, `copilot-cli`, `pi`, `oh-my-pi`, `gemini-cli`, `kiro-cli`, `mistral-vibe`. Invalid values silently fall through to env-based detection. |
Expand Down
16 changes: 11 additions & 5 deletions apps/pi-extension/pi-session-bridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -491,12 +491,14 @@ describe("Pi annotate server with Ask this session", () => {
}),
})).json() as { sessionId: string };

// DNS rebinding: same socket, foreign Host header. Refused before the
// question reaches the session.
// Same socket, foreign Host header. Refused before the question
// reaches the session: by the server-wide Host allowlist for a
// foreign name, and by the bridge's own loopback-and-port check for
// a loopback name carrying another port.
const port = Number(new URL(server.url).port);
const rebound = await new Promise<{ status: number; body: string }>((resolve, reject) => {
const askWithHost = (hostHeader: string) => new Promise<{ status: number; body: string }>((resolve, reject) => {
const req = httpRequest(
{ host: "127.0.0.1", port, method: "POST", path: "/api/ai/query", headers: { host: `evil.example:${port}`, "content-type": "application/json" } },
{ host: "127.0.0.1", port, method: "POST", path: "/api/ai/query", headers: { host: hostHeader, "content-type": "application/json" } },
(res) => {
let body = "";
res.on("data", (chunk) => { body += chunk; });
Expand All @@ -506,8 +508,12 @@ describe("Pi annotate server with Ask this session", () => {
req.on("error", reject);
req.end(JSON.stringify({ sessionId, prompt: "injected" }));
});
const rebound = await askWithHost(`evil.example:${port}`);
expect(rebound.status).toBe(403);
expect(rebound.body).toContain("session_bridge_forbidden_host");
expect(rebound.body).toContain("PLANNOTATOR_ALLOWED_HOSTS");
const otherPort = await askWithHost(`localhost:${port + 1}`);
expect(otherPort.status).toBe(403);
expect(otherPort.body).toContain("session_bridge_forbidden_host");
expect(host.sent).toHaveLength(0);

// Node flushes SSE headers with the first chunk, so drive the turn before awaiting.
Expand Down
69 changes: 69 additions & 0 deletions apps/pi-extension/server/request-host-guard.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
/**
* The Pi guard's WebSocket half: node:http hands an upgrade to every
* `upgrade` listener (the annotate agent terminal attaches one), so the guard
* intercepts the event. A refused Host must never reach the listener.
*/
import { afterEach, expect, test } from "bun:test";
import { createServer, request, type Server } from "node:http";
import type { AddressInfo } from "node:net";
import { createRequestHostGuard } from "./request-host-guard.ts";

const saved = { remote: process.env.PLANNOTATOR_REMOTE, allowed: process.env.PLANNOTATOR_ALLOWED_HOSTS };
let server: Server | undefined;

afterEach(async () => {
if (saved.remote === undefined) delete process.env.PLANNOTATOR_REMOTE;
else process.env.PLANNOTATOR_REMOTE = saved.remote;
if (saved.allowed === undefined) delete process.env.PLANNOTATOR_ALLOWED_HOSTS;
else process.env.PLANNOTATOR_ALLOWED_HOSTS = saved.allowed;
await new Promise<void>((resolve) => (server ? server.close(() => resolve()) : resolve()));
server = undefined;
});

// Real Node answers the refused upgrade with the guard's 403 and completes
// the allowed one; Bun's node:http emulation (which runs this suite) does not
// relay raw bytes written to an upgrade socket, so both read as a closed
// socket there. What must hold in both is who reached the listener.
function upgrade(port: number, host: string): Promise<number | "upgraded" | "closed"> {
return new Promise((resolve) => {
const req = request({
host: "127.0.0.1",
port,
path: "/ws",
headers: { host, connection: "Upgrade", upgrade: "websocket" },
});
req.on("response", (res) => {
res.resume();
resolve(res.statusCode ?? 0);
});
req.on("upgrade", (_res, socket) => {
socket.destroy();
resolve("upgraded");
});
req.on("error", () => resolve("closed"));
req.end();
});
}

test("a refused Host never reaches an upgrade listener; an allowed one does", async () => {
process.env.PLANNOTATOR_REMOTE = "0";
delete process.env.PLANNOTATOR_ALLOWED_HOSTS;
const guard = createRequestHostGuard();
const reached: string[] = [];
server = createServer((req, res) => {
if (guard.refuse(req, res)) return;
res.end("ok");
});
guard.attach(server);
server.on("upgrade", (req, socket) => {
reached.push(String(req.headers.host));
socket.end("HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\n");
});
await new Promise<void>((resolve) => server!.listen(0, "127.0.0.1", resolve));
const port = (server.address() as AddressInfo).port;

expect([403, "closed"]).toContain(await upgrade(port, `evil.example:${port}`));
expect(reached).toEqual([]);
expect(["upgraded", "closed"]).toContain(await upgrade(port, `localhost:${port}`));
expect(reached).toEqual([`localhost:${port}`]);
});
103 changes: 103 additions & 0 deletions apps/pi-extension/server/request-host-guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
/**
* node:http adapter for the Host-header allowlist
* (packages/shared/request-host.ts, vendored to generated/request-host.ts).
* Mirrors packages/server/request-host-guard.ts: every Pi server (plan,
* review, annotate) builds one guard at start, refuses a disallowed Host
* before any route, and guards WebSocket upgrades too (the annotate agent
* terminal attaches its own `upgrade` listener, which never sees a refused
* request). Pi has no `--tailscale`, so there are no served hostnames here.
*/

import type { IncomingMessage, Server, ServerResponse } from "node:http";
import type { Duplex } from "node:stream";
import os from "node:os";
import { loadConfig, resolveUrlHost } from "../generated/config.ts";
import {
ALLOWED_HOSTS_ENV,
forwardedPortHostPatterns,
HOST_NOT_ALLOWED_CODE,
hostNotAllowedMessage,
isAllowedRequestHost,
machineHostnames,
parseAllowedHosts,
type RequestHostPolicy,
} from "../generated/request-host.ts";
import { isAutoUrlHost, resolveAutoHostCached } from "../generated/tailscale.ts";
import { isRemoteSession } from "./network.ts";

function remoteExtraHosts(): string[] {
const hosts = machineHostnames(os.hostname());
try {
const urlHost = resolveUrlHost(loadConfig());
const resolved = urlHost && isAutoUrlHost(urlHost) ? resolveAutoHostCached() : urlHost;
if (resolved) hosts.push(resolved);
} catch {
// A broken config never widens the allowlist.
}
return hosts;
}

function hostHeader(req: IncomingMessage): string | undefined {
const value = req.headers.host;
return typeof value === "string" ? value : undefined;
}

export interface PiRequestHostGuard {
/** Answers 403 and returns true when the request's Host is not allowed. */
refuse(req: IncomingMessage, res: ServerResponse): boolean;
/** Refuse disallowed WebSocket upgrades on `server` (call before it listens). */
attach(server: Server): void;
}

/** Build the guard for one server. Env and config are read once, here. */
export function createRequestHostGuard(): PiRequestHostGuard {
const remote = isRemoteSession();
const allowed = parseAllowedHosts(process.env[ALLOWED_HOSTS_ENV]);
// A browser IDE's port proxy (code-server, Coder) forwards its own Host.
const portHostPatterns = forwardedPortHostPatterns(process.env.VSCODE_PROXY_URI);
// Remote-mode names are resolved on the first request that needs them, so
// loopback and IP-literal requests never read config or ask Tailscale.
let remoteHosts: string[] | null = null;
const allows = (req: IncomingMessage): boolean => {
const host = hostHeader(req);
const policy: RequestHostPolicy = { remote, allowed, portHostPatterns };
if (isAllowedRequestHost(host, policy)) return true;
if (!remote) return false;
remoteHosts ??= remoteExtraHosts();
return isAllowedRequestHost(host, { ...policy, extraHosts: remoteHosts });
};

return {
attach(server) {
// node:http hands an upgrade to EVERY `upgrade` listener, so a
// prepended listener cannot stop the others. Intercept the event.
const emit = server.emit.bind(server) as (event: string | symbol, ...args: unknown[]) => boolean;
server.emit = ((event: string | symbol, ...args: unknown[]) => {
if (event === "upgrade") {
const req = args[0] as IncomingMessage;
const socket = args[1] as Duplex;
if (!allows(req)) {
const body = hostNotAllowedMessage(hostHeader(req));
socket.end(
`HTTP/1.1 403 Forbidden\r\nContent-Type: text/plain; charset=utf-8\r\nContent-Length: ${Buffer.byteLength(body)}\r\nConnection: close\r\n\r\n${body}`,
);
return true;
}
}
return emit(event, ...args);
}) as typeof server.emit;
},
refuse(req, res) {
if (allows(req)) return false;
const body = hostNotAllowedMessage(hostHeader(req));
res.writeHead(403, {
"Content-Type": "text/plain; charset=utf-8",
"X-Content-Type-Options": "nosniff",
"X-Plannotator-Error": HOST_NOT_ALLOWED_CODE,
"Content-Length": Buffer.byteLength(body),
});
res.end(body);
return true;
},
};
}
16 changes: 16 additions & 0 deletions apps/pi-extension/server/request-host.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
/**
* Pi servers: the Host-header allowlist. Same scenarios as the Bun servers
* (packages/server/request-host.scenarios.ts), so the mirror cannot drift.
*/
import { defineRequestHostScenarios } from "../../../packages/server/request-host.scenarios.ts";
import { startPlanReviewServer } from "./serverPlan.ts";
import { startAnnotateServer } from "./serverAnnotate.ts";
import { startReviewServer } from "./serverReview.ts";

const PATCH = "diff --git a/a.txt b/a.txt\n--- a/a.txt\n+++ b/a.txt\n@@ -1 +1 @@\n-a\n+b\n";

defineRequestHostScenarios("pi", async (kind, { docPath, htmlContent }) => {
if (kind === "plan") return startPlanReviewServer({ plan: "# Plan\n\n[notes](notes.md)", origin: "pi", htmlContent });
if (kind === "review") return startReviewServer({ rawPatch: PATCH, gitRef: "HEAD", htmlContent });
return startAnnotateServer({ markdown: "# Notes\n\nsecret body\n", filePath: docPath, htmlContent, mode: "annotate" });
});
5 changes: 5 additions & 0 deletions apps/pi-extension/server/serverAnnotate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { isPathAllowed } from "../generated/doc-resolve.ts";
import { countUnsentDraftComments, hostSessionClosedEvent } from "../generated/host-control.ts";
import { checkServerSession, createServerSessionNonce, serverSessionMismatchBody } from "../generated/server-session.ts";
import { handleHostControl, resolveHostControlToken, type HostControl } from "./host-control.ts";
import { createRequestHostGuard } from "./request-host-guard.ts";
import { getPlanVersion, getVersionCount, listVersions } from "../generated/storage.ts";
import { computeAnnotateHistory, deriveAnnotateHistorySlug, persistAnnotateSubmission, type AnnotateHistoryResult } from "../generated/annotate-history.ts";
import { htmlDiff } from "../generated/html-diff.ts";
Expand Down Expand Up @@ -821,7 +822,10 @@ export async function startAnnotateServer(options: {
let liveSessionToken = "";
let liveAppUrl = "";

const requestHostGuard = createRequestHostGuard();
const server = createServer(async (req, res) => {
// Host allowlist first, before any route (request-host-guard.ts).
if (requestHostGuard.refuse(req, res)) return;
const url = requestUrl(req);

if (handleHostControl(req, res, url, { token: hostControlToken, getServerPort: () => boundPort, control: hostControl })) return;
Expand Down Expand Up @@ -1396,6 +1400,7 @@ export async function startAnnotateServer(options: {

let listened: Awaited<ReturnType<typeof listenOnPort>>;
try {
requestHostGuard.attach(server);
listened = await listenOnPort(server);
} catch (error) {
disposeFailedStart();
Expand Down
5 changes: 5 additions & 0 deletions apps/pi-extension/server/serverPlan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { contentHash, deleteDraft, loadDraft } from "../generated/draft.ts";
import { countUnsentDraftComments } from "../generated/host-control.ts";
import { checkServerSession, createServerSessionNonce, serverSessionMismatchBody } from "../generated/server-session.ts";
import { handleHostControl, resolveHostControlToken, type HostControl } from "./host-control.ts";
import { createRequestHostGuard } from "./request-host-guard.ts";
import {
type ArchivedPlan,
generateSlug,
Expand Down Expand Up @@ -292,7 +293,10 @@ export async function startPlanReviewServer(options: {
// Lazy cache for in-session archive tab
let cachedArchivePlans: ArchivedPlan[] | null = null;

const requestHostGuard = createRequestHostGuard();
const server = createServer(async (req, res) => {
// Host allowlist first, before any route (request-host-guard.ts).
if (requestHostGuard.refuse(req, res)) return;
const url = requestUrl(req);

if (handleHostControl(req, res, url, { token: hostControlToken, getServerPort: () => boundPort, control: hostControl })) return;
Expand Down Expand Up @@ -644,6 +648,7 @@ export async function startPlanReviewServer(options: {
}
});

requestHostGuard.attach(server);
const { port, portSource } = await listenOnPort(server);
boundPort = port;
// Remote sessions serve the app page compressed (#1617); start gzip (what
Expand Down
5 changes: 5 additions & 0 deletions apps/pi-extension/server/serverReview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { contentHash } from "../generated/draft.ts";
import { countUnsentDraftComments, hostSessionClosedEvent } from "../generated/host-control.ts";
import { checkServerSession, createServerSessionNonce, serverSessionMismatchBody } from "../generated/server-session.ts";
import { handleHostControl, resolveHostControlToken, type HostControl } from "./host-control.ts";
import { createRequestHostGuard } from "./request-host-guard.ts";
import { createReviewDraftSession, prDraftTargetKey, type ReviewDraftKeys } from "../generated/review-draft.ts";
import { agentToolSaveFailed, loadConfig, saveConfig, detectGitUser, getServerConfig, parseReviewAnalysisConfig, resolveAIEnabled, resolveSharingEnabled, resolveClaudeSandbox, resolveCursorSandbox, resolveFeedbackHistory, resolveGuideHistory, resolveGuideShareUrl, resolveGitRemoteCheck, resolveReviewProgress } from "../generated/config.ts";
import { appendFeedbackRecord, countChangedFiles, deriveFeedbackProject, type FeedbackDecision, type FeedbackReviewTarget } from "../generated/feedback-archive.ts";
Expand Down Expand Up @@ -1962,7 +1963,10 @@ export async function startReviewServer(options: {
let boundPort: number | undefined;
const aiRuntime = aiEnabled ? await createPiAIRuntime({ getCwd: resolveAgentCwd, sessionBridge: options.sessionBridge, getServerPort: () => boundPort }) : null;

const requestHostGuard = createRequestHostGuard();
const server = createServer(async (req, res) => {
// Host allowlist first, before any route (request-host-guard.ts).
if (requestHostGuard.refuse(req, res)) return;
const url = requestUrl(req);

if (handleHostControl(req, res, url, { token: hostControlToken, getServerPort: () => boundPort, control: hostControl })) return;
Expand Down Expand Up @@ -3960,6 +3964,7 @@ export async function startReviewServer(options: {
}
});

requestHostGuard.attach(server);
const { port, portSource } = await listenOnPort(server);
boundPort = port;
// Remote sessions serve the app page compressed (#1617); start gzip (what
Expand Down
Loading
Loading