Files
OpenHands/scripts/static-server.mjs
T
chuckbutkusandopenhands 578589af7f fix(static-server): expose runtime session key via window global (#1091)
The published `agent-canvas` binary is built with no `VITE_SESSION_API_KEY`
baked in. At runtime, `scripts/static-server.mjs --session-api-key <key>`
injects the key into `index.html`, but only as a write to
`localStorage["openhands-agent-server-config"].sessionApiKey`.

PR #1046 ("Simplify backend registry selection") removed the legacy
localStorage fallback from `getAgentServerSessionApiKey()`, leaving it
reading only `import.meta.env.VITE_SESSION_API_KEY`. The combination
broke the first-launch experience for users who installed the npm
package globally:

  1. Fresh browser → `localStorage["openhands-backends"]` is null.
  2. `readLegacyBackend()` requires `baseUrl` AND `sessionApiKey` in
     `openhands-agent-server-config`; the injection only writes the key,
     so it returns null.
  3. `makeDefaultLocalBackend()` reads `VITE_SESSION_API_KEY` → null →
     returns null.
  4. Registry seeds empty → `MissingAgentServerScreen` renders the
     Manage Backends modal ("No extra backends added yet.") with no
     way out.

The fix mirrors how `__AGENT_CANVAS_AUTH_REQUIRED__` already passes the
public-mode flag from `static-server.mjs` to the bundle:

  - `static-server.mjs` now also injects
    `window.__AGENT_CANVAS_SESSION_API_KEY__ = <key>` in the `<head>`
    script. The window global is set first so it is available even if
    the localStorage write throws (private mode, etc.).
  - `getBakedSessionApiKey()` in `src/api/agent-server-config.ts` falls
    back to the window global when `VITE_SESSION_API_KEY` is empty.

Tests:
  - Unit tests in `__tests__/api/agent-server-config.test.ts` cover the
    window-global fallback, env-takes-precedence ordering, whitespace,
    and non-string injected values.
  - `__tests__/scripts/static-server.test.ts` asserts the new window
    assignment lands before the localStorage write.
  - A new mock-LLM E2E spec in
    `tests/e2e/mock-llm/mock-llm-auth-modes.spec.ts` exercises the
    exact user scenario: a fresh browser context with no pre-seeded
    localStorage must reach the onboarding modal (not the Manage
    Backends trap modal) when launched against the prebuilt stack.
  - Updated docstrings in the auth-modes spec and helper to remove
    references to the deleted `syncBakedSessionApiKey()` function.

AGENTS.md updated to document the window-global path and the current
key-rotation reconciliation (`syncLauncherDefaultLocalBackend()` in
`storage.ts`).

Co-authored-by: openhands <openhands@all-hands.dev>
2026-06-03 13:46:25 -04:00

614 lines
21 KiB
JavaScript

/**
* Combined static file server + reverse proxy.
*
* Replaces `sirv-cli` for the static launcher. The reason a plain static
* server is not enough: Vite's dev server (used by `npm run dev`) configures
* a proxy for `/api`, `/sockets`, `/server_info`, `/alive`, `/health`,
* `/ready`, `/docs`, `/redoc`, `/openapi.json` (see vite.config.ts) so
* requests to those paths are forwarded to
* the agent-server even when the browser is hitting Vite directly on :3001.
* sirv-cli has no proxy support, so under `--single` it falls back to
* index.html for any of those paths — making `/server_info` look like HTML
* to the SPA when the user hits the static port directly (e.g. via a tunnel
* that exposes :3001).
*
* This script provides Vite-equivalent behaviour: serve static files from
* --dir, fall back to index.html for HTML navigations, and reverse-proxy
* configured prefixes to upstream backends. The proxy + WebSocket logic is
* deliberately kept identical in spirit to scripts/ingress.mjs so the two
* servers route the same way.
*
* Usage (mirrors scripts/ingress.mjs's --route flag style):
* node scripts/static-server.mjs \
* --port 3001 --host 0.0.0.0 --dir build \
* --route "/api/automation=http://localhost:18001" \
* --route "/api=http://localhost:18000" \
* --route "/server_info=http://localhost:18000" \
* --route "/sockets=http://localhost:18000"
*/
import { createServer, request as httpRequest } from "node:http";
import { createReadStream } from "node:fs";
import { readFile, stat } from "node:fs/promises";
import { extname, isAbsolute, normalize, relative, resolve } from "node:path";
import process from "node:process";
import { pathToFileURL } from "node:url";
// ─────────────────────────────────────────────────────────────────────────────
// MIME types
// ─────────────────────────────────────────────────────────────────────────────
const MIME = {
".html": "text/html; charset=utf-8",
".js": "application/javascript; charset=utf-8",
".mjs": "application/javascript; charset=utf-8",
".css": "text/css; charset=utf-8",
".json": "application/json; charset=utf-8",
".svg": "image/svg+xml",
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".gif": "image/gif",
".ico": "image/x-icon",
".webp": "image/webp",
".woff": "font/woff",
".woff2": "font/woff2",
".ttf": "font/ttf",
".wav": "audio/wav",
".mp3": "audio/mpeg",
".webmanifest": "application/manifest+json",
".txt": "text/plain; charset=utf-8",
".xml": "application/xml",
".map": "application/json",
};
// ─────────────────────────────────────────────────────────────────────────────
// Args
// ─────────────────────────────────────────────────────────────────────────────
export function parseArgs(argv = process.argv.slice(2)) {
const config = {
port: 3001,
host: "0.0.0.0",
dir: "build",
routes: {},
rejectPrefixes: [],
sessionApiKey: null,
authRequired: false,
};
for (let i = 0; i < argv.length; i++) {
const flag = argv[i];
switch (flag) {
case "-p":
case "--port":
config.port = Number.parseInt(argv[++i], 10);
break;
case "-H":
case "--host":
config.host = argv[++i];
break;
case "-d":
case "--dir":
config.dir = argv[++i];
break;
case "-r":
case "--route": {
const value = argv[++i];
const eq = value.indexOf("=");
if (eq < 0) {
throw new Error(`Invalid --route (expected /prefix=url): ${value}`);
}
const prefix = value.slice(0, eq);
const url = value.slice(eq + 1);
if (!prefix.startsWith("/")) {
throw new Error(`--route prefix must start with '/': ${prefix}`);
}
config.routes[prefix] = url;
break;
}
case "--session-api-key":
config.sessionApiKey = argv[++i] || null;
break;
case "--auth-required":
config.authRequired = true;
break;
case "--reject-prefix": {
const prefix = argv[++i];
if (!prefix || !prefix.startsWith("/")) {
throw new Error(
`--reject-prefix value must start with '/': ${prefix ?? "(empty)"}`,
);
}
config.rejectPrefixes.push(prefix);
break;
}
case "-h":
case "--help":
showHelp();
process.exit(0);
default:
throw new Error(`Unknown flag: ${flag}`);
}
}
// Guard: --session-api-key and --auth-required are semantically
// mutually exclusive. The first auto-injects the key (local mode);
// the second forces the user to paste it (public mode). Combining
// both is a misconfiguration.
if (config.sessionApiKey && config.authRequired) {
console.error(
"ERROR: --session-api-key and --auth-required are mutually exclusive.\n" +
" Use --session-api-key for local mode (key auto-injected).\n" +
" Use --auth-required for public mode (user pastes key).",
);
process.exit(1);
}
return config;
}
function showHelp() {
console.log(`
Combined static file server + reverse proxy.
USAGE:
node scripts/static-server.mjs [options]
OPTIONS:
-p, --port <port> Port to bind (default: 3001)
-H, --host <host> Hostname to bind (default: 0.0.0.0)
-d, --dir <dir> Directory to serve (default: build)
-r, --route <prefix=url> Proxy <prefix> (and subpaths) to <url>;
may be repeated. WebSockets supported.
--session-api-key <key> Inject session API key into index.html so the
pre-built frontend authenticates to agent-server
without needing VITE_SESSION_API_KEY baked in.
--auth-required Inject authRequired flag into index.html so the
pre-built frontend shows the API key entry screen
(public mode) without VITE_AUTH_REQUIRED baked in.
--reject-prefix <prefix> Return 503 for requests matching <prefix>
instead of SPA-fallbacking to index.html;
may be repeated. Useful in --frontend-only
mode to cleanly reject API paths.
-h, --help Show this help
ROUTING:
• Routes are matched by longest prefix first (most-specific wins).
• Reject prefixes are checked before SPA fallback — matching requests
get 503 immediately.
• Anything that does not match a route or reject prefix is served
from --dir.
• Unknown paths fall back to index.html (SPA mode), unless they look
like an asset request (have a known file extension), in which case
a 404 is returned.
`);
}
// ─────────────────────────────────────────────────────────────────────────────
// Runtime config injection
// ─────────────────────────────────────────────────────────────────────────────
/**
* Build a tiny inline script that seeds runtime config into the page.
*
* - `sessionApiKey`: exposed to the app two ways so a fresh-localStorage
* browser can authenticate even though the published bundle has no
* VITE_SESSION_API_KEY baked in:
* 1. `window.__AGENT_CANVAS_SESSION_API_KEY__` — read by
* `getBakedSessionApiKey()` in `agent-server-config.ts` as a fallback
* when the env var is empty. This is symmetric with how
* `__AGENT_CANVAS_AUTH_REQUIRED__` works for the auth-required flag.
* 2. Written to `openhands-agent-server-config.sessionApiKey` in
* localStorage for compatibility with the legacy storage key. Useful
* for any code path that still reads it (e.g. e2e test fixtures).
* Always overwrites when the stored value differs so a rotated key
* is not shadowed by a stale one.
*
* - `authRequired`: sets `window.__AGENT_CANVAS_AUTH_REQUIRED__ = true` so the
* pre-built frontend shows the API key entry screen (public mode) without
* VITE_AUTH_REQUIRED baked in.
*/
function makeConfigInjectionScript(sessionApiKey, authRequired) {
const parts = [];
if (sessionApiKey) {
const keyLiteral = JSON.stringify(sessionApiKey);
// Window global — read at module init by getBakedSessionApiKey().
// Set first so it's available even if the localStorage write throws.
parts.push(`window.__AGENT_CANVAS_SESSION_API_KEY__=${keyLiteral};`);
// Always overwrite when the stored key differs from the runtime key.
// A previous session may have persisted a now-stale key; the runtime
// value (from --session-api-key) is the server's truth.
parts.push(
`try{` +
`var _k='openhands-agent-server-config',` +
`_c=JSON.parse(localStorage.getItem(_k)||'{}');` +
`if(_c.sessionApiKey!==${keyLiteral}){` +
`_c.sessionApiKey=${keyLiteral};` +
`localStorage.setItem(_k,JSON.stringify(_c));` +
`}` +
`}catch(e){}`,
);
}
if (authRequired) {
parts.push(`window.__AGENT_CANVAS_AUTH_REQUIRED__=true;`);
}
if (parts.length === 0) return "";
return `<script>(function(){${parts.join("")}}());</script>`;
}
/**
* Serve index.html with runtime config injected into <head>.
* Returns true if the response was written, false if the file was not found.
*/
async function serveInjectedIndexHtml(
req,
res,
indexPath,
{ sessionApiKey, authRequired } = {},
) {
let content;
try {
content = await readFile(indexPath, "utf8");
} catch {
return false;
}
const script = makeConfigInjectionScript(sessionApiKey, authRequired);
// 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.includes("</body>")
? content.replace("</body>", `${script}\n</body>`)
: script + content;
const buf = Buffer.from(injected, "utf8");
res.writeHead(200, {
"Content-Type": "text/html; charset=utf-8",
"Content-Length": buf.length,
"Cache-Control": "no-cache",
});
if (req.method !== "HEAD") res.end(buf);
return true;
}
// ─────────────────────────────────────────────────────────────────────────────
// Router (kept structurally identical to scripts/ingress.mjs)
// ─────────────────────────────────────────────────────────────────────────────
export function createRouter(routes) {
const sortedRoutes = Object.entries(routes).sort(
([a], [b]) => b.length - a.length,
);
return function route(url) {
for (const [prefix, backend] of sortedRoutes) {
if (
url === prefix ||
url.startsWith(prefix + "/") ||
url.startsWith(prefix + "?")
) {
return backend;
}
}
return null;
};
}
function parseBackendUrl(backendUrl) {
const url = new URL(backendUrl);
return {
hostname: url.hostname,
port:
Number.parseInt(url.port, 10) || (url.protocol === "https:" ? 443 : 80),
protocol: url.protocol,
};
}
function proxyRequest(req, res, backendUrl) {
const backend = parseBackendUrl(backendUrl);
const proxyReq = httpRequest(
{
hostname: backend.hostname,
port: backend.port,
path: req.url,
method: req.method,
headers: {
...req.headers,
host: `${backend.hostname}:${backend.port}`,
},
},
(proxyRes) => {
res.writeHead(proxyRes.statusCode ?? 502, proxyRes.headers);
proxyRes.pipe(res, { end: true });
},
);
proxyReq.on("error", (err) => {
console.error(`Proxy error for ${req.url} -> ${backendUrl}:`, err.message);
if (!res.headersSent) {
res.writeHead(502);
res.end(`Bad Gateway: ${err.message}`);
}
});
req.pipe(proxyReq, { end: true });
}
function proxyWebSocket(req, socket, head, backendUrl) {
const backend = parseBackendUrl(backendUrl);
const proxyReq = httpRequest({
hostname: backend.hostname,
port: backend.port,
path: req.url,
method: req.method,
headers: {
...req.headers,
host: `${backend.hostname}:${backend.port}`,
},
});
proxyReq.on("upgrade", (proxyRes, proxySocket, proxyHead) => {
socket.write(
`HTTP/${proxyRes.httpVersion} ${proxyRes.statusCode} ${proxyRes.statusMessage}\r\n`,
);
for (let i = 0; i < proxyRes.rawHeaders.length; i += 2) {
socket.write(
`${proxyRes.rawHeaders[i]}: ${proxyRes.rawHeaders[i + 1]}\r\n`,
);
}
socket.write("\r\n");
if (proxyHead.length > 0) {
socket.write(proxyHead);
}
proxySocket.pipe(socket, { end: true });
socket.pipe(proxySocket, { end: true });
});
proxyReq.on("error", (err) => {
console.error(
`WebSocket proxy error for ${req.url} -> ${backendUrl}:`,
err.message,
);
socket.destroy();
});
proxyReq.end();
}
// ─────────────────────────────────────────────────────────────────────────────
// Static file serving
// ─────────────────────────────────────────────────────────────────────────────
async function tryStat(path) {
try {
const result = await stat(path);
return result.isFile() ? result : null;
} catch {
return null;
}
}
function makeEtag(stats) {
// Match sirv's weak-ETag format: W/"<size>-<mtime ms>"
return `W/"${stats.size}-${Math.floor(stats.mtimeMs)}"`;
}
function pickContentType(filePath) {
return MIME[extname(filePath).toLowerCase()] || "application/octet-stream";
}
function pickCacheControl(urlPath) {
// Vite/react-router builds emit content-hashed assets under /assets/.
// Those are safe to cache forever; everything else (index.html, public/
// copies, locales) should revalidate so a rebuild is picked up.
if (urlPath.startsWith("/assets/")) {
return "public, max-age=31536000, immutable";
}
return "no-cache";
}
function looksLikeAssetRequest(urlPath) {
// If the last path segment has a known file extension, treat 404s as 404
// instead of falling back to the SPA shell. Avoids serving index.html for
// missing /favicon.ico, missing source maps, etc.
const last = urlPath.split("/").pop() ?? "";
const ext = extname(last).toLowerCase();
return Boolean(ext) && ext in MIME;
}
function isPathInsideDir(dirAbs, filePath) {
const relativePath = relative(dirAbs, filePath);
return (
relativePath === "" ||
(!relativePath.startsWith("..") && !isAbsolute(relativePath))
);
}
async function serveFile(req, res, filePath, urlPath) {
const stats = await tryStat(filePath);
if (!stats) return false;
const etag = makeEtag(stats);
if (req.headers["if-none-match"] === etag) {
res.writeHead(304, { ETag: etag });
res.end();
return true;
}
res.writeHead(200, {
"Content-Type": pickContentType(filePath),
"Content-Length": stats.size,
"Cache-Control": pickCacheControl(urlPath),
ETag: etag,
});
if (req.method === "HEAD") {
res.end();
return true;
}
createReadStream(filePath).pipe(res);
return true;
}
async function handleStatic(
req,
res,
dirAbs,
injectionOpts = {},
rejectPrefixes = [],
) {
const rawPath = req.url.split("?")[0];
let urlPath;
try {
urlPath = decodeURIComponent(rawPath);
} catch {
res.writeHead(400);
res.end("Bad Request");
return;
}
const safe = normalize(urlPath);
let filePath = resolve(dirAbs, "." + safe);
if (!isPathInsideDir(dirAbs, filePath)) {
res.writeHead(403);
res.end("Forbidden");
return;
}
// Directory request -> /index.html
if (urlPath.endsWith("/")) {
filePath = resolve(filePath, "index.html");
}
const needsInjection =
injectionOpts.sessionApiKey || injectionOpts.authRequired;
// Serve index.html with runtime config injection when configured.
if (needsInjection && filePath.endsWith("index.html")) {
if (await serveInjectedIndexHtml(req, res, filePath, injectionOpts)) return;
// Fall through to regular serveFile (handles 404 path correctly).
}
if (await serveFile(req, res, filePath, urlPath)) return;
// Reject prefixes: return 503 for known API paths that have no backend
// configured (e.g. in --frontend-only mode). Checked before SPA fallback
// so these paths never silently serve index.html.
if (rejectPrefixes.length > 0) {
for (const prefix of rejectPrefixes) {
if (
urlPath === prefix ||
urlPath.startsWith(prefix + "/") ||
urlPath.startsWith(prefix + "?")
) {
res.writeHead(503, { "Content-Type": "text/plain; charset=utf-8" });
res.end("Service Unavailable (no backend configured for this route)");
return;
}
}
}
// SPA fallback: only for non-asset requests, and not for non-GET/HEAD.
if (
(req.method === "GET" || req.method === "HEAD") &&
!looksLikeAssetRequest(urlPath)
) {
const indexPath = resolve(dirAbs, "index.html");
if (needsInjection) {
if (await serveInjectedIndexHtml(req, res, indexPath, injectionOpts))
return;
} else if (await serveFile(req, res, indexPath, "/")) return;
}
res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
res.end("Not Found");
}
// ─────────────────────────────────────────────────────────────────────────────
// Server
// ─────────────────────────────────────────────────────────────────────────────
export function startStaticServer(config) {
const route = createRouter(config.routes);
const dirAbs = resolve(config.dir);
const injectionOpts = {
sessionApiKey: config.sessionApiKey || null,
authRequired: config.authRequired || false,
};
const rejectPrefixes = config.rejectPrefixes ?? [];
const server = createServer((req, res) => {
const backend = route(req.url);
if (backend) {
proxyRequest(req, res, backend);
return;
}
handleStatic(req, res, dirAbs, injectionOpts, rejectPrefixes).catch((err) => {
console.error(`Static handler error for ${req.url}:`, err);
if (!res.headersSent) {
res.writeHead(500);
res.end("Internal Server Error");
}
});
});
server.on("upgrade", (req, socket, head) => {
const backend = route(req.url);
if (backend) {
proxyWebSocket(req, socket, head, backend);
return;
}
socket.destroy();
});
return new Promise((resolveListen) => {
server.listen(config.port, config.host, () => {
console.log("");
console.log(
`Static-server + proxy listening on http://${config.host}:${config.port}/`,
);
console.log(` Static dir: ${dirAbs}`);
const sortedRoutes = Object.entries(config.routes).sort(
([a], [b]) => b.length - a.length,
);
for (const [prefix, backend] of sortedRoutes) {
console.log(` ${prefix} -> ${backend}`);
}
if (rejectPrefixes.length > 0) {
for (const prefix of rejectPrefixes) {
console.log(` ${prefix} -> 503 (rejected)`);
}
}
console.log(" * (default) -> static files + SPA fallback");
console.log("");
resolveListen(server);
});
});
}
// ─────────────────────────────────────────────────────────────────────────────
// Main
// ─────────────────────────────────────────────────────────────────────────────
const isMainModule =
process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
if (isMainModule) {
try {
const config = parseArgs();
await startStaticServer(config);
} catch (err) {
console.error(err instanceof Error ? err.message : err);
process.exit(1);
}
}