fix: include packaged demo runtime and verify browser payload

This commit is contained in:
amosblomqvist
2026-10-20 14:51:40 +03:00
parent 32febaaae7
commit b39d5cd6ab
5 changed files with 295 additions and 1 deletions
+14
View File
@@ -169,6 +169,20 @@ jobs:
- name: Windows/Linux rehearsal (no publish) - name: Windows/Linux rehearsal (no publish)
if: github.event_name == 'workflow_dispatch' && runner.os != 'macOS' if: github.event_name == 'workflow_dispatch' && runner.os != 'macOS'
run: npx electron-builder ${{ matrix.builder_args }} --publish never 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 - name: Save installers and update metadata
uses: actions/upload-artifact@v7 uses: actions/upload-artifact@v7
with: with:
+3
View File
@@ -55,6 +55,9 @@ const nextConfig: NextConfig = {
// caught it; unit tests run unbundled and never see it. // caught it; unit tests run unbundled and never see it.
outputFileTracingIncludes: { 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/legacy/**/*",
"node_modules/pdfjs-dist/standard_fonts/**/*", "node_modules/pdfjs-dist/standard_fonts/**/*",
// Wave E (2026-10-10): pdf_render paints via @napi-rs/canvas — its // Wave E (2026-10-10): pdf_render paints via @napi-rs/canvas — its
+179
View File
@@ -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 <dir> --runtime <electron binary> [--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<string, string | undefined>} env @param {string} home @returns {Record<string, string | undefined>} */
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, '<!doctype html><meta charset="utf-8"><button id="increment">Increment</button><output id="count">0</output><script>document.querySelector("#increment").onclick = () => { document.querySelector("#count").textContent = "1"; };</script>');
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; });
}
+5 -1
View File
@@ -25,6 +25,7 @@
* way, same day). * way, same day).
*/ */
import { spawn } from "node:child_process"; import { spawn } from "node:child_process";
import { probeEnv, verifyDemoRuntime } from "./smoke-demo-runtime.mjs";
import { createRequire } from "node:module"; import { createRequire } from "node:module";
import * as fs from "node:fs"; import * as fs from "node:fs";
import * as http from "node:http"; import * as http from "node:http";
@@ -76,6 +77,9 @@ function get(url) {
} }
async function main() { 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 port = await freePort();
const url = `http://127.0.0.1:${port}`; const url = `http://127.0.0.1:${port}`;
const home = fs.mkdtempSync(path.join(os.tmpdir(), "lh-smoke-")); const home = fs.mkdtempSync(path.join(os.tmpdir(), "lh-smoke-"));
@@ -104,7 +108,7 @@ async function main() {
const child = spawn(RUNTIME, [SERVER], { const child = spawn(RUNTIME, [SERVER], {
cwd: STANDALONE, cwd: STANDALONE,
env: { env: {
...process.env, ...probeEnv(process.env, home),
...(ELECTRON_MODE ? { ELECTRON_RUN_AS_NODE: "1" } : {}), ...(ELECTRON_MODE ? { ELECTRON_RUN_AS_NODE: "1" } : {}),
NODE_ENV: "production", NODE_ENV: "production",
PORT: String(port), PORT: String(port),
+94
View File
@@ -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<typeof spawnSync>;
}) => 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");
});
});