diff --git a/.gitignore b/.gitignore index b1ef941..320a543 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,6 @@ dist/ # dev auth/keys store (auth-keys wave): repo-root auth.json — secrets never committed auth.json + +# dev-fresh launcher scratch (fresh-user sim): disposable per launch +.fresh-data/ diff --git a/next-env.d.ts b/next-env.d.ts index ce4e94a..a419cbe 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,7 +1,7 @@ /// /// -import "./.next/types/routes.d.ts"; -import "./.next/types/root-params.d.ts"; +import "./.next/dev/types/routes.d.ts"; +import "./.next/dev/types/root-params.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/package.json b/package.json index 8fefdc1..20406e6 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "lint": "eslint .", "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": "next build && node scripts/build-server.mjs && electron-builder --mac --arm64" }, "dependencies": { diff --git a/scripts/dev-fresh.sh b/scripts/dev-fresh.sh new file mode 100755 index 0000000..eb8ab03 --- /dev/null +++ b/scripts/dev-fresh.sh @@ -0,0 +1,99 @@ +#!/usr/bin/env bash +# +# THE EXACT NEW-USER SIMULATOR (owner, interlude 2026-09-22): one command, +# a fresh instance with zero keys and zero data — setup stage 1, every +# launch. The owner's repeatable first-run BYOK test bed. +# +# bash scripts/dev-fresh.sh # http://localhost:3005 +# npm run dev:fresh # the same, via package.json +# scripts/dev-fresh.sh --port 3006 # any port OUTSIDE 3000-3004 +# scripts/dev-fresh.sh --keep # don't wipe between runs +# (multi-session testing) +# +# WHY A SWITCH, NOT AN ENV SCRUB: a script cannot merely unset shell +# *_API_KEY vars — Next injects .env.local (the dev secrets) into the +# server env at boot, so a "fresh" instance would silently federate the +# real keys. LH_IGNORE_ENV_KEYS=1 (the keys seam, src/server/keys.ts) +# deletes every managed key var from the process after Next's env load: +# pi-ai reads provider keys from process.env NATIVELY at every request, +# so deletion hides keys from the whole app, model engines included. +# +# LH_DATA_DIR → .fresh-data/ (wiped each launch unless --keep) +# LH_AUTH_FILE → .fresh-data/auth.json (NEVER the repo-root auth.json +# — that file may symlink the real keys) +# LH_IGNORE_ENV_KEYS=1 +# +# Safe to rerun. The data dir is disposable scratch — never copy it back. + +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +PORT="3005" +DATA_DIR="$ROOT/.fresh-data" +KEEP=false + +usage() { + sed -n '2,20p' "$0" | sed 's/^# \{0,1\}//' +} + +while [ $# -gt 0 ]; do + case "$1" in + --port) + [ $# -ge 2 ] || { echo "error: --port needs a value" >&2; exit 1; } + PORT="$2" + shift 2 + ;; + --port=*) + PORT="${1#--port=}" + shift + ;; + --keep) + KEEP=true + shift + ;; + -h | --help) + usage + exit 0 + ;; + *) + echo "error: unknown flag: $1" >&2 + usage >&2 + exit 1 + ;; + esac +done + +if ! [[ "$PORT" =~ ^[0-9]+$ ]]; then + echo "error: --port must be a number, got \"$PORT\"" >&2 + exit 1 +fi +case "$PORT" in +3000 | 3001 | 3002 | 3003 | 3004) + echo "error: port $PORT is a standing assignment (3000 master, 3001 UI, 3002 graph, 3003 teacher, 3004 platform)" >&2 + echo " — the fresh instance runs on a dedicated port (default 3005)" >&2 + exit 1 + ;; +esac + +if [ "$KEEP" != "true" ]; then + rm -rf "$DATA_DIR" +else + # --keep preserves prior sessions (multi-session testing); the dir is + # still created if this is the first run. + : +fi +mkdir -p "$DATA_DIR" + +export LH_DATA_DIR="$DATA_DIR" +export LH_AUTH_FILE="$DATA_DIR/auth.json" +export LH_IGNORE_ENV_KEYS=1 +export PORT + +echo "── fresh-user instance ─────────────────────────────────" +echo " URL : http://localhost:$PORT" +echo " data dir : $DATA_DIR ($( [ "$KEEP" = "true" ] && echo 'kept across runs' || echo 'wiped next launch unless --keep' ))" +echo " keys : env keys hidden (LH_IGNORE_ENV_KEYS=1), auth.json empty" +echo "─────────────────────────────────────────────────────────" + +cd "$ROOT" +exec npm run dev diff --git a/src/server/keys.ts b/src/server/keys.ts index 35cceb0..5779e2b 100644 --- a/src/server/keys.ts +++ b/src/server/keys.ts @@ -24,6 +24,19 @@ * Unknown vars in the file are preserved verbatim and surfaced as * generic status rows (no catalog form to edit through — only curated * keys have shapes and docs; core/auth-keys.ts). + * + * THE FRESH-USER SWITCH (owner, interlude 2026-09-22): LH_IGNORE_ENV_KEYS=1 + * makes every env key invisible APP-WIDE — the exact new-user simulation + * (a fresh empty data dir + the switch = zero keys, zero federated + * providers, setup stage 1). Under the switch real env vars are DELETED + * from the process (not merely ignored by this seam): pi-ai resolves + * provider keys from process.env NATIVELY at every request + * (auth/context.js's defaultProviderAuthContext reads live env), so + * deletion is the one mechanism that hides keys from every consumer — + * model engines included. Only the file store (auth.json) counts; the + * fill-gap still reflects file keys so a fresh user can add keys + * mid-session and the engines see them with no restart. Unset switch = + * today's behavior, byte-for-byte. */ import { @@ -72,6 +85,13 @@ export function providerKeyList() { return [...AUTH_PROVIDER_KEYS].sort((a, b) => a.id.localeCompare(b.id)); } +/** The fresh-user switch (owner, interlude 2026-09-22): env keys are + * invisible app-wide — deleted from the process at boot, file keys only. + * Read live per call (the launcher exports it before boot). */ +export function envKeysIgnored(): boolean { + return process.env.LH_IGNORE_ENV_KEYS === "1"; +} + /** * The boot fill-gap — idempotent. First call snapshots which managed names * are real env; later calls (HMR re-import, or after a hand-edit) only fill @@ -89,7 +109,15 @@ export function initAuthEnv(): void { // semantics, exactly like the curated vars. ...AUTH_PROVIDER_KEYS.map((p) => p.envVar), ]); - if (record.realEnv.size === 0 && record.fillGap.size === 0) { + if (envKeysIgnored()) { + // THE FRESH-USER SWITCH: delete every managed env var (re-run on + // every call — a reintroduced var never resurrects). realEnv stays + // EMPTY (nothing counts as env); the fill-gap below then reflects + // auth.json keys so engines still see mid-session additions. + for (const name of managed) { + delete process.env[name]; + } + } else if (record.realEnv.size === 0 && record.fillGap.size === 0) { // First init in this process: what's already in the env is REAL env. for (const name of managed) { if (process.env[name] !== undefined) record.realEnv.add(name); @@ -158,6 +186,10 @@ export function keyStatuses(): AuthKeyStatus[] { * STT route was the one cold-boot hole: a first-ever request to it would * otherwise read env before any fill-gap ran). */ export function keyValue(id: string): string | undefined { + // The fresh-user switch: ONLY the file store counts (belt-and- + // suspenders — the boot deletion already emptied the env; this guards + // any env key reintroduced after boot). + if (envKeysIgnored()) return authStore.authVars()[id]; return process.env[id] ?? authStore.authVars()[id]; } diff --git a/tests/fresh-env-keys.test.ts b/tests/fresh-env-keys.test.ts new file mode 100644 index 0000000..e8cec39 --- /dev/null +++ b/tests/fresh-env-keys.test.ts @@ -0,0 +1,142 @@ +/** + * LH_IGNORE_ENV_KEYS=1 — the fresh-user switch (owner, interlude, + * 2026-09-22): env keys invisible app-wide; only auth.json counts. + * + * This file is deliberately its OWN PROCESS under node:test: the switch + * is BOOT-TIME (initAuthEnv deletes the managed env vars once per + * process), so the switched boot can only be pinned in a process that + * starts with the switch set. The UNSET behavior (env wins, fill-gap, + * shadow semantics) is pinned byte-for-byte by tests/server-keys.test.ts + * — that file IS the visible case; this file is the hidden one. + * + * The engine path is the critical subtlety this file pins: pi-ai reads + * provider keys from process.env NATIVELY at every request + * (auth/context.js's defaultProviderAuthContext reads live env), so the + * switch works by DELETING the vars from the process — assert the + * deletion directly, because that is what a fresh user's engines see. + * + * LH_AUTH_FILE redirects to a fresh tmp dir BEFORE the imports (the + * seam is module-load bound). All values are throwaway test strings — + * never real secrets. + */ + +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 { after, describe, it } from "node:test"; + +// THIS PROCESS'S ENV IS THE TEST SUBJECT — strip any inherited catalog +// vars (a developer shell may export REAL keys; tests use throwaway +// values only, never real secrets). +for (const varName of [ + "OPENROUTER_API_KEY", + "DEEPGRAM_API_KEY", + "TAVILY_API_KEY", + "BRAVE_API_KEY", + "OPENAI_API_KEY", + "ZAI_API_KEY", +]) { + delete process.env[varName]; +} + +const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "lh-fresh-env-keys-")); +const AUTH = path.join(TMP, "auth.json"); +process.env.LH_AUTH_FILE = AUTH; + +// The file BEFORE first boot: one file-only key. +fs.writeFileSync(AUTH, JSON.stringify({ OPENAI_API_KEY: "sk-file-fresh-file-fresh" })); + +// The env the switch must hide — set BEFORE the module's first import: +// - an env-ONLY provider key (the engine-path specimen) +// - the SAME var the file holds (the collision specimen: under the +// switch the FILE value must win, and the env copy must be deleted) +process.env.OPENROUTER_API_KEY = "sk-or-env-env-env"; +process.env.OPENAI_API_KEY = "sk-openai-env-env-env"; + +// THE SWITCH — set before first import (exactly how the launcher boots). +process.env.LH_IGNORE_ENV_KEYS = "1"; + +// Import AFTER env setup — module init runs the boot deletion + fill-gap. +const { deleteKey, envKeysIgnored, initAuthEnv, keyStatuses, keyValue, saveKey } = await import( + "../src/server/keys.ts" +); +const { authStore } = await import("../src/store/auth.ts"); + +after(() => { + fs.rmSync(TMP, { recursive: true, force: true }); +}); + +describe("the fresh-user switch (boot under LH_IGNORE_ENV_KEYS=1)", () => { + it("the switch reads live (the launcher exports it before boot)", () => { + assert.equal(envKeysIgnored(), true); + }); + + it("THE ENGINE PATH: env-only keys are DELETED from the process — pi-ai's native per-request env resolution finds nothing", () => { + assert.equal(process.env.OPENROUTER_API_KEY, undefined, "deleted from process.env at boot"); + }); + + it("the env copy of a file-backed var is deleted; the FILE value is what fills the env (only auth.json counts)", () => { + assert.equal(process.env.OPENAI_API_KEY, "sk-file-fresh-file-fresh", "the env copy died; the fill-gap reflected the file value"); + assert.equal(authStore.authVars().OPENAI_API_KEY, "sk-file-fresh-file-fresh"); + }); + + it("keyValue: env keys invisible, file keys visible — and a post-boot env reintroduction stays invisible (belt-and-suspenders)", () => { + assert.equal(keyValue("OPENROUTER_API_KEY"), undefined, "env key invisible under the switch"); + assert.equal(keyValue("OPENAI_API_KEY"), "sk-file-fresh-file-fresh", "file key visible"); + // A stray consumer re-sets an env key AFTER boot: the seam still + // ignores it (the deletion is boot-time; the read guard is live). + process.env.OPENROUTER_API_KEY = "sk-or-reintroduced-late"; + try { + assert.equal(keyValue("OPENROUTER_API_KEY"), undefined, "reintroduced env key stays invisible to the seam"); + } finally { + delete process.env.OPENROUTER_API_KEY; + } + }); + + it("unset switch = today's env-wins behavior (the read seam, live-checked)", () => { + // The dynamic half of the seam: WITHOUT the switch, keyValue reads + // env first — today's behavior, byte-for-byte (the boot-snapshot + // half is pinned in tests/server-keys.test.ts, the visible case). + process.env.LH_IGNORE_ENV_KEYS = undefined; + process.env.OPENROUTER_API_KEY = "sk-or-env-wins"; + try { + assert.equal(keyValue("OPENROUTER_API_KEY"), "sk-or-env-wins", "env wins without the switch"); + assert.equal(keyValue("OPENAI_API_KEY"), "sk-file-fresh-file-fresh", "file is the fallback without the switch"); + } finally { + delete process.env.OPENROUTER_API_KEY; + process.env.LH_IGNORE_ENV_KEYS = "1"; + } + }); + + it("the status view is the fresh machine's: file sources only, no env rows, no shadowing", () => { + const rows = keyStatuses(); + const openai = rows.find((r) => r.id === "OPENAI_API_KEY"); + assert.ok(openai, "the file-backed provider key has a row"); + assert.equal(openai!.present, true); + assert.equal(openai!.source, "file", "realEnv is empty under the switch — nothing reports as env"); + assert.equal(openai!.shadowedByEnv, false); + assert.equal(rows.some((r) => r.id === "OPENROUTER_API_KEY"), false, "the env-only key is gone everywhere — no row"); + assert.equal(rows.some((r) => r.source === "env"), false, "no env-owned key under the switch"); + }); + + it("a fresh user CAN add keys mid-session: save reflects file → env (engines see it, no restart)", () => { + const result = saveKey("ZAI_API_KEY", "zai-fresh-sim-key-1234567890"); + assert.equal(result.ok, true); + assert.equal(authStore.authVars().ZAI_API_KEY, "zai-fresh-sim-key-1234567890", "persisted to the file"); + assert.equal(process.env.ZAI_API_KEY, "zai-fresh-sim-key-1234567890", "reflected into env for pi-ai"); + const row = keyStatuses().find((r) => r.id === "ZAI_API_KEY"); + assert.equal(row!.source, "file", "the status honestly reports the file as the source"); + // and DELETE removes both (fill-gap semantics hold under the switch) + deleteKey("ZAI_API_KEY"); + assert.equal(process.env.ZAI_API_KEY, undefined); + assert.equal(authStore.authVars().ZAI_API_KEY, undefined); + }); + + it("re-running initAuthEnv is idempotent — deleted env keys never resurrect", () => { + process.env.OPENROUTER_API_KEY = "sk-or-zombie"; + initAuthEnv(); + assert.equal(process.env.OPENROUTER_API_KEY, undefined, "a reintroduced env key is re-deleted"); + assert.equal(process.env.OPENAI_API_KEY, "sk-file-fresh-file-fresh", "the file reflection survives re-init"); + }); +});