From 3377f4726be21d83109c80df229370fe53f9f58d Mon Sep 17 00:00:00 2001 From: "sorokin.oleg.edu" Date: Sun, 1 Nov 2026 01:13:43 +0300 Subject: [PATCH] chore: auto-commit after stop GSD-Unit: M001 --- scripts/smoke-server.mjs | 223 +++++++++++++++++-------------------- tests/smoke-server.test.ts | 103 +++++++++++++++++ 2 files changed, 204 insertions(+), 122 deletions(-) create mode 100644 tests/smoke-server.test.ts diff --git a/scripts/smoke-server.mjs b/scripts/smoke-server.mjs index fc67c83..c77fda4 100644 --- a/scripts/smoke-server.mjs +++ b/scripts/smoke-server.mjs @@ -1,30 +1,13 @@ /** - * smoke-server.mjs — boot the packaged standalone server and prove it serves. - * - * The payload gate that pays for itself (2026-10-08): `next build` success - * and a completed electron-builder run say NOTHING about whether the - * packaged server actually starts. On its first day this smoke caught two - * real defects nothing else saw: (1) a trace-exclude regression that - * stripped the pi engine packages to bare package.jsons — the server - * answered 500 on every route with ERR_MODULE_NOT_FOUND; (2) before that, - * the dev auth.json (BYOK keys) shipping inside the bundle. It runs - * between build-server.mjs and electron-builder so a broken payload fails - * in seconds, not after a full packaging pass. - * - * What it does: spawns .next/standalone/server.js exactly the way the - * Electron shell does (electron/main.cjs) — with --electron it uses - * Electron's bundled Node via ELECTRON_RUN_AS_NODE, otherwise host Node — - * 127.0.0.1 on a kernel-assigned - * free port, NODE_ENV=production, a THROWAWAY LH_DATA_DIR and an EMPTY - * LH_AUTH_FILE under os.tmpdir() (no real keys or data ever touch it) — - * polls GET / until 200, then probes the GET-only API surface (graph / - * models / settings — no side effects). Any non-200, an early server - * exit, or a 30s boot timeout fails the gate and prints the server's own - * log tail for diagnosis. The child is ALWAYS killed before exit — a - * leaked server holds its port against the next run (learned the hard - * way, same day). + * Boot a standalone payload, then probe its GET-only desktop API surface. + * node scripts/smoke-server.mjs [--standalone ] [--runtime ] [--electron] + * Defaults to .next/standalone and host Node; --electron retains its legacy meaning. + * Uses an isolated empty home/data/auth environment and a per-launch API capability. + * No real keys, provider generation, installed app UI or production data are used. */ +import assert from "node:assert/strict"; import { spawn } from "node:child_process"; +import { randomBytes } from "node:crypto"; import { probeEnv, verifyDemoRuntime } from "./smoke-demo-runtime.mjs"; import { verifyProviderRuntime } from "./smoke-provider-runtime.mjs"; import { verifyCompressionRuntime } from "./smoke-compression-runtime.mjs"; @@ -34,161 +17,157 @@ import * as http from "node:http"; import * as net from "node:net"; import * as os from "node:os"; import * as path from "node:path"; +import { fileURLToPath } from "node:url"; const ROOT = path.resolve(import.meta.dirname, ".."); -const STANDALONE = path.join(ROOT, ".next", "standalone"); -const SERVER = path.join(STANDALONE, "server.js"); -const ELECTRON_MODE = process.argv.includes("--electron"); -const RUNTIME = ELECTRON_MODE ? createRequire(import.meta.url)("electron") : process.execPath; - const POLL_MS = 500; const BOOT_TIMEOUT_MS = 30_000; -/** GET-only probes — the routes that exercised today's failure class. */ const PROBES = ["/api/graph", "/api/models", "/api/settings"]; -if (!fs.existsSync(SERVER)) { - console.error("[smoke-server] .next/standalone/server.js missing — run next build first."); - process.exit(1); +/** Match the provider smoke's checkout default and caller-relative explicit paths. */ +export function resolveSmokeOptions(args = []) { + let standalone = path.join(ROOT, ".next", "standalone"); + let runtime; + let electron = false; + for (let i = 0; i < args.length; i++) { + const option = args[i]; + if (option === "--electron") { electron = true; continue; } + assert.ok(option === "--standalone" || option === "--runtime", `Unknown option: ${option}`); + const value = args[++i]; + assert.ok(value && !value.startsWith("--"), `${option} requires a path`); + if (option === "--standalone") standalone = path.resolve(value); + else runtime = path.resolve(value); + } + assert.ok(!(electron && runtime), "Use either --electron or --runtime, not both"); + return { + standalone, + server: path.join(standalone, "server.js"), + runtime: runtime ?? (electron ? createRequire(import.meta.url)("electron") : process.execPath), + }; +} + +export function validateSmokeOptions({ standalone, server, runtime }) { + assert.ok(fs.existsSync(standalone) && fs.statSync(standalone).isDirectory(), `Standalone directory not found: ${standalone}`); + assert.ok(fs.existsSync(server) && fs.statSync(server).isFile(), `Standalone server not found: ${server} — run next build + node scripts/build-server.mjs first`); + assert.ok(fs.existsSync(runtime) && fs.statSync(runtime).isFile(), `Runtime not found: ${runtime}`); + fs.accessSync(runtime, fs.constants.X_OK); + for (const rel of ["auth.json", ".env", ".env.local", ".env.production", ".env.production.local", ".learnharness", "data"]) { + assert.ok(!fs.existsSync(path.join(standalone, rel)), `Credential/data path leaked into standalone: ${rel}`); + } } -/** A free TCP port (listen(0) → the kernel assigns one) — mirrors main.cjs. */ function freePort() { return new Promise((resolve, reject) => { const srv = net.createServer(); srv.listen(0, "127.0.0.1", () => { const { port } = srv.address(); - srv.close(() => resolve(port)); + srv.close((error) => error ? reject(error) : resolve(port)); }); srv.on("error", reject); }); } -/** One GET; resolves the status code (0 = connection refused/failed). */ -function get(url) { +/** Bounded GET; failed/refused connections return status 0 for boot polling. */ +function get(url, headers = {}) { return new Promise((resolve) => { - const req = http.get(url, (res) => { + const req = http.get(url, { headers }, (res) => { res.resume(); resolve(res.statusCode ?? 0); }); req.on("error", () => resolve(0)); - req.setTimeout(5000, () => { - req.destroy(); - resolve(0); - }); + req.setTimeout(5000, () => { req.destroy(); resolve(0); }); }); } async function main() { - // HTTP boot alone never imports the dynamically loaded demo engine. - // Fail before packaging if its standalone dependency graph is incomplete. - verifyDemoRuntime(STANDALONE, RUNTIME); - // /api/models only reads catalogs; initialize the real lazy SDK offline too. - verifyProviderRuntime(STANDALONE, RUNTIME); - // A plain GET without Accept-Encoding never exercises gzip backpressure. - verifyCompressionRuntime(STANDALONE, RUNTIME); + const { standalone, server, runtime } = resolveSmokeOptions(process.argv.slice(2)); + validateSmokeOptions({ standalone, server, runtime }); + console.log(`[smoke-server] standalone: ${standalone}; runtime: ${runtime}`); + // All nested gates use the SAME payload and runtime, never staging fallbacks. + verifyDemoRuntime(standalone, runtime); + verifyProviderRuntime(standalone, runtime); + verifyCompressionRuntime(standalone, runtime); const port = await freePort(); const url = `http://127.0.0.1:${port}`; const home = fs.mkdtempSync(path.join(os.tmpdir(), "lh-smoke-")); const dataDir = path.join(home, "data"); const authFile = path.join(home, "auth.json"); - fs.writeFileSync(authFile, "{}\n", "utf-8"); + const secret = randomBytes(32).toString("hex"); + const headers = { "x-learnharness-desktop": secret }; - const tail = []; // rolling log tail for failure diagnosis - let tailBytes = 0; - const keep = (buf) => { - for (const line of buf.toString().split("\n")) { - tail.push(line); - tailBytes += line.length + 1; - } - while (tailBytes > 16_000 && tail.length > 2) { - tailBytes -= tail[0].length + 1; - tail.shift(); - } - }; + let tail = ""; + const keep = (buf) => { tail = (tail + buf.toString()).slice(-16_000); }; const dumpTail = () => { console.error("--- server log tail " + "-".repeat(20)); - for (const line of tail.slice(-40)) if (line.trim()) console.error(` ${line}`); + for (const line of tail.replaceAll(secret, "[redacted]").split("\n").slice(-40)) { + if (line.trim()) console.error(` ${line}`); + } }; - console.log(`[smoke-server] runtime: ${ELECTRON_MODE ? "Electron bundled Node" : "host Node"}`); - const child = spawn(RUNTIME, [SERVER], { - cwd: STANDALONE, - env: { - ...probeEnv(process.env, home), - ...(ELECTRON_MODE ? { ELECTRON_RUN_AS_NODE: "1" } : {}), - NODE_ENV: "production", - PORT: String(port), - HOSTNAME: "127.0.0.1", - LH_DATA_DIR: dataDir, - LH_AUTH_FILE: authFile, - }, - stdio: ["ignore", "pipe", "pipe"], - }); + let child; let exited = false; - child.stdout.on("data", keep); - child.stderr.on("data", keep); - child.on("exit", () => { - exited = true; - }); - - const kill = () => - new Promise((resolve) => { - if (exited) return resolve(); - const done = () => resolve(); - child.once("exit", done); - child.kill("SIGTERM"); - setTimeout(() => { - if (!exited) { - try { - child.kill("SIGKILL"); - } catch { - /* already gone */ - } - } - done(); - }, 3000); - }); - - let failed = null; + let launchError; + let failed; try { - // 1) Boot: poll GET / until 200 (or timeout / early exit). + fs.writeFileSync(authFile, "{}\n", { mode: 0o600 }); + child = spawn(runtime, [server], { + cwd: standalone, + env: { + ...probeEnv(process.env, home), + ELECTRON_RUN_AS_NODE: "1", + NODE_ENV: "production", PORT: String(port), HOSTNAME: "127.0.0.1", + LH_DATA_DIR: dataDir, LH_AUTH_FILE: authFile, + LH_DESKTOP_SERVER: "1", LH_DESKTOP_API_SECRET: secret, + }, + stdio: ["ignore", "pipe", "pipe"], + }); + child.stdout.on("data", keep); + child.stderr.on("data", keep); + child.on("exit", () => { exited = true; }); + child.on("error", (error) => { launchError = error; }); + let boot = 0; const deadline = Date.now() + BOOT_TIMEOUT_MS; while (Date.now() < deadline) { - if (exited) throw new Error(`server exited before answering on ${url}`); + if (launchError) throw launchError; + if (exited) throw new Error(`server exited before answering on ${url} (code ${child.exitCode}, signal ${child.signalCode})`); boot = await get(url); if (boot === 200) break; await new Promise((r) => setTimeout(r, POLL_MS)); } if (boot !== 200) throw new Error(`server never answered 200 on ${url} (last status: ${boot})`); console.log(`[smoke-server] boot ok: GET / → 200 (${url})`); - - // 2) API surface: every probe must be 200. + assert.equal(await get(url + "/api/models"), 403, "Desktop API must deny unauthenticated requests"); + console.log("[smoke-server] unauthenticated GET /api/models → 403"); for (const route of PROBES) { - const status = await get(url + route); - if (status !== 200) throw new Error(`GET ${route} → ${status} (expected 200)`); - console.log(`[smoke-server] GET ${route} → 200`); + const status = await get(url + route, headers); + assert.equal(status, 200, `Authenticated GET ${route} → ${status} (expected 200)`); + console.log(`[smoke-server] authenticated GET ${route} → 200`); } } catch (err) { failed = err; } finally { - await kill(); try { + if (child?.pid && !exited) { + await new Promise((resolve, reject) => { + const force = setTimeout(() => child.kill("SIGKILL"), 3000); + const deadline = setTimeout(() => reject(new Error(`Server cleanup timed out (pid ${child.pid})`)), 5000); + child.once("exit", () => { clearTimeout(force); clearTimeout(deadline); resolve(); }); + child.kill("SIGTERM"); + }); + } + } finally { fs.rmSync(home, { recursive: true, force: true }); - } catch { - /* best effort — it's under os.tmpdir() */ } + console.log("[smoke-server] owned server stopped; disposable home removed"); } - - if (failed) { - console.error(`[smoke-server] FAIL: ${failed.message}`); - dumpTail(); - process.exit(1); - } + if (failed) { dumpTail(); throw failed; } console.log("[smoke-server] smoke passed — the packaged server boots and serves."); } -main().catch((err) => { - console.error(`[smoke-server] FAIL: ${err.message}`); - process.exit(1); -}); +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + main().catch((err) => { + console.error(`[smoke-server] FAIL: ${err.stack || err}`); + process.exitCode = 1; + }); +} diff --git a/tests/smoke-server.test.ts b/tests/smoke-server.test.ts new file mode 100644 index 0000000..142524e --- /dev/null +++ b/tests/smoke-server.test.ts @@ -0,0 +1,103 @@ +/** Disposable inline fixtures only: never require a built payload or workflow artifacts. */ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { describe, it } from "node:test"; +import { fileURLToPath } from "node:url"; +import { resolveSmokeOptions, validateSmokeOptions } from "../scripts/smoke-server.mjs"; + +const script = fileURLToPath(new URL("../scripts/smoke-server.mjs", import.meta.url)); +const checkout = fileURLToPath(new URL("..", import.meta.url)); +const run = (args: string[]) => spawnSync(process.execPath, [script, ...args], { encoding: "utf8", timeout: 10_000 }); + +function withFixture(test: (root: string) => void) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "lh-server-options-test-")); + try { test(root); } + finally { fs.rmSync(root, { recursive: true, force: true }); } +} + +function fails(args: string[], message: RegExp) { + const result = run(args); + assert.equal(result.error, undefined); + assert.equal(result.status, 1, result.stdout + result.stderr); + assert.match(result.stderr, message); + assert.doesNotMatch(result.stdout, /boot ok|smoke passed|import ok/); +} + +describe("standalone server smoke options", () => { + it("preserves the checkout staging default and host runtime without booting on import", () => { + const options = resolveSmokeOptions(); + assert.equal(options.standalone, path.join(checkout, ".next", "standalone")); + assert.equal(options.server, path.join(options.standalone, "server.js")); + assert.equal(options.runtime, process.execPath); + }); + + it("resolves explicit relative paths against the caller and accepts an explicit root", () => { + const relative = resolveSmokeOptions(["--standalone", "payload", "--runtime", "bin/runtime"]); + assert.equal(relative.standalone, path.resolve("payload")); + assert.equal(relative.runtime, path.resolve("bin/runtime")); + withFixture((root) => { + fs.writeFileSync(path.join(root, "server.js"), "// fixture; never booted"); + const options = resolveSmokeOptions(["--runtime", process.execPath, "--standalone", root]); + assert.equal(options.server, path.join(root, "server.js")); + assert.doesNotThrow(() => validateSmokeOptions(options)); + }); + }); + + for (const option of ["--standalone", "--runtime"]) { + for (const suffix of [[], [""], ["--electron"]]) { + it(`rejects missing or malformed ${option} value ${JSON.stringify(suffix)}`, () => { + fails([option, ...suffix], new RegExp(`${option} requires a path`)); + }); + } + } + + it("rejects unknown flags and conflicting runtime selectors", () => { + fails(["--unknown"], /Unknown option/); + fails(["--electron", "--runtime", process.execPath], /Use either --electron or --runtime/); + }); + + it("retains the legacy Electron runtime selector", () => { + assert.match(resolveSmokeOptions(["--electron"]).runtime, /electron/); + }); + + it("fails early for nonexistent and non-directory roots", () => { + withFixture((root) => { + fails(["--standalone", path.join(root, "absent")], /Standalone directory not found/); + const file = path.join(root, "file"); + fs.writeFileSync(file, "fixture"); + fails(["--standalone", file], /Standalone directory not found/); + }); + }); + + it("fails early for missing or non-file server entries", () => { + withFixture((root) => { + fails(["--standalone", root], /Standalone server not found/); + fs.mkdirSync(path.join(root, "server.js")); + fails(["--standalone", root], /Standalone server not found/); + }); + }); + + it("fails early for missing, non-file and non-executable runtimes", () => { + withFixture((root) => { + fs.writeFileSync(path.join(root, "server.js"), "// fixture"); + fails(["--standalone", root, "--runtime", path.join(root, "missing")], /Runtime not found/); + fails(["--standalone", root, "--runtime", root], /Runtime not found/); + const runtime = path.join(root, "runtime"); + fs.writeFileSync(runtime, "fixture", { mode: 0o600 }); + fails(["--standalone", root, "--runtime", runtime], /EACCES/); + }); + }); + + for (const relative of ["auth.json", ".env", ".env.local", ".env.production", ".env.production.local", ".learnharness", "data"]) { + it(`rejects leaked credential/data path ${relative} before dependency probes`, () => { + withFixture((root) => { + fs.writeFileSync(path.join(root, "server.js"), "// fixture"); + fs.writeFileSync(path.join(root, relative), "not-a-real-credential"); + fails(["--standalone", root], /Credential\/data path leaked into standalone/); + }); + }); + } +});