From 519c856c376558785c9484df7a3c5fb553b86a63 Mon Sep 17 00:00:00 2001 From: Rohit Malhotra Date: Wed, 15 Jul 2026 12:24:11 -0400 Subject: [PATCH] feat: support serving Canvas under a subpath (#1796) * feat: support serving canvas under subpath Co-authored-by: openhands * fix: redirect root app routes to canvas base path Co-authored-by: openhands --------- Co-authored-by: openhands Co-authored-by: hieptl --- .env.sample | 1 + .github/workflows/docker.yml | 4 +- AGENTS.md | 1 + README.md | 21 ++-- README.windows.md | 2 + __tests__/root.test.tsx | 17 +++ __tests__/scripts/static-server.test.ts | 91 +++++++++++++++- __tests__/utils/base-path.test.ts | 34 ++++++ config/defaults.json | 3 +- docker/Dockerfile | 7 ++ docker/entrypoint.sh | 4 + docs/DEVELOPMENT.md | 1 + react-router.config.ts | 11 ++ scripts/dev-static.mjs | 3 + scripts/dev-with-automation.mjs | 3 + scripts/docker-build.mjs | 4 + scripts/static-server.mjs | 100 ++++++++++++++++-- .../settings/agent-server-onboarding.tsx | 3 +- .../shared/modals/settings/settings-modal.tsx | 3 +- src/i18n/index.ts | 3 +- src/root.tsx | 7 +- src/utils/base-path.ts | 33 ++++++ vite.config.ts | 11 ++ 23 files changed, 342 insertions(+), 25 deletions(-) create mode 100644 __tests__/utils/base-path.test.ts create mode 100644 src/utils/base-path.ts diff --git a/.env.sample b/.env.sample index 04354c0448..c3108da9a8 100644 --- a/.env.sample +++ b/.env.sample @@ -16,6 +16,7 @@ VITE_BACKEND_BASE_URL="http://127.0.0.1:8000" # Base URL used by browser-side di VITE_FRONTEND_PORT="3001" # Port to run the frontend application VITE_USE_TLS="false" # Use HTTPS/WSS for proxied backend connections VITE_INSECURE_SKIP_VERIFY="false" # Skip TLS certificate verification for proxied backend requests +# VITE_BASE_PATH="/canvas" # Build/serve the SPA under a subpath. Leave unset for root-local development. # App-level PostHog project key — enables useTracking (identified, behaviour analytics). # Events route to https://us.i.posthog.com under this project. Omit to disable the diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 404bb5abdb..f782d9cf59 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -91,10 +91,12 @@ jobs: AGENT_SERVER_IMAGE_BASE=$(node -p "require('./config/defaults.json').images.agentServer") AUTOMATION_VERSION=$(node -p "require('./config/defaults.json').versions.automation") AGENT_CANVAS_VERSION=$(node -p "require('./package.json').version") + CANVAS_BASE_PATH=$(node -p "require('./config/defaults.json').paths.canvasBasePath") echo "agent_server_version=$AGENT_SERVER_VERSION" >> "$GITHUB_OUTPUT" echo "default_agent_server_image=${AGENT_SERVER_IMAGE_BASE}:${AGENT_SERVER_VERSION}-python" >> "$GITHUB_OUTPUT" echo "default_automation_version=$AUTOMATION_VERSION" >> "$GITHUB_OUTPUT" echo "agent_canvas_version=$AGENT_CANVAS_VERSION" >> "$GITHUB_OUTPUT" + echo "canvas_base_path=$CANVAS_BASE_PATH" >> "$GITHUB_OUTPUT" - name: Compute metadata and tags id: prep @@ -202,6 +204,7 @@ jobs: OPENHANDS_BUILD_GIT_REF=${{ env.RELEVANT_REF }} VITE_APP_ENV=${{ steps.prep.outputs.vite_app_env }} VITE_POSTHOG_CLIENT_KEY=${{ steps.prep.outputs.posthog_client_key }} + VITE_BASE_PATH=${{ steps.config.outputs.canvas_base_path }} cache-from: type=gha cache-to: type=gha,mode=max provenance: true @@ -258,7 +261,6 @@ jobs: (github.event_name == 'pull_request' && !github.event.pull_request.head.repo.fork) runs-on: ubuntu-24.04 - steps: - name: Download build info artifacts uses: actions/download-artifact@v8 diff --git a/AGENTS.md b/AGENTS.md index 6102d5dba3..e6b662df8c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,6 +12,7 @@ - `VITE_SESSION_API_KEY` for optional session auth. - `VITE_WORKING_DIR` for the default workspace path sent when starting conversations. - `VITE_ENABLE_BROWSER_TOOLS=false` to omit `BrowserToolSet` from new conversation payloads. + - `VITE_BASE_PATH` for serving the SPA under a subpath such as `/canvas`; pair it with `scripts/static-server.mjs --base-path` at runtime. - Public skills are loaded from the `@openhands/extensions` npm package at build time via `SKILLS_CATALOG` (exported from `@openhands/extensions/skills`). The frontend's `SkillsService` maps catalog entries to `SkillInfo` objects and merges them with user/project skills fetched from the agent-server (with `load_public: false`). The agent-server no longer clones the extensions repo or uses `EXTENSIONS_REF` for public skills. - Default working-dir fallback is now the relative path `workspace/project` (exported as `DEFAULT_WORKING_DIR` from `src/api/agent-server-config.ts`); git-path heuristics and the default PLAN preview path should reuse that constant instead of hardcoding `/workspace/project`. - The UI keeps most OpenHands routes/layout intact, but hosted-only behavior (org, account management, integrations) has been removed via the fabricated OSS config because there is no separate app backend. diff --git a/README.md b/README.md index 2dda636392..d04824eb12 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,5 @@ +
OpenHands logo

Agent Canvas

@@ -35,19 +36,17 @@ It runs locally on your machine by default, but can connect to multiple “agent Agent Canvas runs the open source OpenHands agent out-of-the-box, but can use any third-party agent like Claude Code and Codex. -| | | -|---|---| -| [**Self-host your way**](https://docs.openhands.dev/openhands/usage/agent-canvas/backend-setup/vm) | Run agents locally, in Docker, on VMs, or anywhere you can run an agent server backend | -| [**Switch between different backends**](https://docs.openhands.dev/openhands/usage/agent-canvas/backends) | Switch between local, remote, and cloud agents without losing focus | -| [**Create automations**](https://docs.openhands.dev/openhands/usage/agent-canvas/prebuilt-automations) | Create automations and workflows that integrate with Slack, GitHub, Linear, and more. Run on a schedule or in response to webhook events | -| [**Integrate with the tools you use**](https://docs.openhands.dev/openhands/usage/agent-canvas/prebuilt-automations) | Connect your automations with third-party services like Slack, GitHub, Notion, and more to automate workflows | -| [**Bring your own model**](https://docs.openhands.dev/openhands/usage/settings/llm-settings#llm-profiles) | Use with any LLM | -| [**Use with any agent**](https://docs.openhands.dev/openhands/usage/agent-canvas/acp-agents) | Use with OpenHands, Claude Code, Codex, Gemini, or any agent with Agent-Client Protocol (ACP). | - +| | | +| -------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- | +| [**Self-host your way**](https://docs.openhands.dev/openhands/usage/agent-canvas/backend-setup/vm) | Run agents locally, in Docker, on VMs, or anywhere you can run an agent server backend | +| [**Switch between different backends**](https://docs.openhands.dev/openhands/usage/agent-canvas/backends) | Switch between local, remote, and cloud agents without losing focus | +| [**Create automations**](https://docs.openhands.dev/openhands/usage/agent-canvas/prebuilt-automations) | Create automations and workflows that integrate with Slack, GitHub, Linear, and more. Run on a schedule or in response to webhook events | +| [**Integrate with the tools you use**](https://docs.openhands.dev/openhands/usage/agent-canvas/prebuilt-automations) | Connect your automations with third-party services like Slack, GitHub, Notion, and more to automate workflows | +| [**Bring your own model**](https://docs.openhands.dev/openhands/usage/settings/llm-settings#llm-profiles) | Use with any LLM | +| [**Use with any agent**](https://docs.openhands.dev/openhands/usage/agent-canvas/acp-agents) | Use with OpenHands, Claude Code, Codex, Gemini, or any agent with Agent-Client Protocol (ACP). | If you have questions or feedback, please open a GitHub issue or join the [#proj-agent-canvas channel in Slack](https://openhands.dev/joinslack). - ## Quickstart You can install OpenHands to run agents on any machine: on your laptop, on a dedicated computer like a Mac Mini, @@ -120,7 +119,7 @@ npm run dev --- -Access the UI at [http://localhost:8000](http://localhost:8000). You can add additional backends directly from the UI. +Access the UI at [http://localhost:8000](http://localhost:8000) for the npm/source launchers, or [http://localhost:8000/canvas](http://localhost:8000/canvas) for the Docker image. You can add additional backends directly from the UI. # Architecture diff --git a/README.windows.md b/README.windows.md index d6efca1d39..58a659c3b0 100644 --- a/README.windows.md +++ b/README.windows.md @@ -24,4 +24,6 @@ docker run -it --rm ` ghcr.io/openhands/agent-canvas:1.4.0 # x-release-please-version ``` +Open [http://localhost:8000/canvas](http://localhost:8000/canvas) in your browser. + The agent will be able to access any project under `PROJECTS_PATH`. diff --git a/__tests__/root.test.tsx b/__tests__/root.test.tsx index 3d8faa27e1..2eece23d09 100644 --- a/__tests__/root.test.tsx +++ b/__tests__/root.test.tsx @@ -714,4 +714,21 @@ describe("App root document links", () => { href: "/favicon.svg", }); }); + + it("prefixes document links when Canvas is mounted under a base path", () => { + // Arrange + vi.stubEnv("VITE_BASE_PATH", "/canvas"); + + // Act + const documentLinks = links(); + + // Assert + expect(documentLinks).toContainEqual({ + rel: "icon", + type: "image/svg+xml", + href: "/canvas/favicon.svg", + }); + + vi.unstubAllEnvs(); + }); }); diff --git a/__tests__/scripts/static-server.test.ts b/__tests__/scripts/static-server.test.ts index 4b8e52313c..93d7555835 100644 --- a/__tests__/scripts/static-server.test.ts +++ b/__tests__/scripts/static-server.test.ts @@ -25,12 +25,16 @@ describe("static-server.mjs", () => { } }); - async function startServer(dir: string) { + async function startServer( + dir: string, + overrides: Partial[0]> = {}, + ) { const server = await startStaticServer({ port: 0, host: "127.0.0.1", dir, routes: {}, + ...overrides, }); servers.push(server); @@ -81,6 +85,21 @@ describe("static-server.mjs", () => { expect(config.lockToCloud).toBeNull(); }); + it("defaults basePath to root", () => { + const config = parseArgs([]); + expect(config.basePath).toBe("/"); + }); + + it("parses and normalizes --base-path", () => { + const config = parseArgs(["--base-path", "canvas/"]); + expect(config.basePath).toBe("/canvas"); + }); + + it("treats empty string as root for basePath", () => { + const config = parseArgs(["--base-path", ""]); + expect(config.basePath).toBe("/"); + }); + it("parses --runtime-services-info", () => { const json = '{"mode":"docker"}'; const config = parseArgs(["--runtime-services-info", json]); @@ -404,6 +423,76 @@ describe("static-server.mjs", () => { await expect(response.text()).resolves.toContain("loaded = true"); }); + describe("base path mounting", () => { + it("serves index.html and injects the base path under the mount", async () => { + const buildDir = mkdtempSync(path.join(tmpdir(), "agent-canvas-build-")); + tempDirs.push(buildDir); + writeFileSync( + path.join(buildDir, "index.html"), + "app", + ); + + const origin = await startServer(buildDir, { basePath: "/canvas" }); + const response = await fetch(`${origin}/canvas/conversations/abc`); + const body = await response.text(); + + expect(response.status).toBe(200); + expect(body).toContain("window.__AGENT_CANVAS_BASE_PATH__"); + expect(body).toContain('"/canvas"'); + }); + + it("serves static assets from underneath the mount", async () => { + const buildDir = mkdtempSync(path.join(tmpdir(), "agent-canvas-build-")); + tempDirs.push(buildDir); + mkdirSync(path.join(buildDir, "assets")); + writeFileSync(path.join(buildDir, "index.html"), "
app
"); + writeFileSync( + path.join(buildDir, "assets", "entry.client-test.js"), + "export const loaded = true;\n", + ); + + const origin = await startServer(buildDir, { basePath: "/canvas" }); + const response = await fetch( + `${origin}/canvas/assets/entry.client-test.js`, + ); + + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).toContain( + "application/javascript", + ); + await expect(response.text()).resolves.toContain("loaded = true"); + }); + + it("redirects app routes outside the mount to the configured base path", async () => { + const buildDir = mkdtempSync(path.join(tmpdir(), "agent-canvas-build-")); + tempDirs.push(buildDir); + writeFileSync(path.join(buildDir, "index.html"), "
app
"); + + const origin = await startServer(buildDir, { basePath: "/canvas" }); + const response = await fetch(`${origin}/conversations/abc?tab=files`, { + redirect: "manual", + }); + + expect(response.status).toBe(308); + expect(response.headers.get("location")).toBe( + "/canvas/conversations/abc?tab=files", + ); + }); + + it("does not redirect asset-like requests outside the configured mount", async () => { + const buildDir = mkdtempSync(path.join(tmpdir(), "agent-canvas-build-")); + tempDirs.push(buildDir); + writeFileSync(path.join(buildDir, "index.html"), "
app
"); + + const origin = await startServer(buildDir, { basePath: "/canvas" }); + const response = await fetch(`${origin}/assets/missing.js`, { + redirect: "manual", + }); + + expect(response.status).toBe(404); + }); + }); + it("keeps paths confined to the static directory", async () => { const parentDir = mkdtempSync(path.join(tmpdir(), "agent-canvas-parent-")); tempDirs.push(parentDir); diff --git a/__tests__/utils/base-path.test.ts b/__tests__/utils/base-path.test.ts new file mode 100644 index 0000000000..08a5145887 --- /dev/null +++ b/__tests__/utils/base-path.test.ts @@ -0,0 +1,34 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + buildAgentCanvasPath, + getAgentCanvasBasePath, +} from "#/utils/base-path"; + +afterEach(() => { + vi.unstubAllEnvs(); + delete (window as unknown as Record) + .__AGENT_CANVAS_BASE_PATH__; +}); + +describe("Agent Canvas base path", () => { + it("defaults to root paths", () => { + expect(getAgentCanvasBasePath()).toBe(""); + expect(buildAgentCanvasPath("/settings")).toBe("/settings"); + }); + + it("uses the build-time VITE_BASE_PATH when configured", () => { + vi.stubEnv("VITE_BASE_PATH", "/canvas/"); + + expect(getAgentCanvasBasePath()).toBe("/canvas"); + expect(buildAgentCanvasPath("/settings")).toBe("/canvas/settings"); + }); + + it("falls back to the runtime-injected base path", () => { + vi.stubEnv("VITE_BASE_PATH", ""); + (window as unknown as Record).__AGENT_CANVAS_BASE_PATH__ = + "canvas"; + + expect(getAgentCanvasBasePath()).toBe("/canvas"); + expect(buildAgentCanvasPath("settings")).toBe("/canvas/settings"); + }); +}); diff --git a/config/defaults.json b/config/defaults.json index f6db018102..f165d1a43a 100644 --- a/config/defaults.json +++ b/config/defaults.json @@ -25,7 +25,8 @@ "stateSubdir": "agent-canvas", "conversations": "agent-canvas/conversations", "bashEvents": "agent-canvas/bash_events", - "automationDb": "automation/automations.db" + "automationDb": "automation/automations.db", + "canvasBasePath": "/canvas" }, "packages": { "agentServer": "openhands-agent-server", diff --git a/docker/Dockerfile b/docker/Dockerfile index 4535f23d0c..b5fe63d257 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -42,10 +42,14 @@ COPY . . # VITE_POSTHOG_CLIENT_KEY is the PostHog project key exposed via the web client # config (option-service). Not a secret — it's a public, client-side key. CI # passes the prod key for tagged releases and the staging key otherwise. +# VITE_BASE_PATH is baked into React Router/Vite so the Docker image serves +# Canvas under the Cloud subpath instead of a dedicated hostname. ARG VITE_APP_ENV="" ARG VITE_POSTHOG_CLIENT_KEY="" +ARG VITE_BASE_PATH="/canvas" ENV VITE_APP_ENV=${VITE_APP_ENV} ENV VITE_POSTHOG_CLIENT_KEY=${VITE_POSTHOG_CLIENT_KEY} +ENV VITE_BASE_PATH=${VITE_BASE_PATH} RUN npm run build # ── Stage 1b: Generate shell-sourceable defaults from config/defaults.json ── @@ -62,6 +66,7 @@ RUN node -e " \ 'CONFIG_CONVERSATIONS=' + c.paths.conversations, \ 'CONFIG_BASH_EVENTS=' + c.paths.bashEvents, \ 'CONFIG_AUTOMATION_DB=' + c.paths.automationDb, \ + 'CONFIG_CANVAS_BASE_PATH=' + c.paths.canvasBasePath, \ ]; \ require('fs').writeFileSync('/tmp/defaults.env', lines.join('\n') + '\n'); \ " @@ -73,6 +78,7 @@ ARG AUTOMATION_VERSION ARG AGENT_SERVER_SDK_GIT_REF="" ARG OPENHANDS_BUILD_GIT_SHA=unknown ARG OPENHANDS_BUILD_GIT_REF=unknown +ARG VITE_BASE_PATH="/canvas" ARG AGENT_CANVAS_VERSION=dev LABEL org.opencontainers.image.title="agent-canvas" @@ -83,6 +89,7 @@ LABEL org.opencontainers.image.revision="${OPENHANDS_BUILD_GIT_SHA}" ENV AGENT_CANVAS_BUILD_GIT_SHA=${OPENHANDS_BUILD_GIT_SHA} ENV AGENT_CANVAS_BUILD_GIT_REF=${OPENHANDS_BUILD_GIT_REF} +ENV AGENT_CANVAS_BASE_PATH=${VITE_BASE_PATH} USER root diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index 1e9d303807..d5e709fe71 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -16,6 +16,7 @@ # PORT – Unified entry point port (default: 8000) # AGENT_SERVER_PORT – Internal agent-server port (default: 18000) # AUTOMATION_PORT – Internal automation port (default: 18001) +# AGENT_CANVAS_BASE_PATH – Static frontend mount path (default: /canvas) # PUBLIC_MODE_PORT – If set, starts a second static server on this port # with --auth-required (no session key injected) # OH_SECRET_KEY – Secret key for settings encryption (auto-generated @@ -55,6 +56,7 @@ fi PORT="${PORT:-${CONFIG_PROXY_PORT:-8000}}" AGENT_SERVER_PORT="${AGENT_SERVER_PORT:-${CONFIG_AGENT_SERVER_PORT:-18000}}" AUTOMATION_PORT="${AUTOMATION_PORT:-${CONFIG_AUTOMATION_PORT:-18001}}" +AGENT_CANVAS_BASE_PATH="${AGENT_CANVAS_BASE_PATH:-${CONFIG_CANVAS_BASE_PATH:-/canvas}}" # Persistence paths — keep settings, conversations, bash history under a # single well-known directory that the VOLUME directive exposes. @@ -248,6 +250,7 @@ node /opt/agent-canvas/static-server.mjs \ --port "$PORT" \ --host :: \ --dir /opt/agent-canvas/frontend \ + --base-path "$AGENT_CANVAS_BASE_PATH" \ --session-api-key "$EFFECTIVE_SESSION_KEY" \ --runtime-services-info "$RUNTIME_SERVICES_INFO" \ --route "/api/automation=http://127.0.0.1:${AUTOMATION_PORT}" \ @@ -274,6 +277,7 @@ if [ -n "${PUBLIC_MODE_PORT:-}" ]; then --port "$PUBLIC_MODE_PORT" \ --host :: \ --dir /opt/agent-canvas/frontend \ + --base-path "$AGENT_CANVAS_BASE_PATH" \ --auth-required \ --runtime-services-info "$RUNTIME_SERVICES_INFO" \ --route "/api/automation=http://127.0.0.1:${AUTOMATION_PORT}" \ diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 97f08fc42d..77882451eb 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -161,6 +161,7 @@ You can create a `.env` file in the project directory with these variables based | `VITE_SESSION_API_KEY` | (Internal) Session API key injected by the launcher — set `LOCAL_BACKEND_API_KEY` instead | - | | `VITE_WORKING_DIR` | Workspace path sent when starting new conversations | `workspace/project` | | `VITE_ENABLE_BROWSER_TOOLS` | Set to `false` to omit `BrowserToolSet` from new conversation payloads | `true` | +| `VITE_BASE_PATH` | Build/serve the SPA under a subpath such as `/canvas` | `/` | | `VITE_MOCK_API` | Enable/disable API mocking with MSW | `false` | | `VITE_USE_TLS` | Use HTTPS/WSS for the Vite proxy target | `false` | | `VITE_FRONTEND_PORT` | Port to run the frontend application | `3001` | diff --git a/react-router.config.ts b/react-router.config.ts index b746abd560..ecf0e6a5c6 100644 --- a/react-router.config.ts +++ b/react-router.config.ts @@ -1,6 +1,16 @@ import type { Config } from "@react-router/dev/config"; import { vercelPreset } from "@vercel/react-router/vite"; +const normalizeBasePath = (value?: string) => { + const raw = value?.trim(); + if (!raw || raw === "/") return undefined; + + const withLeadingSlash = raw.startsWith("/") ? raw : `/${raw}`; + return withLeadingSlash.replace(/\/+$/, ""); +}; + +const basename = normalizeBasePath(process.env.VITE_BASE_PATH); + /** * This script is used to unpack the client directory from the frontend build directory. * Remix SPA mode builds the client directory into the build directory. This function @@ -77,6 +87,7 @@ const unpackClientDirectory = async () => { export default { appDirectory: "src", + ...(basename ? { basename } : {}), buildEnd: unpackClientDirectory, presets: [vercelPreset()], ssr: false, diff --git a/scripts/dev-static.mjs b/scripts/dev-static.mjs index ffcefd1f0c..331cca3342 100644 --- a/scripts/dev-static.mjs +++ b/scripts/dev-static.mjs @@ -386,6 +386,9 @@ function startStaticServer(config) { join(config.canvasPath, "build"), "--port", String(config.vitePort), + ...(process.env.VITE_BASE_PATH + ? ["--base-path", process.env.VITE_BASE_PATH] + : []), // Inject the API key so the pre-built frontend can authenticate // to the agent-server without a baked-in VITE_SESSION_API_KEY. ...(config.sessionApiKey diff --git a/scripts/dev-with-automation.mjs b/scripts/dev-with-automation.mjs index 34c45e00a4..2b0dd0b409 100644 --- a/scripts/dev-with-automation.mjs +++ b/scripts/dev-with-automation.mjs @@ -1376,6 +1376,9 @@ function startStaticFrontend(config, staticDir) { staticDir, "--port", String(config.vitePort), + ...(process.env.VITE_BASE_PATH + ? ["--base-path", process.env.VITE_BASE_PATH] + : []), // In local mode, inject the API key so the pre-built frontend can // authenticate transparently. In public mode, pass --auth-required // so the frontend shows the API key entry screen instead. diff --git a/scripts/docker-build.mjs b/scripts/docker-build.mjs index 93338a988e..66a411c95d 100644 --- a/scripts/docker-build.mjs +++ b/scripts/docker-build.mjs @@ -25,6 +25,7 @@ const config = JSON.parse( const agentServerImage = `${config.images.agentServer}:${config.versions.agentServer}-python`; const automationVersion = config.versions.automation; +const canvasBasePath = config.paths.canvasBasePath; // Parse CLI: --tag and everything after -- is passed to docker build let tag = "agent-canvas:local"; @@ -50,6 +51,8 @@ const cmd = [ `AGENT_SERVER_IMAGE=${agentServerImage}`, "--build-arg", `AUTOMATION_VERSION=${automationVersion}`, + "--build-arg", + `VITE_BASE_PATH=${canvasBasePath}`, "-t", tag, ...extraArgs, @@ -58,6 +61,7 @@ const cmd = [ console.log(`Agent Server image : ${agentServerImage}`); console.log(`Automation version : ${automationVersion}`); +console.log(`Canvas base path : ${canvasBasePath}`); console.log(`Tag : ${tag}`); console.log(`\n$ ${cmd.join(" ")}\n`); diff --git a/scripts/static-server.mjs b/scripts/static-server.mjs index 87aacad68f..4feb41c06e 100644 --- a/scripts/static-server.mjs +++ b/scripts/static-server.mjs @@ -86,6 +86,7 @@ export function parseArgs(argv = process.argv.slice(2)) { authRequired: false, runtimeServicesInfo: null, lockToCloud: null, + basePath: "/", }; for (let i = 0; i < argv.length; i++) { @@ -127,6 +128,9 @@ export function parseArgs(argv = process.argv.slice(2)) { case "--lock-to-cloud": config.lockToCloud = argv[++i] || null; break; + case "--base-path": + config.basePath = normalizeBasePath(argv[++i]); + break; case "--auth-required": config.authRequired = true; @@ -166,6 +170,14 @@ export function parseArgs(argv = process.argv.slice(2)) { return config; } +function normalizeBasePath(value) { + const raw = (value ?? "").trim(); + if (!raw || raw === "/") return "/"; + + const withLeadingSlash = raw.startsWith("/") ? raw : `/${raw}`; + return withLeadingSlash.replace(/\/+$/, ""); +} + function showHelp() { console.log(` Combined static file server + reverse proxy. @@ -194,6 +206,9 @@ OPTIONS: --lock-to-cloud Lock backend setup to a single OpenHands Cloud URL. Hides manual/local backend setup and the custom Cloud URL field in the pre-built frontend. + --base-path Mount the SPA under (default: /). + For example, --base-path /canvas serves + index.html and assets under /canvas. --reject-prefix Return 503 for requests matching instead of SPA-fallbacking to index.html; may be repeated. Useful in --frontend-only @@ -247,12 +262,17 @@ ROUTING: * `window.__AGENT_CANVAS_LOCK_TO_CLOUD__`. Read by `getLockedCloudHost()` in * `agent-server-config.ts` so pre-built frontend bundles can hide manual * backend setup and the custom Cloud URL field at runtime. + * + * - `basePath`: the path prefix the SPA is mounted under, exposed as + * `window.__AGENT_CANVAS_BASE_PATH__` so runtime static assets like locale + * files can resolve through the same subpath as the built bundle. */ function makeConfigInjectionScript( sessionApiKey, authRequired, runtimeServicesInfo, lockToCloud, + basePath, ) { const parts = []; @@ -296,6 +316,12 @@ function makeConfigInjectionScript( ); } + if (basePath && basePath !== "/") { + parts.push( + `window.__AGENT_CANVAS_BASE_PATH__=${JSON.stringify(basePath)};`, + ); + } + if (parts.length === 0) return ""; return ``; @@ -309,7 +335,13 @@ async function serveInjectedIndexHtml( req, res, indexPath, - { sessionApiKey, authRequired, runtimeServicesInfo, lockToCloud } = {}, + { + sessionApiKey, + authRequired, + runtimeServicesInfo, + lockToCloud, + basePath, + } = {}, ) { let content; try { @@ -323,6 +355,7 @@ async function serveInjectedIndexHtml( authRequired, runtimeServicesInfo, lockToCloud, + basePath, ); // Inject right before so the key is available before any app code runs. // replace() targets the first (and only) in well-formed HTML. @@ -370,7 +403,8 @@ function needsRuntimeInjection(injectionOpts) { injectionOpts.sessionApiKey || injectionOpts.authRequired || injectionOpts.runtimeServicesInfo || - injectionOpts.lockToCloud, + injectionOpts.lockToCloud || + (injectionOpts.basePath && injectionOpts.basePath !== "/"), ); } @@ -393,6 +427,36 @@ function notFound(res) { res.end("Not Found"); } +function isMountedPath(urlPath, basePath) { + return ( + basePath === "/" || + urlPath === basePath || + urlPath.startsWith(`${basePath}/`) + ); +} + +function stripBasePathFromUrl(rawUrl, basePath) { + if (basePath === "/") return rawUrl; + + const [rawPath = "/", ...rest] = (rawUrl || "/").split("?"); + const suffix = rawPath.slice(basePath.length) || "/"; + const path = suffix.startsWith("/") ? suffix : `/${suffix}`; + return rest.length > 0 ? `${path}?${rest.join("?")}` : path; +} + +function redirectToMountedPath(req, res, urlPath, basePath) { + if (basePath === "/" || !isGetOrHead(req) || looksLikeAssetRequest(urlPath)) { + return false; + } + + const [, query = ""] = (req.url ?? "/").split("?", 2); + const path = urlPath === "/" ? "/" : urlPath; + const location = `${basePath}${path}${query ? `?${query}` : ""}`; + res.writeHead(308, { Location: location }); + res.end(); + return true; +} + function setStaticHeaders(res, pathname) { const extension = extname(pathname).toLowerCase(); if (extension === ".js" || extension === ".mjs") { @@ -421,29 +485,46 @@ async function handleStatic( staticMiddleware, injectionOpts = {}, rejectPrefixes = [], + basePath = "/", ) { const urlPath = parseUrlPath(req, res); if (urlPath === null) return; + if (!isMountedPath(urlPath, basePath)) { + if (matchesAnyPrefix(urlPath, rejectPrefixes)) { + rejectUnavailable(res); + return; + } + if (!redirectToMountedPath(req, res, urlPath, basePath)) notFound(res); + return; + } + + const mountedUrl = stripBasePathFromUrl(req.url ?? "/", basePath); + const mountedPath = parseUrlPath({ ...req, url: mountedUrl }, res); + if (mountedPath === null) return; + const injectRuntimeConfig = needsRuntimeInjection(injectionOpts); const indexPath = resolve(dirAbs, "index.html"); if ( injectRuntimeConfig && isGetOrHead(req) && - (urlPath === "/" || urlPath === "/index.html") + (mountedPath === "/" || mountedPath === "/index.html") ) { if (await serveInjectedIndexHtml(req, res, indexPath, injectionOpts)) return; } - staticMiddleware(req, res, async () => { - if (matchesAnyPrefix(urlPath, rejectPrefixes)) { + const mountedReq = Object.create(req); + mountedReq.url = mountedUrl; + + staticMiddleware(mountedReq, res, async () => { + if (matchesAnyPrefix(mountedPath, rejectPrefixes)) { rejectUnavailable(res); return; } - if (isGetOrHead(req) && !looksLikeAssetRequest(urlPath)) { + if (isGetOrHead(req) && !looksLikeAssetRequest(mountedPath)) { if (await serveInjectedIndexHtml(req, res, indexPath, injectionOpts)) { return; } @@ -466,7 +547,9 @@ export function startStaticServer(config) { authRequired: config.authRequired || false, runtimeServicesInfo: config.runtimeServicesInfo || null, lockToCloud: config.lockToCloud || null, + basePath: normalizeBasePath(config.basePath), }; + const basePath = injectionOpts.basePath; const rejectPrefixes = config.rejectPrefixes ?? []; const staticMiddleware = createStaticMiddleware(dirAbs); @@ -485,6 +568,7 @@ export function startStaticServer(config) { staticMiddleware, injectionOpts, rejectPrefixes, + basePath, ).catch((err) => { console.error(`Static handler error for ${req.url}:`, err); if (!res.headersSent) { @@ -506,11 +590,13 @@ export function startStaticServer(config) { return new Promise((resolveListen) => { server.listen(config.port, config.host, () => { + const displayPath = basePath === "/" ? "/" : `${basePath}/`; console.log(""); console.log( - `Static-server + proxy listening on http://${config.host}:${config.port}/`, + `Static-server + proxy listening on http://${config.host}:${config.port}${displayPath}`, ); console.log(` Static dir: ${dirAbs}`); + console.log(` Base path: ${basePath}`); const sortedRoutes = Object.entries(config.routes).sort( ([a], [b]) => b.length - a.length, ); diff --git a/src/components/features/settings/agent-server-onboarding.tsx b/src/components/features/settings/agent-server-onboarding.tsx index 79fa549dfc..855043c438 100644 --- a/src/components/features/settings/agent-server-onboarding.tsx +++ b/src/components/features/settings/agent-server-onboarding.tsx @@ -12,6 +12,7 @@ import { } from "#/api/backend-registry/default-backend"; import type { Backend } from "#/api/backend-registry/types"; import { cn } from "#/utils/utils"; +import { buildAgentCanvasPath } from "#/utils/base-path"; import { BrandButton } from "./brand-button"; import { SettingsInput } from "./settings-input"; import { I18nKey } from "#/i18n/declaration"; @@ -44,7 +45,7 @@ export function AgentServerConnectionForm({ const shouldShowSectionHeader = showSectionHeader ?? isOnboarding; const reconnect = () => { - window.location.assign("/"); + window.location.assign(buildAgentCanvasPath("/")); }; const syncDefaultBackendInRegistry = () => { diff --git a/src/components/shared/modals/settings/settings-modal.tsx b/src/components/shared/modals/settings/settings-modal.tsx index 1114145ced..23ff004278 100644 --- a/src/components/shared/modals/settings/settings-modal.tsx +++ b/src/components/shared/modals/settings/settings-modal.tsx @@ -8,6 +8,7 @@ import { Settings } from "#/types/settings"; import { DEFAULT_SETTINGS } from "#/services/settings"; import { HelpLink } from "#/ui/help-link"; import { modalTitleClassName } from "#/utils/modal-classes"; +import { buildAgentCanvasPath } from "#/utils/base-path"; interface SettingsModalProps { settings?: Settings; @@ -34,7 +35,7 @@ export function SettingsModal({ onClose, settings }: SettingsModalProps) { testId="advanced-settings-link" text={`${t(I18nKey.SETTINGS$DESCRIPTION)}. ${t(I18nKey.SETTINGS$FOR_OTHER_OPTIONS)} ${t(I18nKey.COMMON$SEE)}`} linkText={t(I18nKey.COMMON$ADVANCED_SETTINGS)} - href="/settings" + href={buildAgentCanvasPath("/settings")} suffix="." size="settings" linkColor="white" diff --git a/src/i18n/index.ts b/src/i18n/index.ts index 188e82354d..08ef7ee554 100644 --- a/src/i18n/index.ts +++ b/src/i18n/index.ts @@ -2,6 +2,7 @@ import { createInstance, type i18n as I18nInstance } from "i18next"; import Backend from "i18next-http-backend"; import LanguageDetector from "i18next-browser-languagedetector"; import { initReactI18next } from "react-i18next"; +import { buildAgentCanvasPath } from "#/utils/base-path"; // Re-export so library consumers (`@openhands/agent-canvas/i18n`) keep working // without pulling the 1 MB `translation.json` into the app build. Rollup drops @@ -46,7 +47,7 @@ const initializeI18n = (instance: I18nInstance) => { defaultNS: OPENHANDS_I18N_NAMESPACE, fallbackNS: OPENHANDS_I18N_NAMESPACE, backend: { - loadPath: "/locales/{{lng}}/{{ns}}.json", + loadPath: buildAgentCanvasPath("/locales/{{lng}}/{{ns}}.json"), }, // React escapes interpolated values at render time; leaving i18next's // default escaping on double-escapes them, turning paths like diff --git a/src/root.tsx b/src/root.tsx index cd888dccf7..37bd76a800 100644 --- a/src/root.tsx +++ b/src/root.tsx @@ -38,6 +38,7 @@ import { LoadingSpinner } from "#/components/shared/loading-spinner"; import { useConfig } from "#/hooks/query/use-config"; import { QUERY_KEYS } from "#/hooks/query/query-keys"; import { AgentServerUIRoot } from "#/components/providers"; +import { buildAgentCanvasPath } from "#/utils/base-path"; import { useOnboardingCompletion } from "#/components/features/onboarding/use-onboarding-completion"; import { NavigationProvider } from "#/context/navigation-context"; import { @@ -208,7 +209,11 @@ function FirstRunOnboardingScreen({ onClose }: { onClose: () => void }) { } export const links: LinksFunction = () => [ - { rel: "icon", type: "image/svg+xml", href: "/favicon.svg" }, + { + rel: "icon", + type: "image/svg+xml", + href: buildAgentCanvasPath("/favicon.svg"), + }, ]; export const meta: MetaFunction = () => [ diff --git a/src/utils/base-path.ts b/src/utils/base-path.ts new file mode 100644 index 0000000000..f2769d179e --- /dev/null +++ b/src/utils/base-path.ts @@ -0,0 +1,33 @@ +const BASE_PATH_WINDOW_KEY = "__AGENT_CANVAS_BASE_PATH__"; + +function normalizeBasePath(value?: string | null): string { + const raw = value?.trim(); + if (!raw || raw === "/") return ""; + + const withLeadingSlash = raw.startsWith("/") ? raw : `/${raw}`; + return withLeadingSlash.replace(/\/+$/, ""); +} + +export function getAgentCanvasBasePath(): string { + const envPath = normalizeBasePath(import.meta.env.VITE_BASE_PATH); + if (envPath) return envPath; + + if (typeof window !== "undefined") { + const injected = (window as unknown as Record)[ + BASE_PATH_WINDOW_KEY + ]; + if (typeof injected === "string") { + return normalizeBasePath(injected); + } + } + + return ""; +} + +export function buildAgentCanvasPath(path: string): string { + const basePath = getAgentCanvasBasePath(); + if (!basePath) return path.startsWith("/") ? path : `/${path}`; + + const normalizedPath = path.startsWith("/") ? path : `/${path}`; + return `${basePath}${normalizedPath}`; +} diff --git a/vite.config.ts b/vite.config.ts index 7ba6f86586..b9d37d69fa 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -25,6 +25,14 @@ const LIB_EXTERNALS = [ ]; const APP_CHUNK_MAX_BYTES = 450 * 1024; +const normalizeBasePath = (value?: string) => { + const raw = value?.trim(); + if (!raw || raw === "/") return "/"; + + const withLeadingSlash = raw.startsWith("/") ? raw : `/${raw}`; + return `${withLeadingSlash.replace(/\/+$/, "")}/`; +}; + // Absolute path to the bundled extensions skills directory in node_modules. // Injected as __EXTENSIONS_SKILLS_DIR__ so agent-server-adapter.ts can pass // real filesystem paths to the Python agent-server (which uses them to @@ -71,6 +79,7 @@ export default defineConfig(({ mode }) => { VITE_USE_TLS = "false", VITE_FRONTEND_PORT = "3001", VITE_INSECURE_SKIP_VERIFY = "false", + VITE_BASE_PATH, } = loadEnv(mode, process.cwd()); const isLibraryBuild = process.env.BUILD_LIB === "true"; @@ -82,8 +91,10 @@ export default defineConfig(({ mode }) => { const API_URL = `${PROTOCOL}://${VITE_BACKEND_HOST}/`; const WS_URL = `${WS_PROTOCOL}://${VITE_BACKEND_HOST}/`; const FE_PORT = Number.parseInt(VITE_FRONTEND_PORT, 10); + const base = normalizeBasePath(VITE_BASE_PATH); return { + base, define: { // Empty string for library builds so consumers aren't bound to this // machine's node_modules path; agent-server-adapter falls back to