From 8cabec8303cb3208ed21d6e35686a18c662897e8 Mon Sep 17 00:00:00 2001 From: enyst Date: Thu, 11 Jun 2026 22:41:49 +0000 Subject: [PATCH 1/4] Serve bundled agent-canvas frontend Co-authored-by: openhands --- .github/workflows/pypi-release.yml | 3 + .gitignore | 3 + MANIFEST.in | 3 + Makefile | 29 ++++- .../openhands/agent_server/agent-server.spec | 3 + .../openhands/agent_server/api.py | 112 +++++++++++++----- openhands-agent-server/pyproject.toml | 3 +- tests/agent_server/test_api.py | 84 +++++++++++++ 8 files changed, 206 insertions(+), 34 deletions(-) diff --git a/.github/workflows/pypi-release.yml b/.github/workflows/pypi-release.yml index 319b71af4b..72cacc903e 100644 --- a/.github/workflows/pypi-release.yml +++ b/.github/workflows/pypi-release.yml @@ -48,6 +48,9 @@ jobs: version: latest python-version: '3.13' + - name: Bundle agent-canvas frontend assets + run: make agent-canvas-frontend + - name: Build and publish all packages env: UV_PUBLISH_TOKEN: ${{ secrets.PYPI_TOKEN_OPENHANDS }} diff --git a/.gitignore b/.gitignore index 7438f3aabc..81c51d4fe9 100644 --- a/.gitignore +++ b/.gitignore @@ -213,5 +213,8 @@ agent-sdk.workspace.code-workspace tests/integration/outputs/ tests/integration/api_compliance/outputs/ +# Generated agent-canvas frontend bundle +openhands-agent-server/openhands/agent_server/_frontend/ + # Agent-generated temp .agent_tmp/ diff --git a/MANIFEST.in b/MANIFEST.in index 0e31f5b069..5805fe86ca 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -44,5 +44,8 @@ include openhands-agent-server/openhands/agent_server/docker/wallpaper.svg # PyInstaller spec include openhands-agent-server/openhands/agent_server/agent-server.spec +# Bundled agent-canvas frontend assets +recursive-include openhands-agent-server/openhands/agent_server/_frontend * + # VSCode extensions recursive-include openhands-agent-server/openhands/agent_server/vscode_extensions * diff --git a/Makefile b/Makefile index 698dfd1cb9..34e0349c64 100644 --- a/Makefile +++ b/Makefile @@ -13,8 +13,10 @@ UNDERLINE := \033[4m # Required uv version REQUIRED_UV_VERSION := 0.8.13 PKGS ?= openhands-sdk openhands-tools openhands-workspace openhands-agent-server +AGENT_CANVAS_PACKAGE ?= @openhands/agent-canvas +AGENT_SERVER_FRONTEND_DIR := openhands-agent-server/openhands/agent_server/_frontend -.PHONY: build format lint clean help check-uv-version +.PHONY: build build-with-frontend agent-canvas-frontend format lint clean help check-uv-version # Default target .DEFAULT_GOAL := help @@ -41,6 +43,29 @@ build: check-uv-version @$(ECHO) "$(GREEN)Pre-commit hooks installed successfully.$(RESET)" @$(ECHO) "$(GREEN)Build complete! Development environment is ready.$(RESET)" +build-with-frontend: build agent-canvas-frontend + @$(ECHO) "$(GREEN)Build complete with bundled agent-canvas frontend.$(RESET)" + +agent-canvas-frontend: + @$(ECHO) "$(CYAN)Fetching prebuilt agent-canvas frontend...$(RESET)" + @tmp_dir=$$(mktemp -d); \ + trap 'rm -rf "$$tmp_dir"' EXIT; \ + npm --silent pack "$(AGENT_CANVAS_PACKAGE)" --pack-destination "$$tmp_dir" >/dev/null; \ + tarball=$$(find "$$tmp_dir" -maxdepth 1 -name '*.tgz' -print -quit); \ + if [ -z "$$tarball" ]; then \ + $(ECHO) "$(RED)No agent-canvas tarball was downloaded.$(RESET)"; \ + exit 1; \ + fi; \ + tar -xzf "$$tarball" -C "$$tmp_dir"; \ + if [ ! -d "$$tmp_dir/package/build" ]; then \ + $(ECHO) "$(RED)agent-canvas package does not contain build/ assets.$(RESET)"; \ + exit 1; \ + fi; \ + rm -rf "$(AGENT_SERVER_FRONTEND_DIR)"; \ + mkdir -p "$(AGENT_SERVER_FRONTEND_DIR)"; \ + cp -R "$$tmp_dir/package/build/." "$(AGENT_SERVER_FRONTEND_DIR)/" + @$(ECHO) "$(GREEN)Bundled frontend assets in $(AGENT_SERVER_FRONTEND_DIR).$(RESET)" + format: @$(ECHO) "$(YELLOW)Formatting code with uv format...$(RESET)" @uv run ruff format @@ -72,6 +97,8 @@ help: @$(ECHO) "" @$(ECHO) "$(UNDERLINE)Commands:$(RESET)" @$(ECHO) " $(GREEN)build$(RESET) Setup development environment (install deps + hooks)" + @$(ECHO) " $(GREEN)build-with-frontend$(RESET) Setup dev env and bundle agent-canvas frontend" + @$(ECHO) " $(GREEN)agent-canvas-frontend$(RESET) Bundle prebuilt agent-canvas frontend assets" @$(ECHO) " $(GREEN)build-server$(RESET) Build agent-server executable" @$(ECHO) " $(GREEN)test-server-schema$(RESET) Test server schema" @$(ECHO) " $(GREEN)format$(RESET) Format code with uv format" diff --git a/openhands-agent-server/openhands/agent_server/agent-server.spec b/openhands-agent-server/openhands/agent_server/agent-server.spec index b54c077c1c..0c35b7943e 100644 --- a/openhands-agent-server/openhands/agent_server/agent-server.spec +++ b/openhands-agent-server/openhands/agent_server/agent-server.spec @@ -86,6 +86,9 @@ a = Analysis( # OpenHands Tools browser recording JS files *collect_data_files("openhands.tools.browser_use", includes=["js/*.js"]), + # Bundled agent-canvas frontend assets + *collect_data_files("openhands.agent_server", includes=["_frontend/**/*"]), + # Built-in subagent definitions consumed by register_builtins_agents() # at agent-server startup. Without these, the registry stays empty in # PyInstaller builds and downstream clients see an unpopulated diff --git a/openhands-agent-server/openhands/agent_server/api.py b/openhands-agent-server/openhands/agent_server/api.py index 2d0bfe135e..2e7017b772 100644 --- a/openhands-agent-server/openhands/agent_server/api.py +++ b/openhands-agent-server/openhands/agent_server/api.py @@ -12,7 +12,7 @@ import libtmux from fastapi import APIRouter, Depends, FastAPI, HTTPException from fastapi.exceptions import RequestValidationError -from fastapi.responses import JSONResponse, RedirectResponse +from fastapi.responses import FileResponse, JSONResponse, RedirectResponse from fastapi.staticfiles import StaticFiles from starlette.requests import Request @@ -66,6 +66,21 @@ logger = get_logger(__name__) +BUNDLED_FRONTEND_PATH = Path(__file__).parent / "_frontend" +FRONTEND_RESERVED_PATHS = frozenset( + { + "alive", + "api", + "docs", + "health", + "openapi.json", + "ready", + "redoc", + "server_info", + "sockets", + "static", + } +) def _default_server_tmux_tmpdir() -> Path: @@ -345,43 +360,76 @@ def _add_api_routes(app: FastAPI, config: Config) -> None: app.include_router(sockets_router) -def _setup_static_files(app: FastAPI, config: Config) -> None: - """Set up static file serving and root redirect if configured. +def _is_existing_directory(path: Path | None) -> bool: + return path is not None and path.exists() and path.is_dir() - Args: - app: FastAPI application instance. - config: Configuration object containing static files settings. - """ - # Only proceed if static files are configured and directory exists - if not ( - config.static_files_path - and config.static_files_path.exists() - and config.static_files_path.is_dir() + +def _get_bundled_frontend_path() -> Path | None: + if ( + _is_existing_directory(BUNDLED_FRONTEND_PATH) + and (BUNDLED_FRONTEND_PATH / "index.html").is_file() ): - # Map the root path to server info if there are no static files - app.get("/", tags=["Server Details"])(get_server_info) - return + return BUNDLED_FRONTEND_PATH + return None + + +def _is_reserved_frontend_path(path: str) -> bool: + first_segment = path.split("/", maxsplit=1)[0] + return first_segment in FRONTEND_RESERVED_PATHS + + +def _resolve_frontend_file(frontend_dir: Path, path: str) -> Path | None: + frontend_root = frontend_dir.resolve() + requested_path = (frontend_root / path).resolve() + if requested_path == frontend_root: + return frontend_root / "index.html" + if not requested_path.is_relative_to(frontend_root): + return None + if requested_path.is_file(): + return requested_path + return frontend_root / "index.html" - # Mount static files directory - app.mount( - "/static", - StaticFiles(directory=str(config.static_files_path)), - name="static", - ) - # Add root redirect to static files - @app.get("/", tags=["Server Details"]) - async def root_redirect(): - """Redirect root endpoint to static files directory.""" - # Check if index.html exists in the static directory - # We know static_files_path is not None here due to the outer condition - assert config.static_files_path is not None - index_path = config.static_files_path / "index.html" - if index_path.exists(): - return RedirectResponse(url="/static/index.html", status_code=302) - else: +def _setup_bundled_frontend(app: FastAPI, frontend_dir: Path) -> None: + @app.get("/{path:path}", include_in_schema=False) + async def serve_frontend(path: str): + if _is_reserved_frontend_path(path): + raise HTTPException(status_code=404, detail="Not Found") + + file_path = _resolve_frontend_file(frontend_dir, path) + if file_path is None: + raise HTTPException(status_code=404, detail="Not Found") + return FileResponse(file_path) + + +def _setup_static_files(app: FastAPI, config: Config) -> None: + """Set up static file serving and bundled frontend routes.""" + if _is_existing_directory(config.static_files_path): + app.mount( + "/static", + StaticFiles(directory=str(config.static_files_path)), + name="static", + ) + + @app.get("/", tags=["Server Details"]) + async def root_redirect(): + """Redirect root endpoint to static files directory.""" + assert config.static_files_path is not None + index_path = config.static_files_path / "index.html" + if index_path.exists(): + return RedirectResponse(url="/static/index.html", status_code=302) return RedirectResponse(url="/static/", status_code=302) + return + + if config.static_files_path is None: + frontend_dir = _get_bundled_frontend_path() + if frontend_dir is not None: + _setup_bundled_frontend(app, frontend_dir) + return + + app.get("/", tags=["Server Details"])(get_server_info) + def _sanitize_validation_errors(errors: Sequence[Any]) -> list[dict]: """Sanitize validation error details to remove sensitive input values. diff --git a/openhands-agent-server/pyproject.toml b/openhands-agent-server/pyproject.toml index 77c52635f4..89465684d2 100644 --- a/openhands-agent-server/pyproject.toml +++ b/openhands-agent-server/pyproject.toml @@ -38,8 +38,9 @@ namespaces = true [tool.setuptools.package-data] "*" = ["py.typed"] -# Include Docker-related files and VSCode extensions +# Include bundled frontend assets, Docker-related files, and VSCode extensions "openhands.agent_server" = [ + "_frontend/**/*", "docker/Dockerfile", "docker/wallpaper.svg", "vscode_extensions/**/*.json", diff --git a/tests/agent_server/test_api.py b/tests/agent_server/test_api.py index 18b1f3be47..84345263a6 100644 --- a/tests/agent_server/test_api.py +++ b/tests/agent_server/test_api.py @@ -13,6 +13,7 @@ _default_server_tmux_tmpdir, _ensure_server_tmux_tmpdir, _get_root_path, + _resolve_frontend_file, api_lifespan, create_app, ) @@ -260,6 +261,89 @@ def test_no_root_redirect_when_static_directory_missing(self): assert response.status_code == 200 +class TestBundledFrontendServing: + """Test serving bundled agent-canvas assets from the app root.""" + + def test_bundled_frontend_serves_root_assets_and_spa_fallback( + self, tmp_path, monkeypatch + ): + frontend_dir = tmp_path / "frontend" + assets_dir = frontend_dir / "assets" + assets_dir.mkdir(parents=True) + index_content = "Agent Canvas" + asset_content = "console.log('agent-canvas');" + (frontend_dir / "index.html").write_text(index_content) + (assets_dir / "app.js").write_text(asset_content) + monkeypatch.setattr( + "openhands.agent_server.api.BUNDLED_FRONTEND_PATH", frontend_dir + ) + + app = create_app(Config(static_files_path=None)) + client = TestClient(app) + + root_response = client.get("/") + assert root_response.status_code == 200 + assert root_response.text == index_content + assert "text/html" in root_response.headers["content-type"] + + asset_response = client.get("/assets/app.js") + assert asset_response.status_code == 200 + assert asset_response.text == asset_content + assert "text/javascript" in asset_response.headers["content-type"] + + fallback_response = client.get("/settings/llm") + assert fallback_response.status_code == 200 + assert fallback_response.text == index_content + + def test_bundled_frontend_preserves_backend_routes(self, tmp_path, monkeypatch): + frontend_dir = tmp_path / "frontend" + frontend_dir.mkdir() + (frontend_dir / "index.html").write_text("Agent Canvas") + monkeypatch.setattr( + "openhands.agent_server.api.BUNDLED_FRONTEND_PATH", frontend_dir + ) + + app = create_app(Config(static_files_path=None)) + client = TestClient(app) + + server_info_response = client.get("/server_info") + assert server_info_response.status_code == 200 + assert server_info_response.headers["content-type"].startswith( + "application/json" + ) + + missing_api_response = client.get("/api/missing") + assert missing_api_response.status_code == 404 + assert missing_api_response.json() == {"detail": "Not Found"} + + def test_configured_static_files_take_precedence_over_bundled_frontend( + self, tmp_path, monkeypatch + ): + frontend_dir = tmp_path / "frontend" + frontend_dir.mkdir() + (frontend_dir / "index.html").write_text("Agent Canvas") + static_dir = tmp_path / "static" + static_dir.mkdir() + (static_dir / "index.html").write_text("Configured Static") + monkeypatch.setattr( + "openhands.agent_server.api.BUNDLED_FRONTEND_PATH", frontend_dir + ) + + app = create_app(Config(static_files_path=static_dir)) + client = TestClient(app) + + response = client.get("/", follow_redirects=False) + assert response.status_code == 302 + assert response.headers["location"] == "/static/index.html" + + def test_frontend_file_resolution_rejects_directory_traversal(self, tmp_path): + frontend_dir = tmp_path / "frontend" + frontend_dir.mkdir() + (frontend_dir / "index.html").write_text("Agent Canvas") + + assert _resolve_frontend_file(frontend_dir, "../secret.txt") is None + + class TestServiceParallelization: """Test that services are started and stopped in parallel.""" From 7cc55a61e309b7ee0fc828d6b65d0f8c01168963 Mon Sep 17 00:00:00 2001 From: enyst Date: Thu, 11 Jun 2026 23:19:02 +0000 Subject: [PATCH 2/4] Launch agent-canvas from checkout Co-authored-by: openhands --- .github/workflows/pypi-release.yml | 3 - .gitignore | 4 +- MANIFEST.in | 3 - Makefile | 37 +++--- .../openhands/agent_server/agent-server.spec | 3 - .../openhands/agent_server/api.py | 112 +++++------------- openhands-agent-server/pyproject.toml | 3 +- tests/agent_server/test_api.py | 84 ------------- 8 files changed, 57 insertions(+), 192 deletions(-) diff --git a/.github/workflows/pypi-release.yml b/.github/workflows/pypi-release.yml index 72cacc903e..319b71af4b 100644 --- a/.github/workflows/pypi-release.yml +++ b/.github/workflows/pypi-release.yml @@ -48,9 +48,6 @@ jobs: version: latest python-version: '3.13' - - name: Bundle agent-canvas frontend assets - run: make agent-canvas-frontend - - name: Build and publish all packages env: UV_PUBLISH_TOKEN: ${{ secrets.PYPI_TOKEN_OPENHANDS }} diff --git a/.gitignore b/.gitignore index 81c51d4fe9..f15dc2100d 100644 --- a/.gitignore +++ b/.gitignore @@ -213,8 +213,8 @@ agent-sdk.workspace.code-workspace tests/integration/outputs/ tests/integration/api_compliance/outputs/ -# Generated agent-canvas frontend bundle -openhands-agent-server/openhands/agent_server/_frontend/ +# Downloaded prebuilt agent-canvas package +frontend/ # Agent-generated temp .agent_tmp/ diff --git a/MANIFEST.in b/MANIFEST.in index 5805fe86ca..0e31f5b069 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -44,8 +44,5 @@ include openhands-agent-server/openhands/agent_server/docker/wallpaper.svg # PyInstaller spec include openhands-agent-server/openhands/agent_server/agent-server.spec -# Bundled agent-canvas frontend assets -recursive-include openhands-agent-server/openhands/agent_server/_frontend * - # VSCode extensions recursive-include openhands-agent-server/openhands/agent_server/vscode_extensions * diff --git a/Makefile b/Makefile index 34e0349c64..e3bc607f6e 100644 --- a/Makefile +++ b/Makefile @@ -14,9 +14,9 @@ UNDERLINE := \033[4m REQUIRED_UV_VERSION := 0.8.13 PKGS ?= openhands-sdk openhands-tools openhands-workspace openhands-agent-server AGENT_CANVAS_PACKAGE ?= @openhands/agent-canvas -AGENT_SERVER_FRONTEND_DIR := openhands-agent-server/openhands/agent_server/_frontend +AGENT_CANVAS_DIR := frontend/agent-canvas -.PHONY: build build-with-frontend agent-canvas-frontend format lint clean help check-uv-version +.PHONY: build agent-canvas-frontend ensure-agent-canvas canvas format lint clean help check-uv-version # Default target .DEFAULT_GOAL := help @@ -41,13 +41,11 @@ build: check-uv-version @$(ECHO) "$(YELLOW)Setting up pre-commit hooks...$(RESET)" @uv run pre-commit install @$(ECHO) "$(GREEN)Pre-commit hooks installed successfully.$(RESET)" + @$(MAKE) agent-canvas-frontend @$(ECHO) "$(GREEN)Build complete! Development environment is ready.$(RESET)" -build-with-frontend: build agent-canvas-frontend - @$(ECHO) "$(GREEN)Build complete with bundled agent-canvas frontend.$(RESET)" - agent-canvas-frontend: - @$(ECHO) "$(CYAN)Fetching prebuilt agent-canvas frontend...$(RESET)" + @$(ECHO) "$(CYAN)Fetching prebuilt agent-canvas package...$(RESET)" @tmp_dir=$$(mktemp -d); \ trap 'rm -rf "$$tmp_dir"' EXIT; \ npm --silent pack "$(AGENT_CANVAS_PACKAGE)" --pack-destination "$$tmp_dir" >/dev/null; \ @@ -57,14 +55,22 @@ agent-canvas-frontend: exit 1; \ fi; \ tar -xzf "$$tarball" -C "$$tmp_dir"; \ - if [ ! -d "$$tmp_dir/package/build" ]; then \ - $(ECHO) "$(RED)agent-canvas package does not contain build/ assets.$(RESET)"; \ + if [ ! -d "$$tmp_dir/package/build" ] || [ ! -f "$$tmp_dir/package/bin/agent-canvas.mjs" ]; then \ + $(ECHO) "$(RED)agent-canvas package is missing expected build or CLI files.$(RESET)"; \ exit 1; \ fi; \ - rm -rf "$(AGENT_SERVER_FRONTEND_DIR)"; \ - mkdir -p "$(AGENT_SERVER_FRONTEND_DIR)"; \ - cp -R "$$tmp_dir/package/build/." "$(AGENT_SERVER_FRONTEND_DIR)/" - @$(ECHO) "$(GREEN)Bundled frontend assets in $(AGENT_SERVER_FRONTEND_DIR).$(RESET)" + rm -rf "$(AGENT_CANVAS_DIR)"; \ + mkdir -p "$$(dirname "$(AGENT_CANVAS_DIR)")"; \ + mv "$$tmp_dir/package" "$(AGENT_CANVAS_DIR)" + @$(ECHO) "$(GREEN)Installed agent-canvas package in $(AGENT_CANVAS_DIR).$(RESET)" + +ensure-agent-canvas: + @if [ ! -d "$(AGENT_CANVAS_DIR)/build" ] || [ ! -f "$(AGENT_CANVAS_DIR)/bin/agent-canvas.mjs" ]; then \ + $(MAKE) agent-canvas-frontend; \ + fi + +canvas: ensure-agent-canvas + @OH_AGENT_SERVER_LOCAL_PATH="$(abspath .)" node "$(AGENT_CANVAS_DIR)/bin/agent-canvas.mjs" $(ARGS) format: @$(ECHO) "$(YELLOW)Formatting code with uv format...$(RESET)" @@ -96,9 +102,10 @@ help: @$(ECHO) "$(UNDERLINE)Usage:$(RESET) make " @$(ECHO) "" @$(ECHO) "$(UNDERLINE)Commands:$(RESET)" - @$(ECHO) " $(GREEN)build$(RESET) Setup development environment (install deps + hooks)" - @$(ECHO) " $(GREEN)build-with-frontend$(RESET) Setup dev env and bundle agent-canvas frontend" - @$(ECHO) " $(GREEN)agent-canvas-frontend$(RESET) Bundle prebuilt agent-canvas frontend assets" + @$(ECHO) " $(GREEN)build$(RESET) Setup dev environment and fetch agent-canvas" + @$(ECHO) " $(GREEN)canvas$(RESET) Start agent-canvas with this SDK checkout" + @$(ECHO) " $(GREEN)agent-canvas-frontend$(RESET) Refresh the downloaded agent-canvas package" + @$(ECHO) " $(YELLOW) Pass canvas flags with ARGS, e.g. make canvas ARGS='--frontend-only'$(RESET)" @$(ECHO) " $(GREEN)build-server$(RESET) Build agent-server executable" @$(ECHO) " $(GREEN)test-server-schema$(RESET) Test server schema" @$(ECHO) " $(GREEN)format$(RESET) Format code with uv format" diff --git a/openhands-agent-server/openhands/agent_server/agent-server.spec b/openhands-agent-server/openhands/agent_server/agent-server.spec index 0c35b7943e..b54c077c1c 100644 --- a/openhands-agent-server/openhands/agent_server/agent-server.spec +++ b/openhands-agent-server/openhands/agent_server/agent-server.spec @@ -86,9 +86,6 @@ a = Analysis( # OpenHands Tools browser recording JS files *collect_data_files("openhands.tools.browser_use", includes=["js/*.js"]), - # Bundled agent-canvas frontend assets - *collect_data_files("openhands.agent_server", includes=["_frontend/**/*"]), - # Built-in subagent definitions consumed by register_builtins_agents() # at agent-server startup. Without these, the registry stays empty in # PyInstaller builds and downstream clients see an unpopulated diff --git a/openhands-agent-server/openhands/agent_server/api.py b/openhands-agent-server/openhands/agent_server/api.py index 2e7017b772..2d0bfe135e 100644 --- a/openhands-agent-server/openhands/agent_server/api.py +++ b/openhands-agent-server/openhands/agent_server/api.py @@ -12,7 +12,7 @@ import libtmux from fastapi import APIRouter, Depends, FastAPI, HTTPException from fastapi.exceptions import RequestValidationError -from fastapi.responses import FileResponse, JSONResponse, RedirectResponse +from fastapi.responses import JSONResponse, RedirectResponse from fastapi.staticfiles import StaticFiles from starlette.requests import Request @@ -66,21 +66,6 @@ logger = get_logger(__name__) -BUNDLED_FRONTEND_PATH = Path(__file__).parent / "_frontend" -FRONTEND_RESERVED_PATHS = frozenset( - { - "alive", - "api", - "docs", - "health", - "openapi.json", - "ready", - "redoc", - "server_info", - "sockets", - "static", - } -) def _default_server_tmux_tmpdir() -> Path: @@ -360,75 +345,42 @@ def _add_api_routes(app: FastAPI, config: Config) -> None: app.include_router(sockets_router) -def _is_existing_directory(path: Path | None) -> bool: - return path is not None and path.exists() and path.is_dir() - - -def _get_bundled_frontend_path() -> Path | None: - if ( - _is_existing_directory(BUNDLED_FRONTEND_PATH) - and (BUNDLED_FRONTEND_PATH / "index.html").is_file() - ): - return BUNDLED_FRONTEND_PATH - return None - - -def _is_reserved_frontend_path(path: str) -> bool: - first_segment = path.split("/", maxsplit=1)[0] - return first_segment in FRONTEND_RESERVED_PATHS - - -def _resolve_frontend_file(frontend_dir: Path, path: str) -> Path | None: - frontend_root = frontend_dir.resolve() - requested_path = (frontend_root / path).resolve() - if requested_path == frontend_root: - return frontend_root / "index.html" - if not requested_path.is_relative_to(frontend_root): - return None - if requested_path.is_file(): - return requested_path - return frontend_root / "index.html" - - -def _setup_bundled_frontend(app: FastAPI, frontend_dir: Path) -> None: - @app.get("/{path:path}", include_in_schema=False) - async def serve_frontend(path: str): - if _is_reserved_frontend_path(path): - raise HTTPException(status_code=404, detail="Not Found") - - file_path = _resolve_frontend_file(frontend_dir, path) - if file_path is None: - raise HTTPException(status_code=404, detail="Not Found") - return FileResponse(file_path) - - def _setup_static_files(app: FastAPI, config: Config) -> None: - """Set up static file serving and bundled frontend routes.""" - if _is_existing_directory(config.static_files_path): - app.mount( - "/static", - StaticFiles(directory=str(config.static_files_path)), - name="static", - ) - - @app.get("/", tags=["Server Details"]) - async def root_redirect(): - """Redirect root endpoint to static files directory.""" - assert config.static_files_path is not None - index_path = config.static_files_path / "index.html" - if index_path.exists(): - return RedirectResponse(url="/static/index.html", status_code=302) - return RedirectResponse(url="/static/", status_code=302) + """Set up static file serving and root redirect if configured. + Args: + app: FastAPI application instance. + config: Configuration object containing static files settings. + """ + # Only proceed if static files are configured and directory exists + if not ( + config.static_files_path + and config.static_files_path.exists() + and config.static_files_path.is_dir() + ): + # Map the root path to server info if there are no static files + app.get("/", tags=["Server Details"])(get_server_info) return - if config.static_files_path is None: - frontend_dir = _get_bundled_frontend_path() - if frontend_dir is not None: - _setup_bundled_frontend(app, frontend_dir) - return + # Mount static files directory + app.mount( + "/static", + StaticFiles(directory=str(config.static_files_path)), + name="static", + ) - app.get("/", tags=["Server Details"])(get_server_info) + # Add root redirect to static files + @app.get("/", tags=["Server Details"]) + async def root_redirect(): + """Redirect root endpoint to static files directory.""" + # Check if index.html exists in the static directory + # We know static_files_path is not None here due to the outer condition + assert config.static_files_path is not None + index_path = config.static_files_path / "index.html" + if index_path.exists(): + return RedirectResponse(url="/static/index.html", status_code=302) + else: + return RedirectResponse(url="/static/", status_code=302) def _sanitize_validation_errors(errors: Sequence[Any]) -> list[dict]: diff --git a/openhands-agent-server/pyproject.toml b/openhands-agent-server/pyproject.toml index 89465684d2..77c52635f4 100644 --- a/openhands-agent-server/pyproject.toml +++ b/openhands-agent-server/pyproject.toml @@ -38,9 +38,8 @@ namespaces = true [tool.setuptools.package-data] "*" = ["py.typed"] -# Include bundled frontend assets, Docker-related files, and VSCode extensions +# Include Docker-related files and VSCode extensions "openhands.agent_server" = [ - "_frontend/**/*", "docker/Dockerfile", "docker/wallpaper.svg", "vscode_extensions/**/*.json", diff --git a/tests/agent_server/test_api.py b/tests/agent_server/test_api.py index 84345263a6..18b1f3be47 100644 --- a/tests/agent_server/test_api.py +++ b/tests/agent_server/test_api.py @@ -13,7 +13,6 @@ _default_server_tmux_tmpdir, _ensure_server_tmux_tmpdir, _get_root_path, - _resolve_frontend_file, api_lifespan, create_app, ) @@ -261,89 +260,6 @@ def test_no_root_redirect_when_static_directory_missing(self): assert response.status_code == 200 -class TestBundledFrontendServing: - """Test serving bundled agent-canvas assets from the app root.""" - - def test_bundled_frontend_serves_root_assets_and_spa_fallback( - self, tmp_path, monkeypatch - ): - frontend_dir = tmp_path / "frontend" - assets_dir = frontend_dir / "assets" - assets_dir.mkdir(parents=True) - index_content = "Agent Canvas" - asset_content = "console.log('agent-canvas');" - (frontend_dir / "index.html").write_text(index_content) - (assets_dir / "app.js").write_text(asset_content) - monkeypatch.setattr( - "openhands.agent_server.api.BUNDLED_FRONTEND_PATH", frontend_dir - ) - - app = create_app(Config(static_files_path=None)) - client = TestClient(app) - - root_response = client.get("/") - assert root_response.status_code == 200 - assert root_response.text == index_content - assert "text/html" in root_response.headers["content-type"] - - asset_response = client.get("/assets/app.js") - assert asset_response.status_code == 200 - assert asset_response.text == asset_content - assert "text/javascript" in asset_response.headers["content-type"] - - fallback_response = client.get("/settings/llm") - assert fallback_response.status_code == 200 - assert fallback_response.text == index_content - - def test_bundled_frontend_preserves_backend_routes(self, tmp_path, monkeypatch): - frontend_dir = tmp_path / "frontend" - frontend_dir.mkdir() - (frontend_dir / "index.html").write_text("Agent Canvas") - monkeypatch.setattr( - "openhands.agent_server.api.BUNDLED_FRONTEND_PATH", frontend_dir - ) - - app = create_app(Config(static_files_path=None)) - client = TestClient(app) - - server_info_response = client.get("/server_info") - assert server_info_response.status_code == 200 - assert server_info_response.headers["content-type"].startswith( - "application/json" - ) - - missing_api_response = client.get("/api/missing") - assert missing_api_response.status_code == 404 - assert missing_api_response.json() == {"detail": "Not Found"} - - def test_configured_static_files_take_precedence_over_bundled_frontend( - self, tmp_path, monkeypatch - ): - frontend_dir = tmp_path / "frontend" - frontend_dir.mkdir() - (frontend_dir / "index.html").write_text("Agent Canvas") - static_dir = tmp_path / "static" - static_dir.mkdir() - (static_dir / "index.html").write_text("Configured Static") - monkeypatch.setattr( - "openhands.agent_server.api.BUNDLED_FRONTEND_PATH", frontend_dir - ) - - app = create_app(Config(static_files_path=static_dir)) - client = TestClient(app) - - response = client.get("/", follow_redirects=False) - assert response.status_code == 302 - assert response.headers["location"] == "/static/index.html" - - def test_frontend_file_resolution_rejects_directory_traversal(self, tmp_path): - frontend_dir = tmp_path / "frontend" - frontend_dir.mkdir() - (frontend_dir / "index.html").write_text("Agent Canvas") - - assert _resolve_frontend_file(frontend_dir, "../secret.txt") is None - - class TestServiceParallelization: """Test that services are started and stopped in parallel.""" From c05d74066d769f2fe03f4782e625174b79050141 Mon Sep 17 00:00:00 2001 From: enyst Date: Thu, 11 Jun 2026 23:44:49 +0000 Subject: [PATCH 3/4] Pin downloaded agent-canvas package Co-authored-by: openhands --- .gitignore | 2 +- Makefile | 6 ++++-- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/.gitignore b/.gitignore index f15dc2100d..19f30a78d3 100644 --- a/.gitignore +++ b/.gitignore @@ -214,7 +214,7 @@ tests/integration/outputs/ tests/integration/api_compliance/outputs/ # Downloaded prebuilt agent-canvas package -frontend/ +agent-canvas/ # Agent-generated temp .agent_tmp/ diff --git a/Makefile b/Makefile index e3bc607f6e..8b57bbf407 100644 --- a/Makefile +++ b/Makefile @@ -13,8 +13,10 @@ UNDERLINE := \033[4m # Required uv version REQUIRED_UV_VERSION := 0.8.13 PKGS ?= openhands-sdk openhands-tools openhands-workspace openhands-agent-server -AGENT_CANVAS_PACKAGE ?= @openhands/agent-canvas -AGENT_CANVAS_DIR := frontend/agent-canvas +AGENT_CANVAS_PACKAGE_NAME ?= @openhands/agent-canvas +AGENT_CANVAS_VERSION ?= 1.0.0-rc.7 +AGENT_CANVAS_PACKAGE ?= $(AGENT_CANVAS_PACKAGE_NAME)@$(AGENT_CANVAS_VERSION) +AGENT_CANVAS_DIR := agent-canvas .PHONY: build agent-canvas-frontend ensure-agent-canvas canvas format lint clean help check-uv-version From fdee1b18fc555541918df7e4b3ef03f0bab7d266 Mon Sep 17 00:00:00 2001 From: enyst Date: Fri, 12 Jun 2026 00:04:24 +0000 Subject: [PATCH 4/4] Add make run for agent-canvas stack Co-authored-by: openhands --- Makefile | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/Makefile b/Makefile index 8b57bbf407..3363bbd815 100644 --- a/Makefile +++ b/Makefile @@ -18,7 +18,7 @@ AGENT_CANVAS_VERSION ?= 1.0.0-rc.7 AGENT_CANVAS_PACKAGE ?= $(AGENT_CANVAS_PACKAGE_NAME)@$(AGENT_CANVAS_VERSION) AGENT_CANVAS_DIR := agent-canvas -.PHONY: build agent-canvas-frontend ensure-agent-canvas canvas format lint clean help check-uv-version +.PHONY: build agent-canvas-frontend ensure-agent-canvas run canvas format lint clean help check-uv-version # Default target .DEFAULT_GOAL := help @@ -71,9 +71,11 @@ ensure-agent-canvas: $(MAKE) agent-canvas-frontend; \ fi -canvas: ensure-agent-canvas +run: ensure-agent-canvas @OH_AGENT_SERVER_LOCAL_PATH="$(abspath .)" node "$(AGENT_CANVAS_DIR)/bin/agent-canvas.mjs" $(ARGS) +canvas: run + format: @$(ECHO) "$(YELLOW)Formatting code with uv format...$(RESET)" @uv run ruff format @@ -105,9 +107,10 @@ help: @$(ECHO) "" @$(ECHO) "$(UNDERLINE)Commands:$(RESET)" @$(ECHO) " $(GREEN)build$(RESET) Setup dev environment and fetch agent-canvas" - @$(ECHO) " $(GREEN)canvas$(RESET) Start agent-canvas with this SDK checkout" + @$(ECHO) " $(GREEN)run$(RESET) Start the full agent-canvas stack" + @$(ECHO) " $(YELLOW) Pass modes with ARGS, e.g. make run ARGS='--frontend-only'$(RESET)" + @$(ECHO) " $(GREEN)canvas$(RESET) Alias for run" @$(ECHO) " $(GREEN)agent-canvas-frontend$(RESET) Refresh the downloaded agent-canvas package" - @$(ECHO) " $(YELLOW) Pass canvas flags with ARGS, e.g. make canvas ARGS='--frontend-only'$(RESET)" @$(ECHO) " $(GREEN)build-server$(RESET) Build agent-server executable" @$(ECHO) " $(GREEN)test-server-schema$(RESET) Test server schema" @$(ECHO) " $(GREEN)format$(RESET) Format code with uv format"