mirror of
https://github.com/OpenHands/OpenHands.git
synced 2026-10-07 16:38:34 +08:00
fix: inject runtime session key into index.html for published binary (#795)
* fix: inject runtime session key into index.html for published binary
The globally installed agent-canvas binary starts the agent-server with a
persisted session API key (~/.openhands/agent-canvas/session-api-key.txt)
as OH_SESSION_API_KEYS_0, making auth required. However, the pre-built
static frontend in the npm package has a different (or empty)
VITE_SESSION_API_KEY baked in at publish time, so every API request gets
401 Unauthorized.
Fix: static-server.mjs now accepts --session-api-key <key> and injects a
tiny bootstrap <script> before </head> in every index.html response. The
script seeds the key into localStorage['openhands-agent-server-config']
only if no key is already stored there, so explicit user overrides (via
Settings > Agent Server) are always preserved.
dev-with-automation.mjs and dev-static.mjs both pass
--session-api-key ${config.sessionApiKey} when spawning the static server,
so the runtime key is always available regardless of what was baked into
the bundle.
Tests: added 8 new cases to __tests__/scripts/static-server.test.ts
covering parseArgs, injection in direct and SPA-fallback index.html
responses, no injection for non-html assets, cache headers, and null key.
Co-authored-by: openhands <openhands@all-hands.dev>
* fix(docker): pass runtime session key to static-server so frontend can authenticate
The entrypoint computed EFFECTIVE_SESSION_KEY and forwarded it to the
agent-server (OH_SESSION_API_KEYS_0) and automation backends, but did not
pass it to the static-server. As a result the pre-built index.html served
with no session key injected, so every browser API call received 401.
Wire --session-api-key "$EFFECTIVE_SESSION_KEY" into the static-server
launch command so the runtime key is injected into index.html responses
(via the mechanism added in this branch to static-server.mjs).
Co-authored-by: openhands <openhands@all-hands.dev>
* fix: address review suggestions on session key injection
- static-server.mjs: add comment clarifying replace() targets first
</head> only; fall back to inserting before </body> when </head> is
absent (avoids prepending before <!DOCTYPE html>)
- docker/entrypoint.sh: add comment documenting source of
EFFECTIVE_SESSION_KEY before the static-server invocation
- static-server.test.ts: add test for </head>-absent fallback path
confirming injection lands before </body>
Co-authored-by: openhands <openhands@all-hands.dev>
---------
Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: Rohit Malhotra <rohitvinodmalhotra@gmail.com>
This commit is contained in:
committed by
GitHub
co-authored by
openhands
Rohit Malhotra
parent
c34aa03273
commit
cbeeee002e
@@ -388,6 +388,12 @@ function startStaticServer(config) {
|
||||
"0.0.0.0",
|
||||
"--port",
|
||||
String(config.vitePort),
|
||||
// Inject the runtime session key so the pre-built frontend can
|
||||
// authenticate to agent-server without VITE_SESSION_API_KEY being baked
|
||||
// into the bundle at publish time.
|
||||
...(config.sessionApiKey
|
||||
? ["--session-api-key", config.sessionApiKey]
|
||||
: []),
|
||||
"--route",
|
||||
`/api/automation=http://localhost:${config.autoBackendPort}`,
|
||||
"--route",
|
||||
|
||||
@@ -1061,6 +1061,12 @@ function startStaticFrontend(config, staticDir) {
|
||||
"0.0.0.0",
|
||||
"--port",
|
||||
String(config.vitePort),
|
||||
// Inject the runtime session key so the pre-built frontend can
|
||||
// authenticate to agent-server without VITE_SESSION_API_KEY being baked
|
||||
// into the bundle at publish time.
|
||||
...(config.sessionApiKey
|
||||
? ["--session-api-key", config.sessionApiKey]
|
||||
: []),
|
||||
// Proxy routes to backends (same as ingress but for direct access to vitePort)
|
||||
"--route",
|
||||
`/api/automation=http://localhost:${config.autoBackendPort}`,
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
|
||||
import { createServer, request as httpRequest } from "node:http";
|
||||
import { createReadStream } from "node:fs";
|
||||
import { stat } from "node:fs/promises";
|
||||
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";
|
||||
@@ -72,6 +72,7 @@ export function parseArgs(argv = process.argv.slice(2)) {
|
||||
host: "0.0.0.0",
|
||||
dir: "build",
|
||||
routes: {},
|
||||
sessionApiKey: null,
|
||||
};
|
||||
|
||||
for (let i = 0; i < argv.length; i++) {
|
||||
@@ -104,6 +105,9 @@ export function parseArgs(argv = process.argv.slice(2)) {
|
||||
config.routes[prefix] = url;
|
||||
break;
|
||||
}
|
||||
case "--session-api-key":
|
||||
config.sessionApiKey = argv[++i] || null;
|
||||
break;
|
||||
case "-h":
|
||||
case "--help":
|
||||
showHelp();
|
||||
@@ -129,6 +133,9 @@ OPTIONS:
|
||||
-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.
|
||||
-h, --help Show this help
|
||||
|
||||
ROUTING:
|
||||
@@ -140,6 +147,70 @@ ROUTING:
|
||||
`);
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Runtime config injection
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Build a tiny inline script that seeds the session API key into the
|
||||
* `openhands-agent-server-config` localStorage entry the first time the page
|
||||
* loads. Only writes if no key is already stored — explicit user overrides
|
||||
* (set via Settings > Agent Server in the UI) are always preserved.
|
||||
*
|
||||
* This lets the pre-built static binary work without needing VITE_SESSION_API_KEY
|
||||
* baked into the bundle at publish time: the runtime key is injected here instead.
|
||||
*/
|
||||
function makeConfigInjectionScript(sessionApiKey) {
|
||||
if (!sessionApiKey) return "";
|
||||
// JSON.stringify produces a properly escaped JS string literal.
|
||||
const keyLiteral = JSON.stringify(sessionApiKey);
|
||||
return (
|
||||
`<script>` +
|
||||
`(function(){` +
|
||||
`try{` +
|
||||
`var _k='openhands-agent-server-config',` +
|
||||
`_c=JSON.parse(localStorage.getItem(_k)||'{}');` +
|
||||
`if(!_c.sessionApiKey){` +
|
||||
`_c.sessionApiKey=${keyLiteral};` +
|
||||
`localStorage.setItem(_k,JSON.stringify(_c));` +
|
||||
`}` +
|
||||
`}catch(e){}` +
|
||||
`}());` +
|
||||
`</script>`
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Serve index.html with the runtime session key injected into <head>.
|
||||
* Returns true if the response was written, false if the file was not found.
|
||||
*/
|
||||
async function serveInjectedIndexHtml(req, res, indexPath, sessionApiKey) {
|
||||
let content;
|
||||
try {
|
||||
content = await readFile(indexPath, "utf8");
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
||||
const script = makeConfigInjectionScript(sessionApiKey);
|
||||
// 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)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -322,7 +393,7 @@ async function serveFile(req, res, filePath, urlPath) {
|
||||
return true;
|
||||
}
|
||||
|
||||
async function handleStatic(req, res, dirAbs) {
|
||||
async function handleStatic(req, res, dirAbs, sessionApiKey = null) {
|
||||
const rawPath = req.url.split("?")[0];
|
||||
let urlPath;
|
||||
try {
|
||||
@@ -346,6 +417,12 @@ async function handleStatic(req, res, dirAbs) {
|
||||
filePath = resolve(filePath, "index.html");
|
||||
}
|
||||
|
||||
// Serve index.html with runtime key injection when a session key is configured.
|
||||
if (sessionApiKey && filePath.endsWith("index.html")) {
|
||||
if (await serveInjectedIndexHtml(req, res, filePath, sessionApiKey)) return;
|
||||
// Fall through to regular serveFile (handles 404 path correctly).
|
||||
}
|
||||
|
||||
if (await serveFile(req, res, filePath, urlPath)) return;
|
||||
|
||||
// SPA fallback: only for non-asset requests, and not for non-GET/HEAD.
|
||||
@@ -354,7 +431,10 @@ async function handleStatic(req, res, dirAbs) {
|
||||
!looksLikeAssetRequest(urlPath)
|
||||
) {
|
||||
const indexPath = resolve(dirAbs, "index.html");
|
||||
if (await serveFile(req, res, indexPath, "/")) return;
|
||||
if (sessionApiKey) {
|
||||
if (await serveInjectedIndexHtml(req, res, indexPath, sessionApiKey))
|
||||
return;
|
||||
} else if (await serveFile(req, res, indexPath, "/")) return;
|
||||
}
|
||||
|
||||
res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
|
||||
@@ -368,6 +448,7 @@ async function handleStatic(req, res, dirAbs) {
|
||||
export function startStaticServer(config) {
|
||||
const route = createRouter(config.routes);
|
||||
const dirAbs = resolve(config.dir);
|
||||
const sessionApiKey = config.sessionApiKey || null;
|
||||
|
||||
const server = createServer((req, res) => {
|
||||
const backend = route(req.url);
|
||||
@@ -375,7 +456,7 @@ export function startStaticServer(config) {
|
||||
proxyRequest(req, res, backend);
|
||||
return;
|
||||
}
|
||||
handleStatic(req, res, dirAbs).catch((err) => {
|
||||
handleStatic(req, res, dirAbs, sessionApiKey).catch((err) => {
|
||||
console.error(`Static handler error for ${req.url}:`, err);
|
||||
if (!res.headersSent) {
|
||||
res.writeHead(500);
|
||||
|
||||
Reference in New Issue
Block a user