From f98a2b6bc73306b66eedf910b6c5e3cc0bebe4ef Mon Sep 17 00:00:00 2001 From: Felipenguim Date: Fri, 28 Aug 2026 13:32:14 -0300 Subject: [PATCH 1/3] adding acp proxy for coding agents in backend --- marimo/_server/main.py | 48 ++++++++++++++++++++++++++++++++++++++++- marimo/_server/start.py | 1 + 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/marimo/_server/main.py b/marimo/_server/main.py index 6dafde1ddce..7a706098f29 100644 --- a/marimo/_server/main.py +++ b/marimo/_server/main.py @@ -2,7 +2,7 @@ from __future__ import annotations from dataclasses import dataclass -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Final from starlette.applications import Starlette from starlette.exceptions import HTTPException @@ -23,6 +23,7 @@ ProxyMiddleware, SkewProtectionMiddleware, TimeoutMiddleware, + create_proxy_error_handler, ) from marimo._server.api.router import build_routes from marimo._server.errors import handle_error @@ -46,6 +47,17 @@ class LspPorts: copilot: int | None +# This must stay in sync with AGENT_CONFIG in +# frontend/src/components/chat/acp/state.ts +ACP_AGENT_PORTS: Final[dict[str, int]] = { + "claude": 3017, + "gemini": 3019, + "codex": 3021, + "opencode": 3023, + "cursor": 3025, +} + + # Create app def create_starlette_app( *, @@ -56,6 +68,7 @@ def create_starlette_app( enable_auth: bool = True, allow_origins: tuple[str, ...] | None = None, lsp_servers: list[LspServer] | None = None, + enable_acp_proxy: bool = False, skew_protection: bool = True, timeout: float | None = None, ) -> Starlette: @@ -109,6 +122,11 @@ def create_starlette_app( ) ) + if enable_acp_proxy: + final_middlewares.extend( + _create_acp_proxy_middleware(base_url=base_url) + ) + if middleware: final_middlewares.extend(middleware) @@ -145,3 +163,31 @@ def _create_lsps_proxy_middleware( ) for server in servers ) + + +def _create_acp_proxy_middleware(base_url: str) -> Iterator[Middleware]: + """Proxy the external ACP agents' websockets through the marimo server. + + The agents listen on fixed localhost ports, which aren't reachable from + the browser when marimo is served behind a reverse proxy. Proxying them + under `/acp/` keeps the connection same-origin, so it + only needs the port marimo is already served on. + """ + return ( + Middleware( + ProxyMiddleware, + proxy_path=f"{base_url}/acp/{agent_id}", + target_url=f"http://127.0.0.1:{port}", + path_rewrite=_rewrite_acp_path, + connection_error_handler=create_proxy_error_handler( + f"The {agent_id} agent is not running. " + "Start it with the command shown in the agent panel." + ), + ) + for agent_id, port in ACP_AGENT_PORTS.items() + ) + + +def _rewrite_acp_path(_path: str) -> str: + """ACP agents serve a single endpoint, regardless of the proxied path.""" + return "/message" diff --git a/marimo/_server/start.py b/marimo/_server/start.py index 5cca29565b7..8288dbc9b86 100644 --- a/marimo/_server/start.py +++ b/marimo/_server/start.py @@ -354,6 +354,7 @@ def start( lsp_servers=list(lsp_composite_server.servers.values()) if lsp_composite_server is not None else None, + enable_acp_proxy=mode == SessionMode.EDIT, skew_protection=skew_protection, timeout=timeout, ) From ced3fd71f92cc449743592d02c1b58e6ae9b3c8c Mon Sep 17 00:00:00 2001 From: Felipenguim Date: Fri, 28 Aug 2026 13:33:19 -0300 Subject: [PATCH 2/3] testes acp proxy for coding agents in backend --- tests/_server/api/test_middleware.py | 123 +++++++++++++++++++++++++++ 1 file changed, 123 insertions(+) diff --git a/tests/_server/api/test_middleware.py b/tests/_server/api/test_middleware.py index 71bbe983e44..b4c5fc0babd 100644 --- a/tests/_server/api/test_middleware.py +++ b/tests/_server/api/test_middleware.py @@ -32,6 +32,8 @@ from marimo._server.config import StarletteServerStateInit from marimo._server.lsp import BaseLspServer from marimo._server.main import ( + ACP_AGENT_PORTS, + _create_acp_proxy_middleware, _create_lsps_proxy_middleware, create_starlette_app, ) @@ -956,3 +958,124 @@ async def _noop_app( target_url="http://example.com", ) assert middleware.require_auth is True + + +class TestAcpProxyMiddleware: + """Test that ACP agent proxy middleware respects base_url configuration.""" + + EXPECTED_AGENT_PORTS = { + "claude": 3017, + "gemini": 3019, + "codex": 3021, + "opencode": 3023, + "cursor": 3025, + } + + def test_acp_proxy_ports_match_frontend(self) -> None: + assert ACP_AGENT_PORTS == self.EXPECTED_AGENT_PORTS + + def test_acp_proxy_without_base_url(self) -> None: + middlewares = list(_create_acp_proxy_middleware(base_url="")) + + assert { + mw.kwargs["proxy_path"]: mw.kwargs["target_url"] + for mw in middlewares + } == { + f"/acp/{agent_id}": f"http://127.0.0.1:{port}" + for agent_id, port in self.EXPECTED_AGENT_PORTS.items() + } + + def test_acp_proxy_with_base_url(self) -> None: + middlewares = list(_create_acp_proxy_middleware(base_url="/foo")) + + assert {mw.kwargs["proxy_path"] for mw in middlewares} == { + f"/foo/acp/{agent_id}" for agent_id in self.EXPECTED_AGENT_PORTS + } + + def test_acp_proxy_rewrites_path_to_message(self) -> None: + # ACP agents serve everything on /message, so the proxied path is + # discarded. + middlewares = list(_create_acp_proxy_middleware(base_url="/foo")) + + path_rewrite = middlewares[0].kwargs["path_rewrite"] + assert path_rewrite("/foo/acp/claude") == "/message" + + def test_acp_proxy_integration(self) -> None: + """Verify the ACP proxy works with create_starlette_app.""" + app = create_starlette_app(base_url="/marimo", enable_acp_proxy=True) + + proxy_mw = [ + mw + for mw in app.user_middleware + if mw.cls == ProxyMiddleware + and mw.kwargs.get("proxy_path") == "/marimo/acp/claude" + ] + + assert len(proxy_mw) == 1 + assert proxy_mw[0].kwargs["target_url"] == "http://127.0.0.1:3017" + + def test_acp_proxy_not_registered_by_default(self) -> None: + app = create_starlette_app(base_url="") + + proxy_paths = [ + mw.kwargs.get("proxy_path") + for mw in app.user_middleware + if mw.cls == ProxyMiddleware + ] + + assert not any( + path and path.startswith("/acp/") for path in proxy_paths + ) + + +class TestAcpProxyAuth: + """Access control for the ACP agent proxy middleware.""" + + @pytest.fixture + def acp_app(self) -> Starlette: + app = create_starlette_app( + base_url="", + enable_acp_proxy=True, + skew_protection=False, + ) + with_server(app) + init_state( + session_manager=get_mock_session_manager(mode=SessionMode.EDIT), + skew_protection=False, + ).apply(app.state) + return app + + def test_http_unauthenticated_is_rejected( + self, acp_app: Starlette + ) -> None: + client = TestClient(acp_app) + response = client.get("/acp/claude") + assert response.status_code == 401, response.text + + def test_websocket_unauthenticated_is_rejected( + self, acp_app: Starlette + ) -> None: + client = TestClient(acp_app) + with pytest.raises(WebSocketDisconnect) as exc_info: + with client.websocket_connect("/acp/claude"): + pass + assert exc_info.value.code == WebSocketCodes.UNAUTHORIZED + + def test_websocket_bad_access_token_is_rejected( + self, acp_app: Starlette + ) -> None: + client = TestClient(acp_app) + with pytest.raises(WebSocketDisconnect) as exc_info: + with client.websocket_connect( + "/acp/claude?access_token=not-the-right-token" + ): + pass + assert exc_info.value.code == WebSocketCodes.UNAUTHORIZED + + def test_http_authenticated_is_forwarded(self, acp_app: Starlette) -> None: + client = TestClient(acp_app) + response = client.get( + "/acp/claude", + headers=token_header("fake-token"), + ) + assert response.status_code != 401, response.text From 057b6b833aa72581834b8027128b5b3e2e3a5330 Mon Sep 17 00:00:00 2001 From: Felipenguim Date: Fri, 28 Aug 2026 13:33:38 -0300 Subject: [PATCH 3/3] adding acp proxy for coding agents in frontend --- .../chat/acp/__tests__/state.test.ts | 75 ++++++++----------- frontend/src/components/chat/acp/state.ts | 17 +++-- .../core/runtime/__tests__/runtime.test.ts | 64 ++++++++++++++++ frontend/src/core/runtime/runtime.ts | 13 ++++ 4 files changed, 119 insertions(+), 50 deletions(-) diff --git a/frontend/src/components/chat/acp/__tests__/state.test.ts b/frontend/src/components/chat/acp/__tests__/state.test.ts index 948c103f36f..33698ba8163 100644 --- a/frontend/src/components/chat/acp/__tests__/state.test.ts +++ b/frontend/src/components/chat/acp/__tests__/state.test.ts @@ -2,6 +2,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import * as shortcuts from "@/core/hotkeys/shortcuts"; +import { + DEFAULT_RUNTIME_CONFIG, + runtimeConfigAtom, +} from "@/core/runtime/config"; +import { store } from "@/core/state/jotai"; import { type AgentSession, type AgentSessionState, @@ -700,69 +705,51 @@ describe("state utility functions", () => { }); describe("getAgentWebSocketUrl", () => { - const originalLocation = window.location; - afterEach(() => { - // Restore original window.location - Object.defineProperty(window, "location", { - value: originalLocation, - writable: true, - }); - }); - - it("should return ws:// URL with localhost for http protocol", () => { - Object.defineProperty(window, "location", { - value: { - hostname: "localhost", - protocol: "http:", - }, - writable: true, - }); + store.set(runtimeConfigAtom, DEFAULT_RUNTIME_CONFIG); + }); + + const setRuntimeUrl = (url: string) => { + store.set(runtimeConfigAtom, { url, lazy: true }); + }; + + it("should return a ws:// URL for http runtimes", () => { + setRuntimeUrl("http://localhost:2718/"); expect(getAgentWebSocketUrl("claude")).toMatchInlineSnapshot( - `"ws://localhost:3017/message"`, + `"ws://localhost:2718/acp/claude"`, ); }); - it("should return wss:// URL for https protocol", () => { - Object.defineProperty(window, "location", { - value: { - hostname: "example.com", - protocol: "https:", - }, - writable: true, - }); + it("should return a wss:// URL for https runtimes", () => { + setRuntimeUrl("https://example.com/"); expect(getAgentWebSocketUrl("claude")).toMatchInlineSnapshot( - `"wss://example.com:3017/message"`, + `"wss://example.com/acp/claude"`, ); }); - it("should work with IP addresses", () => { - Object.defineProperty(window, "location", { - value: { - hostname: "192.168.1.100", - protocol: "http:", - }, - writable: true, - }); + it("should work with IP addresses and ports", () => { + setRuntimeUrl("http://192.168.1.100:8080/"); expect(getAgentWebSocketUrl("claude")).toMatchInlineSnapshot( - `"ws://192.168.1.100:3017/message"`, + `"ws://192.168.1.100:8080/acp/claude"`, ); }); it("should work with remote hostnames", () => { - Object.defineProperty(window, "location", { - value: { - hostname: "marimo.example.com", - protocol: "https:", - }, - writable: true, - }); + setRuntimeUrl("https://marimo.example.com/"); expect(getAgentWebSocketUrl("gemini")).toMatchInlineSnapshot( - `"wss://marimo.example.com:3019/message"`, + `"wss://marimo.example.com/acp/gemini"`, + ); + }); + + it("should preserve a base path prefix", () => { + setRuntimeUrl("https://example.com/notebooks/abc/"); + + expect(getAgentWebSocketUrl("codex")).toMatchInlineSnapshot( + `"wss://example.com/notebooks/abc/acp/codex"`, ); }); }); diff --git a/frontend/src/components/chat/acp/state.ts b/frontend/src/components/chat/acp/state.ts index 550ec423887..add59ac20aa 100644 --- a/frontend/src/components/chat/acp/state.ts +++ b/frontend/src/components/chat/acp/state.ts @@ -3,6 +3,7 @@ import { atom } from "jotai"; import { atomWithStorage } from "jotai/utils"; import { isPlatformWindows } from "@/core/hotkeys/shortcuts"; +import { getRuntimeManager } from "@/core/runtime/config"; import { jotaiJsonStorage } from "@/utils/storage/jotai"; import { capitalize } from "@/utils/strings"; import type { TypedString } from "@/utils/typed"; @@ -238,15 +239,19 @@ export function getAgentDisplayName(agentId: ExternalAgentId): string { } export function getAgentWebSocketUrl(agentId: ExternalAgentId): string { - const port = AGENT_CONFIG[agentId].port; - // Use the current page's hostname so the agent is reachable when - // marimo is accessed remotely (e.g. via direct IP or reverse proxy). - const hostname = window.location.hostname; - const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; - return `${protocol}//${hostname}:${port}/message` as const; + // Connect through the marimo server (`/acp/`) rather than + // directly to the agent's port, which isn't reachable from the browser when + // marimo is served behind a reverse proxy. + return getRuntimeManager().getAgentWsURL(agentId).toString(); } interface AgentConfig { + /** + * Port the agent's websocket bridge listens on. Only used to render the + * connection command shown to the user -- the browser reaches the agent + * through the marimo server, not this port. Must stay in sync with + * `ACP_AGENT_PORTS` in `marimo/_server/main.py`. + */ port: number; command: string; sessionSupport: SessionSupportType; diff --git a/frontend/src/core/runtime/__tests__/runtime.test.ts b/frontend/src/core/runtime/__tests__/runtime.test.ts index 7710178c8ec..2bd97eeacc5 100644 --- a/frontend/src/core/runtime/__tests__/runtime.test.ts +++ b/frontend/src/core/runtime/__tests__/runtime.test.ts @@ -343,6 +343,70 @@ describe("RuntimeManager", () => { }); }); + describe("getAgentWsURL", () => { + it("should return the agent URL proxied through the server", () => { + const runtime = new RuntimeManager(mockConfig); + const url = runtime.getAgentWsURL("claude"); + + expect(url.protocol).toBe("wss:"); + expect(url.pathname).toBe("/acp/claude"); + expect(url.host).toBe("example.com"); + }); + + it("should respect a base path prefix", () => { + const runtime = new RuntimeManager({ + url: "http://example.com/prefix/", + lazy: true, + }); + const url = runtime.getAgentWsURL("gemini"); + + expect(url.toString()).toBe("ws://example.com/prefix/acp/gemini"); + }); + + it("should strip non-auth query params", () => { + const runtime = new RuntimeManager({ + url: "https://example.com?foo=bar&baz=qux", + lazy: true, + }); + const url = runtime.getAgentWsURL("codex"); + + expect(url.pathname).toBe("/acp/codex"); + expect(url.search).toBe(""); + }); + + it("should preserve access_token when cross-origin", () => { + const runtime = new RuntimeManager( + { + url: "https://sandbox.example.com?foo=bar", + lazy: true, + authToken: "my-secret-token", + }, + true, + ); + const url = runtime.getAgentWsURL("claude"); + + expect(url.pathname).toBe("/acp/claude"); + expect(url.searchParams.get("access_token")).toBe("my-secret-token"); + expect(url.searchParams.get("foo")).toBeNull(); + }); + + it("should not have access_token when same-origin", () => { + const runtime = new RuntimeManager( + { + url: window.location.origin, + lazy: true, + authToken: "my-secret-token", + }, + true, + ); + const url = runtime.getAgentWsURL("claude"); + + expect(url.protocol).toBe("ws:"); + expect(url.pathname).toBe("/acp/claude"); + expect(url.search).toBe(""); + }); + }); + describe("getAiURL", () => { it("should return AI completion URL", () => { const runtime = new RuntimeManager(mockConfig); diff --git a/frontend/src/core/runtime/runtime.ts b/frontend/src/core/runtime/runtime.ts index 1db04cc6fde..ac23fef969e 100644 --- a/frontend/src/core/runtime/runtime.ts +++ b/frontend/src/core/runtime/runtime.ts @@ -206,6 +206,19 @@ export class RuntimeManager { return this.formatWsURL(`/lsp/${lsp}`); } + /** + * The WebSocket URL of an external ACP agent. + */ + getAgentWsURL(agentId: string): URL { + const url = this.formatWsURL(`/acp/${encodeURIComponent(agentId)}`); + const accessToken = url.searchParams.get(KnownQueryParams.accessToken); + url.search = ""; + if (accessToken) { + url.searchParams.set(KnownQueryParams.accessToken, accessToken); + } + return url; + } + getAiURL(path: "completion" | "chat"): URL { return this.formatHttpURL({ path: `/api/ai/${path}` }); }