diff --git a/deploy/deploy.yaml b/deploy/deploy.yaml index c40042a4..8bf1901a 100644 --- a/deploy/deploy.yaml +++ b/deploy/deploy.yaml @@ -586,6 +586,7 @@ lanes: HWLAB_CLOUD_WEB_OPENCODE_UPSTREAM_URL: http://opencode-server.hwlab-v03.svc.cluster.local:4096 HWLAB_CLOUD_WEB_OPENCODE_USERNAME: secretRef:hwlab-opencode-server-auth/username HWLAB_CLOUD_WEB_OPENCODE_PASSWORD: secretRef:hwlab-opencode-server-auth/password + HWLAB_CLOUD_WEB_OPENCODE_DEFAULT_PROJECT: /workspace HWLAB_CLOUD_WEB_OPENCODE_EVENT_DIRECTORY_FROM: /workspace HWLAB_CLOUD_WEB_OPENCODE_EVENT_DIRECTORY_TO: / OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: http://otel-collector.platform-infra.svc.cluster.local:4318/v1/traces diff --git a/internal/dev-entrypoint/cloud-web-runtime.mjs b/internal/dev-entrypoint/cloud-web-runtime.mjs index d09749d7..7ea264c8 100644 --- a/internal/dev-entrypoint/cloud-web-runtime.mjs +++ b/internal/dev-entrypoint/cloud-web-runtime.mjs @@ -5,7 +5,7 @@ import path from "node:path"; import { gzip } from "node:zlib"; import { promisify } from "node:util"; -import { isCloudWebSseRoute, proxyCloudApiRequest, upstreamRequestHeaders } from "./cloud-web-proxy.mjs"; +import { copyProxyResponseHeaders, isCloudWebSseRoute, proxyCloudApiRequest, upstreamRequestHeaders } from "./cloud-web-proxy.mjs"; import { cloudWebProxyRoutePolicy } from "./cloud-web-routes.mjs"; import { UPSTREAM_UNAVAILABLE_ERROR_CODE, UPSTREAM_UNAVAILABLE_MESSAGE } from "./http.mjs"; const gzipAsync = promisify(gzip); @@ -64,6 +64,7 @@ export function createCloudWebServer({ }), opencodeUsername = optionalRuntimeConfigEnv("HWLAB_CLOUD_WEB_OPENCODE_USERNAME") || "", opencodePassword = optionalRuntimeConfigEnv("HWLAB_CLOUD_WEB_OPENCODE_PASSWORD") || "", + opencodeDefaultProject = optionalRuntimeConfigEnv("HWLAB_CLOUD_WEB_OPENCODE_DEFAULT_PROJECT") || "", opencodeEventDirectoryRewrite = opencodeEventDirectoryRewriteFromEnv() }) { return createServer(async (request, response) => { @@ -79,7 +80,7 @@ export function createCloudWebServer({ return; } if (isOpencodeProxyRequest(request, url, opencodeProxyHost)) { - await proxyOpencodeRequest({ request, response, url, cloudApiBaseUrl, cloudApiProxyTimeoutMs, opencodeUpstreamUrl, opencodeProxyTimeoutMs, opencodeUsername, opencodePassword, opencodeEventDirectoryRewrite, serviceId, sendJson }); + await proxyOpencodeRequest({ request, response, url, cloudApiBaseUrl, cloudApiProxyTimeoutMs, opencodeUpstreamUrl, opencodeProxyTimeoutMs, opencodeUsername, opencodePassword, opencodeDefaultProject, opencodeEventDirectoryRewrite, serviceId, sendJson }); return; } if (await handleCloudWebAuth({ request, response, url, cloudApiBaseUrl, cloudApiProxyTimeoutMs, serviceId, sendJson })) { @@ -729,7 +730,7 @@ export async function proxyCloudApi({ request, response, url, cloudApiBaseUrl, c } } -async function proxyOpencodeRequest({ request, response, url, cloudApiBaseUrl, cloudApiProxyTimeoutMs, opencodeUpstreamUrl, opencodeProxyTimeoutMs, opencodeUsername, opencodePassword, opencodeEventDirectoryRewrite = null, serviceId, sendJson }) { +async function proxyOpencodeRequest({ request, response, url, cloudApiBaseUrl, cloudApiProxyTimeoutMs, opencodeUpstreamUrl, opencodeProxyTimeoutMs, opencodeUsername, opencodePassword, opencodeDefaultProject = "", opencodeEventDirectoryRewrite = null, serviceId, sendJson }) { const traceContext = cloudWebTraceContext(request); const startedAtMs = Date.now(); if (!cloudApiBaseUrl) { @@ -821,15 +822,17 @@ async function proxyOpencodeRequest({ request, response, url, cloudApiBaseUrl, c headers: opencodeUpstreamHeaders({ headers: { ...request.headers, ...cloudWebTraceHeaders(traceContext, serviceId) } }, body, authorization) }; try { - const proxyResult = await proxyCloudApiRequest({ - target, - request: upstreamRequest, - response, - body: request.method === "GET" || request.method === "HEAD" ? "" : body, - timeoutMs: opencodeProxyTimeoutMs, - extraResponseHeaders, - streamTransform - }); + const proxyResult = shouldInjectOpencodeDefaultProject({ request, target, opencodeDefaultProject }) + ? await proxyOpencodeHtmlRequest({ target, request: upstreamRequest, response, timeoutMs: opencodeProxyTimeoutMs, extraResponseHeaders, defaultProject: opencodeDefaultProject }) + : await proxyCloudApiRequest({ + target, + request: upstreamRequest, + response, + body: request.method === "GET" || request.method === "HEAD" ? "" : body, + timeoutMs: opencodeProxyTimeoutMs, + extraResponseHeaders, + streamTransform + }); emitOpencodeProxySpanAsync({ traceContext, serviceId, @@ -890,6 +893,78 @@ function opencodeTargetUrl(url, opencodeUpstreamUrl) { return target; } +function shouldInjectOpencodeDefaultProject({ request, target, opencodeDefaultProject }) { + if (!opencodeDefaultProject) return false; + if (request.method !== "GET") return false; + const accept = String(request.headers.accept || ""); + if (accept && !accept.includes("text/html") && !accept.includes("*/*")) return false; + if (target.pathname.startsWith("/assets/")) return false; + if (STATIC_ASSET_EXTENSION_PATTERN.test(target.pathname)) return false; + return true; +} + +async function proxyOpencodeHtmlRequest({ target, request, response, timeoutMs, extraResponseHeaders, defaultProject }) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + try { + const upstream = await fetch(target, { + method: request.method, + headers: request.headers, + signal: controller.signal + }); + const upstreamHeaders = Object.fromEntries(upstream.headers.entries()); + const contentType = String(upstream.headers.get("content-type") || ""); + const inputBody = Buffer.from(await upstream.arrayBuffer()); + const outputBody = /^text\/html(?:\s*;|$)/iu.test(contentType) + ? Buffer.from(opencodeInjectDefaultProjectHtml(inputBody.toString("utf8"), defaultProject), "utf8") + : inputBody; + response.writeHead(upstream.status, { + ...mergeOpencodeProxyResponseHeaders(copyProxyResponseHeaders(upstreamHeaders), extraResponseHeaders), + "content-length": outputBody.length + }); + response.end(outputBody); + return { + statusCode: upstream.status, + streaming: false, + bodyBytes: outputBody.length, + opencodeDefaultProjectInjected: outputBody.length !== inputBody.length + }; + } catch (error) { + if (error?.name === "AbortError") error.timedOut = true; + throw error; + } finally { + clearTimeout(timeout); + } +} + +function opencodeInjectDefaultProjectHtml(html, defaultProject) { + if (!html || html.includes("data-hwlab-opencode-default-project")) return html; + const script = opencodeDefaultProjectBootstrapScript(defaultProject); + if (html.includes("")) return html.replace("", `${script}`); + if (html.includes("
")) return html.replace("", `${script}`); + return `${script}${html}`; +} + +function opencodeDefaultProjectBootstrapScript(defaultProject) { + return ``; +} + +function mergeOpencodeProxyResponseHeaders(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 issueOpencodeTicket(cookieHeader) { pruneOpencodeTickets(); const ticket = randomBytes(24).toString("base64url"); diff --git a/internal/dev-entrypoint/cloud-web-runtime.test.mjs b/internal/dev-entrypoint/cloud-web-runtime.test.mjs index efc26e1f..5d8cc549 100644 --- a/internal/dev-entrypoint/cloud-web-runtime.test.mjs +++ b/internal/dev-entrypoint/cloud-web-runtime.test.mjs @@ -648,6 +648,76 @@ test("cloud web OpenCode frame-url endpoint mints traced tickets", async () => { } }); +test("cloud web OpenCode proxy bootstraps the default project for iframe HTML", async () => { + const restoreEnv = withEnv({ + HWLAB_CLOUD_WEB_OPENCODE_URL: "https://opencode.example.test" + }); + const cloudApi = createServer((request, response) => { + request.resume(); + response.writeHead(200, { "content-type": "application/json" }); + response.end(JSON.stringify({ authenticated: request.headers.cookie === "hwlab_session=session-a" })); + }); + const opencodeRequests = []; + 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/html; charset=utf-8" }); + response.end("