Skip to content

Instantly share code, notes, and snippets.

@arikon
Created July 14, 2026 17:47
Show Gist options
  • Select an option

  • Save arikon/c563c64c1bbbf3150c64a4ac7e3558e0 to your computer and use it in GitHub Desktop.

Select an option

Save arikon/c563c64c1bbbf3150c64a4ac7e3558e0 to your computer and use it in GitHub Desktop.
Proof-of-concept runtime base path patch for OpenHands/agent-canvas v1.3.0
diff --git a/__tests__/scripts/static-server.test.ts b/__tests__/scripts/static-server.test.ts
index 4b8e523..7cc85f0 100644
--- a/__tests__/scripts/static-server.test.ts
+++ b/__tests__/scripts/static-server.test.ts
@@ -43,6 +43,16 @@ describe("static-server.mjs", () => {
}
describe("parseArgs", () => {
+ it("parses and normalizes --base-path", () => {
+ expect(parseArgs(["--base-path", "/tasks/demo/"]).basePath).toBe(
+ "/tasks/demo",
+ );
+ expect(() => parseArgs(["--base-path", "tasks/demo"])).toThrow(
+ /absolute URL path/,
+ );
+ expect(() => parseArgs(["--base-path", "/tasks/../demo"])).toThrow();
+ });
+
it("defaults sessionApiKey to null", () => {
const config = parseArgs([]);
expect(config.sessionApiKey).toBeNull();
@@ -93,6 +103,43 @@ describe("static-server.mjs", () => {
});
});
+ describe("runtime base path", () => {
+ it("serves assets and SPA routes only below the configured prefix", async () => {
+ const buildDir = mkdtempSync(path.join(tmpdir(), "agent-canvas-build-"));
+ tempDirs.push(buildDir);
+ mkdirSync(path.join(buildDir, "assets"));
+ writeFileSync(
+ path.join(buildDir, "index.html"),
+ '<html><head><script type="module" src="./assets/app.js"></script></head><body>app</body></html>',
+ );
+ writeFileSync(path.join(buildDir, "assets", "app.js"), "export {};");
+
+ const server = await startStaticServer({
+ port: 0,
+ host: "127.0.0.1",
+ dir: buildDir,
+ routes: {},
+ basePath: "/tasks/demo",
+ });
+ servers.push(server);
+ const address = server.address();
+ if (!address || typeof address === "string") throw new Error("No port");
+ const origin = `http://127.0.0.1:${address.port}`;
+
+ const page = await fetch(`${origin}/tasks/demo/conversations/123`);
+ const body = await page.text();
+ expect(page.status).toBe(200);
+ expect(body).toContain('<base href="/tasks/demo/">');
+ expect(body).toContain(
+ 'window.__AGENT_CANVAS_BASE_PATH__="/tasks/demo"',
+ );
+ expect((await fetch(`${origin}/tasks/demo/assets/app.js`)).status).toBe(
+ 200,
+ );
+ expect((await fetch(`${origin}/assets/app.js`)).status).toBe(404);
+ });
+ });
+
describe("runtime services info injection", () => {
async function startServerWithRuntimeInfo(
dir: string,
diff --git a/bin/agent-canvas.mjs b/bin/agent-canvas.mjs
index 06732aa..cd3a19a 100755
--- a/bin/agent-canvas.mjs
+++ b/bin/agent-canvas.mjs
@@ -78,6 +78,7 @@ OPTIONS:
--public Enable public mode (see above)
--frontend-only Start only the static frontend behind ingress
--backend-only Start only agent-server + automation behind ingress
+ --base-path <path> Mount the application below this URL path at runtime
-v, --version Show version number
--info Show version and default stack configuration
-h, --help Show this help message
diff --git a/scripts/dev-with-automation.mjs b/scripts/dev-with-automation.mjs
index 34c45e0..0b5f58d 100644
--- a/scripts/dev-with-automation.mjs
+++ b/scripts/dev-with-automation.mjs
@@ -174,6 +174,7 @@ function parseArgs() {
public: false,
frontendOnly: false,
backendOnly: false,
+ basePath: "/",
};
for (let i = 0; i < args.length; i++) {
@@ -213,6 +214,9 @@ function parseArgs() {
case "--backend-only":
config.backendOnly = true;
break;
+ case "--base-path":
+ config.basePath = args[++i];
+ break;
case "-h":
case "--help":
showHelp();
@@ -243,6 +247,7 @@ OPTIONS:
--dynamic Force Vite dev server when a wrapper defaults static
--frontend-only Start only the frontend behind ingress
--backend-only Start only agent-server + automation behind ingress
+ --base-path <path> Mount the static application below this URL path
-v, --verbose Show detailed output
-h, --help Show this help
@@ -448,6 +453,7 @@ async function buildConfig(args, env = process.env) {
isPublic,
frontendOnly,
+ basePath: args.basePath || "/",
backendOnly,
launchFrontend,
launchAgentServer,
@@ -1376,6 +1382,8 @@ function startStaticFrontend(config, staticDir) {
staticDir,
"--port",
String(config.vitePort),
+ "--base-path",
+ config.basePath,
// 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/static-server.mjs b/scripts/static-server.mjs
index 87aacad..ba5a899 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;
@@ -194,6 +198,8 @@ 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> Serve the application below this URL path
+ (default: /). The value is applied at runtime.
--reject-prefix <prefix> Return 503 for requests matching <prefix>
instead of SPA-fallbacking to index.html;
may be repeated. Useful in --frontend-only
@@ -253,9 +259,14 @@ function makeConfigInjectionScript(
authRequired,
runtimeServicesInfo,
lockToCloud,
+ basePath,
) {
const parts = [];
+ parts.push(
+ `window.__AGENT_CANVAS_BASE_PATH__=${JSON.stringify(basePath)};`,
+ );
+
if (sessionApiKey) {
const keyLiteral = JSON.stringify(sessionApiKey);
// Window global — read at module init by getBakedSessionApiKey().
@@ -309,7 +320,7 @@ async function serveInjectedIndexHtml(
req,
res,
indexPath,
- { sessionApiKey, authRequired, runtimeServicesInfo, lockToCloud } = {},
+ { sessionApiKey, authRequired, runtimeServicesInfo, lockToCloud, basePath = "/" } = {},
) {
let content;
try {
@@ -323,11 +334,13 @@ async function serveInjectedIndexHtml(
authRequired,
runtimeServicesInfo,
lockToCloud,
+ basePath,
);
+ const baseElement = `<base href="${basePath === "/" ? "/" : `${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.
const injected = content.includes("</head>")
- ? content.replace("</head>", `${script}\n</head>`)
+ ? content.replace("</head>", `${baseElement}\n${script}\n</head>`)
: content.includes("</body>")
? content.replace("</body>", `${script}\n</body>`)
: script + content;
@@ -366,12 +379,33 @@ function isGetOrHead(req) {
}
function needsRuntimeInjection(injectionOpts) {
- return Boolean(
- injectionOpts.sessionApiKey ||
- injectionOpts.authRequired ||
- injectionOpts.runtimeServicesInfo ||
- injectionOpts.lockToCloud,
- );
+ return true;
+}
+
+export function normalizeBasePath(value) {
+ if (!value || !/^\/(?:[A-Za-z0-9._~-]+(?:\/[A-Za-z0-9._~-]+)*)?\/?$/.test(value)) {
+ throw new Error("--base-path must be an absolute URL path");
+ }
+ const normalized = value === "/" ? "/" : value.replace(/\/+$/, "");
+ if (
+ normalized.includes("//") ||
+ normalized.split("/").some((part) => part === "." || part === "..")
+ ) {
+ throw new Error(`Invalid --base-path: ${value}`);
+ }
+ return normalized;
+}
+
+function stripBasePath(req, res, basePath) {
+ if (basePath === "/") return true;
+ const current = new URL(req.url ?? "/", "http://localhost");
+ if (current.pathname !== basePath && !current.pathname.startsWith(`${basePath}/`)) {
+ notFound(res);
+ return false;
+ }
+ current.pathname = current.pathname.slice(basePath.length) || "/";
+ req.url = `${current.pathname}${current.search}`;
+ return true;
}
function looksLikeAssetRequest(urlPath) {
@@ -466,6 +500,7 @@ export function startStaticServer(config) {
authRequired: config.authRequired || false,
runtimeServicesInfo: config.runtimeServicesInfo || null,
lockToCloud: config.lockToCloud || null,
+ basePath: config.basePath || "/",
};
const rejectPrefixes = config.rejectPrefixes ?? [];
const staticMiddleware = createStaticMiddleware(dirAbs);
@@ -473,6 +508,7 @@ export function startStaticServer(config) {
const uninstallDiagnostics = proxy.installDiagnostics();
const server = createServer((req, res) => {
+ if (!stripBasePath(req, res, injectionOpts.basePath)) return;
const backend = route(req.url ?? "/");
if (backend) {
proxy.proxyHttp(req, res, backend);
@@ -495,6 +531,19 @@ export function startStaticServer(config) {
});
server.on("upgrade", (req, socket, head) => {
+ if (injectionOpts.basePath !== "/") {
+ const current = new URL(req.url ?? "/", "http://localhost");
+ if (
+ current.pathname !== injectionOpts.basePath &&
+ !current.pathname.startsWith(`${injectionOpts.basePath}/`)
+ ) {
+ socket.destroy();
+ return;
+ }
+ current.pathname =
+ current.pathname.slice(injectionOpts.basePath.length) || "/";
+ req.url = `${current.pathname}${current.search}`;
+ }
const backend = route(req.url ?? "/");
if (backend) {
proxy.proxyWebSocket(req, socket, head, backend);
@@ -511,6 +560,7 @@ export function startStaticServer(config) {
`Static-server + proxy listening on http://${config.host}:${config.port}/`,
);
console.log(` Static dir: ${dirAbs}`);
+ console.log(` Base path: ${injectionOpts.basePath}`);
const sortedRoutes = Object.entries(config.routes).sort(
([a], [b]) => b.length - a.length,
);
diff --git a/src/api/agent-server-config.ts b/src/api/agent-server-config.ts
index fba4d7c..631b1f5 100644
--- a/src/api/agent-server-config.ts
+++ b/src/api/agent-server-config.ts
@@ -129,7 +129,12 @@ export function getAgentServerBaseUrl(): string | null {
if (configuredUrl) return configuredUrl;
if (typeof window !== "undefined") {
- return window.location.origin;
+ const runtimeBasePath = (
+ window as unknown as Record<string, unknown>
+ ).__AGENT_CANVAS_BASE_PATH__;
+ return typeof runtimeBasePath === "string" && runtimeBasePath !== "/"
+ ? `${window.location.origin}${runtimeBasePath}`
+ : window.location.origin;
}
return null;
diff --git a/src/entry.client.tsx b/src/entry.client.tsx
index 8e72274..39caf66 100644
--- a/src/entry.client.tsx
+++ b/src/entry.client.tsx
@@ -14,6 +14,22 @@ import {
import { waitForI18n } from "./i18n";
import { shouldStartMockWorker } from "./mocks/should-start-mock-worker";
+function applyRuntimeBasePath() {
+ const runtime = window as unknown as Record<string, unknown>;
+ const basePath = runtime.__AGENT_CANVAS_BASE_PATH__;
+ const routerContext = runtime.__reactRouterContext;
+
+ if (
+ typeof basePath === "string" &&
+ routerContext &&
+ typeof routerContext === "object"
+ ) {
+ (routerContext as Record<string, unknown>).basename = basePath;
+ }
+}
+
+applyRuntimeBasePath();
+
async function prepareApp() {
await waitForI18n();
diff --git a/src/i18n/index.ts b/src/i18n/index.ts
index 188e823..f43afaf 100644
--- a/src/i18n/index.ts
+++ b/src/i18n/index.ts
@@ -46,7 +46,9 @@ const initializeI18n = (instance: I18nInstance) => {
defaultNS: OPENHANDS_I18N_NAMESPACE,
fallbackNS: OPENHANDS_I18N_NAMESPACE,
backend: {
- loadPath: "/locales/{{lng}}/{{ns}}.json",
+ loadPath: `${
+ document.querySelector("base")?.getAttribute("href") ?? "/"
+ }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/vite.config.ts b/vite.config.ts
index 7ba6f86..0e36a6c 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -84,6 +84,9 @@ export default defineConfig(({ mode }) => {
const FE_PORT = Number.parseInt(VITE_FRONTEND_PORT, 10);
return {
+ // Keep the published bundle relocatable. The runtime static server injects
+ // a <base> element and React Router basename for the selected mount path.
+ 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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment