diff --git a/apps/pi-extension/README.md b/apps/pi-extension/README.md index 707663a3f..09f5cac19 100644 --- a/apps/pi-extension/README.md +++ b/apps/pi-extension/README.md @@ -276,6 +276,12 @@ URL targets work too. A loopback `http` URL that answers with an HTML page (a ru Run `/plannotator-last` to annotate the agent's most recent response. The message opens in the annotation UI where you can highlight text, add comments, and send structured feedback back to the agent. +### Tailscale access + +Run `/plannotator-tailscale` once to publish new Plannotator browser sessions privately through `tailscale serve`. Reviews stay bound to loopback, and Plannotator prints an HTTPS URL that another device on the same tailnet can open. This works in an already-running Pi session; no restart or environment variables are needed. + +Use `/plannotator-tailscale off` to stop publishing new sessions. Existing reviews remain available until they close, then their serve mappings are removed. + ### Archive browser The Plannotator archive browser is available through the shared event API as `archive`, which opens the saved plan/decision browser for future callers. The orchestrator does not expose a dedicated archive command yet. @@ -292,6 +298,7 @@ During execution, the agent marks completed steps with `[DONE:n]` markers. Progr | `/plannotator-review` | Open code review UI for current changes | | `/plannotator-annotate ` | Open markdown file in annotation UI | | `/plannotator-last` | Annotate the last assistant message | +| `/plannotator-tailscale [on\|off\|status]` | Enable private tailnet URLs for new browser sessions without restarting Pi | ## Flags diff --git a/apps/pi-extension/index.ts b/apps/pi-extension/index.ts index d2524aba7..670165b38 100644 --- a/apps/pi-extension/index.ts +++ b/apps/pi-extension/index.ts @@ -14,6 +14,7 @@ * - [DONE:n] markers for execution progress tracking * - /plannotator-review command for code review * - /plannotator-annotate command for markdown annotation + * - /plannotator-tailscale command for tailnet browser access */ import { existsSync, readFileSync, statSync, writeFileSync } from "node:fs"; @@ -75,6 +76,7 @@ import { stripPlanningOnlyTools, } from "./tool-scope.ts"; import { isRemoteSession, isUrlHostOverridden } from "./server/network.ts"; +import { isTailscaleModeEnabled, setTailscaleModeEnabled } from "./tailscale-mode.ts"; import { isBrowserSessionStoppedError } from "./browser-session-error.ts"; import { classifyAnnotateOutcome } from "./annotate-outcome.ts"; @@ -188,9 +190,9 @@ function safeNotify( */ function sessionOpenedMessage(label: string, url: string): string { 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() + // A command-enabled Tailscale session and an advertised-URL host override + // are directly reachable, so port-forwarding advice would be wrong. + return isTailscaleModeEnabled() || 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.`; } @@ -659,6 +661,29 @@ export default function plannotator(pi: ExtensionAPI): void { // ── Commands & Shortcuts ───────────────────────────────────────────── + pi.registerCommand("plannotator-tailscale", { + description: "Enable private Tailscale URLs for new Plannotator browser sessions; pass off to disable", + handler: async (args, ctx) => { + const action = (args ?? "").trim().toLowerCase(); + if (action === "status") { + ctx.ui.notify(`Plannotator Tailscale mode is ${isTailscaleModeEnabled() ? "enabled" : "disabled"}.`, "info"); + return; + } + if (action !== "" && action !== "on" && action !== "off") { + ctx.ui.notify("Usage: /plannotator-tailscale [on|off|status]", "error"); + return; + } + const enabled = action !== "off"; + setTailscaleModeEnabled(enabled); + ctx.ui.notify( + enabled + ? "Plannotator Tailscale mode enabled. New reviews will print a private URL you can open on another tailnet device." + : "Plannotator Tailscale mode disabled for new reviews.", + "info", + ); + }, + }); + pi.registerCommand("plannotator-plan-mode", { description: "Toggle plannotator planning mode", handler: async (_args, ctx) => { diff --git a/apps/pi-extension/package.json b/apps/pi-extension/package.json index 3c95e7d77..37ca0a120 100644 --- a/apps/pi-extension/package.json +++ b/apps/pi-extension/package.json @@ -40,6 +40,7 @@ "plannotator-browser-runtime.ts", "plannotator-events.ts", "server.ts", + "tailscale-mode.ts", "tool-scope.ts", "config.ts", "plannotator.json", diff --git a/apps/pi-extension/phase-tools-runtime.test.ts b/apps/pi-extension/phase-tools-runtime.test.ts index 344074d6a..d2de9c09d 100644 --- a/apps/pi-extension/phase-tools-runtime.test.ts +++ b/apps/pi-extension/phase-tools-runtime.test.ts @@ -3,6 +3,10 @@ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import plannotator from "./index.ts"; +import { + isTailscaleModeEnabled, + resetTailscaleModeForTests, +} from "./tailscale-mode.ts"; type Handler = (event: unknown, context: ReturnType) => unknown; @@ -21,6 +25,7 @@ function restoreEnv(name: string, value: string | undefined): void { } afterEach(() => { + resetTailscaleModeForTests(); restoreEnv("HOME", originalHome); restoreEnv("PI_CODING_AGENT_DIR", originalAgentDir); @@ -129,6 +134,20 @@ function createRuntime(initialTools: string[]) { }; } +describe("Plannotator Tailscale command", () => { + test("enables and disables tailnet publishing without restarting Pi", async () => { + const runtime = createRuntime([]); + const context = createContext(); + const command = runtime.commands.get("plannotator-tailscale"); + + expect(command).toBeDefined(); + await command?.handler("", context); + expect(isTailscaleModeEnabled()).toBe(true); + await command?.handler("off", context); + expect(isTailscaleModeEnabled()).toBe(false); + }); +}); + describe("Plannotator phase tool ownership", () => { test("leaving planning removes only tools Plannotator added", async () => { const cwd = makeWorkspace(); diff --git a/apps/pi-extension/plannotator-browser.ts b/apps/pi-extension/plannotator-browser.ts index b1c80a079..06d337538 100644 --- a/apps/pi-extension/plannotator-browser.ts +++ b/apps/pi-extension/plannotator-browser.ts @@ -23,6 +23,7 @@ import { } from "./server.ts"; import { BROWSER_SESSION_STOPPED } from "./browser-session-error.ts"; import { openBrowser, isRemoteSession } from "./server/network.ts"; +import { publishBrowserServerOverTailscale } from "./tailscale-mode.ts"; import { detectProjectName } from "./server/project.ts"; import { parsePRUrl, checkPRAuth, fetchPR } from "./server/pr.ts"; import { @@ -324,7 +325,8 @@ export async function startPlanReviewBrowserSession( pasteApiUrl: process.env.PLANNOTATOR_PASTE_URL || undefined, })); - const session = startBrowserDecisionSession(server, ctx, server.waitForDecision, signal); + const browserServer = publishBrowserServerOverTailscale(server); + const session = startBrowserDecisionSession(browserServer, ctx, server.waitForDecision, signal); server.onDecision(() => { setTimeout(() => session.stop(), 1500); }); @@ -703,7 +705,8 @@ async function createCodeReviewBrowserSession( onCleanup: worktreeCleanup, })); - return startBrowserDecisionSession(server, ctx, server.waitForDecision); + const browserServer = publishBrowserServerOverTailscale(server); + return startBrowserDecisionSession(browserServer, ctx, server.waitForDecision); } export async function openMarkdownAnnotation( @@ -807,7 +810,8 @@ export async function startMarkdownAnnotationSession( project: detectProjectName(), })); - return startBrowserDecisionSession(server, ctx, server.waitForDecision); + const browserServer = publishBrowserServerOverTailscale(server); + return startBrowserDecisionSession(browserServer, ctx, server.waitForDecision); } export async function openLastMessageAnnotation( @@ -865,7 +869,8 @@ export async function openArchiveBrowserAction( pasteApiUrl: process.env.PLANNOTATOR_PASTE_URL || undefined, })); - return openBrowserAndWait(server, ctx, async () => { + const browserServer = publishBrowserServerOverTailscale(server); + return openBrowserAndWait(browserServer, ctx, async () => { if (server.waitForDone) { await server.waitForDone(); } diff --git a/apps/pi-extension/server/network.test.ts b/apps/pi-extension/server/network.test.ts index b396a6f08..9a1fc30b5 100644 --- a/apps/pi-extension/server/network.test.ts +++ b/apps/pi-extension/server/network.test.ts @@ -22,6 +22,10 @@ import { listenOnPort, openBrowser, } from "./network.ts"; +import { + resetTailscaleModeForTests, + setTailscaleModeEnabled, +} from "../tailscale-mode.ts"; const savedEnv: Record = {}; const envKeys = [ @@ -42,6 +46,7 @@ function clearEnv() { } afterEach(() => { + resetTailscaleModeForTests(); for (const key of envKeys) { if (savedEnv[key] !== undefined) { process.env[key] = savedEnv[key]; @@ -100,6 +105,13 @@ describe("pi remote detection", () => { process.env.SSH_TTY = "/dev/pts/0"; expect(isRemoteSession()).toBe(true); }); + + test("uses remote security behavior when Tailscale mode is enabled", () => { + clearEnv(); + process.env.PLANNOTATOR_REMOTE = "false"; + setTailscaleModeEnabled(true); + expect(isRemoteSession()).toBe(true); + }); }); describe("pi port selection", () => { @@ -124,6 +136,14 @@ describe("pi port selection", () => { expect(getServerPort()).toEqual({ port: 9999, portSource: "env" }); }); + test("Tailscale mode keeps the listener loopback-only on a random port", () => { + clearEnv(); + process.env.SSH_CONNECTION = "192.168.1.1 12345 192.168.1.2 22"; + setTailscaleModeEnabled(true); + expect(getServerPort()).toEqual({ port: 0, portSource: "random" }); + expect(getServerHostname()).toBe("127.0.0.1"); + }); + test("expands an inclusive port range", () => { clearEnv(); process.env.PLANNOTATOR_PORT = "19432-19435"; diff --git a/apps/pi-extension/server/network.ts b/apps/pi-extension/server/network.ts index dc486a745..42f0360da 100644 --- a/apps/pi-extension/server/network.ts +++ b/apps/pi-extension/server/network.ts @@ -11,6 +11,7 @@ import { delimiter, join } from "node:path"; import { loadConfig, resolveUrlHost, resolveUseGlimpse } from "../generated/config.ts"; import { parsePortSelection } from "../generated/port-range.ts"; import { isAutoUrlHost, resolveAutoHostCached } from "../generated/tailscale.ts"; +import { isTailscaleModeEnabled } from "../tailscale-mode.ts"; const DEFAULT_REMOTE_PORT = 19432; const LOOPBACK_HOST = "127.0.0.1"; @@ -85,6 +86,9 @@ function getRemoteOverride(): boolean | null { } export function isRemoteSession(): boolean { + // Tailnet-published sessions need remote security behavior even though + // tailscale serve proxies to a loopback-only listener. + if (isTailscaleModeEnabled()) return true; const remoteOverride = getRemoteOverride(); if (remoteOverride !== null) { return remoteOverride; @@ -130,6 +134,11 @@ function getServerPortConfiguration(): { } // Invalid port - fall back silently, caller can check env var themselves } + // `tailscale serve` publishes the chosen loopback port, so let the OS pick + // a free one unless PLANNOTATOR_PORT explicitly pins it. + if (isTailscaleModeEnabled()) { + return { ports: [0], portSource: "random", isRange: false }; + } if (isRemoteSession()) { return { ports: [DEFAULT_REMOTE_PORT], @@ -149,6 +158,9 @@ export function getServerPort(): { } export function getServerHostname(): string { + // Tailscale serves as the network edge; the Plannotator listener itself + // must remain loopback-only. + if (isTailscaleModeEnabled()) return LOOPBACK_HOST; return isRemoteSession() ? "0.0.0.0" : LOOPBACK_HOST; } diff --git a/apps/pi-extension/tailscale-mode.test.ts b/apps/pi-extension/tailscale-mode.test.ts new file mode 100644 index 000000000..5a4a74f31 --- /dev/null +++ b/apps/pi-extension/tailscale-mode.test.ts @@ -0,0 +1,78 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import type { TailscaleRunner } from "./generated/tailscale.ts"; +import { resetTailscaleServeForTests } from "./generated/tailscale-serve.ts"; +import { + isTailscaleModeEnabled, + publishBrowserServerOverTailscale, + resetTailscaleModeForTests, + setTailscaleModeEnabled, +} from "./tailscale-mode.ts"; + +afterEach(() => { + resetTailscaleModeForTests(); + resetTailscaleServeForTests(); +}); + +describe("Pi Tailscale mode", () => { + test("is disabled until the slash command enables it", () => { + expect(isTailscaleModeEnabled()).toBe(false); + setTailscaleModeEnabled(true); + expect(isTailscaleModeEnabled()).toBe(true); + setTailscaleModeEnabled(false); + expect(isTailscaleModeEnabled()).toBe(false); + }); + + test("publishes and removes the serve mapping with the browser session", () => { + setTailscaleModeEnabled(true); + const calls: string[][] = []; + const run: TailscaleRunner = (args) => { + calls.push(args); + if (args[0] === "serve" && args[1] === "status") { + return { status: 0, stdout: "null", stderr: "" }; + } + if (args.at(-1) === "off") { + return { status: 0, stdout: "", stderr: "" }; + } + return { + status: 0, + stdout: "Available within your tailnet:\nhttps://phone-test.tailnet.ts.net:43123/", + stderr: "", + }; + }; + let serverStops = 0; + const published = publishBrowserServerOverTailscale({ + port: 43123, + url: "http://localhost:43123", + stop: () => { serverStops += 1; }, + }, run); + + expect(published.url).toBe("https://phone-test.tailnet.ts.net:43123"); + expect(calls).toEqual([ + ["serve", "status", "--json"], + ["serve", "--bg", "--https=43123", "http://127.0.0.1:43123"], + ]); + + published.stop(); + published.stop(); + expect(calls.at(-1)).toEqual(["serve", "--https=43123", "off"]); + expect(serverStops).toBe(1); + }); + + test("stops the local server when publishing fails", () => { + setTailscaleModeEnabled(true); + let serverStops = 0; + const run: TailscaleRunner = () => ({ + status: null, + stdout: "", + stderr: "", + error: Object.assign(new Error("missing"), { code: "ENOENT" }), + }); + + expect(() => publishBrowserServerOverTailscale({ + port: 43123, + url: "http://localhost:43123", + stop: () => { serverStops += 1; }, + }, run)).toThrow("tailscale` CLI not found"); + expect(serverStops).toBe(1); + }); +}); diff --git a/apps/pi-extension/tailscale-mode.ts b/apps/pi-extension/tailscale-mode.ts new file mode 100644 index 000000000..9e366099c --- /dev/null +++ b/apps/pi-extension/tailscale-mode.ts @@ -0,0 +1,61 @@ +import type { TailscaleRunner } from "./generated/tailscale.ts"; +import { + disableTailscaleServe, + enableTailscaleServe, +} from "./generated/tailscale-serve.ts"; + +let tailscaleModeEnabled = false; + +/** Enable or disable tailnet publishing for new Pi browser sessions. */ +export function setTailscaleModeEnabled(enabled: boolean): void { + tailscaleModeEnabled = enabled; +} + +export function isTailscaleModeEnabled(): boolean { + return tailscaleModeEnabled; +} + +export interface PublishableBrowserServer { + port: number; + url: string; + stop: () => void; +} + +/** + * Publish one loopback-bound Pi server through `tailscale serve` when the + * runtime command enabled tailnet mode. The returned stop owns both the serve + * mapping and the underlying server. + */ +export function publishBrowserServerOverTailscale( + server: PublishableBrowserServer, + run?: TailscaleRunner, +): { url: string; stop: () => void } { + if (!tailscaleModeEnabled) return server; + + let publishedUrl: string; + try { + publishedUrl = enableTailscaleServe(server.port, run).url; + } catch (error) { + server.stop(); + throw error; + } + + let stopped = false; + return { + url: publishedUrl, + stop: () => { + if (stopped) return; + stopped = true; + try { + disableTailscaleServe(server.port, run); + } finally { + server.stop(); + } + }, + }; +} + +/** Test-only: restore the process-local command state. */ +export function resetTailscaleModeForTests(): void { + tailscaleModeEnabled = false; +} diff --git a/apps/pi-extension/vendor.sh b/apps/pi-extension/vendor.sh index 347a446e3..d92f095b5 100755 --- a/apps/pi-extension/vendor.sh +++ b/apps/pi-extension/vendor.sh @@ -63,6 +63,13 @@ for f in agent-review-message codex-review claude-review review-findings marker- > "generated/$f.ts" done +# Tailscale serve orchestration is Node-compatible and shared by the Bun CLI +# and Pi. Its shared parser/runner dependency is vendored flat above. +printf '// @generated — DO NOT EDIT. Source: packages/server/tailscale-serve.ts\n' \ + | cat - "../../packages/server/tailscale-serve.ts" \ + | sed 's|from "@plannotator/shared/tailscale"|from "./tailscale.ts"|' \ + > "generated/tailscale-serve.ts" + # tour-review lives in packages/server/tour/ — parent-relative imports and the # shared tour types package each map to the flat generated/ layout. for f in tour-review; do diff --git a/packages/server/tailscale-serve.ts b/packages/server/tailscale-serve.ts index 428b10e92..6637bcabb 100644 --- a/packages/server/tailscale-serve.ts +++ b/packages/server/tailscale-serve.ts @@ -1,5 +1,5 @@ /** - * `--tailscale` session publishing (Bun CLI only). + * Tailnet session publishing for the Bun CLI and Pi extension. * * The server stays LOOPBACK-bound — this is not remote mode. `tailscale * serve` reverse-proxies an HTTPS tailnet port to 127.0.0.1, so the session diff --git a/packages/shared/tailscale.ts b/packages/shared/tailscale.ts index 45fb517fc..7daa2dd5d 100644 --- a/packages/shared/tailscale.ts +++ b/packages/shared/tailscale.ts @@ -6,8 +6,9 @@ * tailnet host so remote sessions advertise a reachable URL without the * user hand-copying their MagicDNS name into config. Display-only, like * every urlHost value — binding stays governed by PLANNOTATOR_REMOTE. - * - `--tailscale` (Bun CLI): parse/compose the `tailscale serve` commands - * that publish a loopback-bound session over the tailnet with HTTPS. + * - `--tailscale` (Bun CLI) and `/plannotator-tailscale` (Pi): + * parse/compose the `tailscale serve` commands that publish a + * loopback-bound session over the tailnet with HTTPS. * * Pure parsers live here so both runtimes (Bun server, Pi extension) share * them. The only process-spawning edge is `runTailscale`, which never invokes