Created
July 14, 2026 17:47
-
-
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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