Merge pull request #2267 from pikasTech/fix/2263-opencode-ticket

fix: bridge OpenCode iframe auth with tickets
This commit is contained in:
Lyon
2026-06-30 02:52:50 +08:00
committed by GitHub
3 changed files with 197 additions and 15 deletions
+20 -3
View File
@@ -66,7 +66,8 @@ export function proxyCloudApiRequest({
response,
body = "",
timeoutMs,
forceStream = false
forceStream = false,
extraResponseHeaders = {}
}) {
return new Promise((resolve, reject) => {
let timedOut = false;
@@ -96,7 +97,7 @@ export function proxyCloudApiRequest({
(upstreamResponse) => {
const streaming = forceStream || isEventStreamResponse(upstreamResponse.headers);
if (streaming) {
response.writeHead(upstreamResponse.statusCode || 502, copyProxyResponseHeaders(upstreamResponse.headers, { streaming: true }));
response.writeHead(upstreamResponse.statusCode || 502, mergeResponseHeaders(copyProxyResponseHeaders(upstreamResponse.headers, { streaming: true }), extraResponseHeaders));
response.flushHeaders?.();
upstreamResponse.on("data", (chunk) => {
armTimeout();
@@ -130,7 +131,7 @@ export function proxyCloudApiRequest({
upstreamResponse.on("end", () => {
const responseBody = Buffer.concat(chunks);
response.writeHead(upstreamResponse.statusCode || 502, {
...copyProxyResponseHeaders(upstreamResponse.headers),
...mergeResponseHeaders(copyProxyResponseHeaders(upstreamResponse.headers), extraResponseHeaders),
"content-length": responseBody.length
});
response.end(responseBody);
@@ -155,6 +156,22 @@ export function proxyCloudApiRequest({
});
}
function mergeResponseHeaders(baseHeaders = {}, extraHeaders = {}) {
const result = { ...baseHeaders };
for (const [key, value] of Object.entries(extraHeaders)) {
if (value === undefined || value === null || value === "") continue;
const existingKey = Object.keys(result).find((item) => item.toLowerCase() === key.toLowerCase());
if (existingKey && existingKey.toLowerCase() === "set-cookie") {
const existing = Array.isArray(result[existingKey]) ? result[existingKey] : [result[existingKey]];
const next = Array.isArray(value) ? value : [value];
result[existingKey] = [...existing.filter(Boolean), ...next.filter(Boolean)];
continue;
}
result[existingKey || key] = value;
}
return result;
}
function headerValue(headers, name) {
const lowerName = name.toLowerCase();
for (const [key, value] of Object.entries(headers)) {
+80 -12
View File
@@ -1,3 +1,4 @@
import { randomBytes } from "node:crypto";
import { createServer, request as httpRequest } from "node:http";
import { readFile, stat } from "node:fs/promises";
import path from "node:path";
@@ -19,8 +20,13 @@ const CLOUD_WEB_ERROR_HANDLER_INSTALLED = Symbol.for("hwlab.cloud-web.error-hand
const CLIENT_ROUTE_FALLBACK_PATHS = new Set(["/workbench", "/workspace", "/opencode", "/gate", "/diagnostics/gate", "/help", "/skills", "/access"]);
const STATIC_ASSET_EXTENSION_PATTERN = /\.[A-Za-z0-9][A-Za-z0-9_-]*$/u;
const OPENCODE_PROXY_PREFIX = "/_opencode";
const OPENCODE_TICKET_QUERY = "hwlab_opencode_ticket";
const OPENCODE_TICKET_COOKIE = "hwlab_opencode_ticket";
const OPENCODE_TICKET_TTL_MS = 5 * 60 * 1000;
const OPENCODE_TICKET_MAX_AGE_SECONDS = Math.floor(OPENCODE_TICKET_TTL_MS / 1000);
const TRACE_EXPLORER_TEMPLATE_TOKEN = "{trace_id}";
const TRACE_EXPLORER_TEMPLATE_PLACEHOLDER_PATTERN = /\{([A-Za-z0-9_:-]+)\}/gu;
const opencodeTickets = new Map();
export async function serveCloudWeb(options) {
installCloudWebProcessErrorHandlers({ serviceId: options.serviceId });
@@ -87,7 +93,7 @@ export function createCloudWebServer({
const info = await stat(candidate);
if (!info.isFile()) continue;
const type = contentType(candidate);
const body = await staticResponseBody({ candidate, relativePath, type });
const body = await staticResponseBody({ candidate, relativePath, type, request, url, cloudApiBaseUrl, cloudApiProxyTimeoutMs });
const encoded = await encodeStaticBody(request, body, type);
response.writeHead(200, {
"content-type": type,
@@ -124,14 +130,14 @@ export function createCloudWebServer({
});
}
async function staticResponseBody({ candidate, relativePath, type }) {
async function staticResponseBody({ candidate, relativePath, type, request, url, cloudApiBaseUrl, cloudApiProxyTimeoutMs }) {
const body = await readFile(candidate);
if (relativePath !== "index.html" || !String(type).startsWith("text/html")) return body;
return injectCloudWebRuntimeConfig(body);
return await injectCloudWebRuntimeConfig(body, { request, url, cloudApiBaseUrl, cloudApiProxyTimeoutMs });
}
function injectCloudWebRuntimeConfig(body) {
const config = runtimeConfigFromEnv();
async function injectCloudWebRuntimeConfig(body, options = {}) {
const config = await runtimeConfigFromEnv(options);
if (Object.keys(config).length === 0) return body;
const html = body.toString("utf8");
const script = `<script>window.HWLAB_CLOUD_WEB_CONFIG=${safeInlineJson(config)};</script>`;
@@ -140,20 +146,30 @@ function injectCloudWebRuntimeConfig(body) {
return Buffer.from(`${html}${script}`, "utf8");
}
function runtimeConfigFromEnv() {
async function runtimeConfigFromEnv(options = {}) {
const config = { displayTime: displayTimeConfigFromEnv() };
const workbench = workbenchRuntimeConfigFromEnv();
if (workbench) config.workbench = workbench;
const opencode = opencodeRuntimeConfigFromEnv();
const opencode = await opencodeRuntimeConfigFromEnv(options);
if (opencode) config.opencode = opencode;
return config;
}
function opencodeRuntimeConfigFromEnv() {
async function opencodeRuntimeConfigFromEnv(options = {}) {
const url = optionalRuntimeConfigEnv("HWLAB_CLOUD_WEB_OPENCODE_URL");
if (!url) return null;
validateOpencodeUrl(url);
return { url };
return { url: await opencodeFrameUrl(url, options) };
}
async function opencodeFrameUrl(value, { request = null, url = null, cloudApiBaseUrl = "", cloudApiProxyTimeoutMs = 0 } = {}) {
if (!request || normalizeCloudWebRoutePath(url?.pathname || "") !== "/opencode" || !cloudApiBaseUrl || !hasCookieHeader(request.headers.cookie)) return value;
const session = await requestCloudApiJson({ request, method: "GET", pathname: "/auth/session", cloudApiBaseUrl, cloudApiProxyTimeoutMs });
if (!session.ok || session.body?.authenticated !== true) return value;
const ticket = issueOpencodeTicket(request.headers.cookie);
const target = new URL(value);
target.searchParams.set(OPENCODE_TICKET_QUERY, ticket);
return target.toString();
}
function displayTimeConfigFromEnv() {
@@ -579,7 +595,8 @@ async function proxyOpencodeRequest({ request, response, url, cloudApiBaseUrl, c
return;
}
const session = await requestCloudApiJson({ request, method: "GET", pathname: "/auth/session", cloudApiBaseUrl, cloudApiProxyTimeoutMs });
const ticketAuth = opencodeTicketAuthRequest(request, url);
const session = await requestCloudApiJson({ request: ticketAuth.request, method: "GET", pathname: "/auth/session", cloudApiBaseUrl, cloudApiProxyTimeoutMs });
if (!session.ok || session.body?.authenticated !== true) {
sendJson(response, session.statusCode === 403 ? 403 : 401, {
authenticated: false,
@@ -596,6 +613,7 @@ async function proxyOpencodeRequest({ request, response, url, cloudApiBaseUrl, c
}
const target = opencodeTargetUrl(url, opencodeUpstreamUrl);
const extraResponseHeaders = ticketAuth.ticket ? { "set-cookie": opencodeTicketSetCookie(ticketAuth.ticket) } : {};
const upstreamRequest = {
method: request.method,
headers: opencodeUpstreamHeaders(request, body, authorization)
@@ -606,7 +624,8 @@ async function proxyOpencodeRequest({ request, response, url, cloudApiBaseUrl, c
request: upstreamRequest,
response,
body: request.method === "GET" || request.method === "HEAD" ? "" : body,
timeoutMs: opencodeProxyTimeoutMs
timeoutMs: opencodeProxyTimeoutMs,
extraResponseHeaders
});
} catch (error) {
if (response.headersSent || response.writableEnded) {
@@ -636,7 +655,56 @@ function opencodeTargetUrl(url, opencodeUpstreamUrl) {
: url.pathname.startsWith(`${OPENCODE_PROXY_PREFIX}/`)
? url.pathname.slice(OPENCODE_PROXY_PREFIX.length) || "/"
: url.pathname;
return new URL(`${pathname}${url.search}`, opencodeUpstreamUrl);
const target = new URL(`${pathname}${url.search}`, opencodeUpstreamUrl);
target.searchParams.delete(OPENCODE_TICKET_QUERY);
return target;
}
function issueOpencodeTicket(cookieHeader) {
pruneOpencodeTickets();
const ticket = randomBytes(24).toString("base64url");
opencodeTickets.set(ticket, {
cookieHeader: String(cookieHeader || ""),
expiresAtMs: Date.now() + OPENCODE_TICKET_TTL_MS
});
return ticket;
}
function opencodeTicketAuthRequest(request, url) {
const ticket = opencodeTicketFromRequest(request, url);
const record = ticket ? opencodeTicketRecord(ticket) : null;
if (!record) return { request, ticket: "" };
return {
request: { headers: { ...request.headers, cookie: record.cookieHeader } },
ticket
};
}
function opencodeTicketFromRequest(request, url) {
const fromQuery = url.searchParams.get(OPENCODE_TICKET_QUERY) || "";
if (fromQuery) return fromQuery;
return cookiePairsFromHeader(request.headers.cookie).find((pair) => pair.name === OPENCODE_TICKET_COOKIE)?.value || "";
}
function opencodeTicketRecord(ticket) {
pruneOpencodeTickets();
const record = opencodeTickets.get(String(ticket || ""));
if (!record || record.expiresAtMs <= Date.now() || !record.cookieHeader) {
opencodeTickets.delete(String(ticket || ""));
return null;
}
return record;
}
function pruneOpencodeTickets() {
const now = Date.now();
for (const [ticket, record] of opencodeTickets.entries()) {
if (!record || record.expiresAtMs <= now) opencodeTickets.delete(ticket);
}
}
function opencodeTicketSetCookie(ticket) {
return `${OPENCODE_TICKET_COOKIE}=${encodeURIComponent(ticket)}; Path=/; HttpOnly; Secure; SameSite=None; Max-Age=${OPENCODE_TICKET_MAX_AGE_SECONDS}`;
}
function opencodeUpstreamHeaders(request, body, authorization) {
@@ -606,6 +606,103 @@ test("cloud web OpenCode proxy injects upstream Basic Auth without forwarding HW
}
});
test("cloud web OpenCode proxy accepts short-lived tickets minted by the shell", async () => {
const restoreEnv = withEnv({
HWLAB_CLOUD_WEB_DISPLAY_TIME_ZONE: "Asia/Shanghai",
HWLAB_CLOUD_WEB_DISPLAY_TIME_LOCALE: "zh-CN",
HWLAB_CLOUD_WEB_DISPLAY_TIME_LABEL: "北京时间",
HWLAB_CLOUD_WEB_OPENCODE_URL: "https://opencode.example.test"
});
const root = await mkdtemp(path.join(os.tmpdir(), "hwlab-cloud-web-runtime-"));
await writeFile(path.join(root, "index.html"), "<html><head></head><body><div id=\"root\"></div></body></html>\n", "utf8");
const cloudApiCookies = [];
const opencodeRequests = [];
const cloudApi = createServer((request, response) => {
cloudApiCookies.push(request.headers.cookie);
request.resume();
response.writeHead(200, { "content-type": "application/json" });
response.end(JSON.stringify({ authenticated: request.headers.cookie === "hwlab_session=session-a" }));
});
const opencode = createServer((request, response) => {
opencodeRequests.push({
url: request.url,
authorization: request.headers.authorization,
cookie: request.headers.cookie
});
request.resume();
response.writeHead(200, { "content-type": "text/plain" });
response.end("opencode ok\n");
});
await listen(cloudApi);
await listen(opencode);
const cloudWeb = createCloudWebServer({
serviceId: "hwlab-cloud-web",
roots: [root],
cloudApiBaseUrl: serverUrl(cloudApi),
cloudApiProxyTimeoutMs: 1000,
opencodeUpstreamUrl: serverUrl(opencode),
opencodeProxyHost: "",
opencodeProxyTimeoutMs: 1000,
opencodeUsername: "oc_user",
opencodePassword: "oc_password",
healthPayload: () => ({ status: "ok" }),
sendJson(response, statusCode, body) {
const payload = JSON.stringify(body);
response.writeHead(statusCode, { "content-type": "application/json", "content-length": Buffer.byteLength(payload) });
response.end(payload);
}
});
await listen(cloudWeb);
try {
const shell = await fetch(`${serverUrl(cloudWeb)}/opencode`, {
headers: { accept: "text/html", cookie: "hwlab_session=session-a" }
});
assert.equal(shell.status, 200);
const html = await shell.text();
const config = JSON.parse(html.match(/window\.HWLAB_CLOUD_WEB_CONFIG=([^<]+);<\/script>/u)?.[1] ?? "null");
const frameUrl = new URL(config.opencode.url);
const ticket = frameUrl.searchParams.get("hwlab_opencode_ticket");
assert.ok(ticket, "ticket should be appended to OpenCode iframe URL");
const first = await fetch(`${serverUrl(cloudWeb)}/_opencode/?hwlab_opencode_ticket=${encodeURIComponent(ticket)}`, {
headers: { accept: "text/plain" }
});
assert.equal(first.status, 200);
assert.equal(await first.text(), "opencode ok\n");
const ticketCookie = first.headers.get("set-cookie") ?? "";
assert.match(ticketCookie, /hwlab_opencode_ticket=/u);
assert.match(ticketCookie, /HttpOnly/u);
assert.match(ticketCookie, /SameSite=None/u);
const second = await fetch(`${serverUrl(cloudWeb)}/_opencode/assets/index.js`, {
headers: { accept: "text/plain", cookie: ticketCookie.split(";")[0] }
});
assert.equal(second.status, 200);
assert.equal(await second.text(), "opencode ok\n");
assert.deepEqual(cloudApiCookies, ["hwlab_session=session-a", "hwlab_session=session-a", "hwlab_session=session-a"]);
assert.deepEqual(opencodeRequests, [
{
url: "/",
authorization: `Basic ${Buffer.from("oc_user:oc_password", "utf8").toString("base64")}`,
cookie: undefined
},
{
url: "/assets/index.js",
authorization: `Basic ${Buffer.from("oc_user:oc_password", "utf8").toString("base64")}`,
cookie: undefined
}
]);
} finally {
restoreEnv();
await close(cloudWeb);
await close(opencode);
await close(cloudApi);
await rm(root, { recursive: true, force: true });
}
});
function withEnv(values) {
const previous = new Map();
for (const [key, value] of Object.entries(values)) {