From 9793f08664e7e8f8a78cb859ab3b1c0f4c4853ed Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Thu, 6 Aug 2026 11:50:35 -0700 Subject: [PATCH 1/2] feat: add PLANNOTATOR_URL_HOST display-only override for advertised URLs Remote mode binds 0.0.0.0 but every advertised URL hardcoded http://localhost:, so a session opened from another device (e.g. a phone on the same tailnet) got an unopenable link (#657). - resolveUrlHost() in packages/shared/config.ts: PLANNOTATOR_URL_HOST env var over config.json urlHost, validated host-only (bare hostname, IPv4, bracketed IPv6); invalid values warn once and fall back to localhost. - buildAdvertisedUrl(port) in packages/server/remote.ts and its Pi mirror in apps/pi-extension/server/network.ts; all 7 construction sites use it. - Strictly display-only: binding stays governed by PLANNOTATOR_REMOTE, and agent-review jobs get a pinned http://127.0.0.1: API URL. - Remote-ready copy says "open on your device" when the host is overridden; local sessions with an override warn it is unreachable. - Tests for validation, precedence, and URL composition in both runtimes; docs in CLAUDE.md and the marketing site. --- AGENTS.md | 1 + .../docs/getting-started/configuration.md | 1 + .../docs/guides/remote-and-devcontainers.md | 13 +++ .../docs/reference/environment-variables.md | 1 + apps/pi-extension/index.ts | 11 ++- apps/pi-extension/server/agent-jobs.ts | 6 ++ apps/pi-extension/server/network.test.ts | 41 +++++++++ apps/pi-extension/server/network.ts | 29 ++++++- apps/pi-extension/server/serverAnnotate.ts | 4 +- apps/pi-extension/server/serverPlan.ts | 4 +- apps/pi-extension/server/serverReview.ts | 13 ++- packages/server/agent-jobs.ts | 7 +- packages/server/annotate.ts | 4 +- packages/server/goal-setup.ts | 4 +- packages/server/index.ts | 4 +- packages/server/remote.test.ts | 42 ++++++++- packages/server/remote.ts | 27 ++++++ packages/server/review.ts | 13 ++- packages/server/shared-handlers.ts | 7 +- packages/shared/config.test.ts | 87 +++++++++++++++++++ packages/shared/config.ts | 52 +++++++++++ 21 files changed, 345 insertions(+), 26 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 17e3d7140..fa4212540 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -133,6 +133,7 @@ claude --plugin-dir ./apps/hook | `PLANNOTATOR_REMOTE` | Set to `1` / `true` for remote mode, `0` / `false` for local mode, or leave unset for SSH auto-detection. Uses a fixed port in remote mode; browser-opening behavior depends on the environment. | | `PLANNOTATOR_AGENT_TERMINAL_REMOTE` | Set to `1` / `true` to enable the annotate-mode agent terminal while `PLANNOTATOR_REMOTE` is active. Off by default because remote mode binds beyond localhost. | | `PLANNOTATOR_PORT` | Fixed port to use. Default: random locally, `19432` for remote sessions. | +| `PLANNOTATOR_URL_HOST` | Display-only hostname for advertised session URLs (issue #657), e.g. a Tailscale MagicDNS name or tailnet IP, so remote-mode links are reachable from another device instead of `http://localhost:`. Host only — bare hostname, IPv4, or bracketed IPv6 (`[fd7a::1]`); the runtime-chosen port is always appended, and anything carrying a scheme, port, path, credentials, or whitespace warns once on stderr and falls back to `localhost`. Strictly display-only: binding stays governed by `PLANNOTATOR_REMOTE` (a local session with this set warns that the URL will not be reachable but still honors it), and spawned agent-review jobs keep a pinned `http://127.0.0.1:` API URL so a tailnet-only hostname cannot break local jobs. Can also be set via `~/.plannotator/config.json` (`{ "urlHost": "host" }`); the env var takes precedence. Default: unset (`localhost`). | | `PLANNOTATOR_BROWSER` | Custom browser to open plans in. macOS: app name or path. Linux/Windows: executable path. | | `PLANNOTATOR_AI` | Set to `disabled` to disable Ask AI and the Review Agents / Guided Review execution surfaces, including provider and agent-job endpoints. Persisted guide data is retained and its server APIs remain available, but the in-app history browser is hidden while AI is disabled. External agents can still open reviews and submit annotations. The explicit annotate-mode agent terminal is separate and remains controlled by its own settings. Default: enabled. | | `PLANNOTATOR_SHARE` | Set to `disabled` to turn off URL sharing entirely. Default: enabled. Can also be set via `~/.plannotator/config.json` (`{ "share": "disabled" }`); the env var takes precedence. | diff --git a/apps/marketing/src/content/docs/getting-started/configuration.md b/apps/marketing/src/content/docs/getting-started/configuration.md index 0d07624b8..130aaf367 100644 --- a/apps/marketing/src/content/docs/getting-started/configuration.md +++ b/apps/marketing/src/content/docs/getting-started/configuration.md @@ -14,6 +14,7 @@ Plannotator is configured through environment variables, hook/plugin configurati |----------|---------|-------------| | `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. | | `PLANNOTATOR_PORT` | random (local) / `19432` (remote) | Fixed server port. Useful for port forwarding in remote environments. | +| `PLANNOTATOR_URL_HOST` | unset (`localhost`) | Display-only hostname for advertised session URLs in remote mode (e.g. a Tailscale MagicDNS name). The runtime-chosen port is always appended; binding is unaffected. Can also be set via `~/.plannotator/config.json` (`{ "urlHost": "host" }`). | | `PLANNOTATOR_BROWSER` | system default | Custom browser or script to open the UI. | | `PLANNOTATOR_AI` | enabled | Set to `disabled` to disable Ask AI, Review Agents, and Guided Review. External agents can still open reviews and submit annotations; the annotate agent terminal is separate. | | `PLANNOTATOR_SHARE` | enabled | Set to `disabled` to turn off URL sharing entirely. Can also be set via `~/.plannotator/config.json` (`{ "share": "disabled" }`). | diff --git a/apps/marketing/src/content/docs/guides/remote-and-devcontainers.md b/apps/marketing/src/content/docs/guides/remote-and-devcontainers.md index 829f2b014..1109356b3 100644 --- a/apps/marketing/src/content/docs/guides/remote-and-devcontainers.md +++ b/apps/marketing/src/content/docs/guides/remote-and-devcontainers.md @@ -26,6 +26,19 @@ Remote mode changes two behaviors: Plannotator also detects `SSH_TTY` and `SSH_CONNECTION` environment variables for automatic remote mode when `PLANNOTATOR_REMOTE` is unset. Use `PLANNOTATOR_REMOTE=1` / `true` to force remote mode or `PLANNOTATOR_REMOTE=0` / `false` to force local mode. +## Direct-reach hosts (Tailscale, LAN) + +When the machine running Plannotator is directly reachable from your other devices — over a Tailscale tailnet, a VPN, or a trusted LAN — port forwarding is unnecessary, but the advertised URL still says `localhost`, which another device cannot open. Set `PLANNOTATOR_URL_HOST` to the hostname or IP those devices can reach: + +```bash +export PLANNOTATOR_REMOTE=1 +export PLANNOTATOR_URL_HOST=my-machine.tailnet.ts.net +``` + +Plannotator then advertises `http://my-machine.tailnet.ts.net:` (the port is chosen at runtime and always appended), so you can open review sessions straight from a phone or another computer. The setting is host-only and strictly display-only — it never changes which interface the server binds; remote mode (`PLANNOTATOR_REMOTE=1`) is what makes the server reachable beyond localhost. It can also be set persistently via `~/.plannotator/config.json` (`{ "urlHost": "my-machine.tailnet.ts.net" }`); the env var takes precedence. + +Note that the session is served over plain `http`, so browser features that require a secure context — such as creating short share links from the UI — are unavailable from other devices unless you put the session behind HTTPS (e.g. `tailscale serve`). The core review, annotate, and approve flows work over plain `http`. + ## VS Code Remote / devcontainers VS Code sets the `BROWSER` environment variable in devcontainers to a helper script that opens URLs on your local machine. Plannotator respects this — in most cases, the browser opens automatically with no extra configuration. diff --git a/apps/marketing/src/content/docs/reference/environment-variables.md b/apps/marketing/src/content/docs/reference/environment-variables.md index 04e3855e0..c441758f7 100644 --- a/apps/marketing/src/content/docs/reference/environment-variables.md +++ b/apps/marketing/src/content/docs/reference/environment-variables.md @@ -14,6 +14,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. | | `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:`. 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: it never changes which interface the server binds (that stays governed by `PLANNOTATOR_REMOTE`), so it is only useful together with remote mode. Can also be set via `~/.plannotator/config.json` (`{ "urlHost": "host" }`); the env var takes precedence. | | `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`, `gemini-cli`, `kiro-cli`. Invalid values silently fall through to env-based detection. | diff --git a/apps/pi-extension/index.ts b/apps/pi-extension/index.ts index b8eb36c42..6fbca87e0 100644 --- a/apps/pi-extension/index.ts +++ b/apps/pi-extension/index.ts @@ -72,7 +72,7 @@ import { type Phase, stripPlanningOnlyTools, } from "./tool-scope.ts"; -import { isRemoteSession } from "./server/network.ts"; +import { isRemoteSession, isUrlHostOverridden } from "./server/network.ts"; import { isBrowserSessionStoppedError } from "./browser-session-error.ts"; import { classifyAnnotateOutcome } from "./annotate-outcome.ts"; @@ -163,9 +163,12 @@ function safeNotify( * after-turn notify inside openBrowserForServer fires too late to render. */ function sessionOpenedMessage(label: string, url: string): string { - return isRemoteSession() - ? `${label} — open ${url} on your local machine (forward the port if needed). You can keep chatting while it runs.` - : `${label}. You can keep chatting while it runs.`; + if (!isRemoteSession()) return `${label}. You can keep chatting while it runs.`; + // With an advertised-URL host override the link is directly reachable + // (e.g. over a tailnet), so the port-forwarding advice would be wrong. + return isUrlHostOverridden() + ? `${label} — open ${url} on your device. You can keep chatting while it runs.` + : `${label} — open ${url} on your local machine (forward the port if needed). You can keep chatting while it runs.`; } function reportBackgroundError(ctx: ExtensionContext, message: string, err: unknown, origin?: PiSessionIdentity): void { diff --git a/apps/pi-extension/server/agent-jobs.ts b/apps/pi-extension/server/agent-jobs.ts index 4ec35be09..030dc4f47 100644 --- a/apps/pi-extension/server/agent-jobs.ts +++ b/apps/pi-extension/server/agent-jobs.ts @@ -75,6 +75,12 @@ export function whichCmd(cmd: string): boolean { export interface AgentJobHandlerOptions { mode: "plan" | "review" | "annotate"; + /** + * Returns the server's base URL for spawned subprocesses (e.g., + * "http://127.0.0.1:12345"). Late-bound. Jobs run on the same machine, so + * this must stay loopback-reachable regardless of any advertised-URL host + * override. + */ getServerUrl: () => string; getCwd: () => string; /** Build the command server-side for a given provider. */ diff --git a/apps/pi-extension/server/network.test.ts b/apps/pi-extension/server/network.test.ts index 56d4a74b0..738014979 100644 --- a/apps/pi-extension/server/network.test.ts +++ b/apps/pi-extension/server/network.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, test } from "bun:test"; import { createServer } from "node:http"; import { closeServer, occupyConsecutivePorts } from "../../../tests/helpers/ports.ts"; import { + buildAdvertisedUrl, getServerHostname, getServerPort, getServerPorts, @@ -19,6 +20,7 @@ const envKeys = [ "SSH_CONNECTION", "PLANNOTATOR_BROWSER", "BROWSER", + "PLANNOTATOR_URL_HOST", ]; function clearEnv() { @@ -282,3 +284,42 @@ describe("pi browser no-op sentinels", () => { }); }); }); + +describe("pi buildAdvertisedUrl", () => { + test("defaults to localhost", () => { + clearEnv(); + // An empty (but set) env var suppresses any urlHost in the developer's + // real config.json, isolating the default path. + process.env.PLANNOTATOR_URL_HOST = ""; + expect(buildAdvertisedUrl(19432)).toBe("http://localhost:19432"); + }); + + test("appends the runtime port to the override host", () => { + clearEnv(); + process.env.PLANNOTATOR_REMOTE = "1"; + process.env.PLANNOTATOR_URL_HOST = "my-machine.tailnet.ts.net"; + expect(buildAdvertisedUrl(19432)).toBe("http://my-machine.tailnet.ts.net:19432"); + }); + + test("keeps bracketed IPv6 hosts intact", () => { + clearEnv(); + process.env.PLANNOTATOR_REMOTE = "1"; + process.env.PLANNOTATOR_URL_HOST = "[fd7a::1]"; + expect(buildAdvertisedUrl(9999)).toBe("http://[fd7a::1]:9999"); + }); + + test("an invalid host falls back to localhost instead of throwing", () => { + clearEnv(); + process.env.PLANNOTATOR_REMOTE = "1"; + process.env.PLANNOTATOR_URL_HOST = "https://evil.example/path"; + expect(buildAdvertisedUrl(1234)).toBe("http://localhost:1234"); + }); + + test("the override never affects the bind hostname", () => { + clearEnv(); + process.env.PLANNOTATOR_URL_HOST = "my-machine.tailnet.ts.net"; + expect(getServerHostname()).toBe("127.0.0.1"); + process.env.PLANNOTATOR_REMOTE = "1"; + expect(getServerHostname()).toBe("0.0.0.0"); + }); +}); diff --git a/apps/pi-extension/server/network.ts b/apps/pi-extension/server/network.ts index 5427407cd..140a6417a 100644 --- a/apps/pi-extension/server/network.ts +++ b/apps/pi-extension/server/network.ts @@ -8,7 +8,7 @@ import { existsSync } from "node:fs"; import type { Server } from "node:http"; import { release } from "node:os"; import { delimiter, join } from "node:path"; -import { loadConfig, resolveUseGlimpse } from "../generated/config.ts"; +import { loadConfig, resolveUrlHost, resolveUseGlimpse } from "../generated/config.ts"; import { parsePortSelection } from "../generated/port-range.ts"; const DEFAULT_REMOTE_PORT = 19432; @@ -116,6 +116,33 @@ export function getServerHostname(): string { return isRemoteSession() ? "0.0.0.0" : LOOPBACK_HOST; } +/** True when the advertised-URL host is overridden away from localhost. */ +export function isUrlHostOverridden(): boolean { + return resolveUrlHost(loadConfig()) !== undefined; +} + +let warnedLocalUrlHost = false; + +/** + * Compose the URL advertised to the user for a bound port (issue #657). + * Display-only: the PLANNOTATOR_URL_HOST / urlHost override changes what is + * printed and opened, never which interface the server listens on + * (getServerHostname). Same-machine subprocesses must not use this — they get + * a loopback URL so a tailnet-only hostname can't break local agent jobs. + * Mirrors packages/server/remote.ts — keep the two behaviorally identical. + */ +export function buildAdvertisedUrl(port: number): string { + const host = resolveUrlHost(loadConfig()); + if (host === undefined) return `http://localhost:${port}`; + if (!isRemoteSession() && !warnedLocalUrlHost) { + warnedLocalUrlHost = true; + process.stderr.write( + `[plannotator] Warning: an advertised URL host is set but this is a local session (server binds loopback), so http://${host}:${port} will not be reachable from other devices. Set PLANNOTATOR_REMOTE=1 to bind beyond localhost.\n`, + ); + } + return `http://${host}:${port}`; +} + const MAX_RETRIES = 5; const RETRY_DELAY_MS = 500; diff --git a/apps/pi-extension/server/serverAnnotate.ts b/apps/pi-extension/server/serverAnnotate.ts index b570b60ac..df300bb79 100644 --- a/apps/pi-extension/server/serverAnnotate.ts +++ b/apps/pi-extension/server/serverAnnotate.ts @@ -33,7 +33,7 @@ import { import { handleApiNotFound, html, json, parseBody, requestUrl } from "./helpers.ts"; import { createPiAIRuntime, handlePiAIRequest } from "./ai-runtime.ts"; -import { isRemoteSession, listenOnPort } from "./network.ts"; +import { buildAdvertisedUrl, isRemoteSession, listenOnPort } from "./network.ts"; import { getAvailableOpenInApps, openFileInApp } from "./open-in-apps.ts"; import { getRepoInfo } from "./project.ts"; @@ -850,7 +850,7 @@ export async function startAnnotateServer(options: { return { port, portSource, - url: `http://localhost:${port}`, + url: buildAdvertisedUrl(port), waitForDecision: () => decisionPromise, stop: () => { // try/finally: a throwing dispose must never leave the listener bound. diff --git a/apps/pi-extension/server/serverPlan.ts b/apps/pi-extension/server/serverPlan.ts index 5963e0672..4c13adc49 100644 --- a/apps/pi-extension/server/serverPlan.ts +++ b/apps/pi-extension/server/serverPlan.ts @@ -37,7 +37,7 @@ import { saveToObsidian, saveToOctarine, } from "./integrations.ts"; -import { listenOnPort } from "./network.ts"; +import { buildAdvertisedUrl, listenOnPort } from "./network.ts"; import { loadConfig, saveConfig, detectGitUser, getServerConfig, resolveAIEnabled, resolveSharingEnabled } from "../generated/config.ts"; import { readImprovementHook, getImprovementHookExpectedPath } from "../generated/improvement-hooks.ts"; @@ -467,7 +467,7 @@ export async function startPlanReviewServer(options: { reviewId, port, portSource, - url: `http://localhost:${port}`, + url: buildAdvertisedUrl(port), waitForDecision: () => decisionPromise, onDecision: (listener) => { decisionListeners.add(listener); diff --git a/apps/pi-extension/server/serverReview.ts b/apps/pi-extension/server/serverReview.ts index 6ad1ef2cb..c1ec6ba58 100644 --- a/apps/pi-extension/server/serverReview.ts +++ b/apps/pi-extension/server/serverReview.ts @@ -90,7 +90,7 @@ import { import { handleApiNotFound, html, json, parseBody, requestUrl, send } from "./helpers.ts"; import { createPiAIRuntime, handlePiAIRequest } from "./ai-runtime.ts"; -import { isRemoteSession, listenOnPort } from "./network.ts"; +import { buildAdvertisedUrl, isRemoteSession, listenOnPort } from "./network.ts"; import { getAvailableOpenInApps, openFileInApp } from "./open-in-apps.ts"; import { resolveOpenInTarget } from "../generated/html-assets-node.ts"; import { @@ -683,8 +683,12 @@ export async function startReviewServer(options: { ); } - // Agent jobs — background process manager (late-binds serverUrl via getter) + // Agent jobs — background process manager (late-binds serverUrl via getter). + // Spawned jobs run on this machine, so their API URL is pinned to loopback + // and never inherits the advertised-URL host override (a tailnet-only + // hostname must not break local agent jobs). let serverUrl = ""; + let agentApiUrl = ""; function resolveAgentCwd(): string { if (workspace) return workspace.root; if (options.worktreePool && prMeta) { @@ -880,7 +884,7 @@ export async function startReviewServer(options: { const agentJobs = createAgentJobHandler({ mode: "review", - getServerUrl: () => serverUrl, + getServerUrl: () => agentApiUrl, getCwd: resolveAgentCwd, async buildCommand(provider, config) { @@ -2743,7 +2747,8 @@ export async function startReviewServer(options: { }); const { port, portSource } = await listenOnPort(server); - serverUrl = `http://localhost:${port}`; + serverUrl = buildAdvertisedUrl(port); + agentApiUrl = `http://127.0.0.1:${port}`; const exitHandler = () => agentJobs.killAll(); process.once("exit", exitHandler); diff --git a/packages/server/agent-jobs.ts b/packages/server/agent-jobs.ts index 3fd8434a1..f5027122e 100644 --- a/packages/server/agent-jobs.ts +++ b/packages/server/agent-jobs.ts @@ -83,7 +83,12 @@ const SERVER_BUILT_PROVIDERS: ReadonlySet = new Set([ export interface AgentJobHandlerOptions { /** Which server mode this handler is mounted in. */ mode: "plan" | "review" | "annotate"; - /** Returns the server's base URL (e.g., "http://localhost:12345"). Late-bound. */ + /** + * Returns the server's base URL for spawned subprocesses (e.g., + * "http://127.0.0.1:12345"). Late-bound. Jobs run on the same machine, so + * this must stay loopback-reachable regardless of any advertised-URL host + * override. + */ getServerUrl: () => string; /** Returns the working directory for spawned processes. */ getCwd: () => string; diff --git a/packages/server/annotate.ts b/packages/server/annotate.ts index bd4c90e67..ea3e4b1d4 100644 --- a/packages/server/annotate.ts +++ b/packages/server/annotate.ts @@ -11,7 +11,7 @@ * PLANNOTATOR_PORT - Fixed port or inclusive range (default: random locally, 19432 for remote) */ -import { isRemoteSession, getServerHostname, startBunServerOnAvailablePort } from "./remote"; +import { isRemoteSession, getServerHostname, startBunServerOnAvailablePort, buildAdvertisedUrl } from "./remote"; import { getRepoInfo } from "./repo"; import type { Origin } from "@plannotator/shared/agents"; import { handleImage, handleUpload, handleServerReady, handleDraftSave, handleDraftLoad, handleDraftDelete, handleApiNotFound, handleFavicon, handleSaveNotes, readDraftGenerationFromBody, readDraftGenerationFromUrl } from "./shared-handlers"; @@ -902,7 +902,7 @@ export async function startAnnotateServer( ); const port = server.port!; - const serverUrl = `http://localhost:${port}`; + const serverUrl = buildAdvertisedUrl(port); // The cache warm must never gate the listening socket. Its async filesystem // walk yields between directories while requests remain serviceable. diff --git a/packages/server/goal-setup.ts b/packages/server/goal-setup.ts index fc4624a92..466bec47c 100644 --- a/packages/server/goal-setup.ts +++ b/packages/server/goal-setup.ts @@ -15,7 +15,7 @@ import { type GoalSetupQuestionAnswer, type GoalSetupResult, } from "@plannotator/shared/goal-setup"; -import { isRemoteSession, getServerHostname, startBunServerOnAvailablePort } from "./remote"; +import { isRemoteSession, getServerHostname, startBunServerOnAvailablePort, buildAdvertisedUrl } from "./remote"; import { getRepoInfo } from "./repo"; import { handleFavicon, @@ -208,7 +208,7 @@ export async function startGoalSetupServer( ); const port = server.port!; - const serverUrl = `http://localhost:${port}`; + const serverUrl = buildAdvertisedUrl(port); onReady?.(serverUrl, isRemote, port); return { diff --git a/packages/server/index.ts b/packages/server/index.ts index 11e75dffa..1a9ba2b49 100644 --- a/packages/server/index.ts +++ b/packages/server/index.ts @@ -14,7 +14,7 @@ import type { Origin } from "@plannotator/shared/agents"; import { resolve } from "path"; -import { isRemoteSession, getServerHostname, startBunServerOnAvailablePort } from "./remote"; +import { isRemoteSession, getServerHostname, startBunServerOnAvailablePort, buildAdvertisedUrl } from "./remote"; import { openEditorDiff } from "./ide"; import { saveToObsidian, @@ -592,7 +592,7 @@ export async function startPlannotatorServer( ); const port = server.port!; - const serverUrl = `http://localhost:${port}`; + const serverUrl = buildAdvertisedUrl(port); let stopPromise: Promise | undefined; const stop = () => { stopPromise ??= (async () => { diff --git a/packages/server/remote.test.ts b/packages/server/remote.test.ts index 761345d27..ca1fa6264 100644 --- a/packages/server/remote.test.ts +++ b/packages/server/remote.test.ts @@ -13,11 +13,12 @@ import { getServerPort, getServerPorts, startBunServerOnAvailablePort, + buildAdvertisedUrl, } from "./remote"; // Save and restore env between tests const savedEnv: Record = {}; -const envKeys = ["PLANNOTATOR_REMOTE", "PLANNOTATOR_PORT", "SSH_TTY", "SSH_CONNECTION"]; +const envKeys = ["PLANNOTATOR_REMOTE", "PLANNOTATOR_PORT", "SSH_TTY", "SSH_CONNECTION", "PLANNOTATOR_URL_HOST"]; function clearEnv() { for (const key of envKeys) { @@ -274,3 +275,42 @@ describe("getServerHostname", () => { expect(getServerHostname()).toBe("0.0.0.0"); }); }); + +describe("buildAdvertisedUrl", () => { + test("defaults to localhost", () => { + clearEnv(); + // An empty (but set) env var suppresses any urlHost in the developer's + // real config.json, isolating the default path. + process.env.PLANNOTATOR_URL_HOST = ""; + expect(buildAdvertisedUrl(19432)).toBe("http://localhost:19432"); + }); + + test("appends the runtime port to the override host", () => { + clearEnv(); + process.env.PLANNOTATOR_REMOTE = "1"; + process.env.PLANNOTATOR_URL_HOST = "my-machine.tailnet.ts.net"; + expect(buildAdvertisedUrl(19432)).toBe("http://my-machine.tailnet.ts.net:19432"); + }); + + test("keeps bracketed IPv6 hosts intact", () => { + clearEnv(); + process.env.PLANNOTATOR_REMOTE = "1"; + process.env.PLANNOTATOR_URL_HOST = "[fd7a::1]"; + expect(buildAdvertisedUrl(9999)).toBe("http://[fd7a::1]:9999"); + }); + + test("an invalid host falls back to localhost instead of throwing", () => { + clearEnv(); + process.env.PLANNOTATOR_REMOTE = "1"; + process.env.PLANNOTATOR_URL_HOST = "https://evil.example/path"; + expect(buildAdvertisedUrl(1234)).toBe("http://localhost:1234"); + }); + + test("the override never affects the bind hostname", () => { + clearEnv(); + process.env.PLANNOTATOR_URL_HOST = "my-machine.tailnet.ts.net"; + expect(getServerHostname()).toBe("127.0.0.1"); + process.env.PLANNOTATOR_REMOTE = "1"; + expect(getServerHostname()).toBe("0.0.0.0"); + }); +}); diff --git a/packages/server/remote.ts b/packages/server/remote.ts index f796e257b..2013a691d 100644 --- a/packages/server/remote.ts +++ b/packages/server/remote.ts @@ -9,6 +9,7 @@ */ import { parsePortSelection } from "@plannotator/shared/port-range"; +import { loadConfig, resolveUrlHost } from "@plannotator/shared/config"; const DEFAULT_REMOTE_PORT = 19432; const LOOPBACK_HOST = "127.0.0.1"; @@ -149,3 +150,29 @@ export async function startBunServerOnAvailablePort( export function getServerHostname(): string { return isRemoteSession() ? "0.0.0.0" : LOOPBACK_HOST; } + +/** True when the advertised-URL host is overridden away from localhost. */ +export function isUrlHostOverridden(): boolean { + return resolveUrlHost(loadConfig()) !== undefined; +} + +let warnedLocalUrlHost = false; + +/** + * Compose the URL advertised to the user for a bound port (issue #657). + * Display-only: the PLANNOTATOR_URL_HOST / urlHost override changes what is + * printed and opened, never which interface the server listens on + * (getServerHostname). Same-machine subprocesses must not use this — they get + * a loopback URL so a tailnet-only hostname can't break local agent jobs. + */ +export function buildAdvertisedUrl(port: number): string { + const host = resolveUrlHost(loadConfig()); + if (host === undefined) return `http://localhost:${port}`; + if (!isRemoteSession() && !warnedLocalUrlHost) { + warnedLocalUrlHost = true; + process.stderr.write( + `[plannotator] Warning: an advertised URL host is set but this is a local session (server binds loopback), so http://${host}:${port} will not be reachable from other devices. Set PLANNOTATOR_REMOTE=1 to bind beyond localhost.\n`, + ); + } + return `http://${host}:${port}`; +} diff --git a/packages/server/review.ts b/packages/server/review.ts index b9da52572..d29b8c8e0 100644 --- a/packages/server/review.ts +++ b/packages/server/review.ts @@ -9,7 +9,7 @@ * PLANNOTATOR_PORT - Fixed port or inclusive range (default: random locally, 19432 for remote) */ -import { isRemoteSession, getServerHostname, startBunServerOnAvailablePort } from "./remote"; +import { isRemoteSession, getServerHostname, startBunServerOnAvailablePort, buildAdvertisedUrl } from "./remote"; import type { Origin } from "@plannotator/shared/agents"; import { type DiffType, type GitContext, runVcsDiff, getVcsFileContentsForDiff, getVcsDiffFingerprint, canStageFiles, stageFile, unstageFile, resolveVcsCwd, validateFilePath, getVcsContext, detectRemoteDefaultCompareTarget, vcsOwnsDiffType, gitRuntime } from "./vcs"; import { basename } from "node:path"; @@ -659,8 +659,12 @@ export async function startReviewServer( return avatarUrl ? { ...info, avatarUrl } : info; }; - // Agent jobs — background process manager (late-binds serverUrl via getter) + // Agent jobs — background process manager (late-binds serverUrl via getter). + // Spawned jobs run on this machine, so their API URL is pinned to loopback + // and never inherits the advertised-URL host override (a tailnet-only + // hostname must not break local agent jobs). let serverUrl = ""; + let agentApiUrl = ""; const resolveAgentCwd = (): string => { if (workspace) return workspace.root; if (options.worktreePool && prMetadata) { @@ -827,7 +831,7 @@ export async function startReviewServer( const agentJobs = createAgentJobHandler({ mode: "review", - getServerUrl: () => serverUrl, + getServerUrl: () => agentApiUrl, getCwd: resolveAgentCwd, async buildCommand(provider, config) { @@ -2896,7 +2900,8 @@ export async function startReviewServer( ); const port = server.port!; - serverUrl = `http://localhost:${port}`; + serverUrl = buildAdvertisedUrl(port); + agentApiUrl = `http://127.0.0.1:${port}`; const exitHandler = () => agentJobs.killAll(); process.once("exit", exitHandler); diff --git a/packages/server/shared-handlers.ts b/packages/server/shared-handlers.ts index 1859b6c82..b752d08a2 100644 --- a/packages/server/shared-handlers.ts +++ b/packages/server/shared-handlers.ts @@ -9,6 +9,7 @@ import { appendFileSync, mkdirSync } from "node:fs"; import { dirname } from "node:path"; import { openBrowser as openBrowserImpl } from "./browser"; +import { isUrlHostOverridden } from "./remote"; import { validateImagePath, validateUploadExtension, UPLOAD_DIR } from "./image"; import { saveDraft, loadDraft, deleteDraft, getDraftGeneration } from "./draft"; import { FAVICON_PNG_BYTES } from "@plannotator/shared/favicon"; @@ -207,8 +208,12 @@ export async function handleServerReady( // reachable URL is the lifeline. Without it, a sharing-disabled remote user // saw no URL at all and the agent hung waiting on the review. if (isRemote) { + // With an advertised-URL host override the link is directly reachable + // (e.g. over a tailnet), so the port-forwarding advice would be wrong. process.stderr.write( - `\n Plannotator session ready — open on your local machine (forward port ${port} if needed):\n ${url}\n\n`, + isUrlHostOverridden() + ? `\n Plannotator session ready — open on your device:\n ${url}\n\n` + : `\n Plannotator session ready — open on your local machine (forward port ${port} if needed):\n ${url}\n\n`, ); } else if (isCodexDesktopHost()) { process.stderr.write(`\n Plannotator session ready:\n ${url}\n\n`); diff --git a/packages/shared/config.test.ts b/packages/shared/config.test.ts index 4e78f8798..ce7b76624 100644 --- a/packages/shared/config.test.ts +++ b/packages/shared/config.test.ts @@ -7,6 +7,8 @@ import { resolveGuideHistory, resolveUseJina, resolveTodoProviderEnabled, + resolveUrlHost, + isValidUrlHost, } from "./config"; import type { PlannotatorConfig } from "./config"; @@ -62,6 +64,91 @@ describe("resolveTodoProviderEnabled", () => { }); }); +const URL_HOST_ENV = "PLANNOTATOR_URL_HOST"; +const originalUrlHostEnv = process.env[URL_HOST_ENV]; + +describe("isValidUrlHost", () => { + test("accepts bare hostnames, IPv4, and bracketed IPv6", () => { + for (const host of [ + "localhost", + "my-machine", + "my-machine.tailnet.ts.net", + "raspberrypi.local", + "100.101.102.103", + "[fd7a::1]", + "[::1]", + "[::ffff:100.101.102.103]", + ]) { + expect(isValidUrlHost(host)).toBe(true); + } + }); + + test("rejects schemes, paths, ports, credentials, query, fragment, whitespace", () => { + for (const host of [ + "http://my-machine", + "https://my-machine.ts.net", + "my-machine/path", + "my-machine:8080", + "user@my-machine", + "my-machine?x=1", + "my-machine#frag", + "my machine", + "fd7a::1", // unbracketed IPv6 reads as ":" outside brackets + "-leading-hyphen", + ".leading.dot", + "trailing-hyphen-", + "", + ]) { + expect(isValidUrlHost(host)).toBe(false); + } + }); +}); + +describe("resolveUrlHost", () => { + beforeEach(() => { + delete process.env[URL_HOST_ENV]; + }); + afterAll(() => { + if (originalUrlHostEnv === undefined) delete process.env[URL_HOST_ENV]; + else process.env[URL_HOST_ENV] = originalUrlHostEnv; + }); + + test("defaults to undefined (localhost) with no env var and no config key", () => { + expect(resolveUrlHost({})).toBeUndefined(); + }); + + test("config.urlHost is honored when the env var is unset", () => { + expect(resolveUrlHost({ urlHost: "my-machine.tailnet.ts.net" })).toBe("my-machine.tailnet.ts.net"); + }); + + test("env wins over the config key", () => { + process.env[URL_HOST_ENV] = "env-host"; + expect(resolveUrlHost({ urlHost: "config-host" })).toBe("env-host"); + }); + + test("an empty (but set) env var suppresses the config key", () => { + process.env[URL_HOST_ENV] = ""; + expect(resolveUrlHost({ urlHost: "config-host" })).toBeUndefined(); + }); + + test("values are trimmed", () => { + process.env[URL_HOST_ENV] = " my-machine "; + expect(resolveUrlHost({})).toBe("my-machine"); + }); + + test("invalid values fall back to undefined (localhost) instead of throwing", () => { + for (const v of ["http://my-machine", "my-machine:8080", "a@b", "a b", "host/path"]) { + process.env[URL_HOST_ENV] = v; + expect(resolveUrlHost({})).toBeUndefined(); + } + }); + + test("non-string config values are ignored", () => { + expect(resolveUrlHost({ urlHost: 42 as unknown as string })).toBeUndefined(); + expect(resolveUrlHost({ urlHost: null as unknown as string })).toBeUndefined(); + }); +}); + const ENV = "PLANNOTATOR_CURSOR_SANDBOX"; const originalEnv = process.env[ENV]; diff --git a/packages/shared/config.ts b/packages/shared/config.ts index 95fa4c085..ce487b1f3 100644 --- a/packages/shared/config.ts +++ b/packages/shared/config.ts @@ -178,6 +178,15 @@ export interface PlannotatorConfig { * PLANNOTATOR_CURSOR_SANDBOX env var, which takes precedence. */ cursorSandbox?: boolean; + /** + * Display-only hostname for advertised session URLs (issue #657). Lets a + * remote-mode user hand out a reachable link (e.g. a Tailscale MagicDNS + * name or tailnet IP) instead of localhost. Host only — the port is chosen + * at runtime and always appended. Never affects which interface the server + * binds; that stays governed by PLANNOTATOR_REMOTE. Mirrors the + * PLANNOTATOR_URL_HOST env var, which takes precedence. + */ + urlHost?: string; /** * Mirror the approved plan checklist into an editable todo provider during * execution (issue #484). "auto" (default) syncs whenever a provider is @@ -378,6 +387,49 @@ export function resolveSharingEnabled(config: PlannotatorConfig): boolean { return true; } +// Bare hostname or IPv4: letters/digits/dots/hyphens, no leading/trailing +// dot or hyphen. Covers MagicDNS names ("my-machine.tailnet.ts.net"). +const URL_HOST_HOSTNAME_RE = /^[A-Za-z0-9]([A-Za-z0-9.-]*[A-Za-z0-9])?$/; +// Bracketed IPv6 literal, e.g. [fd7a::1]; dots allow IPv4-mapped forms. +const URL_HOST_IPV6_RE = /^\[[0-9A-Fa-f:.]+\]$/; + +/** + * Validate a display host for advertised URLs. Host only: anything carrying a + * scheme, path, query, credentials, whitespace, or a port (":" outside IPv6 + * brackets — the runtime-chosen port is always appended) is rejected. + */ +export function isValidUrlHost(host: string): boolean { + return URL_HOST_HOSTNAME_RE.test(host) || URL_HOST_IPV6_RE.test(host); +} + +let warnedInvalidUrlHost = false; + +/** + * Resolve the display-only hostname used in advertised session URLs. + * Returns undefined when unset (callers advertise localhost). + * + * Priority (highest wins): + * PLANNOTATOR_URL_HOST env var → config.urlHost → undefined + * + * An invalid value warns once on stderr and falls back to localhost — a + * display setting must never crash a server launch. + */ +export function resolveUrlHost(config: PlannotatorConfig): string | undefined { + const envVal = process.env.PLANNOTATOR_URL_HOST; + const raw = envVal !== undefined ? envVal : config.urlHost; + if (typeof raw !== "string") return undefined; + const host = raw.trim(); + if (host === "") return undefined; + if (isValidUrlHost(host)) return host; + if (!warnedInvalidUrlHost) { + warnedInvalidUrlHost = true; + process.stderr.write( + `[plannotator] Warning: invalid advertised URL host "${host}" — expected a bare hostname, IPv4, or bracketed IPv6 (no scheme, port, or path); using localhost\n`, + ); + } + return undefined; +} + /** * Resolve whether Plannotator-managed AI features are enabled. * From 6b7affb679179c786d76dec8c9a433d13010d42a Mon Sep 17 00:00:00 2001 From: Michael Ramos Date: Thu, 6 Aug 2026 12:18:31 -0700 Subject: [PATCH 2/2] fix(review): ignore urlHost in local sessions, harden warning output Review follow-ups on #1225: - Local (loopback-bound) sessions no longer honor the advertised-host override: honoring it auto-opened http://: against a server nothing was listening on, openBrowser still reported success, and the agent blocked on waitForDecision. Local sessions now advertise and open localhost, warning once that PLANNOTATOR_REMOTE=1 is required. - The invalid-host warning JSON-encodes the echoed value so an embedded newline cannot forge extra stderr lines (hosts surface session-ready lines as clickable links); warn-once is now per value. - Docs: local-session behavior reworded, the empty-env-suppresses-config semantic documented, secure-context note generalized. --- AGENTS.md | 2 +- .../docs/getting-started/configuration.md | 2 +- .../docs/guides/remote-and-devcontainers.md | 4 ++-- .../docs/reference/environment-variables.md | 2 +- apps/pi-extension/server/network.test.ts | 7 ++++++ apps/pi-extension/server/network.ts | 20 ++++++++++------ packages/server/remote.test.ts | 7 ++++++ packages/server/remote.ts | 20 ++++++++++------ packages/shared/config.test.ts | 23 +++++++++++++++++- packages/shared/config.ts | 24 ++++++++++++------- 10 files changed, 82 insertions(+), 29 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index fa4212540..2a8261f21 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -133,7 +133,7 @@ claude --plugin-dir ./apps/hook | `PLANNOTATOR_REMOTE` | Set to `1` / `true` for remote mode, `0` / `false` for local mode, or leave unset for SSH auto-detection. Uses a fixed port in remote mode; browser-opening behavior depends on the environment. | | `PLANNOTATOR_AGENT_TERMINAL_REMOTE` | Set to `1` / `true` to enable the annotate-mode agent terminal while `PLANNOTATOR_REMOTE` is active. Off by default because remote mode binds beyond localhost. | | `PLANNOTATOR_PORT` | Fixed port to use. Default: random locally, `19432` for remote sessions. | -| `PLANNOTATOR_URL_HOST` | Display-only hostname for advertised session URLs (issue #657), e.g. a Tailscale MagicDNS name or tailnet IP, so remote-mode links are reachable from another device instead of `http://localhost:`. Host only — bare hostname, IPv4, or bracketed IPv6 (`[fd7a::1]`); the runtime-chosen port is always appended, and anything carrying a scheme, port, path, credentials, or whitespace warns once on stderr and falls back to `localhost`. Strictly display-only: binding stays governed by `PLANNOTATOR_REMOTE` (a local session with this set warns that the URL will not be reachable but still honors it), and spawned agent-review jobs keep a pinned `http://127.0.0.1:` API URL so a tailnet-only hostname cannot break local jobs. Can also be set via `~/.plannotator/config.json` (`{ "urlHost": "host" }`); the env var takes precedence. Default: unset (`localhost`). | +| `PLANNOTATOR_URL_HOST` | Display-only hostname for advertised session URLs (issue #657), e.g. a Tailscale MagicDNS name or tailnet IP, so remote-mode links are reachable from another device instead of `http://localhost:`. Host only — bare hostname, IPv4, or bracketed IPv6 (`[fd7a::1]`); the runtime-chosen port is always appended, and anything carrying a scheme, port, path, credentials, or whitespace warns once on stderr and falls back to `localhost`. Strictly display-only and remote-only: binding stays governed by `PLANNOTATOR_REMOTE`; a local session ignores the override (localhost is advertised and opened, since only loopback is bound) with a once-per-process stderr warning to set `PLANNOTATOR_REMOTE=1`, and spawned agent-review jobs keep a pinned `http://127.0.0.1:` API URL so a tailnet-only hostname cannot break local jobs. Can also be set via `~/.plannotator/config.json` (`{ "urlHost": "host" }`); the env var takes precedence, and an empty-but-set env var (`PLANNOTATOR_URL_HOST=`) suppresses a config-file `urlHost`. Default: unset (`localhost`). | | `PLANNOTATOR_BROWSER` | Custom browser to open plans in. macOS: app name or path. Linux/Windows: executable path. | | `PLANNOTATOR_AI` | Set to `disabled` to disable Ask AI and the Review Agents / Guided Review execution surfaces, including provider and agent-job endpoints. Persisted guide data is retained and its server APIs remain available, but the in-app history browser is hidden while AI is disabled. External agents can still open reviews and submit annotations. The explicit annotate-mode agent terminal is separate and remains controlled by its own settings. Default: enabled. | | `PLANNOTATOR_SHARE` | Set to `disabled` to turn off URL sharing entirely. Default: enabled. Can also be set via `~/.plannotator/config.json` (`{ "share": "disabled" }`); the env var takes precedence. | diff --git a/apps/marketing/src/content/docs/getting-started/configuration.md b/apps/marketing/src/content/docs/getting-started/configuration.md index 130aaf367..417ff6839 100644 --- a/apps/marketing/src/content/docs/getting-started/configuration.md +++ b/apps/marketing/src/content/docs/getting-started/configuration.md @@ -14,7 +14,7 @@ Plannotator is configured through environment variables, hook/plugin configurati |----------|---------|-------------| | `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. | | `PLANNOTATOR_PORT` | random (local) / `19432` (remote) | Fixed server port. Useful for port forwarding in remote environments. | -| `PLANNOTATOR_URL_HOST` | unset (`localhost`) | Display-only hostname for advertised session URLs in remote mode (e.g. a Tailscale MagicDNS name). The runtime-chosen port is always appended; binding is unaffected. Can also be set via `~/.plannotator/config.json` (`{ "urlHost": "host" }`). | +| `PLANNOTATOR_URL_HOST` | unset (`localhost`) | Display-only hostname for advertised session URLs in remote mode (e.g. a Tailscale MagicDNS name). The runtime-chosen port is always appended; binding is unaffected and local sessions ignore the override. Can also be set via `~/.plannotator/config.json` (`{ "urlHost": "host" }`). | | `PLANNOTATOR_BROWSER` | system default | Custom browser or script to open the UI. | | `PLANNOTATOR_AI` | enabled | Set to `disabled` to disable Ask AI, Review Agents, and Guided Review. External agents can still open reviews and submit annotations; the annotate agent terminal is separate. | | `PLANNOTATOR_SHARE` | enabled | Set to `disabled` to turn off URL sharing entirely. Can also be set via `~/.plannotator/config.json` (`{ "share": "disabled" }`). | diff --git a/apps/marketing/src/content/docs/guides/remote-and-devcontainers.md b/apps/marketing/src/content/docs/guides/remote-and-devcontainers.md index 1109356b3..05d413665 100644 --- a/apps/marketing/src/content/docs/guides/remote-and-devcontainers.md +++ b/apps/marketing/src/content/docs/guides/remote-and-devcontainers.md @@ -35,9 +35,9 @@ export PLANNOTATOR_REMOTE=1 export PLANNOTATOR_URL_HOST=my-machine.tailnet.ts.net ``` -Plannotator then advertises `http://my-machine.tailnet.ts.net:` (the port is chosen at runtime and always appended), so you can open review sessions straight from a phone or another computer. The setting is host-only and strictly display-only — it never changes which interface the server binds; remote mode (`PLANNOTATOR_REMOTE=1`) is what makes the server reachable beyond localhost. It can also be set persistently via `~/.plannotator/config.json` (`{ "urlHost": "my-machine.tailnet.ts.net" }`); the env var takes precedence. +Plannotator then advertises `http://my-machine.tailnet.ts.net:` (the port is chosen at runtime and always appended), so you can open review sessions straight from a phone or another computer. The setting is host-only and strictly display-only — it never changes which interface the server binds; remote mode (`PLANNOTATOR_REMOTE=1`) is what makes the server reachable beyond localhost, and a local session ignores the override entirely (localhost is advertised, with a warning). It can also be set persistently via `~/.plannotator/config.json` (`{ "urlHost": "my-machine.tailnet.ts.net" }`); the env var takes precedence. -Note that the session is served over plain `http`, so browser features that require a secure context — such as creating short share links from the UI — are unavailable from other devices unless you put the session behind HTTPS (e.g. `tailscale serve`). The core review, annotate, and approve flows work over plain `http`. +Note that the session is served over plain `http`, so some in-app features that require a secure context (such as creating short share links from the UI) are unavailable from other devices unless you put the session behind HTTPS (e.g. `tailscale serve`). The core review, annotate, and approve flows work over plain `http`. ## VS Code Remote / devcontainers diff --git a/apps/marketing/src/content/docs/reference/environment-variables.md b/apps/marketing/src/content/docs/reference/environment-variables.md index c441758f7..b6b33c1e5 100644 --- a/apps/marketing/src/content/docs/reference/environment-variables.md +++ b/apps/marketing/src/content/docs/reference/environment-variables.md @@ -14,7 +14,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. | | `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:`. 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: it never changes which interface the server binds (that stays governed by `PLANNOTATOR_REMOTE`), so it is only useful together with remote mode. Can also be set via `~/.plannotator/config.json` (`{ "urlHost": "host" }`); the env var takes precedence. | +| `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:`. 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_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`, `gemini-cli`, `kiro-cli`. Invalid values silently fall through to env-based detection. | diff --git a/apps/pi-extension/server/network.test.ts b/apps/pi-extension/server/network.test.ts index 738014979..8c8182802 100644 --- a/apps/pi-extension/server/network.test.ts +++ b/apps/pi-extension/server/network.test.ts @@ -288,12 +288,19 @@ describe("pi browser no-op sentinels", () => { describe("pi buildAdvertisedUrl", () => { test("defaults to localhost", () => { clearEnv(); + process.env.PLANNOTATOR_REMOTE = "1"; // An empty (but set) env var suppresses any urlHost in the developer's // real config.json, isolating the default path. process.env.PLANNOTATOR_URL_HOST = ""; expect(buildAdvertisedUrl(19432)).toBe("http://localhost:19432"); }); + test("a local session ignores the override and advertises localhost", () => { + clearEnv(); + process.env.PLANNOTATOR_URL_HOST = "my-machine.tailnet.ts.net"; + expect(buildAdvertisedUrl(1234)).toBe("http://localhost:1234"); + }); + test("appends the runtime port to the override host", () => { clearEnv(); process.env.PLANNOTATOR_REMOTE = "1"; diff --git a/apps/pi-extension/server/network.ts b/apps/pi-extension/server/network.ts index 140a6417a..c2bf1c886 100644 --- a/apps/pi-extension/server/network.ts +++ b/apps/pi-extension/server/network.ts @@ -127,18 +127,24 @@ let warnedLocalUrlHost = false; * Compose the URL advertised to the user for a bound port (issue #657). * Display-only: the PLANNOTATOR_URL_HOST / urlHost override changes what is * printed and opened, never which interface the server listens on - * (getServerHostname). Same-machine subprocesses must not use this — they get - * a loopback URL so a tailnet-only hostname can't break local agent jobs. + * (getServerHostname). Remote sessions only: a local session binds loopback, + * so honoring the override would advertise (and auto-open) a URL nothing is + * listening on — the override is ignored with a once-per-process warning. + * Same-machine subprocesses must not use this — they get a loopback URL so a + * tailnet-only hostname can't break local agent jobs. * Mirrors packages/server/remote.ts — keep the two behaviorally identical. */ export function buildAdvertisedUrl(port: number): string { const host = resolveUrlHost(loadConfig()); if (host === undefined) return `http://localhost:${port}`; - if (!isRemoteSession() && !warnedLocalUrlHost) { - warnedLocalUrlHost = true; - process.stderr.write( - `[plannotator] Warning: an advertised URL host is set but this is a local session (server binds loopback), so http://${host}:${port} will not be reachable from other devices. Set PLANNOTATOR_REMOTE=1 to bind beyond localhost.\n`, - ); + if (!isRemoteSession()) { + if (!warnedLocalUrlHost) { + warnedLocalUrlHost = true; + process.stderr.write( + `[plannotator] Warning: advertised URL host ${JSON.stringify(host)} ignored — this is a local session, so the server binds loopback and only localhost is reachable. Set PLANNOTATOR_REMOTE=1 to use the override.\n`, + ); + } + return `http://localhost:${port}`; } return `http://${host}:${port}`; } diff --git a/packages/server/remote.test.ts b/packages/server/remote.test.ts index ca1fa6264..788f04697 100644 --- a/packages/server/remote.test.ts +++ b/packages/server/remote.test.ts @@ -279,12 +279,19 @@ describe("getServerHostname", () => { describe("buildAdvertisedUrl", () => { test("defaults to localhost", () => { clearEnv(); + process.env.PLANNOTATOR_REMOTE = "1"; // An empty (but set) env var suppresses any urlHost in the developer's // real config.json, isolating the default path. process.env.PLANNOTATOR_URL_HOST = ""; expect(buildAdvertisedUrl(19432)).toBe("http://localhost:19432"); }); + test("a local session ignores the override and advertises localhost", () => { + clearEnv(); + process.env.PLANNOTATOR_URL_HOST = "my-machine.tailnet.ts.net"; + expect(buildAdvertisedUrl(1234)).toBe("http://localhost:1234"); + }); + test("appends the runtime port to the override host", () => { clearEnv(); process.env.PLANNOTATOR_REMOTE = "1"; diff --git a/packages/server/remote.ts b/packages/server/remote.ts index 2013a691d..ebf722484 100644 --- a/packages/server/remote.ts +++ b/packages/server/remote.ts @@ -162,17 +162,23 @@ let warnedLocalUrlHost = false; * Compose the URL advertised to the user for a bound port (issue #657). * Display-only: the PLANNOTATOR_URL_HOST / urlHost override changes what is * printed and opened, never which interface the server listens on - * (getServerHostname). Same-machine subprocesses must not use this — they get - * a loopback URL so a tailnet-only hostname can't break local agent jobs. + * (getServerHostname). Remote sessions only: a local session binds loopback, + * so honoring the override would advertise (and auto-open) a URL nothing is + * listening on — the override is ignored with a once-per-process warning. + * Same-machine subprocesses must not use this — they get a loopback URL so a + * tailnet-only hostname can't break local agent jobs. */ export function buildAdvertisedUrl(port: number): string { const host = resolveUrlHost(loadConfig()); if (host === undefined) return `http://localhost:${port}`; - if (!isRemoteSession() && !warnedLocalUrlHost) { - warnedLocalUrlHost = true; - process.stderr.write( - `[plannotator] Warning: an advertised URL host is set but this is a local session (server binds loopback), so http://${host}:${port} will not be reachable from other devices. Set PLANNOTATOR_REMOTE=1 to bind beyond localhost.\n`, - ); + if (!isRemoteSession()) { + if (!warnedLocalUrlHost) { + warnedLocalUrlHost = true; + process.stderr.write( + `[plannotator] Warning: advertised URL host ${JSON.stringify(host)} ignored — this is a local session, so the server binds loopback and only localhost is reachable. Set PLANNOTATOR_REMOTE=1 to use the override.\n`, + ); + } + return `http://localhost:${port}`; } return `http://${host}:${port}`; } diff --git a/packages/shared/config.test.ts b/packages/shared/config.test.ts index ce7b76624..b7b23d0b9 100644 --- a/packages/shared/config.test.ts +++ b/packages/shared/config.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, test, beforeEach, afterAll } from "bun:test"; +import { describe, expect, test, beforeEach, afterAll, spyOn } from "bun:test"; import { resolveAIEnabled, resolveCursorSandbox, @@ -147,6 +147,27 @@ describe("resolveUrlHost", () => { expect(resolveUrlHost({ urlHost: 42 as unknown as string })).toBeUndefined(); expect(resolveUrlHost({ urlHost: null as unknown as string })).toBeUndefined(); }); + + test("the invalid-host warning stays a single line for newline-embedded values", () => { + // Hosts surface stderr lines like "Plannotator session ready" as clickable + // links, so an echoed value must not be able to forge extra lines. + const writes: string[] = []; + const spy = spyOn(process.stderr, "write").mockImplementation(((chunk: unknown) => { + writes.push(String(chunk)); + return true; + }) as typeof process.stderr.write); + try { + process.env[URL_HOST_ENV] = "bad\nPlannotator session ready:\n http://evil.example"; + expect(resolveUrlHost({})).toBeUndefined(); + } finally { + spy.mockRestore(); + } + const warning = writes.find((w) => w.includes("invalid advertised URL host")); + expect(warning).toBeDefined(); + // One trailing newline terminates the warning; no interior newlines. + expect(warning!.endsWith("\n")).toBe(true); + expect(warning!.slice(0, -1)).not.toContain("\n"); + }); }); const ENV = "PLANNOTATOR_CURSOR_SANDBOX"; diff --git a/packages/shared/config.ts b/packages/shared/config.ts index ce487b1f3..346260b09 100644 --- a/packages/shared/config.ts +++ b/packages/shared/config.ts @@ -183,8 +183,10 @@ export interface PlannotatorConfig { * remote-mode user hand out a reachable link (e.g. a Tailscale MagicDNS * name or tailnet IP) instead of localhost. Host only — the port is chosen * at runtime and always appended. Never affects which interface the server - * binds; that stays governed by PLANNOTATOR_REMOTE. Mirrors the - * PLANNOTATOR_URL_HOST env var, which takes precedence. + * binds; that stays governed by PLANNOTATOR_REMOTE. Applied only in remote + * sessions: a local session binds loopback, so the override is ignored + * (localhost is advertised) with a once-per-process stderr warning. + * Mirrors the PLANNOTATOR_URL_HOST env var, which takes precedence. */ urlHost?: string; /** @@ -402,17 +404,21 @@ export function isValidUrlHost(host: string): boolean { return URL_HOST_HOSTNAME_RE.test(host) || URL_HOST_IPV6_RE.test(host); } -let warnedInvalidUrlHost = false; +const warnedInvalidUrlHosts = new Set(); /** * Resolve the display-only hostname used in advertised session URLs. - * Returns undefined when unset (callers advertise localhost). + * Returns undefined when unset (callers advertise localhost). An empty (but + * set) env var suppresses the config key. Callers apply this only to remote + * sessions; local sessions ignore it (see buildAdvertisedUrl). * * Priority (highest wins): * PLANNOTATOR_URL_HOST env var → config.urlHost → undefined * - * An invalid value warns once on stderr and falls back to localhost — a - * display setting must never crash a server launch. + * An invalid value warns once per value on stderr and falls back to + * localhost — a display setting must never crash a server launch. The echoed + * value is JSON-encoded so an embedded newline cannot forge extra stderr + * lines (hosts surface "Plannotator session ready" lines as clickable links). */ export function resolveUrlHost(config: PlannotatorConfig): string | undefined { const envVal = process.env.PLANNOTATOR_URL_HOST; @@ -421,10 +427,10 @@ export function resolveUrlHost(config: PlannotatorConfig): string | undefined { const host = raw.trim(); if (host === "") return undefined; if (isValidUrlHost(host)) return host; - if (!warnedInvalidUrlHost) { - warnedInvalidUrlHost = true; + if (!warnedInvalidUrlHosts.has(host)) { + warnedInvalidUrlHosts.add(host); process.stderr.write( - `[plannotator] Warning: invalid advertised URL host "${host}" — expected a bare hostname, IPv4, or bracketed IPv6 (no scheme, port, or path); using localhost\n`, + `[plannotator] Warning: invalid advertised URL host ${JSON.stringify(host)} — expected a bare hostname, IPv4, or bracketed IPv6 (no scheme, port, or path); using localhost\n`, ); } return undefined;