mirror of
https://github.com/OpenHands/OpenHands.git
synced 2026-09-28 05:54:50 +08:00
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:
committed by
GitHub
co-authored by
openhands
hieptl
parent
2b7ceea667
commit
519c856c37
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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}" \
|
||||
|
||||
@@ -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` |
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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`);
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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 = () => [
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user