Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/dashboard-public-url.md
Original file line number Diff line number Diff line change
@@ -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:<port>`. 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.
7 changes: 7 additions & 0 deletions SETUP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:<port>`
- 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)
Expand Down
68 changes: 68 additions & 0 deletions packages/cli/__tests__/lib/dashboard-url.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
5 changes: 3 additions & 2 deletions packages/cli/src/commands/dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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) => {
Expand Down
25 changes: 13 additions & 12 deletions packages/cli/src/commands/start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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"));
}

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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);
}

Expand Down Expand Up @@ -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)}`);
}
}

Expand Down Expand Up @@ -1331,7 +1332,7 @@ export function registerStart(program: Command): void {
// exit. Project-id args fall through to attach+spawn so
// automation can `ao start <id>` 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`);
Expand All @@ -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`);

Expand Down Expand Up @@ -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") {
Expand Down Expand Up @@ -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") {
Expand Down Expand Up @@ -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);
}
Expand Down
24 changes: 24 additions & 0 deletions packages/cli/src/lib/dashboard-url.ts
Original file line number Diff line number Diff line change
@@ -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}`;
}
4 changes: 3 additions & 1 deletion packages/cli/src/lib/routes.ts
Original file line number Diff line number Diff line change
@@ -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)}`;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<WebSocket>((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`);
Expand Down
7 changes: 6 additions & 1 deletion packages/web/server/direct-terminal-ws.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Expand Down
Loading