feat(cli): support AO_PUBLIC_URL for reverse-proxied dashboards#1757
feat(cli): support AO_PUBLIC_URL for reverse-proxied dashboards#1757thapecroth wants to merge 4 commits intoComposioHQ:mainfrom
Conversation
When AO runs inside a remote dev container or behind a reverse proxy
(Caddy/nginx/Traefik), `http://localhost:${port}` was hardcoded across
the CLI for console output, `ao open` browser launches, and the
session URLs surfaced to the orchestrator agent. None of those URLs
were reachable from outside the host.
Add an `AO_PUBLIC_URL` env var. When set, the new `dashboardUrl(port)`
helper returns it (with trailing slashes stripped) instead of the
localhost fallback. The helper replaces every user-facing
`http://localhost:${port}` literal in:
- `commands/dashboard.ts` — startup banner + browser open
- `commands/start.ts` — 12 spots: spinner, "Dashboard:" prints,
orchestrator URL fallback, `openUrl()` calls, and the running-state
reuse paths
- `lib/routes.ts` — `projectSessionUrl()` (used in the orchestrator
prompt template, so worker links land on the public hostname)
Internal IPC (`lib/daemon.ts` calling its own dashboard's
`/api/projects/reload`) is intentionally left on localhost — that
traffic never leaves the host, and routing it through a public URL
would just add latency and a failure surface.
Tests cover the env-var/localhost paths, whitespace trimming,
trailing-slash stripping, sub-path preservation, and non-default-port
URLs (`__tests__/lib/dashboard-url.test.ts`, 10 cases).
Setup guide gets a new "Public dashboard URL" entry under optional
env vars.
Greptile SummaryThis PR introduces
Confidence Score: 5/5Safe to merge — the core dashboardUrl() change is a straightforward env-var substitution with good test coverage; the new single-port proxy is opt-in and isolated behind AO_PATH_BASED_MUX=1. The dashboardUrl() helper and all its call-site migrations are correct and well-tested. The opt-in single-port proxy works but has proxy hygiene gaps (hop-by-hop headers forwarded verbatim, no X-Forwarded-* injection, no response-event handler for non-101 upstream replies); none of these affect the default flow or block functionality for the targeted use case. packages/web/server/single-port-server.ts — the new HTTP+WS proxy has proxy hygiene issues worth addressing before wider adoption.
|
| Filename | Overview |
|---|---|
| packages/cli/src/lib/dashboard-url.ts | New helper that reads AO_PUBLIC_URL with trim + trailing-slash strip; clean, well-commented, fully tested. |
| packages/cli/src/commands/start.ts | 15 localhost URL literals replaced with dashboardUrl(); all code paths covered correctly. |
| packages/web/server/single-port-server.ts | New opt-in HTTP+WS proxy; forwards hop-by-hop headers verbatim, omits X-Forwarded-* headers, and has no response-event handler in tunnelUpgrade for non-101 replies. |
| packages/web/server/start-all.ts | Adds pathBasedMux branching; NEXT_INTERNAL_PORT is pinned in env before the single-port child is launched — ordering is correct. |
| packages/web/server/direct-terminal-ws.ts | Adds /ao-terminal-mux as an alias for /mux; well-tested in the accompanying integration test. |
| packages/cli/tests/lib/dashboard-url.test.ts | 10 unit tests cover all dashboardUrl() edge cases comprehensively. |
Flowchart
%%{init: {'theme': 'neutral'}}%%
flowchart TD
ENV{AO_PUBLIC_URL set?}
ENV -->|yes| PUB["Public URL"]
ENV -->|no| LOC["http://localhost:port"]
PUB --> OUT["Console output / browser open / projectSessionUrl"]
LOC --> OUT
subgraph Default
C1["Client"] -->|HTTP| N1["Next.js on PORT"]
C1 -->|WS| D1["direct-terminal-ws"]
end
subgraph PathBasedMux["AO_PATH_BASED_MUX mode"]
C2["Client"] --> SPX["single-port-server on PORT"]
SPX -->|HTTP| N2["Next.js on PORT+1000"]
SPX -->|WS ao-terminal-mux| D2["direct-terminal-ws /mux"]
end
Prompt To Fix All With AI
Fix the following 3 code review issues. Work through them one at a time, proposing concise fixes.
---
### Issue 1 of 3
packages/web/server/single-port-server.ts:70-92
**Missing X-Forwarded-* headers in HTTP proxy**
The HTTP proxy forwards `req.headers` verbatim to Next.js but never injects `X-Forwarded-For`, `X-Forwarded-Proto`, or `X-Forwarded-Host`. If the operator sets `AO_PUBLIC_URL=https://ao.example.com` and enables `AO_PATH_BASED_MUX=1`, Next.js will see every request as originating from `127.0.0.1` over plain HTTP, breaking any server-side logic that depends on the real client IP (e.g. rate limiting) or the `HTTPS` protocol flag (e.g. `next/headers` detecting HTTPS for `Set-Cookie: Secure`).
### Issue 2 of 3
packages/web/server/single-port-server.ts:105-153
**Unhandled response event on non-101 upstream replies may leak the client socket**
`tunnelUpgrade` only listens for the `upgrade` event on `proxyReq`. If the upstream returns a plain HTTP response — e.g., a 404, 403, or `426 Upgrade Required` — Node.js fires a `response` event instead. With no `response` handler, the response body is never consumed and the client socket is never closed, leaving it hanging until OS timeout.
### Issue 3 of 3
packages/web/server/single-port-server.ts:72-78
**Hop-by-hop headers forwarded to Next.js**
The proxy copies `req.headers` directly including `Connection`, `Keep-Alive`, and `Transfer-Encoding` — all hop-by-hop headers that must not be forwarded per RFC 7230 §6.1. Forwarding `Connection: upgrade` to Next.js for a plain HTTP request may confuse its internal connection handling.
```suggestion
{
host: "127.0.0.1",
port: nextInternalPort,
method: req.method,
path: req.url,
headers: {
...req.headers,
connection: undefined,
"keep-alive": undefined,
"transfer-encoding": undefined,
"proxy-authorization": undefined,
"x-forwarded-for": req.socket.remoteAddress,
"x-forwarded-proto": "https",
"x-forwarded-host": req.headers.host,
},
},
```
Reviews (4): Last reviewed commit: "feat(web): opt-in single-port mode (AO_P..." | Re-trigger Greptile
…L setup The AO_PUBLIC_URL entry only mentioned terminal ports needing to be reachable, which over-specifies what's required when fronting AO with HTTPS through a reverse proxy. The dashboard's MuxProvider already auto-detects standard ports (`loc.port === ""`/`"443"`/`"80"`) and routes the mux WebSocket through `/ao-terminal-mux` on the same hostname, so a single proxy rule pointing at the dashboard port is sufficient — no extra subdomain or port forwarding for the WS. For non-standard ports or custom paths, document the existing but previously-undiscoverable `TERMINAL_WS_PATH` env var (read by `/api/runtime/terminal/route.ts` and threaded through `MuxProvider` as `proxyWsPath`). Adds a minimal Caddy snippet so users have a working starting point.
|
Hey @thapecroth , can you file an issue for this aswell. |
…al-ws The dashboard's MuxProvider already constructs `wss://hostname/ao-terminal-mux` when accessed on a standard HTTPS port (443), but until now nothing on the server side recognized that path — direct-terminal-ws only matched `/mux`, and the Next.js dashboard doesn't handle WS upgrades at all. Deployments fronted by a path-routing reverse proxy (cloudflared, nginx, Caddy, …) hit the server at `/ao-terminal-mux`, fall through to Next.js, get a 404, and the dashboard's terminal panes hang at "Connecting…" forever. Fix is one line in the upgrade-routing allow-list: accept `/ao-terminal-mux` in addition to `/mux`. The proxy can now route the path-based mux URL straight at DIRECT_TERMINAL_PORT without needing a path-rewrite rule (which most proxies — including cloudflared — don't natively support). Existing `/mux` clients continue to work; the alias is strictly additive. SETUP.md's AO_PUBLIC_URL section is updated to mention the path requirement in one sentence, and a new integration test pins the behavior.
… deployments
Default behavior unchanged. When AO_PATH_BASED_MUX=1, start-all spawns a
small bundled HTTP/WS proxy on PORT that demultiplexes:
- HTTP requests forwarded to Next.js (shifted to PORT + 1000;
override with NEXT_INTERNAL_PORT)
- `wss://hostname/ao-terminal-mux` upgrades tunneled to
DIRECT_TERMINAL_PORT/mux
Use it when the reverse proxy in front of AO can only forward one
hostname:port pair upstream (e.g. Cloudflare Tunnel pointed at a single
`service:` URL with no path-based ingress, or a managed-app platform
where you don't control the proxy config). One proxy rule then
suffices — the WS path is multiplexed onto the same TCP port and
demuxed inside the AO process.
Tradeoff: one extra Node process and one extra hop per HTTP request,
in exchange for proxy-config simplicity. For deployments that *can*
do path-based routing the alias added in the previous commit
(direct-terminal-ws accepting `/ao-terminal-mux` on its own port) is
the lower-overhead path.
The new server is pure Node http; no `next` import or other extra
dependencies. It's strictly opt-in — the env-var gate keeps the code
inert by default, so existing deployments see no behavior change and
no extra startup cost.
|
@Priyanchew done — split into three focused issues, one per problem the PR addresses:
The PR body now opens with three |
Closes #1794
Closes #1795
Closes #1796
Summary
Three changes that together make AO usable behind a reverse proxy with a public hostname (e.g. when running on a remote dev container, VPS, or anywhere the operator's browser and the AO process are on different machines).
AO_PUBLIC_URLenv var (closes Make dashboard URL configurable for reverse-proxied deployments (AO_PUBLIC_URL) #1794) —http://localhost:${port}was hardcoded acrosscommands/dashboard.ts,commands/start.ts, andlib/routes.tsfor console output,ao openbrowser launches, and the session URLs surfaced to the orchestrator agent. None of those were reachable from outside the host. A newdashboardUrl(port)helper returnsprocess.env.AO_PUBLIC_URL(whitespace-trimmed, trailing slashes stripped) when set, falling back to localhost. Every user-facing call site goes through it. Internal IPC (daemon.ts → /api/projects/reload) is intentionally left on localhost — same-host call, no reason to bounce through DNS/TLS/proxy./ao-terminal-muxpath alias ondirect-terminal-ws(closes Dashboard's path-based mux URL hits a 404: '/ao-terminal-mux' has no upgrade handler #1795) —MuxProvider.tsxalready constructswss://hostname/ao-terminal-muxfor standard-port deployments, but no server-side handler matched. Adding/ao-terminal-muxto the upgrade allow-list lets path-capable proxies (cloudflared with path-based ingress, nginx, Caddy) route the URL toDIRECT_TERMINAL_PORTdirectly — no path-rewrite rule required. Strictly additive: existing/muxconnections keep working.AO_PATH_BASED_MUX=1opt-in single-port mode (closes Single-port deployments can't serve dashboard + mux through one upstream proxy #1796) — for proxies that can only forward one upstreamservice:(dashboard-managed Cloudflare Tunnel, Fly.io/Render/Railway, simplecaddy reverse-proxy --to, etc.), the path-based approach in feat: implement runtime and workspace plugins (tmux, process, worktree, clone) #2 isn't reachable because the proxy can't fan out to a second port. Adds a small bundled HTTP/WS proxy onPORTthat demultiplexes HTTP → Next.js (shifted toPORT + 1000) andwss://.../ao-terminal-mux→DIRECT_TERMINAL_PORT/mux. Default off; one extra Node process and one HTTP hop per request when on.Caveats called out in the docs
Terminal.tsx(the legacy ttyd iframe) still hardcodes${hostname}:${TERMINAL_PORT}— left alone because it's only referenced from a dev-only test page (/dev/terminal-test); the production terminal isDirectTerminal.tsxviaMuxProvider, which is covered.TERMINAL_WS_PATHexisted in code but wasn't documented; SETUP.md now describes it alongsideAO_PUBLIC_URLso users have the full reverse-proxy story in one place.Test plan
pnpm build— clean across all packagespnpm test—__tests__/lib/dashboard-url.test.tscovers env-var/localhost paths, whitespace trimming, trailing-slash stripping, sub-path preservation, non-default-port URLs (10 cases).direct-terminal-ws.integration.test.tsgets a new test pinning the/ao-terminal-muxalias.pnpm typecheck— cleanpnpm lint— 0 errors (50 pre-existing warnings unchanged)single-port-server.tsin isolation: HTTP forwarding returns 502 when upstream Next.js isn't running, WS upgrade attempts return ECONNREFUSED when direct-terminal-ws isn't running, both behaviors are correct.Files changed
🤖 Generated with Claude Code