Visitar URL original
Make dashboard work from proxied origins via same-origin proxy by ThomasK33 · Pull Request #1111 · vercel-labs/agent-browser · GitHub
Skip to content

Make dashboard work from proxied origins via same-origin proxy - #1111

Merged
ctate merged 4 commits into
vercel-labs:mainfrom
coder:dashboard-frontend-j62a
May 7, 2026
Merged

ctate merged 4 commits into
vercel-labs:mainfrom
coder:dashboard-frontend-j62a

Conversation

@ThomasK33

Copy link
Copy Markdown
Contributor

Summary

  • make the dashboard work from forwarded and proxied origins without exposing per-session localhost ports
  • add validated same-origin HTTP and WebSocket proxy routes for per-session tabs, status, and stream traffic
  • harden the proxy by removing CORS from proxy responses, enforcing WebSocket Origin-vs-Host checks, and adding timeout and response size limits
  • update frontend routing, docs, and Portless guidance, with tests for route parsing and proxy security edge cases

Validation

  • cargo test --manifest-path cli/Cargo.toml -- stream::tests
  • cargo build --release --manifest-path cli/Cargo.toml
  • cargo fmt --manifest-path cli/Cargo.toml -- --check
  • cargo clippy --manifest-path cli/Cargo.toml -- -D warnings
  • pnpm --dir packages/dashboard build
  • manual Portless dogfooding via http://dashboard.localhost:1355

📋 Implementation Plan

Plan: make the dashboard work from a forwarded / proxied workspace URL

Goal

Make agent-browser dashboard work when the UI is opened through a forwarded HTTPS workspace URL (for example, a Coder app URL) without requiring the browser to reach raw localhost ports. Preserve existing local http://localhost:4848 behavior, keep the session stream servers loopback-only, and fix the dashboard in a way that is robust for any same-origin reverse-proxy deployment.

Context and verified evidence

  • The frontend currently hardcodes dashboard API calls to http://localhost:4848 in packages/dashboard/src/lib/exec.ts.
  • packages/dashboard/src/store/sessions.ts only uses relative /api/sessions when window.location.origin literally contains :4848; otherwise it falls back to http://localhost:4848/api/sessions.
  • packages/dashboard/src/store/sessions.ts polls tabs from http://localhost:${s.port}/api/tabs.
  • packages/dashboard/src/store/stream.ts opens the live stream with new WebSocket(ws://localhost:${port}).
  • The standalone dashboard server in cli/src/native/stream.rs currently exposes static files plus /api/sessions, /api/exec, and /api/kill; it does not proxy per-session /api/tabs or WebSocket traffic.
  • The per-session stream server in cli/src/native/stream.rs serves /api/tabs, /api/status, /api/command, and the WebSocket stream, and its is_allowed_origin() check only allows loopback/file origins today.
Why the fix should not be a new host flag or "hostname + :port" frontend logic

The browser-visible URL in forwarded environments is owned by the reverse proxy, not by the dashboard process. Building URLs like ${window.location.hostname}:4848 or ${window.location.hostname}:${sessionPort} is brittle and does not match wildcard-subdomain forwarding setups.

The safer and smaller fix is to make the browser talk only to the dashboard origin and let the dashboard server proxy to loopback-only session ports on the workspace VM/container.

Proposed design

Core decision

Move the dashboard to a single-origin browser model:

  1. The browser talks only to the dashboard origin:
    • /api/sessions
    • /api/exec
    • /api/kill
    • /api/session/<port>/tabs
    • ws(s)://<dashboard-origin>/api/session/<port>/stream
  2. The dashboard server proxies session-specific HTTP and WebSocket traffic to 127.0.0.1:<session-port>.
  3. Session stream servers remain loopback-only and keep their stricter origin policy because browsers no longer connect to them directly.

This removes all browser-side localhost assumptions and avoids widening the per-session WebSocket trust boundary.

Workstreams

1) Backend: add same-origin proxy routes on the dashboard server

Primary file: cli/src/native/stream.rs

Add explicit dashboard-only proxy routes inside handle_dashboard_connection() for session traffic:

  • GET /api/session/<port>/tabs
  • GET /api/session/<port>/status (optional but cheap to add while route parsing exists)
  • WS /api/session/<port>/stream

Implementation notes:

  • Add a small route parser/helper instead of stringly-typed path slicing all over the handler.
  • Validate that <port> parses as a u16, is non-zero, and belongs to a currently active session before proxying.
  • Reuse discover_sessions() data or add a small helper that returns a validated active-port set/map.
  • For HTTP proxying, connect to 127.0.0.1:<port> and relay the minimal request needed (initially GET /api/tabs and optionally GET /api/status).
  • For WebSocket proxying, accept a browser WebSocket on the dashboard server, open an upstream WebSocket to ws://127.0.0.1:<port>, and pump frames/messages in both directions.
  • Keep the per-session is_allowed_origin() behavior intact; the dashboard proxy should connect server-to-server, so the browser never hits the per-session WS endpoint directly.

Defensive programming requirements:

  • Add assertions / explicit validation around parsed ports and route shape.
  • Reject stale or unknown ports with a clear 404/400 JSON response rather than forwarding blindly.
  • Document any assumptions in helper comments near the route parser / proxy entrypoints.

Quality gate after this workstream

  • Add focused Rust unit tests for route parsing and active-port validation in cli/src/native/stream.rs.
  • Add at least one test for rejecting malformed or inactive ports.
  • Manually verify a local GET /api/session/<port>/tabs response before changing the frontend.

2) Frontend: remove functional localhost usage and centralize route building

Primary files:

  • packages/dashboard/src/lib/exec.ts
  • packages/dashboard/src/store/sessions.ts
  • packages/dashboard/src/store/stream.ts
  • packages/dashboard/src/components/viewport.tsx
  • new helper, e.g. packages/dashboard/src/lib/dashboard-routes.ts

Refactor the frontend so functional traffic always targets the current dashboard origin:

  • Create a small shared helper module for route construction instead of repeating URL logic inline.
  • Convert dashboard API calls to relative paths:
    • /api/sessions
    • /api/exec
    • /api/kill
  • Convert per-session tab polling to /api/session/<port>/tabs.
  • Convert the stream connection to ws:/wss: based on window.location.protocol, pointing at /api/session/<port>/stream on window.location.host.
  • Update the viewport/status label so it shows the same-origin stream endpoint, not ws://localhost:<port>.

Recommended helper surface:

getDashboardApiPath(path: string): string
getSessionTabsPath(port: number): string
getSessionStreamUrl(port: number): string

Keep the helper intentionally dumb and explicit; do not reintroduce environment guessing or host/port rewriting.

Active-session selection cleanup (small but worthwhile)

packages/dashboard/src/store/sessions.ts currently defaults the active port to 9223, which causes early connection attempts to an arbitrary port before session discovery completes.

Fix this as part of the refactor by making the initial active port invalid/empty until either:

  • ?port=<n> is provided, or
  • the first discovered session is selected.

Minimal implementation options:

  • use 0 as the sentinel and guard useStreamSync() when port <= 0, or
  • make the atom nullable if the type churn is acceptable.

This keeps the browser from making noisy / misleading connection attempts during startup.

Quality gate after this workstream

  • pnpm --dir packages/dashboard build
  • Search the functional frontend code for remaining http://localhost: / ws://localhost: callsites and remove them unless they are non-functional examples/tests.
  • Smoke-test the dashboard locally before moving on to docs.

3) Security and compatibility hardening

Primary file: cli/src/native/stream.rs

Because the dashboard can now be opened through a forwarded origin, do not expand trust by broadening the per-session origin whitelist. The proxy route design avoids that need.

Hardening tasks:

  • Reassess the current permissive dashboard CORS behavior (Access-Control-Allow-Origin: *). Same-origin usage means the dashboard does not need permissive cross-origin browser access for normal operation.
  • At minimum, do not make CORS/origin behavior more permissive while adding the proxy routes.
  • Prefer port validation against active sessions over blind localhost forwarding.
  • If the route surface grows, factor JSON response helpers to keep error handling consistent.

Quality gate after this workstream

  • Verify that a forwarded dashboard session works without widening is_allowed_origin().
  • Verify that invalid ports cannot be proxied.

4) Documentation and help text updates

Because this changes user-visible dashboard behavior, update all required docs/help surfaces called out in AGENTS.md.

Files to update

  • cli/src/output.rs
  • README.md
  • skills/agent-browser/SKILL.md
  • docs/src/app/observe/page.mdx
  • docs/src/app/commands/page.mdx (if the dashboard behavior text/examples need matching updates)
  • inline doc comments near the new proxy helpers / route builders

Doc changes should explain:

  • the dashboard now works from the local URL or a forwarded workspace URL
  • the browser talks only to the dashboard origin
  • session ports remain internal implementation details
  • agent-browser dashboard install still installs the static assets, but dogfooding local changes requires serving the locally built dashboard bundle instead of the released zip

Quality gate after this workstream

  • Review help text and docs for any remaining claim that the UI must directly call localhost:<session-port>.

Acceptance criteria

The change is done when all of the following are true:

  1. Opening the dashboard at http://localhost:4848 still works for local use.
  2. Opening the dashboard through the forwarded workspace URL loads without mixed-content or CORS failures.
  3. The frontend no longer makes functional requests from the browser to http://localhost:* or ws://localhost:*.
  4. Session discovery, session creation, session kill, and exec-driven UI actions work through same-origin dashboard endpoints.
  5. The live viewport connects through a same-origin ws:/wss: dashboard route and remains interactive.
  6. Session tab lists still populate for all visible sessions.
  7. Invalid or stale session ports are rejected by the dashboard proxy instead of being blindly forwarded.
  8. Documentation/help text reflects the new deployment model.

Dogfooding / self-verification plan

Environment setup

  1. Build the dashboard assets from the workspace copy of the repo:
    • pnpm build:dashboard
  2. Install the local built assets into the dashboard serve location used by the CLI during testing (do not rely on dashboard install, which fetches the released bundle).
  3. Build and run the CLI from the workspace checkout:
    • cargo build --manifest-path cli/Cargo.toml
    • start the dashboard from the built CLI on port 4848
  4. Start at least one browser session from the same checkout so the dashboard has a live stream source.
  5. If available, install Portless as a global external tool (do not add it to this repo and do not make the fix depend on it).

Portless-assisted rehearsal (recommended local preflight)

Use Portless as a local rehearsal layer before the real workspace test. The goal is to open the dashboard through a named host-based URL instead of localhost:4848, so we can catch the exact class of bugs that assume raw port URLs.

Suggested flow:

  1. Start the Portless proxy with HTTPS enabled.
  2. Register a static alias for the real dashboard server port (4848) under a stable name such as dashboard.
  3. Open the dashboard via the resulting Portless URL instead of http://localhost:4848.
  4. Run the same smoke checks as the forwarded-workspace scenario.

Example command sequence:

portless proxy start --https
portless alias dashboard 4848
portless list

Use the named Portless URL shown by portless list for the rehearsal run.

What this rehearsal should catch:

  • mixed-content failures from any lingering http://localhost:* fetches
  • secure-page WebSocket failures from any lingering ws://localhost:* usage
  • frontend assumptions that the visible origin must literally include :4848

Important guardrail:

  • If the implementation still requires exposing or aliasing per-session ports through Portless, the design is still wrong. Only the standalone dashboard origin should need exposure; session ports must stay behind same-origin dashboard proxy routes.

What Portless does not replace:

  • final dogfooding in the real forwarded workspace URL
  • proxy/header/cookie quirks specific to the actual workspace platform

Dogfood scenarios

Scenario A: local regression check

  • Open the dashboard at http://localhost:4848
  • Verify sessions appear, the viewport connects, tabs populate, and one interactive action works (for example: create tab, switch tab, or close tab)

Scenario B: Portless host-based URL rehearsal

  • Open the dashboard through the Portless HTTPS host-based URL rather than a raw localhost port URL
  • Verify DevTools shows same-origin /api/... requests and a same-origin wss: stream URL
  • Verify there are no mixed-content, CORS, or localhost connection errors
  • Verify the viewport streams, session selection works, and at least one action path using exec.ts succeeds

Scenario C: forwarded workspace URL (primary target)

  • Open the forwarded HTTPS URL for the dashboard port in the workspace environment
  • Verify the page loads cleanly
  • Verify DevTools shows same-origin /api/... requests and a same-origin ws:/wss: stream URL
  • Verify there are no mixed-content, CORS, or localhost connection errors
  • Verify the viewport streams, session selection works, and at least one action path using exec.ts succeeds

Scenario D: invalid-port handling

  • Attempt to hit a proxy route with a non-existent / stale port
  • Verify the dashboard returns a clean error and does not hang or forward blindly

Required review artifacts

Capture and attach all of the following during dogfooding:

  • Screenshot: dashboard loaded via the Portless host-based URL with a connected session visible
  • Screenshot: dashboard loaded via the forwarded workspace URL with a connected session visible
  • Screenshot: browser DevTools Network/Console showing same-origin API + WebSocket traffic and no mixed-content errors
  • Video recording: end-to-end flow through the Portless URL (open dashboard, session appears, viewport connects, one interactive action succeeds)
  • Video recording: end-to-end flow in the forwarded workspace environment (open dashboard, session appears, viewport connects, one interactive action succeeds)
  • Optional extra screenshot/video for local localhost:4848 regression proof

Validation commands

Run these before declaring success:

  • pnpm --dir packages/dashboard build
  • cargo test --manifest-path cli/Cargo.toml
  • any targeted test command added for the new proxy helpers/routes

Risks and watchouts

  • WebSocket relay is the highest-risk change. Keep the first version narrow: just proxy the existing stream channel; do not redesign the message format.
  • Local asset confusion: agent-browser dashboard install pulls the released dashboard bundle, so manual testing can accidentally exercise stale assets unless the locally built bundle is installed into the serve directory.
  • Session-port direct browsing: if the team still cares about opening the dashboard directly on a session stream port, explicitly regression-test that path or consciously document it as secondary behavior. The primary fix should optimize for the standalone dashboard origin.
  • Security drift: avoid “just allow more origins” as the fix. The proxy design is better because it preserves loopback-only trust around the per-session servers.

Recommended implementation order

  1. Add backend route parsing + validated HTTP proxy for tabs/status.
  2. Add backend WebSocket proxy for the stream path.
  3. Refactor frontend to a shared same-origin route helper and remove functional localhost usage.
  4. Clean up initial active-port selection so the UI does not guess 9223.
  5. Update docs/help text.
  6. Dogfood in both local and forwarded workspace environments with screenshots + video.

Generated with mux • Model: openai:gpt-5.4 • Thinking: xhigh

@vercel

vercel Bot commented Apr 1, 2026

Copy link
Copy Markdown
Contributor

@ThomasK33 is attempting to deploy a commit to the Vercel Labs Team on Vercel.

A member of the Team first needs to authorize it.

@ThomasK33
ThomasK33 force-pushed the dashboard-frontend-j62a branch from c8da69e to 38fc7c8 Compare April 3, 2026 00:36

@vercel vercel Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Additional Suggestion:

thread_local! storage for AGENTCORE_INFO and AGENTCORE_WS_HEADERS is unsound in the multi-threaded tokio runtime, causing get_agentcore_info() to return None after task migration across OS threads.

Fix on Vercel

@ThomasK33
ThomasK33 force-pushed the dashboard-frontend-j62a branch from 2a9355d to 5fda1b9 Compare April 3, 2026 02:51
@ThomasK33

Copy link
Copy Markdown
Contributor Author

Addressed the AgentCore review note in the latest push.

Changes:

  • replaced thread_local! AgentCore session/header storage with shared OnceLock<Mutex<...>> stores so state survives Tokio task migration across worker threads
  • added a cross-thread regression test covering AgentCore session info visibility
  • rebased the branch onto the latest vercel/main and reran fmt, clippy, targeted provider tests, and the dashboard build

Latest commit: fix: make agentcore session state thread-safe

@ctate

ctate commented Apr 4, 2026

Copy link
Copy Markdown
Collaborator

Great contribution @ThomasK33 — the single-origin proxy architecture is the right call and the security hardening is solid. One round of changes requested:

Portless URLs need updating. Portless defaults to HTTPS now and best practice is a root domain, so the canonical URL is https://dashboard.agent-browser.localhost (not http://dashboard.localhost:1355). Please:

  • Update test_same_origin_ws_request_proxied in stream.rs to use https://dashboard.agent-browser.localhost (no port)
  • Add a test for the HTTPS-without-port origin check — that's the real Portless path and isn't covered today
  • Show the concrete URL in README, SKILL.md, docs/commands/page.mdx, and docs/dashboard/page.mdx instead of just "such as a Portless URL"

@ThomasK33
ThomasK33 force-pushed the dashboard-frontend-j62a branch from 5fda1b9 to 80c8bff Compare April 4, 2026 22:34
@ThomasK33

Copy link
Copy Markdown
Contributor Author

Addressed the Portless URL review note in the latest push.

Changes:

  • updated test_same_origin_ws_request_proxied to use the canonical Portless URL https://dashboard.agent-browser.localhost with no port
  • added test_normalize_origin_authority_https_without_port to cover the HTTPS root-domain Portless path explicitly
  • updated README, SKILL.md, dashboard docs, commands docs, and dashboard help text to show the concrete Portless URL instead of the generic wording

@akarshghale

Copy link
Copy Markdown

I hope this will be pushed. @ctate

@ThomasK33
ThomasK33 force-pushed the dashboard-frontend-j62a branch 4 times, most recently from ead856b to 5eba900 Compare April 20, 2026 14:18
@ThomasK33
ThomasK33 force-pushed the dashboard-frontend-j62a branch from 5eba900 to 7cffce5 Compare April 21, 2026 14:23
@ctate

ctate commented Apr 21, 2026

Copy link
Copy Markdown
Collaborator

Thanks for the update @ThomasK33!

One thing: can you split out the AgentCore thread_local! → OnceLock<Mutex<...>> fix and the doctor cast fix into a separate PR? They're valid changes but unrelated to the proxy work.

@ThomasK33
ThomasK33 force-pushed the dashboard-frontend-j62a branch from 7cffce5 to af00414 Compare April 22, 2026 14:32
@akarshghale

Copy link
Copy Markdown

Can we get this merged already?

@ThomasK33
ThomasK33 force-pushed the dashboard-frontend-j62a branch 2 times, most recently from 5e2ef0b to 95b82f6 Compare May 6, 2026 07:25
@ctate

ctate commented May 6, 2026

Copy link
Copy Markdown
Collaborator

@ThomasK33 Thanks! Can you please look into these and sign your commit(s) before merge?

1. No origin check on HTTP proxy routes (/tabs, /status)
dashboard.rs — The /stream WebSocket endpoint validates is_same_origin_ws_request(), but the HTTP proxy routes at /api/session/<port>/tabs and /api/session/<port>/status skip origin checking entirely. Responses omit Access-Control-Allow-Origin which blocks cross-origin fetch() reads in browsers, but doesn't prevent non-browser clients or side-channel techniques (<img>, <script> tags) from hitting these endpoints.

2. Origin/Host mismatch on explicit default ports
dashboard.rs:145-155 — normalize_origin_authority strips default ports (via url::Url::port() returning None for 80/443), but the Host header is compared as-is. A proxy that sets Host: example.com:443 with Origin: https://example.com produces "example.com:443" != "example.com" → legitimate WebSocket upgrades get 403'd. Not exploitable, but could lock users out behind certain proxy configs.

@ThomasK33
ThomasK33 force-pushed the dashboard-frontend-j62a branch from 95b82f6 to 8d109d7 Compare May 7, 2026 07:34
ThomasK33 added 3 commits May 7, 2026 15:03
Change-Id: I36ffc3727ce44100121bc94a81510a5f009ee0bc
Signed-off-by: Thomas Kosiewski <tk@coder.com>
Change-Id: I80356f64d618dab9d07b610ba67def14539f98ac
Signed-off-by: Thomas Kosiewski <tk@coder.com>
Change-Id: Id0913c64e7a6f2cbbfc429ef03b34dae185d8487
Signed-off-by: Thomas Kosiewski <tk@coder.com>
@ThomasK33
ThomasK33 force-pushed the dashboard-frontend-j62a branch from 8d109d7 to b08223d Compare May 7, 2026 13:04
Change-Id: I792bc859a24cd47314bd46c94344ef3dfb7d6db5
Signed-off-by: Thomas Kosiewski <tk@coder.com>
@ThomasK33
ThomasK33 force-pushed the dashboard-frontend-j62a branch from b08223d to c4de7d7 Compare May 7, 2026 13:12
@ctate

ctate commented May 7, 2026

Copy link
Copy Markdown
Collaborator

Thank you @ThomasK33!

@ctate
ctate merged commit d33bdb3 into vercel-labs:main May 7, 2026
9 of 11 checks passed
@ThomasK33
ThomasK33 deleted the dashboard-frontend-j62a branch May 7, 2026 14:17
@ctate ctate mentioned this pull request May 7, 2026
leeguooooo pushed a commit to leeguooooo/chrome-use that referenced this pull request Sep 15, 2026
…l-labs#1111)

* Restore dashboard session proxy routes

Change-Id: I36ffc3727ce44100121bc94a81510a5f009ee0bc
Signed-off-by: Thomas Kosiewski <tk@coder.com>

* Port dashboard frontend and docs

Change-Id: I80356f64d618dab9d07b610ba67def14539f98ac
Signed-off-by: Thomas Kosiewski <tk@coder.com>

* docs: restore dashboard note in skill

Change-Id: Id0913c64e7a6f2cbbfc429ef03b34dae185d8487
Signed-off-by: Thomas Kosiewski <tk@coder.com>

* fix: tighten dashboard proxy same-origin checks

Change-Id: I792bc859a24cd47314bd46c94344ef3dfb7d6db5
Signed-off-by: Thomas Kosiewski <tk@coder.com>

---------

Signed-off-by: Thomas Kosiewski <tk@coder.com>
tahodev pushed a commit to tahodev/agent-browser that referenced this pull request Oct 20, 2026
…l-labs#1111)

* Restore dashboard session proxy routes

Change-Id: I36ffc3727ce44100121bc94a81510a5f009ee0bc
Signed-off-by: Thomas Kosiewski <tk@coder.com>

* Port dashboard frontend and docs

Change-Id: I80356f64d618dab9d07b610ba67def14539f98ac
Signed-off-by: Thomas Kosiewski <tk@coder.com>

* docs: restore dashboard note in skill

Change-Id: Id0913c64e7a6f2cbbfc429ef03b34dae185d8487
Signed-off-by: Thomas Kosiewski <tk@coder.com>

* fix: tighten dashboard proxy same-origin checks

Change-Id: I792bc859a24cd47314bd46c94344ef3dfb7d6db5
Signed-off-by: Thomas Kosiewski <tk@coder.com>

---------

Signed-off-by: Thomas Kosiewski <tk@coder.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants