desktop: the payload fixed — 7.4GB→144MB, dev keys out, boot-smoke gate

- desktop:dist cleans dist/ + .next/standalone before next build: the
  trace sweep was shipping the PREVIOUS build's DMG inside every new one
  (standalone/dist = 8.0GB inside the app; measured 7.4GB DMG → 144MB).
- the same sweep shipped the repo-root auth.json (dev BYOK keys,
  byte-identical) in every DMG ever built — excluded in the trace and
  REFUSED by a new build-server.mjs guard (.env* belt-and-braces).
- WARNING (next.config.ts): dir-style trace excludes (dist/** etc.,
  "./"-anchored or not) strip server-external packages to bare
  package.jsons — pi-ai/pi-agent-core lost their dist/, the packaged
  server 500'd on ERR_MODULE_NOT_FOUND. Only the three verified-safe
  entries stay.
- new scripts/smoke-server.mjs gates desktop:dist between payload
  curation and packaging: boots the standalone exactly as the Electron
  shell does (temp LH_DATA_DIR, empty auth) and requires 200s on / +
  graph/models/settings. Caught both defects above on its first day.
- entitlements: allow-unsigned-executable-memory dropped (2019-era
  relic per current electron-builder docs; weakens hardened runtime).
- pi-ai + pi-agent-core pinned exact 1.0.2 — persisted chat records
  carry their wire shapes; post-release a bump is a migration event.

Gates: typecheck, lint, 1459/1459 tests, full desktop:dist + smoke PASS.
This commit is contained in:
amosblomqvist
2026-10-08 17:35:15 +03:00
parent d5c4c3bf9f
commit 8cb5e14691
6 changed files with 226 additions and 10 deletions
+177
View File
@@ -0,0 +1,177 @@
/**
* 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) — 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).
*/
import { spawn } from "node:child_process";
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";
const ROOT = path.resolve(import.meta.dirname, "..");
const STANDALONE = path.join(ROOT, ".next", "standalone");
const SERVER = path.join(STANDALONE, "server.js");
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);
}
/** 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.on("error", reject);
});
}
/** One GET; resolves the status code (0 = connection refused/failed). */
function get(url) {
return new Promise((resolve) => {
const req = http.get(url, (res) => {
res.resume();
resolve(res.statusCode ?? 0);
});
req.on("error", () => resolve(0));
req.setTimeout(5000, () => {
req.destroy();
resolve(0);
});
});
}
async function main() {
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 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();
}
};
const dumpTail = () => {
console.error("--- server log tail " + "-".repeat(20));
for (const line of tail.slice(-40)) if (line.trim()) console.error(` ${line}`);
};
const child = spawn(process.execPath, [SERVER], {
cwd: STANDALONE,
env: {
...process.env,
NODE_ENV: "production",
PORT: String(port),
HOSTNAME: "127.0.0.1",
LH_DATA_DIR: dataDir,
LH_AUTH_FILE: authFile,
},
stdio: ["ignore", "pipe", "pipe"],
});
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;
try {
// 1) Boot: poll GET / until 200 (or timeout / early exit).
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}`);
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.
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`);
}
} catch (err) {
failed = err;
} finally {
await kill();
try {
fs.rmSync(home, { recursive: true, force: true });
} catch {
/* best effort — it's under os.tmpdir() */
}
}
if (failed) {
console.error(`[smoke-server] FAIL: ${failed.message}`);
dumpTail();
process.exit(1);
}
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);
});