feat: support serving Canvas under a subpath (#1796)

* feat: support serving canvas under subpath

Co-authored-by: openhands <openhands@all-hands.dev>

* fix: redirect root app routes to canvas base path

Co-authored-by: openhands <openhands@all-hands.dev>

---------

Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: hieptl <hieptl.developer@gmail.com>
This commit is contained in:
Rohit Malhotra
2026-07-15 12:24:11 -04:00
committed by GitHub
co-authored by openhands hieptl
parent 2b7ceea667
commit 519c856c37
23 changed files with 342 additions and 25 deletions
+1
View File
@@ -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
+3 -1
View File
@@ -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
+1
View File
@@ -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.
+10 -11
View File
@@ -1,4 +1,5 @@
<a name="readme-top"></a>
<div align="center">
<img src="https://assets.openhands.dev/logo-whitebackground.png" alt="OpenHands logo" width="340">
<h1 align="center" style="border-bottom: none">Agent Canvas</h1>
@@ -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
+2
View File
@@ -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`.
+17
View File
@@ -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();
});
});
+90 -1
View File
@@ -25,12 +25,16 @@ describe("static-server.mjs", () => {
}
});
async function startServer(dir: string) {
async function startServer(
dir: string,
overrides: Partial<Parameters<typeof startStaticServer>[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"),
"<html><head></head><body>app</body></html>",
);
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"), "<main>app</main>");
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"), "<main>app</main>");
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"), "<main>app</main>");
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);
+34
View File
@@ -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<string, unknown>)
.__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<string, unknown>).__AGENT_CANVAS_BASE_PATH__ =
"canvas";
expect(getAgentCanvasBasePath()).toBe("/canvas");
expect(buildAgentCanvasPath("settings")).toBe("/canvas/settings");
});
});
+2 -1
View File
@@ -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",
+7
View File
@@ -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
+4
View File
@@ -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}" \
+1
View File
@@ -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` |
+11
View File
@@ -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,
+3
View File
@@ -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
+3
View File
@@ -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.
+4
View File
@@ -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 <name> 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`);
+93 -7
View File
@@ -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 <cloud-url> 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 <path> Mount the SPA under <path> (default: /).
For example, --base-path /canvas serves
index.html and assets under /canvas.
--reject-prefix <prefix> Return 503 for requests matching <prefix>
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 `<script>(function(){${parts.join("")}}());</script>`;
@@ -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 </head> so the key is available before any app code runs.
// replace() targets the first (and only) </head> 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,
);
@@ -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 = () => {
@@ -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"
+2 -1
View File
@@ -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
+6 -1
View File
@@ -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 = () => [
+33
View File
@@ -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<string, unknown>)[
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}`;
}
+11
View File
@@ -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