diff --git a/internal/dev-entrypoint/artifact-runtime.mjs b/internal/dev-entrypoint/artifact-runtime.mjs new file mode 100644 index 00000000..ac496068 --- /dev/null +++ b/internal/dev-entrypoint/artifact-runtime.mjs @@ -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(); +} diff --git a/internal/dev-entrypoint/cloud-web-runtime.mjs b/internal/dev-entrypoint/cloud-web-runtime.mjs new file mode 100644 index 00000000..98b01665 --- /dev/null +++ b/internal/dev-entrypoint/cloud-web-runtime.mjs @@ -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"; +} diff --git a/internal/dev-entrypoint/cloud-web-runtime.test.mjs b/internal/dev-entrypoint/cloud-web-runtime.test.mjs new file mode 100644 index 00000000..fd314723 --- /dev/null +++ b/internal/dev-entrypoint/cloud-web-runtime.test.mjs @@ -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}`; +} diff --git a/scripts/artifact-publish-bun-runtime.test.mjs b/scripts/artifact-publish-bun-runtime.test.mjs deleted file mode 100644 index 196e9dd1..00000000 --- a/scripts/artifact-publish-bun-runtime.test.mjs +++ /dev/null @@ -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\\"]')); -}); diff --git a/scripts/artifact-publish.mjs b/scripts/artifact-publish.mjs index 6814b66b..2c39e5df 100644 --- a/scripts/artifact-publish.mjs +++ b/scripts/artifact-publish.mjs @@ -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", diff --git a/scripts/g14-ci-plan.test.mjs b/scripts/g14-ci-plan.test.mjs index d9b25bae..858180ba 100644 --- a/scripts/g14-ci-plan.test.mjs +++ b/scripts/g14-ci-plan.test.mjs @@ -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"), "\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"); diff --git a/scripts/src/dev-cloud-workbench-smoke-lib.mjs b/scripts/src/dev-cloud-workbench-smoke-lib.mjs index 5726e40b..8722402f 100644 --- a/scripts/src/dev-cloud-workbench-smoke-lib.mjs +++ b/scripts/src/dev-cloud-workbench-smoke-lib.mjs @@ -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(/]*\bid=["']login-shell["'][^>]*>/u)?.[0] ?? ""; - const appShellTag = html.match(/]*\bdata-app-shell\b[^>]*>/u)?.[0] ?? ""; - const loginHtml = html.match(/]*\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) ); } diff --git a/scripts/src/g14-ci-plan-lib.mjs b/scripts/src/g14-ci-plan-lib.mjs index f4fab236..2f36acc9 100644 --- a/scripts/src/g14-ci-plan-lib.mjs +++ b/scripts/src/g14-ci-plan-lib.mjs @@ -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/"] });