diff --git a/internal/dev-entrypoint/cloud-web-proxy.mjs b/internal/dev-entrypoint/cloud-web-proxy.mjs index bfd2b927..1a435049 100644 --- a/internal/dev-entrypoint/cloud-web-proxy.mjs +++ b/internal/dev-entrypoint/cloud-web-proxy.mjs @@ -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)) { diff --git a/internal/dev-entrypoint/cloud-web-runtime.mjs b/internal/dev-entrypoint/cloud-web-runtime.mjs index d8204d54..ae7cb815 100644 --- a/internal/dev-entrypoint/cloud-web-runtime.mjs +++ b/internal/dev-entrypoint/cloud-web-runtime.mjs @@ -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 = ``; @@ -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) { diff --git a/internal/dev-entrypoint/cloud-web-runtime.test.mjs b/internal/dev-entrypoint/cloud-web-runtime.test.mjs index 813ce9d0..5128d3d4 100644 --- a/internal/dev-entrypoint/cloud-web-runtime.test.mjs +++ b/internal/dev-entrypoint/cloud-web-runtime.test.mjs @@ -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"), "
\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)) {