diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 9c26c5c..a4b167c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -63,5 +63,7 @@ jobs: NODE_ENV: test ELECTRON_RUN_AS_NODE: "1" - run: npm run build + # Check the fresh trace before any static copying/curation. + - run: node scripts/smoke-provider-runtime.mjs - run: node scripts/build-server.mjs - run: node scripts/smoke-server.mjs --electron diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index feb8377..276edf1 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -129,6 +129,8 @@ jobs: NODE_ENV: test ELECTRON_RUN_AS_NODE: "1" - run: npm run build + # Check the fresh trace before any static copying/curation. + - run: node scripts/smoke-provider-runtime.mjs - run: node scripts/build-server.mjs - run: node scripts/smoke-server.mjs --electron - name: Validate macOS release secrets @@ -169,6 +171,25 @@ 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: Initialize packaged providers offline (host and packaged Electron Node) + run: | + case "$RUNNER_OS" in + macOS) + app="dist/mac-arm64/LearnHarness.app/Contents" + payload="$app/Resources/server/standalone" + runtime="$app/MacOS/LearnHarness" + ;; + Windows) + payload="dist/win-unpacked/resources/server/standalone" + runtime="dist/win-unpacked/LearnHarness.exe" + ;; + Linux) + payload="dist/linux-unpacked/resources/server/standalone" + runtime="dist/linux-unpacked/learnharness" + ;; + esac + node scripts/smoke-provider-runtime.mjs --standalone "$payload" + node scripts/smoke-provider-runtime.mjs --standalone "$payload" --runtime "$runtime" - name: Exercise packaged demo runtime (own Electron, no system Chrome) run: | case "$RUNNER_OS" in diff --git a/next.config.ts b/next.config.ts index 93ab859..55e5758 100644 --- a/next.config.ts +++ b/next.config.ts @@ -17,7 +17,8 @@ const nextConfig: NextConfig = { // into the DMG (the packaged app never reads it: the shell passes // LH_AUTH_FILE → userData). Excluded here; build-server.mjs also REFUSES // to package any standalone still containing it. .env* belt-and-braces - // the same class (not observed swept, same seam). + // the same class. Next also copies loaded bare .env after tracing; + // the build-server refusal remains required even with these excludes. // // ⚠ EXCLUDE PATTERNS ARE LOAD-BEARING IN WAYS THE DOCS DON'T DESCRIBE // (empirically established 2026-10-08): adding dir excludes like @@ -26,8 +27,11 @@ const nextConfig: NextConfig = { // under their dist/ — the traced copy collapsed to a bare package.json // and the packaged server died on ERR_MODULE_NOT_FOUND; openai/ // @anthropic-ai — pi-ai's deps — vanished from the trace entirely). - // Anchored "./"-prefixed forms behave the same. Only the three entries - // below are verified safe (complete externals trace + clean payload). + // Anchored "./"-prefixed forms behave the same. **/data/**/* fixes + // suffix matching in Next's JavaScript collector, but NOT its native + // Turbopack tracer: that still prunes gcp-metadata and its private deps. + // Keep the narrow closure workaround below AND the native runtime gate. + // Real SDK data/ directories still need their explicit includes too. // The recursive-DMG payload (repo dist/ swept into the bundle, shipping // the previous build inside the next) is instead prevented by the // desktop:dist clean step AND targeted post-trace curation in @@ -37,7 +41,7 @@ const nextConfig: NextConfig = { // stay intact, and the script guards against leaked data/keys. outputFileTracingExcludes: { "*": [ - "data/**/*", + "**/data/**/*", "auth.json", ".env*", ], @@ -71,6 +75,14 @@ const nextConfig: NextConfig = { "node_modules/@resvg/resvg-js-*/**/*", "node_modules/@earendil-works/pi-ai/dist/providers/data/**/*", "node_modules/@earendil-works/pi-agent-core/dist/**/*.json", + // Next 16.3.2 native Turbopack applies unanchored excludes while + // traversing modules; **/data/**/* still prunes gcp-metadata and + // its private runtime chain. Includes are applied after traversal. + // This is the verified GoogleAuth closure, not a general dep copy; + // the offline native Pi gate must catch future SDK closure changes. + "node_modules/gcp-metadata/**/*", + "node_modules/json-bigint/**/*", + "node_modules/bignumber.js/**/*", ], }, // Framework-free core lives in src/ (plain TS, imported by routes and CLI alike). diff --git a/package.json b/package.json index fdd6c84..18b592b 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,7 @@ "typecheck": "tsc --noEmit", "desktop:dev": "concurrently -k \"next dev\" \"LH_DEV_URL=http://localhost:3000 electron .\"", "dev:fresh": "bash scripts/dev-fresh.sh", - "desktop:dist": "rm -rf dist .next/standalone && next build && node scripts/build-server.mjs && node scripts/smoke-server.mjs && DEVELOPER_DIR=${DEVELOPER_DIR:-/Applications/Xcode.app/Contents/Developer} APPLE_KEYCHAIN_PROFILE=LH_NOTARY APPLE_TEAM_ID=JS6XN22HY6 electron-builder --mac --arm64" + "desktop:dist": "rm -rf dist .next/standalone && next build && node scripts/smoke-provider-runtime.mjs && node scripts/build-server.mjs && node scripts/smoke-server.mjs && DEVELOPER_DIR=${DEVELOPER_DIR:-/Applications/Xcode.app/Contents/Developer} APPLE_KEYCHAIN_PROFILE=LH_NOTARY APPLE_TEAM_ID=JS6XN22HY6 electron-builder --mac --arm64" }, "dependencies": { "@earendil-works/pi-agent-core": "1.4.0", diff --git a/scripts/smoke-provider-runtime.mjs b/scripts/smoke-provider-runtime.mjs new file mode 100644 index 0000000..58f3c3f --- /dev/null +++ b/scripts/smoke-provider-runtime.mjs @@ -0,0 +1,130 @@ +/** + * OFFLINE native Pi Google initialization gate (no generation or provider calls). + * Unlike /api/models, this traverses Pi's lazy adapter and constructs GoogleGenAI. + * The real onPayload hook throws a unique sentinel BEFORE generateContentStream. + * All file modules must resolve inside the payload, including nested dependencies. + * + * node scripts/smoke-provider-runtime.mjs [--standalone ] [--runtime ] + */ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import * as fs from "node:fs"; +import { createRequire, registerHooks, syncBuiltinESMExports } from "node:module"; +import * as os from "node:os"; +import * as path from "node:path"; +import { randomUUID } from "node:crypto"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { formatProbeError, probeEnv } from "./smoke-demo-runtime.mjs"; + +export function verifyProviderRuntime(standalone, runtime = process.execPath) { + const home = fs.mkdtempSync(path.join(os.tmpdir(), "lh-provider-runtime-")); + try { + fs.writeFileSync(path.join(home, "auth.json"), "{}\n", { mode: 0o600 }); + const result = spawnSync(runtime, [fileURLToPath(import.meta.url), "--probe", path.resolve(standalone)], { + cwd: home, + env: { ...probeEnv(process.env, home), ELECTRON_RUN_AS_NODE: "1" }, + encoding: "utf8", timeout: 30_000, maxBuffer: 128 * 1024, + }); + if (result.status !== 0 || result.error) { + throw new Error(`provider runtime probe failed (${result.error?.message ?? result.status}):\n${result.stdout ?? ""}${result.stderr ?? ""}`); + } + assert.match(result.stdout, /native Pi Google initialization passed/, "probe exited without success marker"); + process.stdout.write(result.stdout); + } finally { fs.rmSync(home, { recursive: true, force: true }); } +} + +// Dedicated child only: nothing delegates to a live transport, even if the SDK +// changes its fetch path. Count attempts as well: a swallowed guard error fails. +function closeNetwork() { + let attempts = 0; + const deny = () => { attempts++; throw new Error("OFFLINE provider gate: network forbidden"); }; + globalThis.fetch = deny; + globalThis.WebSocket = class { constructor() { deny(); } }; + const require = createRequire(import.meta.url); + for (const [name, methods] of [ + ["http", ["request", "get"]], ["https", ["request", "get"]], + ["net", ["connect", "createConnection"]], ["tls", ["connect"]], + ["dgram", ["createSocket"]], + ["dns", ["lookup", "resolve", "resolve4", "resolve6", "resolveAny", "reverse"]], + ["dns/promises", ["lookup", "resolve", "resolve4", "resolve6", "resolveAny", "reverse"]], + ]) { + const module = require(`node:${name}`); + for (const method of methods) module[method] = deny; + } + require("node:net").Socket.prototype.connect = deny; + syncBuiltinESMExports(); + return () => assert.equal(attempts, 0, "SDK attempted network access"); +} + +async function probe(standalone) { + const noNetwork = closeNetwork(); + 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 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(fileURLToPath(result.url)); + } else assert.ok(result.url.startsWith("node:"), `unexpected dependency URL: ${result.url}`); + return result; + }, + }); + try { + // Pi is an explicit server external. Import its shipped public entry and + // factories, never the checkout's module or a direct/fake Google adapter. + const entry = pathToFileURL(path.join(root, "node_modules/@earendil-works/pi-ai/dist/index.js")); + const { createModels } = await import(entry.href); + const models = createModels(); + for (const [name, factory] of [["google", "googleProvider"], ["opencode", "opencodeProvider"], ["opencode-go", "opencodeGoProvider"]]) { + const provider = (await import(new URL(`providers/${name}.js`, entry).href))[factory](); + models.setProvider(provider); + const model = provider.getModels().find((candidate) => candidate.api === "google-generative-ai"); + assert.ok(model, `${name}: missing Google-backed catalog model`); + const sentinel = `LH_OFFLINE_ON_PAYLOAD_${randomUUID()}`; + let reached = 0; + const result = await models.streamSimple(model, { messages: [{ role: "user", content: "Offline initialization only", timestamp: 0 }] }, { + apiKey: "dummy-offline-provider-gate-not-a-key", + onPayload(payload, selected) { + assert.equal(selected.api, "google-generative-ai"); + assert.ok(payload && typeof payload === "object"); + reached++; + throw new Error(sentinel); + }, + }).result(); + assert.equal(reached, 1, `${name}: did not reach onPayload: ${result.errorMessage}`); + assert.equal(result.stopReason, "error"); + assert.equal(result.errorMessage, sentinel, `${name}: unexpected initialization error`); + noNetwork(); + console.log(`[smoke-provider-runtime] ${name}: native lazy Google adapter reached onPayload; generation stopped`); + } + // Resolution/evaluation, not folder-exists checks. This also covers the + // previously missing transitive edge and permits legitimate nested deps. + for (const name of ["@google/genai", "google-auth-library", "gcp-metadata", "json-bigint", "bignumber.js"]) { + assert.ok([...loaded].some((file) => file.includes(`${path.sep}node_modules${path.sep}${name.replaceAll("/", path.sep)}${path.sep}`)), + `Google runtime chain was not loaded: ${name}`); + } + console.log(`[smoke-provider-runtime] native Pi Google initialization passed; ${loaded.size} modules confined to ${root}; zero network attempts`); + } finally { hooks.deregister(); } +} + +async function main() { + const args = process.argv.slice(2); + if (args[0] === "--probe") return probe(args[1]); + 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]); + }; + verifyProviderRuntime(value("--standalone", path.resolve(import.meta.dirname, "../.next/standalone")), value("--runtime", process.execPath)); +} + +if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { + main().catch((error) => { console.error(`[smoke-provider-runtime] FAIL: ${formatProbeError(error)}`); process.exitCode = 1; }); +} diff --git a/scripts/smoke-server.mjs b/scripts/smoke-server.mjs index 53b53e6..91b6cb5 100644 --- a/scripts/smoke-server.mjs +++ b/scripts/smoke-server.mjs @@ -26,6 +26,7 @@ */ import { spawn } from "node:child_process"; import { probeEnv, verifyDemoRuntime } from "./smoke-demo-runtime.mjs"; +import { verifyProviderRuntime } from "./smoke-provider-runtime.mjs"; import { createRequire } from "node:module"; import * as fs from "node:fs"; import * as http from "node:http"; @@ -80,6 +81,8 @@ 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); const port = await freePort(); const url = `http://127.0.0.1:${port}`; const home = fs.mkdtempSync(path.join(os.tmpdir(), "lh-smoke-")); diff --git a/tests/provider-runtime-payload.test.ts b/tests/provider-runtime-payload.test.ts new file mode 100644 index 0000000..dec3213 --- /dev/null +++ b/tests/provider-runtime-payload.test.ts @@ -0,0 +1,42 @@ +/** Real installed Pi/Google SDK only; no provider stubs, keys, or generation. */ +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"; + +const script = fileURLToPath(new URL("../scripts/smoke-provider-runtime.mjs", import.meta.url)); +const checkout = fileURLToPath(new URL("..", import.meta.url)); +const run = (payload: string) => spawnSync(process.execPath, [script, "--standalone", payload], { encoding: "utf8", timeout: 40_000 }); + +describe("offline native Pi Google initialization gate", () => { + it("reaches the sentinel for direct Google and Google-backed OpenCode adapters using the real SDK", () => { + // This is an unbundled positive control, NOT proof of standalone completeness. + const result = run(checkout); + assert.equal(result.status, 0, String(result.stderr)); + for (const provider of ["google", "opencode", "opencode-go"]) assert.ok(String(result.stdout).includes(`${provider}: native lazy Google adapter reached onPayload`)); + assert.match(String(result.stdout), /zero network attempts/); + }); + it("rejects a missing Pi entry instead of treating any error as success", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "lh-provider-missing-")); + try { + const result = run(root); + assert.equal(result.status, 1); + assert.doesNotMatch(String(result.stdout), /initialization passed/); + assert.match(String(result.stderr), /MODULE_NOT_FOUND|ENOENT/); + assert.deepEqual(fs.readdirSync(root), [], "gate modified the payload"); + } finally { fs.rmSync(root, { recursive: true, force: true }); } + }); + it("rejects an otherwise complete SDK outside the payload (no host fallback)", () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "lh-provider-escape-")); + try { + fs.symlinkSync(path.join(checkout, "node_modules"), path.join(root, "node_modules"), process.platform === "win32" ? "junction" : "dir"); + const result = run(root); + assert.equal(result.status, 1); + assert.match(String(result.stderr), /dependency escaped standalone payload/); + assert.doesNotMatch(String(result.stdout), /initialization passed/); + } finally { fs.rmSync(root, { recursive: true, force: true }); } + }); +}); diff --git a/tests/provider-tracing.test.ts b/tests/provider-tracing.test.ts new file mode 100644 index 0000000..43aae9c --- /dev/null +++ b/tests/provider-tracing.test.ts @@ -0,0 +1,62 @@ +/** JS matcher + config-policy regressions; native Turbopack needs the real payload gate. */ +import assert from "node:assert/strict"; +import { createRequire } from "node:module"; +import { describe, it } from "node:test"; +import nextConfig from "../next.config.ts"; + +const require = createRequire(import.meta.url); +// Use Next's actual bundled matcher, not a different glob implementation. +const picomatch = require("next/dist/compiled/picomatch"); +const { makeIgnoreFn } = require("next/dist/build/collect-build-traces"); +const patterns = nextConfig.outputFileTracingExcludes!["*"]; + +describe("standalone JavaScript tracing exclusions and native workaround policy", () => { + for (const windows of [false, true]) { + const root = windows ? "C:\\build\\learnharness" : "/build/learnharness"; + const file = (relative: string) => root + (windows ? "\\" : "/") + (windows ? relative.replaceAll("/", "\\") : relative); + const ignored = picomatch(patterns, { contains: true, dot: true, windows }); + it(`excludes genuine data directories and secrets (${windows ? "Windows" : "POSIX"})`, () => { + for (const relative of ["data/canary.json", "nested/data/private.json", "auth.json", ".env", ".env.local"]) { + assert.equal(ignored(file(relative)), true, relative); + } + // The explicit includes remain necessary: this raw ignore still matches SDK data. + assert.equal(ignored(file("node_modules/@earendil-works/pi-ai/dist/providers/data/models.json")), true); + assert.ok(nextConfig.outputFileTracingIncludes!["*"]!.includes("node_modules/@earendil-works/pi-ai/dist/providers/data/**/*")); + }); + it(`does not confuse dependency suffixes with data directories (${windows ? "Windows" : "POSIX"})`, () => { + for (const relative of ["node_modules/gcp-metadata/build/src/index.js", "node_modules/form-data/lib/form_data.js", "node_modules/@swc/helpers/_/_ts_metadata.js", "node_modules/@swc/helpers/_/_ts_metadata/index.js", "node_modules/somedata/runtime.js"]) { + assert.equal(ignored(file(relative)), false, relative); + assert.equal(picomatch("data/**/*", { contains: true, dot: true, windows })(file("node_modules/gcp-metadata/build/src/index.js")), true, "old pattern must reproduce the bug"); + } + }); + } + it("uses the installed Next JavaScript early-ignore function on the host platform", () => { + const root = process.cwd(); + const ignore = makeIgnoreFn(root, patterns); + assert.equal(ignore(`${root}/data/private.json`), true); + assert.equal(ignore(`${root}/node_modules/gcp-metadata/build/src/index.js`), false); + }); + it("preserves all secret exclusions exactly", () => { + assert.deepEqual(patterns, ["**/data/**/*", "auth.json", ".env*"]); + }); + it("retains exactly the narrow native-Turbopack closure workaround and existing runtime includes", () => { + assert.deepEqual(nextConfig.outputFileTracingIncludes!["*"], [ + "node_modules/@puppeteer/browsers/lib/browser-data/**/*.js", + "node_modules/pdfjs-dist/legacy/**/*", + "node_modules/pdfjs-dist/standard_fonts/**/*", + "node_modules/@napi-rs/canvas-*/**/*", + "node_modules/@resvg/resvg-js-*/**/*", + "node_modules/@earendil-works/pi-ai/dist/providers/data/**/*", + "node_modules/@earendil-works/pi-agent-core/dist/**/*.json", + "node_modules/gcp-metadata/**/*", + "node_modules/json-bigint/**/*", + "node_modules/bignumber.js/**/*", + ]); + }); + it("limits the workaround to its three packages, never learner data or unrelated dependencies", () => { + const closure = nextConfig.outputFileTracingIncludes!["*"]!.slice(-3); + const included = picomatch(closure, { dot: true }); + for (const file of ["node_modules/gcp-metadata/build/src/index.js", "node_modules/json-bigint/lib/parse.js", "node_modules/bignumber.js/bignumber.js"]) assert.equal(included(file), true, file); + for (const file of ["data/private.json", "nested/data/private.json", "auth.json", ".env.local", "node_modules/unrelated/runtime.js", "node_modules/gcp-metadata-lookalike/index.js"]) assert.equal(included(file), false, file); + }); +});