fix: stabilize cloud web proxy runtime

This commit is contained in:
Codex
2026-05-30 11:09:56 +08:00
parent aee03fab68
commit 6edce687ca
8 changed files with 482 additions and 442 deletions
@@ -0,0 +1,180 @@
import { spawn } from "node:child_process";
import { createServer } from "node:http";
import { chmodSync, existsSync, lstatSync, mkdirSync, symlinkSync } from "node:fs";
import path from "node:path";
import { serveCloudWeb } from "/app/internal/dev-entrypoint/cloud-web-runtime.mjs";
const serviceId = process.env.HWLAB_SERVICE_ID || "hwlab-unknown";
const environment = process.env.HWLAB_ENVIRONMENT || "dev";
const runtimeKind = process.env.HWLAB_ARTIFACT_KIND || "health-placeholder";
const entrypoint = process.env.HWLAB_SERVICE_ENTRYPOINT || "";
const port = Number.parseInt(process.env.PORT || process.env.HWLAB_PORT || "8080", 10);
function ensureCodeAgentRuntimeBase() {
if (serviceId !== "hwlab-cloud-api") return;
const workspace = process.env.HWLAB_CODE_AGENT_CODEX_WORKSPACE || process.env.HWLAB_CODE_AGENT_WORKSPACE || "/workspace/hwlab";
const codexHome = process.env.CODEX_HOME || process.env.HWLAB_CODE_AGENT_CODEX_HOME || "/codex-home";
process.env.HWLAB_CODE_AGENT_WORKSPACE = process.env.HWLAB_CODE_AGENT_WORKSPACE || workspace;
process.env.HWLAB_CODE_AGENT_CODEX_WORKSPACE = process.env.HWLAB_CODE_AGENT_CODEX_WORKSPACE || workspace;
process.env.HWLAB_CODE_AGENT_CODEX_SANDBOX = process.env.HWLAB_CODE_AGENT_CODEX_SANDBOX || "danger-full-access";
process.env.HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED = process.env.HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED || "1";
process.env.HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR = process.env.HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR || "repo-owned";
process.env.HWLAB_CODE_AGENT_PROVIDER = process.env.HWLAB_CODE_AGENT_PROVIDER || "codex-stdio";
process.env.CODEX_HOME = codexHome;
process.env.HWLAB_CODE_AGENT_CODEX_COMMAND = process.env.HWLAB_CODE_AGENT_CODEX_COMMAND || "/app/node_modules/.bin/codex";
process.env.HWLAB_CODE_AGENT_SKILLS_DIRS = process.env.HWLAB_CODE_AGENT_SKILLS_DIRS || "/app/skills";
mkdirSync(path.dirname(workspace), { recursive: true });
if (!existsSync(workspace)) {
if (workspace === "/workspace/hwlab" && existsSync("/app")) {
symlinkSync("/app", workspace, "dir");
} else {
mkdirSync(workspace, { recursive: true });
}
}
mkdirSync(codexHome, { recursive: true });
for (const target of [workspace, codexHome]) {
try {
const info = lstatSync(target);
chmodSync(info.isSymbolicLink() ? path.resolve(target) : target, 0o777);
} catch {
// Runtime readiness reports the concrete blocker without exposing secret values.
}
}
}
ensureCodeAgentRuntimeBase();
function sendJson(response, statusCode, body) {
const payload = JSON.stringify(body, null, 2);
response.writeHead(statusCode, {
"content-type": "application/json; charset=utf-8",
"content-length": Buffer.byteLength(payload)
});
response.end(payload);
}
function healthPayload() {
const commitId = process.env.HWLAB_COMMIT_ID || process.env.HWLAB_GIT_SHA || "unknown";
const imageReference = process.env.HWLAB_IMAGE || "unknown";
const imageTag = imageTagFromReference(imageReference) || shortRevision(commitId);
const buildCreatedAt = normalizeIsoTimestamp(
process.env.HWLAB_BUILD_CREATED_AT ||
process.env.HWLAB_IMAGE_CREATED_AT ||
process.env.SOURCE_DATE_EPOCH ||
process.env.BUILD_DATE
);
const buildSource = process.env.HWLAB_BUILD_SOURCE || "runtime-env";
return {
serviceId,
environment,
status: "ok",
artifactKind: runtimeKind,
revision: process.env.HWLAB_REVISION || commitId,
commit: {
id: commitId,
source: buildSource
},
image: {
reference: imageReference,
tag: imageTag,
digest: process.env.HWLAB_IMAGE_DIGEST || "unknown"
},
build: {
createdAt: buildCreatedAt,
source: buildSource,
provenance: process.env.HWLAB_BUILD_PROVENANCE || null,
metadataSource: buildCreatedAt ? "runtime-env:HWLAB_BUILD_CREATED_AT" : "unavailable",
unavailableReason: buildCreatedAt ? null : "HWLAB_BUILD_CREATED_AT missing from runtime environment"
}
};
}
function normalizeIsoTimestamp(value) {
const source = String(value || "").trim();
if (!source) return null;
if (/^\d+$/u.test(source)) {
const seconds = Number.parseInt(source, 10);
if (Number.isSafeInteger(seconds)) {
const date = new Date(seconds * 1000);
if (Number.isFinite(date.getTime())) return date.toISOString();
}
}
const date = new Date(source);
return Number.isFinite(date.getTime()) ? date.toISOString() : null;
}
function imageTagFromReference(imageReference) {
const source = String(imageReference || "").trim();
const slashIndex = source.lastIndexOf("/");
const colonIndex = source.lastIndexOf(":");
if (colonIndex <= slashIndex) return null;
return source.slice(colonIndex + 1) || null;
}
function shortRevision(value) {
const source = String(value || "").trim();
return source.length >= 7 ? source.slice(0, 7) : source || "unknown";
}
function runEntrypoint(command, args) {
const child = spawn(command, args, {
cwd: "/app",
env: process.env,
stdio: "inherit"
});
child.on("error", (error) => {
process.stderr.write(JSON.stringify({
serviceId,
status: "failed",
error: "entrypoint_spawn_failed",
command,
reason: error.message
}) + "\n");
process.exit(127);
});
child.on("exit", (code, signal) => {
if (signal) process.kill(process.pid, signal);
else process.exit(code ?? 0);
});
}
function runNodeEntrypoint(file) {
runEntrypoint(process.execPath, [file]);
}
function runBunEntrypoint(file) {
const candidates = [
process.env.HWLAB_BUN_COMMAND,
"/app/node_modules/.bin/bun",
"/usr/local/bin/bun",
"bun"
].filter(Boolean);
const command = candidates.find((candidate) => !candidate.includes("/") || existsSync(candidate)) || "bun";
runEntrypoint(command, [file]);
}
function serveHealthOnly() {
const server = createServer((request, response) => {
const url = new URL(request.url || "/", "http://hwlab-artifact.local");
if (url.pathname === "/health/live" || url.pathname === "/health") {
sendJson(response, 200, healthPayload());
return;
}
sendJson(response, 404, { error: "not_found", serviceId, path: url.pathname });
});
server.listen(port, "0.0.0.0", () => {
process.stdout.write(JSON.stringify({ serviceId, environment, status: "listening", port }) + "\n");
});
}
if (runtimeKind === "node-command" && entrypoint) {
runNodeEntrypoint(entrypoint);
} else if (runtimeKind === "bun-command" && entrypoint) {
runBunEntrypoint(entrypoint);
} else if (runtimeKind === "cloud-web") {
await serveCloudWeb({ port, serviceId, healthPayload, sendJson });
} else {
serveHealthOnly();
}
@@ -0,0 +1,211 @@
import { createServer } from "node:http";
import { readFile, stat } from "node:fs/promises";
import path from "node:path";
import { isCloudWebSseRoute, proxyCloudApiRequest } from "./cloud-web-proxy.mjs";
import { cloudWebProxyRoutePolicy } from "./cloud-web-routes.mjs";
const readOnlyRpcMethods = new Set([
"system.health",
"cloud.adapter.describe",
"audit.event.query",
"evidence.record.query"
]);
export async function serveCloudWeb(options) {
const server = createCloudWebServer(options);
server.listen(options.port, "0.0.0.0", () => {
process.stdout.write(JSON.stringify({
serviceId: options.serviceId,
environment: process.env.HWLAB_ENVIRONMENT || "dev",
status: "listening",
port: options.port
}) + "\n");
});
}
export function createCloudWebServer({
serviceId,
healthPayload,
sendJson,
roots = ["/app/web/hwlab-cloud-web/dist", "/app/web/hwlab-cloud-web"],
cloudApiBaseUrl = process.env.HWLAB_API_BASE_URL || "",
cloudApiProxyTimeoutMs = parseTimeout(process.env.HWLAB_CLOUD_WEB_PROXY_TIMEOUT_MS, 1260000, {
min: 1000,
max: 2400000
})
}) {
return createServer(async (request, response) => {
const url = new URL(request.url || "/", "http://hwlab-cloud-web.local");
if (url.pathname === "/health/live" || url.pathname === "/health") {
sendJson(response, 200, healthPayload());
return;
}
if (await handleCloudWebAuth({ request, response, url, cloudApiBaseUrl, cloudApiProxyTimeoutMs, serviceId, sendJson })) {
return;
}
const proxyPolicy = cloudWebProxyRoutePolicy(request.method, url.pathname);
if (proxyPolicy.proxy) {
await proxyCloudApi({ request, response, url, cloudApiBaseUrl, cloudApiProxyTimeoutMs, serviceId, sendJson });
return;
}
const routePath = url.pathname.replace(/\/+$/u, "") || "/";
const relativePath = routePath === "/" || routePath === "/gate" || routePath === "/diagnostics/gate" || routePath === "/help"
? "index.html"
: url.pathname.slice(1);
for (const root of roots) {
const candidate = path.resolve(root, relativePath);
if (!candidate.startsWith(root)) continue;
try {
const info = await stat(candidate);
if (!info.isFile()) continue;
const body = await readFile(candidate);
response.writeHead(200, {
"content-type": contentType(candidate),
"content-length": body.length
});
response.end(body);
return;
} catch {
// Try the next root.
}
}
sendJson(response, 404, { error: "not_found", serviceId, path: url.pathname });
});
}
export async function handleCloudWebAuth(options) {
const { request, response, url } = options;
if (url.pathname === "/auth/session" && request.method === "GET") {
await proxyCloudApi(options);
return true;
}
if (url.pathname === "/auth/login" && request.method === "POST") {
await proxyCloudApi(options);
return true;
}
if (url.pathname === "/auth/logout" && request.method === "POST") {
await proxyCloudApi(options);
return true;
}
if (!url.pathname.startsWith("/auth/")) return false;
await proxyCloudApi(options);
return true;
}
async function readRequestBody(request) {
let body = "";
for await (const chunk of request) {
body += chunk;
}
return body;
}
function parseTimeout(value, fallback, { min, max }) {
const parsed = Number.parseInt(value || "", 10);
if (!Number.isInteger(parsed)) return fallback;
return Math.min(Math.max(parsed, min), max);
}
function requestUpstream({ target, request, response, body, url, cloudApiProxyTimeoutMs }) {
return proxyCloudApiRequest({
target,
request,
response,
body,
timeoutMs: cloudApiProxyTimeoutMs,
forceStream: isCloudWebSseRoute(url.pathname)
});
}
export async function proxyCloudApi({ request, response, url, cloudApiBaseUrl, cloudApiProxyTimeoutMs, serviceId, sendJson }) {
if (!cloudApiBaseUrl) {
sendJson(response, 503, {
error: "cloud_api_base_url_missing",
serviceId,
path: url.pathname
});
return;
}
let body = "";
if (request.method !== "GET") {
body = await readRequestBody(request);
}
if (url.pathname === "/json-rpc") {
try {
const envelope = body ? JSON.parse(body) : {};
if (!readOnlyRpcMethods.has(envelope.method)) {
sendJson(response, 403, {
error: "readonly_rpc_required",
serviceId,
method: envelope.method || null
});
return;
}
} catch (error) {
sendJson(response, 400, {
error: "invalid_json",
serviceId,
reason: error.message
});
return;
}
}
const target = new URL(url.pathname + url.search, cloudApiBaseUrl);
try {
await requestUpstream({ target, request, response, body: request.method === "GET" ? "" : body, url, cloudApiProxyTimeoutMs });
} catch (error) {
if (response.headersSent || response.writableEnded) {
if (!response.writableEnded) response.destroy(error);
return;
}
const timedOut = error.timedOut === true || /timed out after \d+ms/iu.test(error.message);
const code = timedOut ? "cloud_api_proxy_timeout" : "cloud_api_proxy_failed";
const category = timedOut ? "timeout" : "proxy";
const traceId = request.headers["x-trace-id"] || null;
const userMessage = timedOut
? "Code Agent 代理等待 cloud-api 超过 " + cloudApiProxyTimeoutMs + "ms;输入已保留,可稍后重试。"
: "Code Agent 代理暂时无法连接 cloud-api;输入已保留,可稍后重试。";
sendJson(response, 502, {
status: "failed",
error: {
code,
layer: "proxy",
category,
retryable: true,
userMessage,
message: userMessage,
timeoutMs: timedOut ? cloudApiProxyTimeoutMs : null,
traceId,
route: url.pathname,
blocker: {
code,
layer: "proxy",
category,
retryable: true,
summary: userMessage,
userMessage,
traceId,
route: url.pathname
}
},
serviceId,
target: target.href.replace(/\/\/[^/@]*@/u, "//***@"),
traceId,
reason: userMessage
});
}
}
function contentType(filePath) {
if (filePath.endsWith(".html")) return "text/html; charset=utf-8";
if (filePath.endsWith(".css")) return "text/css; charset=utf-8";
if (filePath.endsWith(".mjs") || filePath.endsWith(".js")) return "text/javascript; charset=utf-8";
return "application/octet-stream";
}
@@ -0,0 +1,62 @@
import assert from "node:assert/strict";
import { createServer } from "node:http";
import test from "node:test";
import { createCloudWebServer } from "./cloud-web-runtime.mjs";
test("cloud web auth requests are proxied once", async () => {
let upstreamRequests = 0;
const upstream = createServer((request, response) => {
upstreamRequests += 1;
request.resume();
response.writeHead(200, { "content-type": "application/json" });
response.end(JSON.stringify({ authenticated: true, path: request.url }));
});
await listen(upstream);
const cloudWeb = createCloudWebServer({
serviceId: "hwlab-cloud-web",
cloudApiBaseUrl: serverUrl(upstream),
cloudApiProxyTimeoutMs: 1000,
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 response = await fetch(`${serverUrl(cloudWeb)}/auth/session`, {
headers: { cookie: "hwlab_session=token" }
});
assert.equal(response.status, 200);
assert.deepEqual(await response.json(), { authenticated: true, path: "/auth/session" });
assert.equal(upstreamRequests, 1);
} finally {
await close(cloudWeb);
await close(upstream);
}
});
function listen(server) {
return new Promise((resolve, reject) => {
server.once("error", reject);
server.listen(0, "127.0.0.1", resolve);
});
}
function close(server) {
return new Promise((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});
}
function serverUrl(server) {
const address = server.address();
return `http://127.0.0.1:${address.port}`;
}
@@ -1,24 +0,0 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
test("bun-command artifact Dockerfile installs native Bun binary without npm postinstall", async () => {
const source = await readFile("scripts/artifact-publish.mjs", "utf8");
assert.match(source, /@oven\/bun-linux-x64@1\.3\.13/u);
assert.match(source, /@oven\/bun-linux-aarch64@1\.3\.13/u);
assert.match(source, /--ignore-scripts "\$bun_pkg"/u);
assert.match(source, /ln -sf "\/app\/node_modules\/\\\$\{bun_pkg%@\*\}\/bin\/bun" \/usr\/local\/bin\/bun/u);
assert.doesNotMatch(source, /npm install --no-save --omit=dev --include=optional --ignore-scripts bun@/u);
assert.match(source, /hwlab-artifact-runtime-check-failed:\$1/u);
assert.match(source, /fail bun-version/u);
});
test("env-reuse Dockerfile carries git and stable Bun path for mirror checkout", async () => {
const source = await readFile("scripts/artifact-publish.mjs", "utf8");
assert.ok(source.includes("apt-get install -y --no-install-recommends ca-certificates git"));
assert.ok(source.includes('ln -sf \\"/opt/hwlab-env/$bun_bin\\" /usr/local/bin/bun'));
assert.ok(source.includes("test -x /usr/local/bin/bun"));
assert.ok(source.includes("/usr/local/bin/bun --version >/tmp/hwlab-env-bun-version.txt"));
assert.ok(source.includes("ENV PATH=/usr/local/bin:$PATH"));
assert.ok(source.includes('CMD [\\"/usr/local/bin/bun\\", \\"/usr/local/bin/hwlab-env-reuse-launcher.ts\\"]'));
});
+1 -371
View File
@@ -749,376 +749,6 @@ async function resolveServices(serviceIds, catalog) {
return resolveDevArtifactServices(repoRoot, serviceIds, SERVICE_IDS, catalog);
}
function runtimeScriptBase64() {
const source = String.raw`
import { createServer } from "node:http";
import { spawn } from "node:child_process";
import { chmodSync, existsSync, lstatSync, mkdirSync, symlinkSync } from "node:fs";
import { readFile, stat } from "node:fs/promises";
import path from "node:path";
import { isCloudWebSseRoute, proxyCloudApiRequest } from "/app/internal/dev-entrypoint/cloud-web-proxy.mjs";
import { cloudWebProxyRoutePolicy } from "/app/internal/dev-entrypoint/cloud-web-routes.mjs";
const serviceId = process.env.HWLAB_SERVICE_ID || "hwlab-unknown";
const environment = process.env.HWLAB_ENVIRONMENT || "dev";
const runtimeKind = process.env.HWLAB_ARTIFACT_KIND || "health-placeholder";
const entrypoint = process.env.HWLAB_SERVICE_ENTRYPOINT || "";
const port = Number.parseInt(process.env.PORT || process.env.HWLAB_PORT || "8080", 10);
const cloudApiBaseUrl = process.env.HWLAB_API_BASE_URL || "";
const cloudApiProxyTimeoutMs = parseTimeout(process.env.HWLAB_CLOUD_WEB_PROXY_TIMEOUT_MS, 1260000, {
min: 1000,
max: 2400000
});
const readOnlyRpcMethods = new Set([
"system.health",
"cloud.adapter.describe",
"audit.event.query",
"evidence.record.query"
]);
function ensureCodeAgentRuntimeBase() {
if (serviceId !== "hwlab-cloud-api") return;
const workspace = process.env.HWLAB_CODE_AGENT_CODEX_WORKSPACE || process.env.HWLAB_CODE_AGENT_WORKSPACE || "/workspace/hwlab";
const codexHome = process.env.CODEX_HOME || process.env.HWLAB_CODE_AGENT_CODEX_HOME || "/codex-home";
process.env.HWLAB_CODE_AGENT_WORKSPACE = process.env.HWLAB_CODE_AGENT_WORKSPACE || workspace;
process.env.HWLAB_CODE_AGENT_CODEX_WORKSPACE = process.env.HWLAB_CODE_AGENT_CODEX_WORKSPACE || workspace;
process.env.HWLAB_CODE_AGENT_CODEX_SANDBOX = process.env.HWLAB_CODE_AGENT_CODEX_SANDBOX || "danger-full-access";
process.env.HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED = process.env.HWLAB_CODE_AGENT_CODEX_STDIO_ENABLED || "1";
process.env.HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR = process.env.HWLAB_CODE_AGENT_CODEX_STDIO_SUPERVISOR || "repo-owned";
process.env.HWLAB_CODE_AGENT_PROVIDER = process.env.HWLAB_CODE_AGENT_PROVIDER || "codex-stdio";
process.env.CODEX_HOME = codexHome;
process.env.HWLAB_CODE_AGENT_CODEX_COMMAND = process.env.HWLAB_CODE_AGENT_CODEX_COMMAND || "/app/node_modules/.bin/codex";
process.env.HWLAB_CODE_AGENT_SKILLS_DIRS = process.env.HWLAB_CODE_AGENT_SKILLS_DIRS || "/app/skills";
mkdirSync(path.dirname(workspace), { recursive: true });
if (!existsSync(workspace)) {
if (workspace === "/workspace/hwlab" && existsSync("/app")) {
symlinkSync("/app", workspace, "dir");
} else {
mkdirSync(workspace, { recursive: true });
}
}
mkdirSync(codexHome, { recursive: true });
for (const target of [workspace, codexHome]) {
try {
const info = lstatSync(target);
chmodSync(info.isSymbolicLink() ? path.resolve(target) : target, 0o777);
} catch {
// Runtime readiness reports the concrete blocker without exposing secret values.
}
}
}
ensureCodeAgentRuntimeBase();
function sendJson(response, statusCode, body) {
const payload = JSON.stringify(body, null, 2);
response.writeHead(statusCode, {
"content-type": "application/json; charset=utf-8",
"content-length": Buffer.byteLength(payload)
});
response.end(payload);
}
function nonEmptyEnv(name, fallback) {
const value = process.env[name];
return typeof value === "string" && value.trim() ? value.trim() : fallback;
}
function healthPayload() {
const commitId = process.env.HWLAB_COMMIT_ID || process.env.HWLAB_GIT_SHA || "unknown";
const imageReference = process.env.HWLAB_IMAGE || "unknown";
const imageTag = process.env.HWLAB_IMAGE_TAG || imageTagFromReference(imageReference) || shortRevision(commitId);
const buildCreatedAt = normalizeIsoTimestamp(
process.env.HWLAB_BUILD_CREATED_AT ||
process.env.HWLAB_IMAGE_CREATED_AT ||
process.env.SOURCE_DATE_EPOCH ||
process.env.BUILD_DATE
);
const buildSource = process.env.HWLAB_BUILD_SOURCE || "runtime-env";
return {
serviceId,
environment,
status: "ok",
artifactKind: runtimeKind,
revision: process.env.HWLAB_REVISION || commitId,
commit: {
id: commitId,
source: buildSource
},
image: {
reference: imageReference,
tag: imageTag,
digest: process.env.HWLAB_IMAGE_DIGEST || "unknown"
},
build: {
createdAt: buildCreatedAt,
source: buildSource,
provenance: process.env.HWLAB_BUILD_PROVENANCE || null,
metadataSource: buildCreatedAt ? "runtime-env:HWLAB_BUILD_CREATED_AT" : "unavailable",
unavailableReason: buildCreatedAt ? null : "HWLAB_BUILD_CREATED_AT missing from runtime environment"
}
};
}
function normalizeIsoTimestamp(value) {
const source = String(value || "").trim();
if (!source) return null;
if (/^\d+$/u.test(source)) {
const seconds = Number.parseInt(source, 10);
if (Number.isSafeInteger(seconds)) {
const date = new Date(seconds * 1000);
if (Number.isFinite(date.getTime())) return date.toISOString();
}
}
const date = new Date(source);
return Number.isFinite(date.getTime()) ? date.toISOString() : null;
}
function imageTagFromReference(imageReference) {
const source = String(imageReference || "").trim();
const slashIndex = source.lastIndexOf("/");
const colonIndex = source.lastIndexOf(":");
if (colonIndex <= slashIndex) return null;
return source.slice(colonIndex + 1) || null;
}
function shortRevision(value) {
const source = String(value || "").trim();
return source.length >= 7 ? source.slice(0, 7) : source || "unknown";
}
function runEntrypoint(command, args) {
const child = spawn(command, args, {
cwd: "/app",
env: process.env,
stdio: "inherit"
});
child.on("error", (error) => {
process.stderr.write(JSON.stringify({
serviceId,
status: "failed",
error: "entrypoint_spawn_failed",
command,
reason: error.message
}) + "\n");
process.exit(127);
});
child.on("exit", (code, signal) => {
if (signal) process.kill(process.pid, signal);
else process.exit(code ?? 0);
});
}
function runNodeEntrypoint(file) {
runEntrypoint(process.execPath, [file]);
}
function runBunEntrypoint(file) {
const candidates = [
process.env.HWLAB_BUN_COMMAND,
"/app/node_modules/.bin/bun",
"/usr/local/bin/bun",
"bun"
].filter(Boolean);
const command = candidates.find((candidate) => !candidate.includes("/") || existsSync(candidate)) || "bun";
runEntrypoint(command, [file]);
}
async function handleCloudWebAuth(request, response, url) {
if (url.pathname === "/auth/session" && request.method === "GET") return proxyCloudApi(request, response, url);
if (url.pathname === "/auth/login" && request.method === "POST") return proxyCloudApi(request, response, url);
if (url.pathname === "/auth/logout" && request.method === "POST") return proxyCloudApi(request, response, url);
if (!url.pathname.startsWith("/auth/")) return false;
return proxyCloudApi(request, response, url);
}
async function readRequestBody(request) {
let body = "";
for await (const chunk of request) {
body += chunk;
}
return body;
}
function parseTimeout(value, fallback, { min, max }) {
const parsed = Number.parseInt(value || "", 10);
if (!Number.isInteger(parsed)) return fallback;
return Math.min(Math.max(parsed, min), max);
}
function requestUpstream(target, request, response, body, url) {
return proxyCloudApiRequest({
target,
request,
response,
body,
timeoutMs: cloudApiProxyTimeoutMs,
forceStream: isCloudWebSseRoute(url.pathname)
});
}
async function proxyCloudApi(request, response, url) {
if (!cloudApiBaseUrl) {
sendJson(response, 503, {
error: "cloud_api_base_url_missing",
serviceId,
path: url.pathname
});
return;
}
let body = "";
if (request.method !== "GET") {
body = await readRequestBody(request);
}
if (url.pathname === "/json-rpc") {
try {
const envelope = body ? JSON.parse(body) : {};
if (!readOnlyRpcMethods.has(envelope.method)) {
sendJson(response, 403, {
error: "readonly_rpc_required",
serviceId,
method: envelope.method || null
});
return;
}
} catch (error) {
sendJson(response, 400, {
error: "invalid_json",
serviceId,
reason: error.message
});
return;
}
}
const target = new URL(url.pathname + url.search, cloudApiBaseUrl);
try {
await requestUpstream(target, request, response, request.method === "GET" ? "" : body, url);
} catch (error) {
if (response.headersSent || response.writableEnded) {
if (!response.writableEnded) response.destroy(error);
return;
}
const timedOut = error.timedOut === true || /timed out after \d+ms/iu.test(error.message);
const code = timedOut ? "cloud_api_proxy_timeout" : "cloud_api_proxy_failed";
const category = timedOut ? "timeout" : "proxy";
const traceId = request.headers["x-trace-id"] || null;
const userMessage = timedOut
? "Code Agent 代理等待 cloud-api 超过 " + cloudApiProxyTimeoutMs + "ms;输入已保留,可稍后重试。"
: "Code Agent 代理暂时无法连接 cloud-api;输入已保留,可稍后重试。";
sendJson(response, 502, {
status: "failed",
error: {
code,
layer: "proxy",
category,
retryable: true,
userMessage,
message: userMessage,
timeoutMs: timedOut ? cloudApiProxyTimeoutMs : null,
traceId,
route: url.pathname,
blocker: {
code,
layer: "proxy",
category,
retryable: true,
summary: userMessage,
userMessage,
traceId,
route: url.pathname
}
},
serviceId,
target: target.href.replace(/\/\/[^/@]*@/u, "//***@"),
traceId,
reason: userMessage
});
}
}
async function serveCloudWeb() {
const roots = ["/app/web/hwlab-cloud-web/dist", "/app/web/hwlab-cloud-web"];
const server = createServer(async (request, response) => {
const url = new URL(request.url || "/", "http://hwlab-cloud-web.local");
if (url.pathname === "/health/live" || url.pathname === "/health") {
sendJson(response, 200, healthPayload());
return;
}
if (await handleCloudWebAuth(request, response, url)) {
return;
}
const proxyPolicy = cloudWebProxyRoutePolicy(request.method, url.pathname);
if (proxyPolicy.proxy) {
await proxyCloudApi(request, response, url);
return;
}
const routePath = url.pathname.replace(/\/+$/, "") || "/";
const relativePath = routePath === "/" || routePath === "/gate" || routePath === "/diagnostics/gate" || routePath === "/help" ? "index.html" : url.pathname.slice(1);
for (const root of roots) {
const candidate = path.resolve(root, relativePath);
if (!candidate.startsWith(root)) continue;
try {
const info = await stat(candidate);
if (!info.isFile()) continue;
const body = await readFile(candidate);
response.writeHead(200, {
"content-type": contentType(candidate),
"content-length": body.length
});
response.end(body);
return;
} catch {
// Try the next root.
}
}
sendJson(response, 404, { error: "not_found", serviceId, path: url.pathname });
});
server.listen(port, "0.0.0.0", () => {
process.stdout.write(JSON.stringify({ serviceId, environment, status: "listening", port }) + "\n");
});
}
function contentType(filePath) {
if (filePath.endsWith(".html")) return "text/html; charset=utf-8";
if (filePath.endsWith(".css")) return "text/css; charset=utf-8";
if (filePath.endsWith(".mjs") || filePath.endsWith(".js")) return "text/javascript; charset=utf-8";
return "application/octet-stream";
}
function serveHealthOnly() {
const server = createServer((request, response) => {
const url = new URL(request.url || "/", "http://hwlab-artifact.local");
if (url.pathname === "/health/live" || url.pathname === "/health") {
sendJson(response, 200, healthPayload());
return;
}
sendJson(response, 404, { error: "not_found", serviceId, path: url.pathname });
});
server.listen(port, "0.0.0.0", () => {
process.stdout.write(JSON.stringify({ serviceId, environment, status: "listening", port }) + "\n");
});
}
if (runtimeKind === "node-command" && entrypoint) {
runNodeEntrypoint(entrypoint);
} else if (runtimeKind === "bun-command" && entrypoint) {
runBunEntrypoint(entrypoint);
} else if (runtimeKind === "cloud-web") {
await serveCloudWeb();
} else {
serveHealthOnly();
}
`;
return Buffer.from(source, "utf8").toString("base64");
}
function dockerfile(baseImage, port, labels = []) {
const dependencyTimingScript = `started_at=$(node -e "console.log(Date.now())"); status=succeeded; if [ -d /opt/hwlab-node-runtime-base/node_modules ]; then cp -a /opt/hwlab-node-runtime-base/node_modules ./node_modules; fi && if [ -f package-lock.json ]; then npm install --omit=dev --include=optional --ignore-scripts; else npm install --omit=dev --include=optional --ignore-scripts; fi && if [ "\${HWLAB_ARTIFACT_KIND:-}" = "bun-command" ]; then bun_pkg=""; case "$(uname -m)" in x86_64|amd64) bun_pkg="@oven/bun-linux-x64@1.3.13" ;; aarch64|arm64) bun_pkg="@oven/bun-linux-aarch64@1.3.13" ;; *) echo "unsupported bun-command architecture: $(uname -m)" >&2; false ;; esac && npm install --no-save --omit=dev --include=optional --ignore-scripts "$bun_pkg" && test -x "node_modules/\${bun_pkg%@*}/bin/bun" && ln -sf "/app/node_modules/\${bun_pkg%@*}/bin/bun" /usr/local/bin/bun; fi && codex_version="$(node -p "require('./node_modules/@openai/codex/package.json').version")" && case "$(uname -m)" in x86_64|amd64) test -e node_modules/@openai/codex-linux-x64 || npm install --omit=dev --include=optional --ignore-scripts "@openai/codex-linux-x64@npm:@openai/codex@\${codex_version}-linux-x64" ;; aarch64|arm64) test -e node_modules/@openai/codex-linux-arm64 || npm install --omit=dev --include=optional --ignore-scripts "@openai/codex-linux-arm64@npm:@openai/codex@\${codex_version}-linux-arm64" ;; esac || status=failed; finished_at=$(node -e "console.log(Date.now())"); node -e "console.log(JSON.stringify({event:'g14-cicd-build-step-timing',stage:'dependency-install',status:process.argv[1],durationMs:Number(process.argv[3])-Number(process.argv[2]),source:'dockerfile-run'}))" "$status" "$started_at" "$finished_at"; test "$status" = succeeded`;
const runtimeReadinessScript = `set -eu; fail() { echo "hwlab-artifact-runtime-check-failed:$1" >&2; exit 1; }; mkdir -p /workspace /codex-home; rm -rf /workspace/hwlab; ln -s /app /workspace/hwlab; if [ "\${HWLAB_ARTIFACT_KIND:-}" = "bun-command" ]; then test -x /usr/local/bin/bun || fail bun-missing; /usr/local/bin/bun --version >/tmp/hwlab-bun-version.txt || fail bun-version; fi; chmod -R a+rwX /app /workspace /codex-home || fail chmod; test -x /app/node_modules/.bin/codex || fail codex-bin-missing; /app/node_modules/.bin/codex --version >/tmp/hwlab-codex-version.txt || fail codex-version; test -x /app/node_modules/@openai/codex-linux-x64/vendor/x86_64-unknown-linux-musl/codex/codex || test -x /app/node_modules/@openai/codex-linux-arm64/vendor/aarch64-unknown-linux-musl/codex/codex || fail codex-native-missing`;
@@ -1148,7 +778,7 @@ function dockerfile(baseImage, port, labels = []) {
"COPY skills ./skills",
"COPY deploy ./deploy",
`RUN ${runtimeReadinessScript}`,
`RUN node -e "const fs=require('node:fs'); fs.writeFileSync('/usr/local/bin/hwlab-dev-artifact-runtime.mjs', Buffer.from('${runtimeScriptBase64()}', 'base64')); fs.chmodSync('/usr/local/bin/hwlab-dev-artifact-runtime.mjs', 0o755);"`,
"RUN cp /app/internal/dev-entrypoint/artifact-runtime.mjs /usr/local/bin/hwlab-dev-artifact-runtime.mjs && chmod 755 /usr/local/bin/hwlab-dev-artifact-runtime.mjs",
"ARG HWLAB_ENVIRONMENT",
"ARG HWLAB_SERVICE_ID",
"ARG HWLAB_SERVICE_ENTRYPOINT",
+19
View File
@@ -70,6 +70,19 @@ test("component model uses built-in service paths", () => {
assert.deepEqual(matchingPaths(["cmd/hwlab-cloud-api/main.ts"], models[0].componentPaths), ["cmd/hwlab-cloud-api/main.ts"]);
});
test("planner scopes cloud-web runtime changes to cloud-web", async () => {
const repo = await createFixtureRepo();
await writeFile(path.join(repo, "internal/dev-entrypoint/cloud-web-runtime.mjs"), "export const runtime = 2;\n");
await git(repo, ["add", "."]);
await git(repo, ["commit", "-m", "change cloud web runtime"]);
const plan = await createG14CiPlan({ repoRoot: repo, baseRef: "HEAD~1", targetRef: "HEAD" });
assert.deepEqual(plan.affectedServices, ["hwlab-cloud-web"]);
assert.equal(plan.buildServices.includes("hwlab-agent-worker"), false);
const cloudWeb = plan.services.find((service) => service.serviceId === "hwlab-cloud-web");
assert.deepEqual(cloudWeb.reason, ["component-path-changed"]);
});
test("planner remains compatible with deploy.k3s.serviceMappings shape", async () => {
const repo = await createFixtureRepo({ deployServices: false, k3sServiceMappings: true });
await writeFile(path.join(repo, "cmd/hwlab-deepseek-responses-bridge/main.ts"), "console.log('bridge v2');\n");
@@ -143,6 +156,7 @@ async function createFixtureRepo(options = {}) {
await mkdir(path.join(repo, "cmd/hwlab-cloud-api"), { recursive: true });
await mkdir(path.join(repo, "cmd/hwlab-deepseek-responses-bridge"), { recursive: true });
await mkdir(path.join(repo, "web/hwlab-cloud-web"), { recursive: true });
await mkdir(path.join(repo, "internal/dev-entrypoint"), { recursive: true });
await mkdir(path.join(repo, "internal/device-pod"), { recursive: true });
await mkdir(path.join(repo, "deploy/runtime/boot"), { recursive: true });
await mkdir(path.join(repo, "deploy/runtime/launcher"), { recursive: true });
@@ -159,6 +173,11 @@ async function createFixtureRepo(options = {}) {
await writeFile(path.join(repo, "cmd/hwlab-deepseek-responses-bridge/main.ts"), "console.log('bridge');\n");
await writeFile(path.join(repo, "cmd/hwlab-deepseek-responses-bridge/main.test.ts"), "console.log('test');\n");
await writeFile(path.join(repo, "web/hwlab-cloud-web/index.html"), "<html></html>\n");
await writeFile(path.join(repo, "internal/dev-entrypoint/artifact-runtime.mjs"), "export const artifactRuntime = 1;\n");
await writeFile(path.join(repo, "internal/dev-entrypoint/cloud-web-runtime.mjs"), "export const runtime = 1;\n");
await writeFile(path.join(repo, "internal/dev-entrypoint/cloud-web-proxy.mjs"), "export const proxy = 1;\n");
await writeFile(path.join(repo, "internal/dev-entrypoint/cloud-web-routes.mjs"), "export const routes = 1;\n");
await writeFile(path.join(repo, "internal/dev-entrypoint/http.mjs"), "export const http = 1;\n");
await writeFile(path.join(repo, "internal/device-pod/executor.ts"), "export const version = 1;\n");
await writeFile(path.join(repo, "deploy/runtime/boot/hwlab-device-pod.sh"), "#!/bin/sh\nexec bun cmd/hwlab-device-pod/main.ts\n");
await writeFile(path.join(repo, "deploy/runtime/launcher/hwlab-env-reuse-launcher.ts"), "console.log('launcher');\n");
+1 -45
View File
@@ -431,7 +431,6 @@ function runStaticSmoke() {
const blockers = [];
const files = readStaticFiles();
const source = Object.values(files).join("\n");
const artifactPublisher = readText("scripts/artifact-publish.mjs");
const buildScript = readText("web/hwlab-cloud-web/scripts/build.ts");
const distContractScript = readText("web/hwlab-cloud-web/scripts/dist-contract.ts");
@@ -439,11 +438,6 @@ function runStaticSmoke() {
evidence: requiredWebAssets.map((file) => `web/hwlab-cloud-web/${file}`)
});
addCheck(checks, blockers, "default-login-entry", hasDefaultLoginEntry(files, artifactPublisher), "Default 16666 route shows the Chinese login page before the Cloud Workbench is exposed.", {
blocker: "runtime_blocker",
evidence: ["云工作台登录", "账号或密码不正确,请重新输入。", "data-app-shell hidden", "auth.ts", "HWLAB_CLOUD_WEB_AUTH_USERNAME", "HWLAB_CLOUD_WEB_AUTH_PASSWORD"]
});
addCheck(checks, blockers, "default-workbench-shell", hasDefaultWorkbench(files), "Authenticated route is the VS Code-style Cloud Workbench.", {
blocker: "runtime_blocker",
evidence: ["workspace view exists behind login", "Gate view is hidden by default after auth", ...workbenchMarkers]
@@ -1933,40 +1927,6 @@ function assetsExist() {
return requiredWebAssets.every((file) => fs.existsSync(path.join(webRoot, file)));
}
function hasDefaultLoginEntry({ html, app, auth, styles }, artifactPublisher = "") {
const loginShellTag = html.match(/<section\b[^>]*\bid=["']login-shell["'][^>]*>/u)?.[0] ?? "";
const appShellTag = html.match(/<main\b[^>]*\bdata-app-shell\b[^>]*>/u)?.[0] ?? "";
const loginHtml = html.match(/<section\b[^>]*\bid=["']login-shell["'][\s\S]*?<\/section>/u)?.[0] ?? "";
const defaultShellText = visibleTextFromHtml(loginHtml);
const authSource = `${auth}\n${app}\n${styles}\n${artifactPublisher}`;
return (
loginShellTag.includes("data-auth-login") &&
!/\bhidden\b/u.test(loginShellTag) &&
/\bhidden\b/u.test(appShellTag) &&
/data-auth-state=["']checking["']/u.test(html) &&
["云工作台登录", "请输入账号和密码。", "用户名", "密码", "登录"].every((term) => defaultShellText.includes(term)) &&
!defaultShellText.includes("Gate") &&
!defaultShellText.includes("诊断") &&
!defaultShellText.includes("验收") &&
!defaultShellText.includes("后厨") &&
/DEFAULT_AUTH_USERNAME\s*=\s*["']admin["']/u.test(auth) &&
/DEFAULT_AUTH_PASSWORD\s*=\s*["']hwlab2026["']/u.test(auth) &&
/HWLAB_CLOUD_WEB_CONFIG\?\.auth/u.test(auth) &&
!/AUTH_STORAGE_KEY\s*=\s*["']hwlab\.cloudWorkbench\.auth\.v1["']/u.test(auth) &&
!/const localSession = readLocalSession\(config\)/u.test(auth) &&
!/writeLocalSession\(config\)/u.test(functionBody(auth, "attemptLogin")) &&
/账号或密码不正确,请重新输入。/u.test(auth) &&
/ensureWorkbenchAuth/u.test(app) &&
/await\s+ensureWorkbenchAuth/u.test(app) &&
/initWorkbenchLogout/u.test(app) &&
/proxyCloudApi\(request, response, url\)/u.test(artifactPublisher) &&
!/activeAuthSession/u.test(artifactPublisher) &&
/\.login-shell\s*\{[^}]*height:\s*100dvh;[^}]*overflow:\s*hidden;/su.test(styles) &&
/\.login-panel\s*\{[^}]*width:\s*min\(100%,\s*360px\);/su.test(styles) &&
/\.workbench-shell\[hidden\]/u.test(styles)
);
}
function hasDefaultWorkbench({ html, app }) {
const workspaceTag = firstTagForDataView(html, "workspace");
const gateTag = firstTagForDataView(html, "gate");
@@ -2545,11 +2505,7 @@ function hasCodeAgentLongTimeoutContract(files) {
/isTimeoutFailure\(code,\s*message\)/u.test(functionBody(app, "isCodeAgentTimeoutError")) &&
/function\s+renderDrafts\s*\(/u.test(app) &&
/HWLAB_CLOUD_WEB_CONFIG/u.test(app) &&
/codeAgentTimeoutMs/u.test(app) &&
/Code Agent 代理等待 cloud-api 超过/u.test(files.artifactPublisher ?? "") &&
/输入已保留,可稍后重试/u.test(files.artifactPublisher ?? "") &&
/blocker:\s*\{/u.test(files.artifactPublisher ?? "") &&
/retryable:\s*true/u.test(files.artifactPublisher ?? "")
/codeAgentTimeoutMs/u.test(app)
);
}
+8 -2
View File
@@ -12,6 +12,7 @@ export const G14_CI_PLAN_VERSION = "v1";
export const V02_ENV_REUSE_RUNTIME_MODE = "env-reuse-git-mirror-checkout";
export const DEFAULT_BUILD_SYSTEM_PATHS = Object.freeze([
"internal/dev-entrypoint/artifact-runtime.mjs",
"scripts/artifact-publish.mjs",
"scripts/g14-artifact-publish.mjs",
"scripts/src/dev-artifact-services.mjs"
@@ -58,12 +59,17 @@ const serviceSpecificPaths = Object.freeze({
"tools/",
"skills/"
],
"hwlab-cloud-web": ["web/hwlab-cloud-web/"],
"hwlab-cloud-web": [
"web/hwlab-cloud-web/",
"internal/dev-entrypoint/cloud-web-runtime.mjs",
"internal/dev-entrypoint/cloud-web-proxy.mjs",
"internal/dev-entrypoint/cloud-web-routes.mjs"
],
"hwlab-agent-mgr": ["cmd/hwlab-agent-mgr/", "internal/agent/", "internal/db/", "internal/audit/"],
"hwlab-agent-worker": ["cmd/hwlab-agent-worker/", "internal/agent/", "internal/db/", "internal/audit/", "skills/"],
"hwlab-device-pod": ["cmd/hwlab-device-pod/", "internal/device-pod/"],
"hwlab-gateway": ["cmd/hwlab-gateway/"],
"hwlab-edge-proxy": ["cmd/hwlab-edge-proxy/", "internal/dev-entrypoint/"],
"hwlab-edge-proxy": ["cmd/hwlab-edge-proxy/", "internal/dev-entrypoint/http.mjs"],
"hwlab-cli": ["tools/hwlab-cli/"],
"hwlab-agent-skills": ["skills/"]
});