diff --git a/.changeset/dashboard-public-url.md b/.changeset/dashboard-public-url.md new file mode 100644 index 000000000..433d7463c --- /dev/null +++ b/.changeset/dashboard-public-url.md @@ -0,0 +1,7 @@ +--- +"@aoagents/ao-cli": minor +--- + +Add `AO_PUBLIC_URL` environment variable for users running AO behind a reverse proxy (remote dev containers, VPS deployments, internal tooling). When set, all user-facing dashboard URLs — `ao start` / `ao dashboard` console output, `ao open` browser launches, and `projectSessionUrl()` links surfaced to the orchestrator agent — use the public URL instead of `http://localhost:`. Internal IPC (`daemon.ts` reload calls) still uses localhost since that traffic stays on the host. + +Also documents the existing `TERMINAL_WS_PATH` env var and the dashboard's automatic path-based mux WebSocket routing for standard-port (HTTPS / HTTP) deployments — these together let users front AO with a single hostname and one reverse-proxy rule, no extra ports or subdomains. diff --git a/SETUP.md b/SETUP.md index 81782df10..08402f832 100644 --- a/SETUP.md +++ b/SETUP.md @@ -59,6 +59,13 @@ Comprehensive guide to installing, configuring, and troubleshooting Agent Orches - Create incoming webhook: https://api.slack.com/messaging/webhooks - Set environment variable: `export SLACK_WEBHOOK_URL="https://hooks.slack.com/services/..."` +- **Public dashboard URL** - If running AO behind a reverse proxy (e.g. inside a remote dev container, on a VPS fronted by Caddy/nginx/Traefik) + - Set `AO_PUBLIC_URL` to the externally-reachable URL of the dashboard + - All console output, `ao open` browser launches, and orchestrator-prompt session links use this URL instead of `http://localhost:` + - Example: `export AO_PUBLIC_URL="https://ao.example.com"` + - When the dashboard is served on a standard port (HTTPS 443 / HTTP 80) the dashboard JS connects the mux WebSocket to `/ao-terminal-mux` on the same hostname. Your proxy needs to forward that path to the direct terminal server (`DIRECT_TERMINAL_PORT`, default 14801) — its upgrade handler accepts both `/mux` and `/ao-terminal-mux`. For custom paths set `TERMINAL_WS_PATH=/your/path`. + - **`AO_PATH_BASED_MUX=1`** (opt-in) — if your proxy can only forward one hostname:port pair (e.g. Cloudflare Tunnel pointed at a single `service:` URL with no path-based ingress), set this and `ao start` will run a small bundled HTTP/WS proxy on `PORT` that demultiplexes: HTTP forwards to Next.js (shifted to `PORT + 1000`, override with `NEXT_INTERNAL_PORT`), and `wss://hostname/ao-terminal-mux` is tunneled to `DIRECT_TERMINAL_PORT/mux`. Tradeoff: an extra Node process and one extra hop per HTTP request, in exchange for a one-line proxy config on the operator side. + ## Installation ### Install via npm (recommended) diff --git a/packages/cli/__tests__/lib/dashboard-url.test.ts b/packages/cli/__tests__/lib/dashboard-url.test.ts new file mode 100644 index 000000000..e97b83b3f --- /dev/null +++ b/packages/cli/__tests__/lib/dashboard-url.test.ts @@ -0,0 +1,68 @@ +import { describe, it, expect, beforeEach, afterEach } from "vitest"; +import { dashboardUrl } from "../../src/lib/dashboard-url.js"; + +describe("dashboardUrl", () => { + const original = process.env.AO_PUBLIC_URL; + + beforeEach(() => { + delete process.env.AO_PUBLIC_URL; + }); + + afterEach(() => { + if (original === undefined) { + delete process.env.AO_PUBLIC_URL; + } else { + process.env.AO_PUBLIC_URL = original; + } + }); + + it("falls back to localhost when AO_PUBLIC_URL is unset", () => { + expect(dashboardUrl(3000)).toBe("http://localhost:3000"); + }); + + it("falls back to localhost when AO_PUBLIC_URL is empty", () => { + process.env.AO_PUBLIC_URL = ""; + expect(dashboardUrl(8094)).toBe("http://localhost:8094"); + }); + + it("falls back to localhost when AO_PUBLIC_URL is whitespace only", () => { + process.env.AO_PUBLIC_URL = " "; + expect(dashboardUrl(8094)).toBe("http://localhost:8094"); + }); + + it("uses AO_PUBLIC_URL when set", () => { + process.env.AO_PUBLIC_URL = "https://ao.example.com"; + expect(dashboardUrl(3000)).toBe("https://ao.example.com"); + }); + + it("ignores the port argument when AO_PUBLIC_URL is set", () => { + process.env.AO_PUBLIC_URL = "https://ao.example.com"; + expect(dashboardUrl(3000)).toBe("https://ao.example.com"); + expect(dashboardUrl(8094)).toBe("https://ao.example.com"); + }); + + it("strips a trailing slash from AO_PUBLIC_URL", () => { + process.env.AO_PUBLIC_URL = "https://ao.example.com/"; + expect(dashboardUrl(3000)).toBe("https://ao.example.com"); + }); + + it("strips multiple trailing slashes from AO_PUBLIC_URL", () => { + process.env.AO_PUBLIC_URL = "https://ao.example.com///"; + expect(dashboardUrl(3000)).toBe("https://ao.example.com"); + }); + + it("preserves a sub-path in AO_PUBLIC_URL", () => { + process.env.AO_PUBLIC_URL = "https://example.com/ao"; + expect(dashboardUrl(3000)).toBe("https://example.com/ao"); + }); + + it("trims surrounding whitespace from AO_PUBLIC_URL", () => { + process.env.AO_PUBLIC_URL = " https://ao.example.com "; + expect(dashboardUrl(3000)).toBe("https://ao.example.com"); + }); + + it("supports a non-default port in AO_PUBLIC_URL", () => { + process.env.AO_PUBLIC_URL = "http://192.168.1.5:9000"; + expect(dashboardUrl(3000)).toBe("http://192.168.1.5:9000"); + }); +}); diff --git a/packages/cli/src/commands/dashboard.ts b/packages/cli/src/commands/dashboard.ts index addd9bd30..f9abbf817 100644 --- a/packages/cli/src/commands/dashboard.ts +++ b/packages/cli/src/commands/dashboard.ts @@ -12,6 +12,7 @@ import { } from "../lib/dashboard-rebuild.js"; import { preflight } from "../lib/preflight.js"; import { DEFAULT_PORT } from "../lib/constants.js"; +import { dashboardUrl } from "../lib/dashboard-url.js"; export function registerDashboard(program: Command): void { program @@ -42,7 +43,7 @@ export function registerDashboard(program: Command): void { const webDir = localWebDir; - console.log(chalk.bold(`Starting dashboard on http://localhost:${port}\n`)); + console.log(chalk.bold(`Starting dashboard on ${dashboardUrl(port)}\n`)); const env = await buildDashboardEnv( port, @@ -90,7 +91,7 @@ export function registerDashboard(program: Command): void { if (opts.open !== false) { openAbort = new AbortController(); - void waitForPortAndOpen(port, `http://localhost:${port}`, openAbort.signal); + void waitForPortAndOpen(port, dashboardUrl(port), openAbort.signal); } child.on("exit", (code) => { diff --git a/packages/cli/src/commands/start.ts b/packages/cli/src/commands/start.ts index 42a6ab502..74d671828 100644 --- a/packages/cli/src/commands/start.ts +++ b/packages/cli/src/commands/start.ts @@ -78,6 +78,7 @@ import { type DetectedAgent, } from "../lib/detect-agent.js"; import { detectDefaultBranch } from "../lib/git-utils.js"; +import { dashboardUrl } from "../lib/dashboard-url.js"; import { promptConfirm, promptSelect, promptText } from "../lib/prompts.js"; import { extractOwnerRepo, isValidRepoString } from "../lib/repo-utils.js"; import { @@ -883,7 +884,7 @@ async function runStartup( config.directTerminalPort, opts?.dev, ); - spinner.succeed(`Dashboard starting on http://localhost:${port}`); + spinner.succeed(`Dashboard starting on ${dashboardUrl(port)}`); console.log(chalk.dim(" (Dashboard will be ready in a few seconds)\n")); } @@ -1063,7 +1064,7 @@ async function runStartup( console.log(chalk.bold.green("\n✓ Startup complete\n")); if (opts?.dashboard !== false) { - console.log(chalk.cyan("Dashboard:"), `http://localhost:${port}`); + console.log(chalk.cyan("Dashboard:"), dashboardUrl(port)); } if (shouldStartLifecycle) { @@ -1097,7 +1098,7 @@ async function runStartup( openAbort = new AbortController(); const orchestratorUrl = selectedOrchestratorId ? projectSessionUrl(port, projectId, selectedOrchestratorId) - : `http://localhost:${port}`; + : dashboardUrl(port); void waitForPortAndOpen(port, orchestratorUrl, openAbort.signal); } @@ -1264,10 +1265,10 @@ async function attachAndSpawnOrchestrator(opts: { } if (isHumanCaller()) { - console.log(chalk.dim(` Opening dashboard: http://localhost:${daemon.port}\n`)); - openUrl(`http://localhost:${daemon.port}`); + console.log(chalk.dim(` Opening dashboard: ${dashboardUrl(daemon.port)}\n`)); + openUrl(dashboardUrl(daemon.port)); } else { - console.log(`Dashboard: http://localhost:${daemon.port}`); + console.log(`Dashboard: ${dashboardUrl(daemon.port)}`); } } @@ -1331,7 +1332,7 @@ export function registerStart(program: Command): void { // exit. Project-id args fall through to attach+spawn so // automation can `ao start ` against a live daemon. console.log(`AO is already running.`); - console.log(`Dashboard: http://localhost:${running.port}`); + console.log(`Dashboard: ${dashboardUrl(running.port)}`); console.log(`PID: ${running.pid}`); console.log(`Projects: ${running.projects.join(", ")}`); console.log(`To restart: ao stop && ao start`); @@ -1341,7 +1342,7 @@ export function registerStart(program: Command): void { if (isHumanCaller() && !projectArg) { console.log(chalk.cyan(`\nℹ AO is already running.`)); - console.log(` Dashboard: ${chalk.cyan(`http://localhost:${running.port}`)}`); + console.log(` Dashboard: ${chalk.cyan(dashboardUrl(running.port))}`); console.log(` PID: ${running.pid} | Up since: ${running.startedAt}`); console.log(` Projects: ${running.projects.join(", ")}\n`); @@ -1388,7 +1389,7 @@ export function registerStart(program: Command): void { ); if (choice === "open") { - openUrl(`http://localhost:${running.port}`); + openUrl(dashboardUrl(running.port)); unlockStartup(); process.exit(0); } else if (choice === "quit") { @@ -1420,7 +1421,7 @@ export function registerStart(program: Command): void { ), ); } - openUrl(`http://localhost:${running.port}`); + openUrl(dashboardUrl(running.port)); unlockStartup(); process.exit(0); } else if (choice === "new") { @@ -1499,9 +1500,9 @@ export function registerStart(program: Command): void { running.projects.includes(projectId) ) { console.log(chalk.cyan(`\nℹ AO is already running.`)); - console.log(` Dashboard: ${chalk.cyan(`http://localhost:${running.port}`)}`); + console.log(` Dashboard: ${chalk.cyan(dashboardUrl(running.port))}`); console.log(` Project "${projectId}" is already registered and running.\n`); - openUrl(`http://localhost:${running.port}`); + openUrl(dashboardUrl(running.port)); unlockStartup(); process.exit(0); } diff --git a/packages/cli/src/lib/dashboard-url.ts b/packages/cli/src/lib/dashboard-url.ts new file mode 100644 index 000000000..a27c2c2a2 --- /dev/null +++ b/packages/cli/src/lib/dashboard-url.ts @@ -0,0 +1,24 @@ +/** + * Returns the user-facing base URL of the dashboard. + * + * When `AO_PUBLIC_URL` is set in the environment, AO is being fronted by a + * reverse proxy (e.g. when running inside a remote dev container or behind + * Caddy/nginx). All console output, `ao open` browser launches, and session + * URLs surfaced to humans should use that public URL instead of localhost. + * + * The trailing slash is stripped for consistency so callers can append paths + * without producing `//`. + * + * Internal IPC (the daemon hitting its own dashboard's API) is intentionally + * **not** routed through this helper — those calls always use localhost since + * they happen on the same host as the dashboard process. + * + * @param port - the local dashboard port; only used in the localhost fallback + */ +export function dashboardUrl(port: number): string { + const publicUrl = process.env.AO_PUBLIC_URL?.trim(); + if (publicUrl) { + return publicUrl.replace(/\/+$/, ""); + } + return `http://localhost:${port}`; +} diff --git a/packages/cli/src/lib/routes.ts b/packages/cli/src/lib/routes.ts index 959b76424..1aab21280 100644 --- a/packages/cli/src/lib/routes.ts +++ b/packages/cli/src/lib/routes.ts @@ -1,3 +1,5 @@ +import { dashboardUrl } from "./dashboard-url.js"; + export function projectSessionUrl(port: number, projectId: string, sessionId: string): string { - return `http://localhost:${port}/projects/${encodeURIComponent(projectId)}/sessions/${encodeURIComponent(sessionId)}`; + return `${dashboardUrl(port)}/projects/${encodeURIComponent(projectId)}/sessions/${encodeURIComponent(sessionId)}`; } diff --git a/packages/web/server/__tests__/direct-terminal-ws.integration.test.ts b/packages/web/server/__tests__/direct-terminal-ws.integration.test.ts index 39f2caaa6..236a0f84e 100644 --- a/packages/web/server/__tests__/direct-terminal-ws.integration.test.ts +++ b/packages/web/server/__tests__/direct-terminal-ws.integration.test.ts @@ -180,6 +180,19 @@ describeWithTmux("WebSocket upgrade routing", () => { ws.close(); }); + it("accepts connections on /ao-terminal-mux (alias for /mux)", async () => { + // The dashboard's MuxProvider uses this path on standard-port deployments + // so a path-routing reverse proxy can forward it here without a rewrite. + const ws = await new Promise((resolve, reject) => { + const sock = new WebSocket(`ws://localhost:${port}/ao-terminal-mux`); + sock.on("open", () => resolve(sock)); + sock.on("error", reject); + setTimeout(() => reject(new Error("WebSocket connect timeout")), 5000); + }); + expect(ws.readyState).toBe(WebSocket.OPEN); + ws.close(); + }); + it("destroys connections on unknown paths", async () => { const result = await new Promise<{ code: number }>((resolve) => { const ws = new WebSocket(`ws://localhost:${port}/ws`); diff --git a/packages/web/server/direct-terminal-ws.ts b/packages/web/server/direct-terminal-ws.ts index 99f028b1e..b54ff73c9 100644 --- a/packages/web/server/direct-terminal-ws.ts +++ b/packages/web/server/direct-terminal-ws.ts @@ -61,10 +61,15 @@ export function createDirectTerminalServer(tmuxPath?: string | null): DirectTerm // Manual upgrade routing — ws library doesn't support multiple WebSocketServer // instances with different `path` options on the same HTTP server. + // `/ao-terminal-mux` is accepted as an alias of `/mux` so deployments fronted + // by a path-routing reverse proxy (e.g. cloudflared, nginx) can forward the + // dashboard's path-based mux URL straight at this port without needing a + // path-rewrite rule. The dashboard's MuxProvider already constructs that + // path when accessed on a standard HTTPS port; see `packages/web/src/providers/MuxProvider.tsx`. server.on("upgrade", (request, socket, head) => { const pathname = new URL(request.url ?? "/", "ws://localhost").pathname; - if (pathname === "/mux" && muxWss) { + if ((pathname === "/mux" || pathname === "/ao-terminal-mux") && muxWss) { muxWss.handleUpgrade(request, socket, head, (ws) => { muxWss!.emit("connection", ws, request); }); diff --git a/packages/web/server/single-port-server.ts b/packages/web/server/single-port-server.ts new file mode 100644 index 000000000..7fee20924 --- /dev/null +++ b/packages/web/server/single-port-server.ts @@ -0,0 +1,167 @@ +/** + * Single-port server (opt-in) — a thin HTTP + WebSocket proxy that puts + * Next.js and the `/ao-terminal-mux` WebSocket upgrade on the same public + * port. Spawned by start-all.ts when AO_PATH_BASED_MUX=1, in front of a + * Next.js process that has shifted to an internal port. + * + * ┌──────────────────────┐ HTTP ┌──────────────────────┐ + * │ proxy on PORT │───────▶│ next start │ + * │ (this file) │ │ on NEXT_INTERNAL_PORT │ + * │ │ └──────────────────────┘ + * │ │ WS upgrade /ao-terminal-mux + * │ │───────▶┌──────────────────────┐ + * │ │ │ direct-terminal-ws │ + * │ │ │ on DIRECT_TERMINAL │ + * │ │ └──────────────────────┘ + * └──────────────────────┘ + * + * The default flow (AO_PATH_BASED_MUX unset) is unchanged: Next.js runs on + * PORT directly, direct-terminal-ws runs on DIRECT_TERMINAL_PORT, and the + * dashboard JS picks one of three URLs at connection time + * (see `packages/web/src/providers/MuxProvider.tsx`): + * + * 1. proxyWsPath (TERMINAL_WS_PATH) — explicit path-based routing + * 2. standard port (loc.port "" / 443 / 80) — `/ao-terminal-mux` on same host + * 3. fallback — direct connection to `:DIRECT_TERMINAL_PORT/mux` + * + * Path #1 and #3 require the operator to do something at the proxy layer + * (path rewrite or per-port routing). Path #2 only works if *something* is + * listening for the `/ao-terminal-mux` upgrade on the dashboard port. Until + * now, nothing was — Next.js doesn't handle upgrades, so the request fell + * through to its 404 handler. This server is that something. + * + * Use this when the reverse proxy in front of AO can only forward one + * hostname:port pair upstream (e.g. Cloudflare Tunnel pointed at one + * `service:` URL with no path-based ingress). With this enabled, a single + * proxy rule pointing at PORT is sufficient — the WS path is multiplexed + * onto the same TCP port and demuxed here. + */ + +import { createServer, request as httpRequest, type IncomingMessage } from "node:http"; +import type { Socket } from "node:net"; + +const MUX_PATH = "/ao-terminal-mux"; +const SHUTDOWN_TIMEOUT_MS = 5_000; + +const port = parseInt(process.env.PORT ?? "3000", 10); +const directTerminalPort = parseInt(process.env.DIRECT_TERMINAL_PORT ?? "14801", 10); +const nextInternalPort = parseInt(process.env.NEXT_INTERNAL_PORT ?? "0", 10); + +if (!Number.isInteger(port) || port < 1 || port > 65_535) { + console.error(`[single-port] Invalid PORT: ${process.env.PORT}`); + process.exit(1); +} +if (!Number.isInteger(directTerminalPort) || directTerminalPort < 1 || directTerminalPort > 65_535) { + console.error(`[single-port] Invalid DIRECT_TERMINAL_PORT: ${process.env.DIRECT_TERMINAL_PORT}`); + process.exit(1); +} +if ( + !Number.isInteger(nextInternalPort) || + nextInternalPort < 1 || + nextInternalPort > 65_535 || + nextInternalPort === port +) { + console.error( + `[single-port] Invalid NEXT_INTERNAL_PORT (must differ from PORT): ${process.env.NEXT_INTERNAL_PORT}`, + ); + process.exit(1); +} + +const server = createServer((req, res) => { + const proxyReq = httpRequest( + { + host: "127.0.0.1", + port: nextInternalPort, + method: req.method, + path: req.url, + headers: req.headers, + }, + (proxyRes) => { + res.writeHead(proxyRes.statusCode ?? 502, proxyRes.headers); + proxyRes.pipe(res); + }, + ); + + proxyReq.on("error", (err) => { + if (!res.headersSent) { + res.writeHead(502, { "content-type": "text/plain" }); + } + res.end(`Bad gateway: ${err.message}`); + }); + + req.pipe(proxyReq); +}); + +server.on("upgrade", (req, socket, head) => { + const pathname = new URL(req.url ?? "/", "http://localhost").pathname; + const target = + pathname === MUX_PATH + ? { host: "127.0.0.1", port: directTerminalPort, path: "/mux" } + : { host: "127.0.0.1", port: nextInternalPort, path: req.url ?? "/" }; + + tunnelUpgrade(req, socket as Socket, head, target); +}); + +function tunnelUpgrade( + req: IncomingMessage, + clientSocket: Socket, + clientHead: Buffer, + target: { host: string; port: number; path: string }, +): void { + const proxyReq = httpRequest({ + host: target.host, + port: target.port, + method: "GET", + path: target.path, + headers: req.headers, + }); + + proxyReq.on("upgrade", (proxyRes, proxySocket, proxyHead) => { + const lines = [ + `HTTP/1.1 ${proxyRes.statusCode ?? 101} ${proxyRes.statusMessage ?? "Switching Protocols"}`, + ]; + for (const [key, value] of Object.entries(proxyRes.headers)) { + if (value === undefined) continue; + lines.push(`${key}: ${Array.isArray(value) ? value.join(", ") : String(value)}`); + } + lines.push("\r\n"); + clientSocket.write(lines.join("\r\n")); + + if (proxyHead.length > 0) clientSocket.write(proxyHead); + if (clientHead.length > 0) proxySocket.write(clientHead); + + clientSocket.pipe(proxySocket); + proxySocket.pipe(clientSocket); + + const teardown = (): void => { + clientSocket.destroy(); + proxySocket.destroy(); + }; + proxySocket.on("error", teardown); + proxySocket.on("close", teardown); + clientSocket.on("error", teardown); + clientSocket.on("close", teardown); + }); + + proxyReq.on("error", (err) => { + console.error( + `[single-port] upstream upgrade error (${target.host}:${target.port}${target.path}): ${err.message}`, + ); + clientSocket.destroy(); + }); + + proxyReq.end(); +} + +server.listen(port, () => { + console.log( + `[single-port] listening on ${port}; HTTP → 127.0.0.1:${nextInternalPort}; ${MUX_PATH} → 127.0.0.1:${directTerminalPort}/mux`, + ); +}); + +function shutdown(): void { + server.close(() => process.exit(0)); + setTimeout(() => process.exit(1), SHUTDOWN_TIMEOUT_MS).unref(); +} +process.on("SIGINT", shutdown); +process.on("SIGTERM", shutdown); diff --git a/packages/web/server/start-all.ts b/packages/web/server/start-all.ts index dd3c3dbaf..6deaecfca 100644 --- a/packages/web/server/start-all.ts +++ b/packages/web/server/start-all.ts @@ -101,14 +101,32 @@ function resolveNextBin(): string { // Start Next.js production server const port = process.env["PORT"] || "3000"; +const pathBasedMux = process.env["AO_PATH_BASED_MUX"] === "1"; + +// When AO_PATH_BASED_MUX=1, single-port-server.js owns PORT and Next.js is +// shifted to PORT + 1000 (overridable via NEXT_INTERNAL_PORT). The proxy +// forwards HTTP to Next.js and tunnels `/ao-terminal-mux` WS upgrades to +// direct-terminal-ws. Default off — Next.js stays on PORT directly. +const NEXT_INTERNAL_OFFSET = 1000; +const nextPort = pathBasedMux + ? (process.env["NEXT_INTERNAL_PORT"] ?? String(parseInt(port, 10) + NEXT_INTERNAL_OFFSET)) + : port; + const nextBin = resolveNextBin(); if (process.platform === "win32" && nextBin !== "next") { // On Windows, run the JS entry point via the current node binary. // spawn() can't execute .js files directly on Windows. - spawnProcess("next", process.execPath, [nextBin, "start", "-p", port]); + spawnProcess("next", process.execPath, [nextBin, "start", "-p", nextPort]); } else { - spawnProcess("next", nextBin, ["start", "-p", port]); + spawnProcess("next", nextBin, ["start", "-p", nextPort]); +} + +if (pathBasedMux) { + // Surface the internal port to the child so it doesn't have to re-derive + // the offset; pin it explicitly. + process.env["NEXT_INTERNAL_PORT"] = nextPort; + spawnProcess("single-port", process.execPath, [resolve(__dirname, "single-port-server.js")]); } // Start direct terminal WebSocket server (auto-restart on crash)