fix: stabilize cloud web proxy runtime
This commit is contained in:
@@ -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\\"]'));
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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/"]
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user