/** * 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"; import { createRequire } from "node:module"; import * as fs from "node:fs"; 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 POLL_MS = 500; const BOOT_TIMEOUT_MS = 30_000; const PROBES = ["/api/graph", "/api/models", "/api/settings"]; /** 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}`); } } function freePort() { return new Promise((resolve, reject) => { const srv = net.createServer(); srv.listen(0, "127.0.0.1", () => { const { port } = srv.address(); srv.close((error) => error ? reject(error) : resolve(port)); }); srv.on("error", reject); }); } /** Bounded GET; failed/refused connections return status 0 for boot polling. */ function get(url, headers = {}) { return new Promise((resolve) => { 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); }); }); } async function main() { 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"); const secret = randomBytes(32).toString("hex"); const headers = { "x-learnharness-desktop": secret }; 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.replaceAll(secret, "[redacted]").split("\n").slice(-40)) { if (line.trim()) console.error(` ${line}`); } }; let child; let exited = false; let launchError; let failed; try { 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 (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})`); 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, headers); assert.equal(status, 200, `Authenticated GET ${route} → ${status} (expected 200)`); console.log(`[smoke-server] authenticated GET ${route} → 200`); } } catch (err) { failed = err; } finally { 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 }); } console.log("[smoke-server] owned server stopped; disposable home removed"); } if (failed) { dumpTail(); throw failed; } console.log("[smoke-server] smoke passed — the packaged server boots and serves."); } 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; }); }