Skip to content

feat(cli): support AO_PUBLIC_URL for reverse-proxied dashboards#1757

Open
thapecroth wants to merge 4 commits intoComposioHQ:mainfrom
thapecroth:feat/public-url-env
Open

feat(cli): support AO_PUBLIC_URL for reverse-proxied dashboards#1757
thapecroth wants to merge 4 commits intoComposioHQ:mainfrom
thapecroth:feat/public-url-env

Conversation

@thapecroth
Copy link
Copy Markdown

@thapecroth thapecroth commented May 9, 2026

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).

  1. AO_PUBLIC_URL env var (closes Make dashboard URL configurable for reverse-proxied deployments (AO_PUBLIC_URL) #1794) — http://localhost:${port} was hardcoded across commands/dashboard.ts, commands/start.ts, and lib/routes.ts for console output, ao open browser launches, and the session URLs surfaced to the orchestrator agent. None of those were reachable from outside the host. A new dashboardUrl(port) helper returns process.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.
  2. /ao-terminal-mux path alias on direct-terminal-ws (closes Dashboard's path-based mux URL hits a 404: '/ao-terminal-mux' has no upgrade handler #1795) — MuxProvider.tsx already constructs wss://hostname/ao-terminal-mux for standard-port deployments, but no server-side handler matched. Adding /ao-terminal-mux to the upgrade allow-list lets path-capable proxies (cloudflared with path-based ingress, nginx, Caddy) route the URL to DIRECT_TERMINAL_PORT directly — no path-rewrite rule required. Strictly additive: existing /mux connections keep working.
  3. AO_PATH_BASED_MUX=1 opt-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 upstream service: (dashboard-managed Cloudflare Tunnel, Fly.io/Render/Railway, simple caddy 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 on PORT that demultiplexes HTTP → Next.js (shifted to PORT + 1000) and wss://.../ao-terminal-muxDIRECT_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 is DirectTerminal.tsx via MuxProvider, which is covered.
  • TERMINAL_WS_PATH existed in code but wasn't documented; SETUP.md now describes it alongside AO_PUBLIC_URL so users have the full reverse-proxy story in one place.

Test plan

  • pnpm build — clean across all packages
  • pnpm test__tests__/lib/dashboard-url.test.ts covers env-var/localhost paths, whitespace trimming, trailing-slash stripping, sub-path preservation, non-default-port URLs (10 cases). direct-terminal-ws.integration.test.ts gets a new test pinning the /ao-terminal-mux alias.
  • pnpm typecheck — clean
  • pnpm lint — 0 errors (50 pre-existing warnings unchanged)
  • Smoke test of single-port-server.ts in 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

.changeset/dashboard-public-url.md                   (new)
SETUP.md                                             (env-var docs)
packages/cli/src/lib/dashboard-url.ts                (new helper)
packages/cli/src/lib/routes.ts                       (use helper)
packages/cli/src/commands/dashboard.ts               (use helper, 2 spots)
packages/cli/src/commands/start.ts                   (use helper, 15 spots)
packages/cli/__tests__/lib/dashboard-url.test.ts     (new tests)
packages/web/server/direct-terminal-ws.ts            (accept /ao-terminal-mux)
packages/web/server/__tests__/direct-terminal-ws.integration.test.ts  (alias test)
packages/web/server/single-port-server.ts            (new, opt-in proxy)
packages/web/server/start-all.ts                     (conditional spawn)

🤖 Generated with Claude Code

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-apps
Copy link
Copy Markdown
Contributor

greptile-apps Bot commented May 9, 2026

Greptile Summary

This PR introduces AO_PUBLIC_URL to replace hardcoded http://localhost:<port> URLs in all user-facing CLI output, browser-open calls, and orchestrator session links. It also adds an opt-in AO_PATH_BASED_MUX=1 mode with a new thin HTTP+WebSocket proxy (single-port-server.ts) that multiplexes Next.js and the terminal mux WebSocket onto a single port.

  • dashboardUrl(port) helper (dashboard-url.ts) — reads, trims, and strips trailing slashes from AO_PUBLIC_URL; falls back to http://localhost:<port>. All 15+ call sites in start.ts/dashboard.ts/routes.ts are migrated. The helper is well-tested (10 unit cases).
  • /ao-terminal-mux alias (direct-terminal-ws.ts) — the WS upgrade handler now accepts /ao-terminal-mux as an alias for /mux so path-routing proxies can forward the path without a rewrite rule.
  • single-port-server.ts (opt-in, AO_PATH_BASED_MUX=1) — new Node.js HTTP+WS proxy that sits on PORT, forwards HTTP to an internally-shifted Next.js, and tunnels /ao-terminal-mux upgrades to direct-terminal-ws. The proxy currently forwards hop-by-hop headers verbatim and omits X-Forwarded-* headers; see inline comments for details.

Confidence Score: 5/5

Safe 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.

Important Files Changed

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
Loading
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.
@Priyanchew
Copy link
Copy Markdown
Collaborator

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.
@thapecroth
Copy link
Copy Markdown
Author

@Priyanchew done — split into three focused issues, one per problem the PR addresses:

The PR body now opens with three Closes #… lines and the description maps each commit to the issue it addresses. Each issue also links back here.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants