feat: Electron desktop wrapper — LearnHarness.app (macOS arm64)

The web app becomes a desktop app without changing shape: the Electron
shell spawns the Next standalone server on a loopback port and points a
sandboxed window at it. All app logic stays in the Next server.

Bands:

1. LH_DATA_DIR seam (config.ts): PATHS.data/chats/graph derive from
   process.env.LH_DATA_DIR (else repo data/, unchanged in dev). New
   PATHS.modelCache routes the transformers.js embedding-model cache
   (~300MB) to <data>/models when the seam is set — the default cache
   inside node_modules is read-only in a packaged bundle. All existing
   fs writes flow through PATHS; teacherPrompt stays ROOT-anchored
   (read-only asset, ships inside the bundle by design).
   tests/data-dir-seam.test.ts covers set/unset via child process.

2. Standalone build (next.config.ts + scripts/build-server.mjs):
   output 'standalone'; outputFileTracingExcludes data/**/* keeps the
   repo's real session data out of the bundle (verified: an unexcluded
   scratch build traced 34 files / 716K of real chats + ledgers).
   Two empirically-verified tracer gaps required outputFileTracingIncludes:
   pi-ai's provider catalog (JSON import attributes — 39 files, 500s on
   /api/models without them) and onnxruntime-node's sibling dylib
   (libonnxruntime.dlopen via @rpath). build-server.mjs copies public/
   and .next/static per docs, refuses a leaked data/ dir, and asserts
   the runtime-read prompt .md assets are traced.

3. Electron shell (electron/main.cjs, no preload):
   - single-instance lock (two instances would fight over the data dir);
   - free port via listen(0); spawn via process.execPath +
     ELECTRON_RUN_AS_NODE=1 with PORT/HOSTNAME=127.0.0.1/LH_DATA_DIR;
   - BYOK: userData/auth.json, flat {VAR: value} JSON, mode 0600,
     merged into the child env only when absent from the real env
     (auth UI is the settings milestone; file is hand-editable);
   - poll until the server answers (no ready event), then open a
     contextIsolation+sandbox window; deny-by-default permission
     handler (media allowed only from the loopback origin — mic/STT
     groundwork); will-navigate pinned to origin, window.open denied
     with external links to the system browser; macOS Edit-menu roles;
   - quit path SIGTERM→3s→SIGKILL + exit/SIGHUP nets; child stdout
     mirrored to userData/logs/main.log;
   - dev mode via LH_DEV_URL: no spawn, points at next dev (HMR).

4. Packaging (electron-builder.yml + build/entitlements.mac.plist):
   files = electron shell only; the whole .next payload ships via
   extraResources from .next (filter standalone/**/*) — NOT from
   .next/standalone directly: electron-builder hard-excludes a copy's
   root node_modules and the default pattern is a literal match, so
   node_modules/ and .next/ were silently dropped and the server died
   with 'Cannot find module next'. Asar holds no native code.
   Unsigned (identity: null) until a Developer ID cert exists; hardened
   runtime + audio-input entitlements laid now for the STT milestone.
   desktop:dev / desktop:dist scripts (electron 44, electron-builder 26).

Live smoke of the packaged .app: server up on a random loopback port,
all API routes 200, a real GLM chat turn answered end-to-end via
auth.json BYOK, chat persisted under userData/data, second instance
exited via the lock, no orphaned server child after quit.

Gates: typecheck, eslint, 500/500 tests (+2 data-dir-seam), build.
This commit is contained in:
amosblomqvist
2026-09-17 15:32:28 +03:00
parent 769a3da186
commit 7902984bd6
12 changed files with 3306 additions and 6 deletions
+1
View File
@@ -9,3 +9,4 @@ data/
SCOPE.md
# raw real-data import sources (user directive 2026-09-13: never commit)
real-data-extraction/
dist/
+19
View File
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<!-- Hardened-runtime entitlements (applied once the app is signed).
Laid now (2026-09-17) so the STT milestone doesn't rebuild packaging:
- allow-jit / allow-unsigned-executable-memory: Electron's V8 needs
both under hardened runtime (standard Electron entitlements);
- audio-input: macOS mic for the STT milestone (the Developer-ID
variant — com.apple.security.device.microphone is the App-Store
sandbox variant and does NOT apply to Developer ID apps). -->
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.device.audio-input</key>
<true/>
</dict>
</plist>
+22 -3
View File
@@ -4,14 +4,33 @@ import { fileURLToPath } from "node:url";
/** Repository root (this file is at <root>/config.ts). */
export const ROOT = path.dirname(fileURLToPath(import.meta.url));
/**
* THE data-location seam (the Electron contract, 2026-09-17): LH_DATA_DIR
* relocates every MUTABLE record — chat sessions, graph ledgers, the
* embedding model cache — when set. The desktop shell points it at
* userData so nothing is written inside the read-only .app bundle
* (macOS translocation guarantees the bundle is read-only anyway).
* Unset (plain web dev / repo work) it resolves to the repo's data/,
* byte-for-byte as before. Read-only assets (prompts) stay ROOT-anchored
* on purpose: they ship inside the app bundle by design.
*/
const DATA_ROOT = process.env.LH_DATA_DIR
? path.resolve(process.env.LH_DATA_DIR)
: path.join(ROOT, "data");
export const PATHS = {
root: ROOT,
/** Teacher system prompt — the agent's instructions (src/agent/). */
teacherPrompt: path.join(ROOT, "src", "agent", "system-prompt.md"),
data: path.join(ROOT, "data"),
data: DATA_ROOT,
/** Chat sessions (AI SDK UIMessage transcripts, one JSON file per chat). */
chats: path.join(ROOT, "data", "chats"),
graph: path.join(ROOT, "data", "graph"),
chats: path.join(DATA_ROOT, "chats"),
graph: path.join(DATA_ROOT, "graph"),
/** transformers.js model cache (server-side embeddings, ~300MB).
* null in dev = keep the package default (the model is already
* downloaded there); under LH_DATA_DIR the packaged app must never
* try to cache inside its own bundle. */
modelCache: process.env.LH_DATA_DIR ? path.join(DATA_ROOT, "models") : null,
} as const;
// ─── Models ────────────────────────────────────────────────────────────────
+49
View File
@@ -0,0 +1,49 @@
# electron-builder config — LearnHarness desktop (macOS arm64 first).
#
# Shape (fact-checked 2026-09-17):
# - the app's real server (.next/standalone, curated by
# scripts/build-server.mjs) ships via extraResources — OUTSIDE the asar,
# because native .node/dylib can't dlopen from a read-only archive and
# server.js process.chdir()s into its own dir;
# - the asar holds only the shell (electron/main.cjs + package.json);
# - signing/notarization config is laid now but inert until a Developer
# ID certificate exists (identity: null = unsigned local build, which
# runs fine for personal use — Gatekeeper only gates quarantined apps).
appId: com.learnharness.LearnHarness
productName: LearnHarness
directories:
output: dist
files:
# Only the shell goes into the asar.
- electron/**/*
- package.json
extraResources:
# Copy from the PARENT dir (.next), not .next/standalone itself:
# electron-builder hard-excludes a copy's root node_modules and the
# default no-filter pattern is a literal path match (empirically both
# node_modules/ and .next/ were silently dropped → server died on
# "Cannot find module 'next'"). With `from: .next` the filter's
# relative paths start with standalone/, dodging the root-node_modules
# rule, and the explicit wildcard (minimatch dot:true) reaches
# standalone/.next and standalone/node_modules.
- from: .next
to: server
filter:
- "standalone/**/*"
mac:
target:
- target: dmg
arch:
- arm64
category: public.app-category.education
# Laid for the STT milestone (researcher-verified): the TCC mic prompt
# reads this string; macOS asks regardless of Electron's own handlers.
extendInfo:
NSMicrophoneUsageDescription: "LearnHarness uses the microphone only for speech-to-text input into your sessions."
hardenedRuntime: true
entitlements: build/entitlements.mac.plist
# Unsigned until a Developer ID certificate exists (research-confirmed:
# local builds run fine; distribution needs signing + notarization).
identity: null
dmg:
title: "${productName} ${version}"
+272
View File
@@ -0,0 +1,272 @@
/**
* LearnHarness — the Electron shell (wrapper milestone, 2026-09-17).
*
* What this process is and is not:
* IS: a thin launcher + security guard around the app's real server —
* `next build`'s standalone server.js (Next 16), spawned as a child
* on 127.0.0.1 with the BYOK keys and the LH_DATA_DIR seam.
* NOT: a host for any app logic. Everything — teacher turns, extraction,
* the graph — runs in the Next server, unchanged.
*
* Pattern (fact-checked 2026-09-17 against current Electron 44 / Next 16
* docs — see .memory release-strategy + electron-wrapper notes):
* - spawn via process.execPath + ELECTRON_RUN_AS_NODE=1 (Electron's
* bundled Node 24; the runAsNode fuse must stay enabled);
* - the server dir ships UNPACKED via extraResources (asar archives are
* read-only and native .node/dylib can't dlopen from one);
* - all mutable state under userData (never inside the .app bundle —
* translocated apps are read-only);
* - keys ride the child's ENV, never argv; auth.json (BYOK) is a flat
* {VAR: value} JSON map, plaintext, mode 0600, hand-edited until the
* settings milestone builds the UI.
*/
const { app, BrowserWindow, Menu, session, shell } = require("electron");
const { spawn } = require("node:child_process");
const fs = require("node:fs");
const http = require("node:http");
const net = require("node:net");
const path = require("node:path");
const PRODUCT_NAME = "LearnHarness";
const AUTH_FILE = () => path.join(app.getPath("userData"), "auth.json");
const DATA_DIR = () => path.join(app.getPath("userData"), "data");
/** The packaged standalone server (extraResources → Resources/server).
* In dev LH_DEV_URL short-circuits everything: no spawn, no packaging —
* the window points at the running `next dev` server (full HMR). */
function serverEntry() {
// extraResources ships the whole .next payload under server/standalone/
// (copied from .next with an explicit filter — see electron-builder.yml).
return path.join(process.resourcesPath, "server", "standalone", "server.js");
}
// ─── Single instance: two instances would fight over the data dir ─────────
if (!app.requestSingleInstanceLock()) {
app.quit();
} else {
app.on("second-instance", () => {
const win = BrowserWindow.getAllWindows()[0];
if (win) {
if (win.isMinimized()) win.restore();
win.focus();
}
});
main();
}
let serverProcess = null;
/** A free TCP port (listen(0) → the kernel assigns one). The close→spawn
* race is theoretical; localhost ports are rarely contended, and a failed
* spawn surfaces in the health check with the child's stderr in the log. */
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);
});
}
/** BYOK keys: auth.json in userData, flat {VAR: value} map, 0600.
* Absent file = no keys injected — the app runs, model calls fail with the
* engine's own clear error until the settings milestone adds the UI.
* Real environment variables win over the file (process.env precedence is
* also Next's own env loading order, so this stays consistent). */
function authEnv() {
try {
const raw = JSON.parse(fs.readFileSync(AUTH_FILE(), "utf-8"));
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
return Object.fromEntries(
Object.entries(raw).filter(([, v]) => typeof v === "string").map(([k, v]) => [k, v]),
);
} catch {
return {}; // absent or corrupt — the documented state is "no keys yet"
}
}
function log(line) {
const stamp = new Date().toISOString();
console.log(`[lh ${stamp}] ${line}`);
// Belt and braces: also append to userData/logs so a misbehaving child
// can be diagnosed after the window is gone. Best effort only.
try {
const dir = path.join(app.getPath("userData"), "logs");
fs.mkdirSync(dir, { recursive: true });
fs.appendFileSync(path.join(dir, "main.log"), `[${stamp}] ${line}\n`);
} catch {
/* best effort */
}
}
function startServer(port) {
return new Promise((resolve, reject) => {
const child = spawn(
process.execPath,
[serverEntry()],
{
cwd: path.dirname(serverEntry()),
env: {
...process.env,
...authEnv(),
ELECTRON_RUN_AS_NODE: "1",
NODE_ENV: "production",
PORT: String(port),
HOSTNAME: "127.0.0.1", // the server defaults to 0.0.0.0 — never
LH_DATA_DIR: DATA_DIR(),
},
stdio: ["ignore", "pipe", "pipe"],
},
);
serverProcess = child;
const fwd = (buf) => buf.toString().trimEnd().split("\n").forEach((l) => l && log(`server: ${l}`));
child.stdout.on("data", fwd);
child.stderr.on("data", fwd);
child.on("exit", (code, signal) => {
log(`server exited (code=${code} signal=${signal})`);
serverProcess = null;
});
resolve(child);
});
}
/** No "ready" event exists — poll until the server answers (docs-confirmed). */
function waitUntilUp(url, tries = 150) {
return new Promise((resolve, reject) => {
const attempt = (n) => {
const req = http.get(url, (res) => {
res.resume();
resolve(res.statusCode !== undefined);
});
req.on("error", () => {
if (n <= 0) reject(new Error(`server never answered on ${url}`));
else setTimeout(() => attempt(n - 1), 200);
});
};
attempt(tries);
});
}
function killServer() {
if (!serverProcess) return;
const child = serverProcess;
serverProcess = null;
child.kill("SIGTERM");
setTimeout(() => {
try {
child.kill("SIGKILL");
} catch {
/* already gone */
}
}, 3000);
}
/** Security posture (all defaults asserted explicitly; the guards are the
* checklist's own recommendations for localhost-app shells):
* - deny-by-default permission handler allowing only our loopback origin
* (groundwork for the mic/STT milestone — Electron auto-approves by
* default, we do not);
* - will-navigate pinned to the app origin; window.open always denied —
* external links go to the real browser via shell.openExternal. */
function createWindow(url) {
const win = new BrowserWindow({
width: 1440,
height: 900,
title: PRODUCT_NAME,
webPreferences: {
contextIsolation: true,
sandbox: true,
nodeIntegration: false,
},
});
const allowedOrigin = () => new URL(url).origin;
// Deny-by-default; the ONLY grants are media (mic/STT groundwork) from
// our own loopback origin. Electron's default is auto-approve-everything
// — we do not ship that.
session.defaultSession.setPermissionRequestHandler((_wc, permission, callback, details) => {
const requestOrigin = details.requestingUrl ? new URL(details.requestingUrl).origin : null;
callback(requestOrigin === allowedOrigin() && permission === "media");
});
session.defaultSession.setPermissionCheckHandler((_wc, permission, origin) => {
return origin === allowedOrigin() && permission === "media";
});
win.webContents.on("will-navigate", (event, target) => {
if (new URL(target).origin !== allowedOrigin()) {
event.preventDefault();
void shell.openExternal(target);
}
});
win.webContents.setWindowOpenHandler(({ url: target }) => {
void shell.openExternal(target);
return { action: "deny" };
});
void win.loadURL(url);
return win;
}
/** macOS needs an application menu with the Edit roles — without one,
* Cmd+C/V/A do nothing in the renderer. Everything else stays minimal. */
function installMenu() {
const template = [
{
label: app.name,
submenu: [
{ role: "about" },
{ type: "separator" },
{ role: "hide" },
{ role: "hideOthers" },
{ type: "separator" },
{ role: "quit" },
],
},
{ label: "Edit", submenu: [{ role: "undo" }, { role: "redo" }, { type: "separator" }, { role: "cut" }, { role: "copy" }, { role: "paste" }, { role: "selectAll" }] },
{
label: "View",
submenu: [{ role: "reload" }, { role: "toggleDevTools" }, { type: "separator" }, { role: "resetZoom" }, { role: "zoomIn" }, { role: "zoomOut" }, { type: "separator" }, { role: "togglefullscreen" }],
},
{ role: "windowMenu" },
];
Menu.setApplicationMenu(Menu.buildFromTemplate(template));
}
async function main() {
app.on("window-all-closed", () => {
// macOS convention: the app stays resident, reactivated from the Dock.
if (process.platform !== "darwin") app.quit();
});
// The child must not outlive us — cover every quit path.
app.on("before-quit", killServer);
process.on("exit", killServer);
process.on("SIGHUP", killServer);
await app.whenReady();
// Dev mode: point at a running `next dev` server (LH_DEV_URL) — no
// spawn, no packaging, full HMR. The rest of this shell still applies
// (guards, menu, single instance).
const devUrl = process.env.LH_DEV_URL;
const url = devUrl ?? `http://127.0.0.1:${await freePort()}`;
if (!devUrl) {
log(`starting server: ${serverEntry()}`);
log(`data dir: ${DATA_DIR()}${fs.existsSync(AUTH_FILE()) ? " · auth.json: present" : " · auth.json: ABSENT (no BYOK keys yet)"}`);
await startServer(new URL(url).port);
await waitUntilUp(url);
log(`server up: ${url}`);
} else {
log(`dev mode: ${devUrl}`);
}
installMenu();
app.on("activate", () => {
if (BrowserWindow.getAllWindows().length === 0) createWindow(url);
});
createWindow(url);
log(`window open: ${url}`);
}
+3 -1
View File
@@ -20,7 +20,9 @@ import importPlugin from "eslint-plugin-import";
*/
export default defineConfig([
globalIgnores([".next/**", "node_modules/**", "data/**", "next-env.d.ts", "scripts/**"]),
// electron/** — the desktop shell: plain Node, no framework, outside the
// import law (same rationale as scripts/).
globalIgnores([".next/**", "node_modules/**", "data/**", "next-env.d.ts", "scripts/**", "electron/**", "dist/**"]),
{
files: ["**/*.{js,mjs,ts,tsx}"],
languageOptions: {
+28
View File
@@ -1,6 +1,34 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
// Desktop target (Electron wrapper, 2026-09-17): standalone output = a
// self-contained .next/standalone/server.js the Electron shell spawns
// on 127.0.0.1. Harmless for plain web deploys (extra build output).
// The manual copies the docs require — public/ and .next/static — are
// done by scripts/build-server.mjs.
output: "standalone",
// SAFETY (empirically verified 2026-09-17): without this, output file
// tracing sweeps the repo's real data/ — chat records, graph ledgers —
// INTO the standalone bundle. A shipped desktop app would carry the
// developer's session data inside it.
outputFileTracingExcludes: {
"*": ["data/**/*"],
},
// Empirically verified gaps (2026-09-17 standalone smoke): the tracer
// misses JSON loaded via `import ... with { type: "json" }` attributes —
// pi-ai's provider catalog (39 JSON files) vanished from the trace and
// the server 500s on module load. Include the whole data dirs of the
// runtime-loaded engine packages.
outputFileTracingIncludes: {
"*": [
"node_modules/@earendil-works/pi-ai/dist/providers/data/**/*",
"node_modules/@earendil-works/pi-agent-core/dist/**/*.json",
// onnxruntime-node: the .node binding was traced, but its
// sibling dylib (libonnxruntime.*.dylib, dlopen'd via @rpath)
// was not — dlopen fails at module load.
"node_modules/onnxruntime-node/bin/**/*",
],
},
// Framework-free core lives in src/ (plain TS, imported by routes and CLI alike).
// The pi engine must stay external: its providers are runtime-loaded Node
// code (undici streams, catalog data) that breaks when bundled into routes.
+2781
View File
File diff suppressed because it is too large Load Diff
+7 -1
View File
@@ -4,13 +4,16 @@
"description": "A standalone learning harness: a teacher agent (AI SDK) with quiz/ask interactions, session artifacts, and a derived knowledge graph of what the learner knows.",
"type": "module",
"private": true,
"main": "electron/main.cjs",
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"test": "NODE_ENV=test tsx --test tests/*.test.ts tests/*.test.tsx",
"lint": "eslint .",
"typecheck": "tsc --noEmit"
"typecheck": "tsc --noEmit",
"desktop:dev": "concurrently -k \"next dev\" \"LH_DEV_URL=http://localhost:3000 electron .\"",
"desktop:dist": "next build && node scripts/build-server.mjs && electron-builder --mac --arm64"
},
"dependencies": {
"@ai-sdk/react": "^4.0.79",
@@ -66,6 +69,9 @@
"@types/react-dom": "^19.2.4",
"@types/turndown": "^5.0.6",
"@typescript-eslint/parser": "^8.68.0",
"concurrently": "^10.0.5",
"electron": "^44.4.1",
"electron-builder": "^26.15.3",
"eslint": "^9.39.5",
"eslint-import-resolver-typescript": "^4.4.5",
"eslint-plugin-import": "^2.32.0",
+60
View File
@@ -0,0 +1,60 @@
/**
* build-server.mjs — curate the Electron shell's server payload.
*
* `next build` (output: "standalone") produces .next/standalone/ with
* server.js + the traced node_modules subset, but leaves two pieces
* outside that the docs require us to copy: public/ and .next/static/.
* This script finishes the job and sanity-checks the result — including
* the exclusion that keeps the repo's real data/ OUT of the bundle
* (see next.config.ts outputFileTracingExcludes).
*/
import * as fs from "node:fs";
import * as path from "node:path";
const ROOT = path.resolve(import.meta.dirname, "..");
const STANDALONE = path.join(ROOT, ".next", "standalone");
const copyTree = (from, to) => {
fs.cpSync(from, to, { recursive: true });
};
const exists = (p) => fs.existsSync(p);
if (!exists(path.join(STANDALONE, "server.js"))) {
console.error("[build-server] .next/standalone/server.js missing — run `next build` first (output: 'standalone').");
process.exit(1);
}
// 1) Static assets: public/ → standalone/public, .next/static → standalone/.next/static
const staticFrom = path.join(ROOT, ".next", "static");
const staticTo = path.join(STANDALONE, ".next", "static");
if (!exists(staticFrom)) {
console.error("[build-server] .next/static missing — the build looks incomplete.");
process.exit(1);
}
copyTree(staticFrom, staticTo);
if (exists(path.join(ROOT, "public"))) {
copyTree(path.join(ROOT, "public"), path.join(STANDALONE, "public"));
}
// 2) Safety: the repo's real data/ must NEVER ride along in the bundle.
const leakedData = path.join(STANDALONE, "data");
if (exists(leakedData)) {
console.error("[build-server] REFUSING: standalone contains a data/ directory — repo session data would ship in the app.");
console.error(" Check outputFileTracingExcludes in next.config.ts.");
process.exit(1);
}
// 3) Sanity: the runtime-read prompt assets must be present (the teacher
// agent reads them from disk at request time).
for (const asset of [
path.join("src", "agent", "system-prompt.md"),
path.join("src", "agent", "prompts", "teacher", "20-teaching.md"),
]) {
if (!exists(path.join(STANDALONE, asset))) {
console.error(`[build-server] prompt asset missing from the trace: ${asset}`);
process.exit(1);
}
}
console.log("[build-server] server payload ready:", STANDALONE);
+12 -1
View File
@@ -18,7 +18,9 @@
* the whole ledger stale and this module re-embeds from scratch (minutes
* at current scale).
*/
import { pipeline, type FeatureExtractionPipeline } from "@huggingface/transformers";
import * as fs from "node:fs";
import { env, pipeline, type FeatureExtractionPipeline } from "@huggingface/transformers";
import { PATHS } from "../../config.ts";
import type { EmbeddingsLedger, GraphNode } from "../core/contracts.ts";
import { semanticClusters, SEMANTIC_SIM_THRESHOLD } from "../core/semantic-clusters.ts";
@@ -30,6 +32,15 @@ export const EMBEDDING_DIM = 512;
* keeps the pipeline warm without thrashing. */
const EMBED_BATCH = 32;
// Model cache routing (Electron seam, 2026-09-17): the default cacheDir
// lives inside node_modules — read-only inside a packaged .app bundle.
// When LH_DATA_DIR is set the cache follows the data seam (userData);
// in dev it stays at the package default (the model is already there).
if (PATHS.modelCache) {
fs.mkdirSync(PATHS.modelCache, { recursive: true });
env.cacheDir = PATHS.modelCache;
}
let pipelinePromise: Promise<FeatureExtractionPipeline> | null = null;
function getPipeline(): Promise<FeatureExtractionPipeline> {
+52
View File
@@ -0,0 +1,52 @@
/**
* The LH_DATA_DIR seam (Electron contract, 2026-09-17): when set, every
* mutable record relocates under it; when unset, byte-for-byte the old
* repo-data/ behavior. The desktop shell's whole data story rests on this,
* so pin both directions.
*
* The seam is evaluated at MODULE LOAD, so the "set" case must set the env
* BEFORE the first import of config.ts in this process (dynamic import,
* below) and the "unset" case needs a fresh child process.
*/
import assert from "node:assert/strict";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { execFileSync } from "node:child_process";
import { describe, it } from "node:test";
const REPO_ROOT = path.resolve(import.meta.dirname, "..");
const TSX_LOADER = path.join(REPO_ROOT, "node_modules", "tsx", "dist", "loader.mjs");
describe("LH_DATA_DIR seam", () => {
it("relocates data/chats/graph + modelCache under LH_DATA_DIR, keeps prompts ROOT-anchored", async () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "lh-seam-"));
process.env.LH_DATA_DIR = dir;
try {
const { PATHS, ROOT } = await import("../config.ts");
assert.equal(PATHS.data, dir, "PATHS.data resolves the env value (resolved absolute)");
assert.equal(PATHS.chats, path.join(dir, "chats"));
assert.equal(PATHS.graph, path.join(dir, "graph"));
assert.equal(PATHS.modelCache, path.join(dir, "models"), "the HF model cache follows the seam");
assert.equal(
PATHS.teacherPrompt,
path.join(ROOT, "src", "agent", "system-prompt.md"),
"read-only prompt assets stay bundle-anchored on purpose",
);
} finally {
delete process.env.LH_DATA_DIR;
}
});
it("unset = the repo data/ exactly as before (fresh child process, no env)", () => {
const out = execFileSync(
process.execPath,
["--import", TSX_LOADER, "--eval", `const m = await import(${JSON.stringify(path.join(REPO_ROOT, "config.ts"))}); console.log(JSON.stringify({ data: m.PATHS.data, chats: m.PATHS.chats, modelCache: m.PATHS.modelCache }));`],
{ cwd: REPO_ROOT, encoding: "utf-8" },
);
const parsed = JSON.parse(out) as { data: string; chats: string | null; modelCache: string | null };
assert.equal(parsed.data, path.join(REPO_ROOT, "data"));
assert.equal(parsed.chats, path.join(REPO_ROOT, "data", "chats"));
assert.equal(parsed.modelCache, null, "dev keeps the transformers package default cache");
});
});