fix: bootstrap opencode default project

This commit is contained in:
UniDesk Codex
2026-06-30 21:40:24 +08:00
parent 9a9f6afe90
commit e76a0b37d0
4 changed files with 162 additions and 12 deletions
+1
View File
@@ -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
+87 -12
View File
@@ -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) => {
+4
View File
@@ -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 ?? [])