fix: bootstrap opencode default project
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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("</head>")) return html.replace("</head>", `${script}</head>`);
|
||||
if (html.includes("</body>")) return html.replace("</body>", `${script}</body>`);
|
||||
return `${script}${html}`;
|
||||
}
|
||||
|
||||
function opencodeDefaultProjectBootstrapScript(defaultProject) {
|
||||
return `<script data-hwlab-opencode-default-project>(function(){try{var project=${safeInlineJson(defaultProject)};if(!project)return;var serverKey="opencode.global.dat:server";var server=JSON.parse(localStorage.getItem(serverKey)||"{}");var projects=server.projects&&typeof server.projects==="object"?server.projects:{};var local=Array.isArray(projects.local)?projects.local.slice():[];var found=false;local=local.map(function(item){if(!item||item.worktree!==project)return item;found=true;return Object.assign({},item,{expanded:true});});if(!found)local.unshift({worktree:project,expanded:true});projects.local=local;server.projects=projects;server.lastProject=Object.assign({},server.lastProject||{},{local:project});localStorage.setItem(serverKey,JSON.stringify(server));var layoutKey="opencode.global.dat:layout.page";var layout=JSON.parse(localStorage.getItem(layoutKey)||"{}");layout.workspaceExpanded=Object.assign({},layout.workspaceExpanded||{});layout.workspaceExpanded[project]=true;localStorage.setItem(layoutKey,JSON.stringify(layout));}catch(error){console.warn("HWLAB OpenCode default project bootstrap failed",error);}})();</script>`;
|
||||
}
|
||||
|
||||
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");
|
||||
|
||||
@@ -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("<html><head></head><body><div id=\"root\"></div></body></html>\n");
|
||||
});
|
||||
await listen(cloudApi);
|
||||
await listen(opencode);
|
||||
|
||||
const cloudWeb = createCloudWebServer({
|
||||
serviceId: "hwlab-cloud-web",
|
||||
roots: [],
|
||||
cloudApiBaseUrl: serverUrl(cloudApi),
|
||||
cloudApiProxyTimeoutMs: 1000,
|
||||
opencodeUpstreamUrl: serverUrl(opencode),
|
||||
opencodeProxyHost: "",
|
||||
opencodeProxyTimeoutMs: 1000,
|
||||
opencodeUsername: "oc_user",
|
||||
opencodePassword: "oc_password",
|
||||
opencodeDefaultProject: "/workspace",
|
||||
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 frame = await fetch(`${serverUrl(cloudWeb)}/opencode/frame-url`, {
|
||||
headers: { accept: "application/json", cookie: "hwlab_session=session-a" }
|
||||
});
|
||||
const ticket = new URL((await frame.json()).url).searchParams.get("hwlab_opencode_ticket");
|
||||
const proxied = await fetch(`${serverUrl(cloudWeb)}/_opencode/?hwlab_opencode_ticket=${encodeURIComponent(ticket)}`, {
|
||||
headers: { accept: "text/html" }
|
||||
});
|
||||
assert.equal(proxied.status, 200);
|
||||
const html = await proxied.text();
|
||||
assert.match(html, /data-hwlab-opencode-default-project/u);
|
||||
assert.match(html, /opencode\.global\.dat:server/u);
|
||||
assert.match(html, /opencode\.global\.dat:layout\.page/u);
|
||||
assert.match(html, /\/workspace/u);
|
||||
assert.deepEqual(opencodeRequests, [{
|
||||
url: "/",
|
||||
authorization: `Basic ${Buffer.from("oc_user:oc_password", "utf8").toString("base64")}`,
|
||||
cookie: undefined
|
||||
}]);
|
||||
} finally {
|
||||
restoreEnv();
|
||||
await close(cloudWeb);
|
||||
await close(opencode);
|
||||
await close(cloudApi);
|
||||
}
|
||||
});
|
||||
|
||||
test("cloud web OpenCode proxy injects upstream Basic Auth without forwarding HWLAB cookie", async () => {
|
||||
const opencodeRequests = [];
|
||||
const cloudApi = createServer((request, response) => {
|
||||
|
||||
@@ -744,6 +744,10 @@ test("v03 render includes JD01 secret-plane smoke on JD01 gitops root", { timeou
|
||||
const cloudApiContainer = collectContainersFromItem(cloudApi).find((container) => container.name === "hwlab-cloud-api");
|
||||
const cloudApiEnvEntries = new Map((cloudApiContainer?.env ?? []).map((entry) => [entry.name, entry]));
|
||||
assert.deepEqual(cloudApiEnvEntries.get("HWLAB_SECRET_PLANE_SMOKE")?.valueFrom?.secretKeyRef, { name: "hwlab-secret-plane-smoke", key: "password", optional: true });
|
||||
const cloudWeb = (workloadsJson.items ?? []).find((item) => item.kind === "Deployment" && item.metadata?.name === "hwlab-cloud-web");
|
||||
const cloudWebContainer = collectContainersFromItem(cloudWeb).find((container) => container.name === "hwlab-cloud-web");
|
||||
const cloudWebEnvEntries = new Map((cloudWebContainer?.env ?? []).map((entry) => [entry.name, entry]));
|
||||
assert.equal(cloudWebEnvEntries.get("HWLAB_CLOUD_WEB_OPENCODE_DEFAULT_PROJECT")?.value, "/workspace");
|
||||
const agentRunNodeEnv = (workloadsJson.items ?? [])
|
||||
.flatMap((item) => collectContainersFromItem(item))
|
||||
.flatMap((container) => container.env ?? [])
|
||||
|
||||
Reference in New Issue
Block a user