From b39d5cd6abbb006be6e879cffc3de5e407b2dcbe Mon Sep 17 00:00:00 2001 From: amosblomqvist Date: Tue, 20 Oct 2026 14:51:40 +0300 Subject: [PATCH] fix: include packaged demo runtime and verify browser payload --- .github/workflows/release.yml | 14 +++ next.config.ts | 3 + scripts/smoke-demo-runtime.mjs | 179 +++++++++++++++++++++++++++++ scripts/smoke-server.mjs | 6 +- tests/demo-runtime-payload.test.ts | 94 +++++++++++++++ 5 files changed, 295 insertions(+), 1 deletion(-) create mode 100644 scripts/smoke-demo-runtime.mjs create mode 100644 tests/demo-runtime-payload.test.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a370b1c..feb8377 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -169,6 +169,20 @@ jobs: - name: Windows/Linux rehearsal (no publish) if: github.event_name == 'workflow_dispatch' && runner.os != 'macOS' run: npx electron-builder ${{ matrix.builder_args }} --publish never + - name: Exercise packaged demo runtime (own Electron, no system Chrome) + run: | + case "$RUNNER_OS" in + macOS) + app="dist/mac-arm64/LearnHarness.app/Contents" + node scripts/smoke-demo-runtime.mjs --standalone "$app/Resources/server/standalone" --runtime "$app/MacOS/LearnHarness" --browser + ;; + Windows) + node scripts/smoke-demo-runtime.mjs --standalone dist/win-unpacked/resources/server/standalone --runtime dist/win-unpacked/LearnHarness.exe --browser + ;; + Linux) + xvfb-run -a node scripts/smoke-demo-runtime.mjs --standalone dist/linux-unpacked/resources/server/standalone --runtime dist/linux-unpacked/learnharness --browser + ;; + esac - name: Save installers and update metadata uses: actions/upload-artifact@v7 with: diff --git a/next.config.ts b/next.config.ts index 8c6c6b2..93ab859 100644 --- a/next.config.ts +++ b/next.config.ts @@ -55,6 +55,9 @@ const nextConfig: NextConfig = { // caught it; unit tests run unbundled and never see it. outputFileTracingIncludes: { "*": [ + // The standalone trace omitted this entire runtime-imported directory + // in 0.3.0, so importing puppeteer-core failed before browser startup. + "node_modules/@puppeteer/browsers/lib/browser-data/**/*.js", "node_modules/pdfjs-dist/legacy/**/*", "node_modules/pdfjs-dist/standard_fonts/**/*", // Wave E (2026-10-10): pdf_render paints via @napi-rs/canvas — its diff --git a/scripts/smoke-demo-runtime.mjs b/scripts/smoke-demo-runtime.mjs new file mode 100644 index 0000000..4030111 --- /dev/null +++ b/scripts/smoke-demo-runtime.mjs @@ -0,0 +1,179 @@ +/** + * Import the demo dependency graph from ONLY a standalone payload. With --browser, + * also drive the packaged app's own --browser-service (never system Chrome). + * No server, model, keys, installed app UI, or production diagnostic route needed. + * + * node scripts/smoke-demo-runtime.mjs --standalone --runtime [--browser] + * --runtime defaults to host Node for the import-only gate. + */ +import assert from "node:assert/strict"; +import { spawn, spawnSync } from "node:child_process"; +import * as fs from "node:fs"; +import { createRequire, registerHooks } from "node:module"; +import * as os from "node:os"; +import * as path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +/** @param {Record} env @param {string} home @returns {Record} */ +export function probeEnv(env, home) { + const clean = Object.fromEntries(Object.entries(env).filter(([key]) => + !/KEY|TOKEN|SECRET|PASSWORD|CREDENTIAL/i.test(key) && + !/^(AWS_|GOOGLE_|GCLOUD_|CLOUDSDK_|AZURE_|ANTHROPIC_|OPENAI_|OPENROUTER_|GEMINI_)/.test(key) && + !["NODE_OPTIONS", "NODE_PATH", "LH_DEV_URL", "ELECTRON_RUN_AS_NODE"].includes(key))); + return { + ...clean, + HOME: home, USERPROFILE: home, + APPDATA: path.join(home, "config"), LOCALAPPDATA: path.join(home, "local"), + XDG_CONFIG_HOME: path.join(home, "config"), XDG_CACHE_HOME: path.join(home, "cache"), + LH_DATA_DIR: path.join(home, "data"), LH_AUTH_FILE: path.join(home, "auth.json"), + LH_IGNORE_ENV_KEYS: "1", NODE_ENV: "production", + }; +} + +export function verifyDemoRuntime(standalone, runtime = process.execPath, browser = false) { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "lh-demo-runtime-")); + try { + fs.writeFileSync(path.join(home, "auth.json"), "{}\n", { mode: 0o600 }); + const result = spawnSync(runtime, [fileURLToPath(import.meta.url), "--probe", path.resolve(standalone), ...(browser ? ["--browser"] : [])], { + cwd: home, + env: { ...probeEnv(process.env, home), ELECTRON_RUN_AS_NODE: "1" }, + encoding: "utf8", timeout: 60_000, maxBuffer: 128 * 1024, + }); + if (result.status !== 0 || result.error) { + throw new Error(`demo runtime probe failed (${result.error?.message ?? result.status}):\n${result.stdout ?? ""}${result.stderr ?? ""}`); + } + process.stdout.write(result.stdout); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + } +} + +async function importPayload(standalone) { + const root = fs.realpathSync(standalone); + const inside = (file) => { + const relative = path.relative(root, fs.realpathSync(file)); + assert.ok(relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative), + `dependency escaped standalone payload: ${file}`); + }; + const require = createRequire(path.join(root, "server.js")); + const entry = require.resolve("puppeteer-core"); + inside(entry); // require.resolve must not silently fall back to checkout node_modules. + const loaded = new Set(); + const hooks = registerHooks({ + resolve(specifier, context, nextResolve) { + const result = nextResolve(specifier, context); + if (result.url.startsWith("file:")) { + inside(fileURLToPath(result.url)); + loaded.add(result.url); + } else { + assert.ok(result.url.startsWith("node:"), `unexpected dependency URL: ${result.url}`); + } + return result; + }, + }); + try { + const { default: puppeteer } = await import(pathToFileURL(entry).href); + assert.equal(typeof puppeteer.connect, "function"); + console.log(`[smoke-demo-runtime] puppeteer-core import ok; ${loaded.size} modules confined to ${root}`); + return { puppeteer, hooks }; + } catch (error) { + hooks.deregister(); + throw error; + } +} + +async function browserProbe(puppeteer) { + assert.ok(process.versions.electron, "--browser requires a packaged Electron --runtime binary"); + const home = process.env.HOME; + const env = { ...process.env }; + delete env.ELECTRON_RUN_AS_NODE; + delete env.LH_DEV_URL; + const helper = spawn(process.execPath, [ + "--browser-service", `--user-data-dir=${path.join(home, "browser-profile")}`, + "--disable-background-networking", "--disable-component-update", "--no-proxy-server", + // Hosted Linux runners disallow Chromium's unprivileged sandbox. + ...(process.platform === "linux" ? ["--no-sandbox"] : []), + ], { cwd: home, env, stdio: ["pipe", "pipe", "pipe"] }); + let output = ""; + let errors = ""; + helper.stderr.on("data", (chunk) => { errors = (errors + chunk).slice(-16_000); }); + const closed = new Promise((resolve) => helper.once("close", (code, signal) => resolve({ code, signal }))); + let browser; + try { + const port = await new Promise((resolve, reject) => { + const timer = setTimeout(() => reject(new Error("browser-service handshake timed out")), 15_000); + helper.once("error", (err) => { clearTimeout(timer); reject(err); }); + helper.once("exit", (code) => { clearTimeout(timer); reject(new Error(`browser-service exited early: ${code}`)); }); + helper.stdout.on("data", (chunk) => { + output = (output + chunk).slice(-16_000); + const match = output.match(/^BROWSER_SERVICE_CDP (\d+)\r?$/m); + if (match) { clearTimeout(timer); resolve(Number(match[1])); } + }); + }); + const info = await (await fetch(`http://127.0.0.1:${port}/json/version`, { signal: AbortSignal.timeout(5000) })).json(); + browser = await puppeteer.connect({ browserWSEndpoint: info.webSocketDebuggerUrl, defaultViewport: null, protocolTimeout: 10_000 }); + // Electron CDP has no Target.createTarget: use the helper's existing page. + const pages = await browser.pages(); + const page = pages.find((candidate) => candidate.target().type() === "page"); + assert.ok(page, "browser-service has no page target"); + const failures = []; + page.on("console", (msg) => { if (msg.type() === "error") failures.push(msg.text()); }); + page.on("pageerror", (err) => failures.push(String(err))); + page.on("requestfailed", (req) => failures.push(`failed request: ${req.url()}`)); + await page.setRequestInterception(true); + page.on("request", (req) => { + if (/^(file:|about:|data:)/.test(req.url())) void req.continue(); + else { failures.push(`unexpected network request: ${req.url()}`); void req.abort(); } + }); + const html = path.join(home, "demo.html"); + fs.writeFileSync(html, '0'); + await page.goto(pathToFileURL(html).href, { waitUntil: "load", timeout: 10_000 }); + assert.equal(await page.$eval("#count", (element) => element.textContent), "0"); + await page.click("#increment"); + assert.equal(await page.$eval("#count", (element) => element.textContent), "1"); + await page.goto("about:blank"); + assert.equal(await page.$("#increment"), null, "page state was not cleared"); + assert.deepEqual(failures, [], "demo console/page/network errors"); + console.log("[smoke-demo-runtime] packaged browser-service: JS button 0 → 1, clean console/page, page reset ok (no Chrome fallback)"); + } catch (err) { + throw new Error(`${err.stack ?? err}\n--- browser-service output ---\n${output}\n${errors}`); + } finally { + browser?.disconnect(); + helper.stdin.end(); // The shipping lifetime contract: EOF must quit the helper. + let timer; + const result = await Promise.race([ + closed, + new Promise((resolve) => { timer = setTimeout(() => resolve(null), 5000); }), + ]); + clearTimeout(timer); + if (!result) { + helper.kill("SIGKILL"); + await closed; + throw new Error("browser-service did not exit on stdin EOF; force-killed"); + } + assert.deepEqual(result, { code: 0, signal: null }, "browser-service shutdown failed"); + console.log("[smoke-demo-runtime] browser-service exited cleanly on stdin EOF (no resident helper)"); + } +} + +async function main() { + const args = process.argv.slice(2); + if (args[0] === "--probe") { + const { puppeteer, hooks } = await importPayload(args[1]); + try { + if (args.includes("--browser")) await browserProbe(puppeteer); + } finally { hooks.deregister(); } + return; + } + const value = (name, fallback) => { + const index = args.indexOf(name); + if (index < 0) return fallback; + assert.ok(args[index + 1] && !args[index + 1].startsWith("--"), `${name} requires a path`); + return path.resolve(args[index + 1]); + }; + verifyDemoRuntime(value("--standalone", path.resolve(import.meta.dirname, "../.next/standalone")), value("--runtime", process.execPath), args.includes("--browser")); +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + main().catch((err) => { console.error(`[smoke-demo-runtime] FAIL: ${err.stack ?? err}`); process.exitCode = 1; }); +} diff --git a/scripts/smoke-server.mjs b/scripts/smoke-server.mjs index 5721d01..53b53e6 100644 --- a/scripts/smoke-server.mjs +++ b/scripts/smoke-server.mjs @@ -25,6 +25,7 @@ * way, same day). */ import { spawn } from "node:child_process"; +import { probeEnv, verifyDemoRuntime } from "./smoke-demo-runtime.mjs"; import { createRequire } from "node:module"; import * as fs from "node:fs"; import * as http from "node:http"; @@ -76,6 +77,9 @@ function get(url) { } 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); const port = await freePort(); const url = `http://127.0.0.1:${port}`; const home = fs.mkdtempSync(path.join(os.tmpdir(), "lh-smoke-")); @@ -104,7 +108,7 @@ async function main() { const child = spawn(RUNTIME, [SERVER], { cwd: STANDALONE, env: { - ...process.env, + ...probeEnv(process.env, home), ...(ELECTRON_MODE ? { ELECTRON_RUN_AS_NODE: "1" } : {}), NODE_ENV: "production", PORT: String(port), diff --git a/tests/demo-runtime-payload.test.ts b/tests/demo-runtime-payload.test.ts new file mode 100644 index 0000000..eeca601 --- /dev/null +++ b/tests/demo-runtime-payload.test.ts @@ -0,0 +1,94 @@ +/** No display, real browser, installed app, or production dependency tree needed. */ +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, pathToFileURL } from "node:url"; +import { probeEnv } from "../scripts/smoke-demo-runtime.mjs"; + +const script = fileURLToPath(new URL("../scripts/smoke-demo-runtime.mjs", import.meta.url)); + +function withPayload(test: (fixture: { + root: string; + write: (relative: string, content: string) => void; + run: () => ReturnType; +}) => void) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "lh-demo-payload-test-")); + const write = (relative: string, content: string) => { + const file = path.join(root, relative); + fs.mkdirSync(path.dirname(file), { recursive: true }); + fs.writeFileSync(file, content); + }; + try { + write("server.js", "// fixture"); + write("node_modules/puppeteer-core/package.json", JSON.stringify({ name: "puppeteer-core", type: "module", main: "lib/index.js" })); + write("node_modules/puppeteer-core/lib/index.js", 'import "./browser-data/browser-data.js"; export default { connect() {} };'); + test({ root, write, run: () => spawnSync(process.execPath, [script, "--standalone", root], { + encoding: "utf8", timeout: 10_000, + }) }); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +} + +describe("demo standalone dependency gate", () => { + it("imports a complete payload and does not change it", () => { + withPayload(({ root, write, run }) => { + write("node_modules/puppeteer-core/lib/browser-data/browser-data.js", "export const fixture = true;"); + const before = fs.readdirSync(root, { recursive: true }); + const result = run(); + assert.equal(result.status, 0, String(result.stderr)); + assert.match(String(result.stdout), /puppeteer-core import ok/); + assert.deepEqual(fs.readdirSync(root, { recursive: true }), before); + }); + }); + + it("fails when a transitive runtime subdirectory is missing", () => { + withPayload(({ root, run }) => { + const before = fs.readdirSync(root, { recursive: true }); + const result = run(); + assert.equal(result.status, 1); + assert.match(String(result.stderr), /ERR_MODULE_NOT_FOUND/); + assert.match(String(result.stderr), /browser-data/); + assert.deepEqual(fs.readdirSync(root, { recursive: true }), before); + }); + }); + + it("rejects imports outside the payload even when they resolve successfully", () => { + withPayload(({ write, run }) => { + // A sibling file, not just a missing file: resolution succeeds but confinement must fail. + const sibling = fs.mkdtempSync(path.join(os.tmpdir(), "lh-demo-outside-")); + try { + const external = path.join(sibling, "dependency.mjs"); + fs.writeFileSync(external, "export const outside = true;"); + write("node_modules/puppeteer-core/lib/index.js", `import ${JSON.stringify(pathToFileURL(external).href)}; export default { connect() {} };`); + const result = run(); + assert.equal(result.status, 1); + assert.match(String(result.stderr), /dependency escaped standalone payload/); + } finally { fs.rmSync(sibling, { recursive: true, force: true }); } + }); + }); + + it("cannot substitute the checkout's Puppeteer for an absent payload package", () => { + withPayload(({ root, run }) => { + fs.rmSync(path.join(root, "node_modules"), { recursive: true }); + const result = run(); + assert.equal(result.status, 1); + assert.doesNotMatch(String(result.stdout), /import ok/); + }); + }); + + it("strips secrets and uses only scratch data/auth/profile locations", () => { + const original = { PATH: "/bin", API_KEY: "fake", GH_TOKEN: "fake", MY_SECRET: "fake", PASSWORD: "fake", NODE_OPTIONS: "--require anything", NODE_PATH: "/outside", ELECTRON_RUN_AS_NODE: "1", LH_DEV_URL: "http://localhost", LH_DATA_DIR: "/real", LH_AUTH_FILE: "/real/auth.json", GOOGLE_APPLICATION_CREDENTIALS: "/real/google.json", AWS_SHARED_CREDENTIALS_FILE: "/real/aws", AWS_PROFILE: "real-profile", CLOUDSDK_CONFIG: "/real/gcloud" }; + const env = probeEnv(original, "/scratch"); + for (const key of ["API_KEY", "GH_TOKEN", "MY_SECRET", "PASSWORD", "NODE_OPTIONS", "NODE_PATH", "ELECTRON_RUN_AS_NODE", "LH_DEV_URL", "GOOGLE_APPLICATION_CREDENTIALS", "AWS_SHARED_CREDENTIALS_FILE", "AWS_PROFILE", "CLOUDSDK_CONFIG"]) assert.equal(key in env, false, key); + assert.equal(env.PATH, "/bin"); + assert.equal(env.LH_DATA_DIR, path.join("/scratch", "data")); + assert.equal(env.LH_AUTH_FILE, path.join("/scratch", "auth.json")); + assert.equal(env.LH_IGNORE_ENV_KEYS, "1"); + assert.equal(env.HOME, "/scratch"); + assert.equal(original.API_KEY, "fake"); + }); +});